diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-06-26 19:57:45 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-06-26 19:57:45 +0000 |
| commit | dc4f6c8016ccece79f8f2b75a5378c643e884f0b (patch) | |
| tree | 3e2c9e998a2f97244a37f3f8cb7f1831e4108ab7 /packages/backend/src | |
| parent | merge: Release 2024.3.3 (!501) (diff) | |
| parent | merge: parse `notRespondingSince` from redis instance cache (!560) (diff) | |
| download | sharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.tar.gz sharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.tar.bz2 sharkey-dc4f6c8016ccece79f8f2b75a5378c643e884f0b.zip | |
merge: release 2024.5.0 (!556)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/556
Approved-by: Tess K <me@thvxl.se>
Diffstat (limited to 'packages/backend/src')
167 files changed, 2470 insertions, 765 deletions
diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index ae74a43c84..d1744b4b4b 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -15,6 +15,7 @@ import Logger from '@/logger.js'; import { envOption } from '../env.js'; import { masterMain } from './master.js'; import { workerMain } from './worker.js'; +import { readyRef } from './ready.js'; import 'reflect-metadata'; @@ -75,10 +76,12 @@ async function main() { ev.mount(); } } - if (cluster.isWorker || envOption.disableClustering) { + if (cluster.isWorker) { await workerMain(); } + readyRef.value = true; + // ユニットテスト時にMisskeyが子プロセスで起動された時のため // それ以外のときは process.send は使えないので弾く if (process.send) { diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index aafd43beea..303ba94207 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -10,6 +10,8 @@ import * as os from 'node:os'; import cluster from 'node:cluster'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; @@ -74,6 +76,24 @@ export async function masterMain() { bootLogger.succ('Sharkey initialized'); + if (config.sentryForBackend) { + Sentry.init({ + integrations: [ + ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), + ], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.sentryForBackend.options, + }); + } + if (envOption.disableClustering) { if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/boot/ready.ts b/packages/backend/src/boot/ready.ts new file mode 100644 index 0000000000..591ae5cb58 --- /dev/null +++ b/packages/backend/src/boot/ready.ts @@ -0,0 +1,6 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const readyRef = { value: false }; diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c99bc7ae03..58c4d028aa 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import { globSync } from 'glob'; +import * as Sentry from '@sentry/node'; import type { RedisOptions } from 'ioredis'; type RedisOptionsSource = Partial<RedisOptions> & { @@ -57,6 +58,8 @@ type Source = { index: string; scope?: 'local' | 'global' | string[]; }; + sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; }; + sentryForFrontend?: { options: Partial<Sentry.NodeOptions> }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -97,6 +100,12 @@ type Source = { perChannelMaxNoteCacheCount?: number; perUserNotificationsMaxCount?: number; deactivateAntennaThreshold?: number; + + import?: { + downloadTimeout: number; + maxFileSize: number; + }; + pidFile: string; }; @@ -174,9 +183,17 @@ export type Config = { redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; + sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined; + sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; + + import: { + downloadTimeout: number; + maxFileSize: number; + } | undefined; + pidFile: string; }; @@ -212,6 +229,8 @@ export function loadConfig(): Config { {} as Source, ) as Source; + applyEnvOverrides(config); + const url = tryCreateUrl(config.url); const version = meta.version; const host = url.host; @@ -249,6 +268,8 @@ export function loadConfig(): Config { redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, + sentryForBackend: config.sentryForBackend, + sentryForFrontend: config.sentryForFrontend, id: config.id, proxy: config.proxy, proxySmtp: config.proxySmtp, @@ -282,6 +303,7 @@ export function loadConfig(): Config { perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), + import: config.import, pidFile: config.pidFile, }; } @@ -304,3 +326,128 @@ function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOp db: options.db ?? 0, }; } + +/* + this function allows overriding any string-valued config option with + a sensible-named environment variable + + e.g. `MK_CONFIG_MEILISEARCH_APIKEY` sets `config.meilisearch.apikey` + + you can also override a single `dbSlave` value, + e.g. `MK_CONFIG_DBSLAVES_1_PASS` sets the password for the 2nd + database replica (the first one would be + `MK_CONFIG_DBSLAVES_0_PASS`); in this case, `config.dbSlaves` must + be set to an array of the right size already in the file + + values can be read from files, too: setting `MK_DB_PASS_FILE` to + `/some/file` would set the main database password to the contents of + `/some/file` (trimmed of whitespaces) + */ +function applyEnvOverrides(config: Source) { + // these inner functions recurse through the config structure, using + // the given steps, building the env variable name + + function _apply_top(steps: (string | string[] | number | number[])[]) { + _walk('', [], steps); + } + + function _walk(name: string, path: (string | number)[], steps: (string | string[] | number | number[])[]) { + // are there more steps after this one? recurse + if (steps.length > 1) { + const thisStep = steps.shift(); + if (thisStep === null || thisStep === undefined) return; + + // if a step is not a simple value, iterate through it + if (typeof thisStep === 'object') { + for (const thisOneStep of thisStep) { + _descend(name, path, thisOneStep, steps); + } + } else { + _descend(name, path, thisStep, steps); + } + + // the actual override has happened at the bottom of the + // recursion, we're done + return; + } + + // this is the last step, same thing as above + const lastStep = steps[0]; + + if (typeof lastStep === 'object') { + for (const lastOneStep of lastStep) { + _lastBit(name, path, lastOneStep); + } + } else { + _lastBit(name, path, lastStep); + } + } + + function _step2name(step: string|number): string { + return step.toString().replaceAll(/[^a-z0-9]+/gi, '').toUpperCase(); + } + + // this recurses down, bailing out if there's no config to override + function _descend(name: string, path: (string | number)[], thisStep: string | number, steps: (string | string[] | number | number[])[]) { + name = `${name}${_step2name(thisStep)}_`; + path = [...path, thisStep]; + _walk(name, path, steps); + } + + // this is the bottom of the recursion: look at the environment and + // set the value + function _lastBit(name: string, path: (string | number)[], lastStep: string | number) { + name = `MK_CONFIG_${name}${_step2name(lastStep)}`; + + const val = process.env[name]; + if (val !== null && val !== undefined) { + _assign(path, lastStep, val); + } + + const file = process.env[`${name}_FILE`]; + if (file) { + _assign(path, lastStep, fs.readFileSync(file, 'utf-8').trim()); + } + } + + const alwaysStrings = { 'chmodSocket': true } as { [key: string]: boolean }; + + function _assign(path: (string | number)[], lastStep: string | number, value: string) { + let thisConfig = config as any; + for (const step of path) { + if (!thisConfig[step]) { + thisConfig[step] = {}; + } + thisConfig = thisConfig[step]; + } + + if (!alwaysStrings[lastStep]) { + if (value.match(/^[0-9]+$/)) { + thisConfig[lastStep] = parseInt(value); + return; + } else if (value.match(/^(true|false)$/i)) { + thisConfig[lastStep] = !!value.match(/^true$/i); + return; + } + } + + thisConfig[lastStep] = value; + } + + // these are all the settings that can be overridden + + _apply_top([['url', 'port', 'socket', 'chmodSocket', 'disableHsts']]); + _apply_top(['db', ['host', 'port', 'db', 'user', 'pass']]); + _apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]); + _apply_top([ + ['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines'], + ['host', 'port', 'username', 'pass', 'db', 'prefix'], + ]); + _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']); + _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); + _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'videoThumbnailGenerator']]); + _apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]); + _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); +} diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 5bd885df40..b6b591d240 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -305,7 +305,7 @@ export class AccountMoveService { let resultUser: MiLocalUser | MiRemoteUser | null = null; if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(dst.uri); } dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; @@ -321,7 +321,7 @@ export class AccountMoveService { if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(srcUri); } diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index b298a70929..9b60df2cae 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -4,13 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; +import { Brackets, EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -29,6 +30,7 @@ export class AnnouncementService { private idService: IdService, private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, + private announcementEntityService: AnnouncementEntityService, ) { } @@ -79,7 +81,7 @@ export class AnnouncementService { userId: values.userId, }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); - const packed = (await this.packMany([announcement]))[0]; + const packed = await this.announcementEntityService.pack(announcement); if (values.userId) { this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { @@ -178,6 +180,24 @@ export class AnnouncementService { } @bindThis + public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> { + const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId }); + if (me) { + if (announcement.userId && announcement.userId !== me.id) { + throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId }); + } + + const read = await this.announcementReadsRepository.findOneBy({ + announcementId: announcement.id, + userId: me.id, + }); + return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me); + } else { + return this.announcementEntityService.pack(announcement, null); + } + } + + @bindThis public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> { try { await this.announcementReadsRepository.insert({ @@ -193,29 +213,4 @@ export class AnnouncementService { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } } - - @bindThis - public async packMany( - announcements: MiAnnouncement[], - me?: { id: MiUser['id'] } | null | undefined, - options?: { - reads?: MiAnnouncementRead[]; - }, - ): Promise<Packed<'Announcement'>[]> { - const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: this.idService.parse(announcement.id).date.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - text: announcement.text, - title: announcement.title, - imageUrl: announcement.imageUrl, - icon: announcement.icon, - display: announcement.display, - needConfirmationToRead: announcement.needConfirmationToRead, - silence: announcement.silence, - forYou: announcement.userId === me?.id, - isRead: reads.some(read => read.announcementId === announcement.id), - })); - } } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 4f956a43ed..89e475b5f1 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -92,7 +92,7 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> { + public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> { const antennas = await this.getAntennas(); const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); @@ -110,10 +110,12 @@ export class AntennaService implements OnApplicationShutdown { // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている @bindThis - public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> { + public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> { if (note.visibility === 'specified') return false; if (note.visibility === 'followers') return false; + if (antenna.excludeBots && noteUser.isBot) return false; + if (antenna.localOnly && noteUser.host != null) return false; if (!antenna.withReplies && note.replyId != null) return false; @@ -131,13 +133,17 @@ export class AntennaService implements OnApplicationShutdown { const { username, host } = Acct.parse(x); return this.utilityService.getFullApAccount(username, host).toLowerCase(); }); - if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase(); + const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase(); + if (!accts.includes(matchUser) && !accts.includes(matchWildcard)) return false; } else if (antenna.src === 'users_blacklist') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); return this.utilityService.getFullApAccount(username, host).toLowerCase(); }); - if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase(); + const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase(); + if (accts.includes(matchUser) || accts.includes(matchWildcard)) return false; } const keywords = antenna.keywords diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 75843b9773..12251595e2 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 48a2a2b80c..9baec9a59f 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -84,6 +84,7 @@ import ApRequestChart from './chart/charts/ap-request.js'; import { ChartManagementService } from './chart/ChartManagementService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; @@ -127,7 +128,7 @@ import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; import { ApResolverService } from './activitypub/ApResolverService.js'; -import { LdSignatureService } from './activitypub/LdSignatureService.js'; +import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; import { WebfingerService } from './WebfingerService.js'; @@ -223,6 +224,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -266,7 +268,7 @@ const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmSer const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService }; const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService }; const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService }; -const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService }; +const $JsonLdService: Provider = { provide: 'JsonLdService', useExisting: JsonLdService }; const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService }; const $RemoteUserResolveService: Provider = { provide: 'RemoteUserResolveService', useExisting: RemoteUserResolveService }; const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService }; @@ -363,6 +365,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -406,7 +409,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -499,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -542,7 +546,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, @@ -635,6 +639,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -678,7 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -770,6 +775,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -813,7 +819,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index acc1c604e6..bfbc2b172d 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -22,7 +22,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import type { Config } from '@/config.js'; import { DriveService } from './DriveService.js'; -const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; +const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { @@ -369,10 +369,11 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> { const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); - const res = {} as any; + const res = {} as Record<string, string>; for (let i = 0; i < emojiNames.length; i++) { - if (emojis[i] != null) { - res[emojiNames[i]] = emojis[i]; + const resolvedEmoji = emojis[i]; + if (resolvedEmoji != null) { + res[emojiNames[i]] = resolvedEmoji; } } return res; diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 21ae798f9f..83452845d4 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -35,14 +35,14 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise<{ + public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number} = {} ): Promise<{ filename: string; }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); - const timeout = 30 * 1000; - const operationTimeout = 60 * 1000; - const maxSize = this.config.maxFileSize ?? 262144000; + const timeout = options.timeout ?? 30 * 1000; + const operationTimeout = options.operationTimeout ?? 60 * 1000; + const maxSize = options.maxSize ?? this.config.maxFileSize ?? 262144000; const urlObj = new URL(url); let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index f64568ee9a..af5451bfc8 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -477,14 +477,20 @@ export class DriveService { if (user && !force) { // Check if there is a file with the same hash - const much = await this.driveFilesRepository.findOneBy({ + const matched = await this.driveFilesRepository.findOneBy({ md5: info.md5, userId: user.id, }); - if (much) { - this.registerLogger.info(`file with same hash is found: ${much.id}`); - return much; + if (matched) { + this.registerLogger.info(`file with same hash is found: ${matched.id}`); + if (sensitive && !matched.isSensitive) { + // The file is federated as sensitive for this time, but was federated as non-sensitive before. + // Therefore, update the file to sensitive. + await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true }); + matched.isSensitive = true; + } + return matched; } } @@ -632,7 +638,8 @@ export class DriveService { @bindThis public async updateFile(file: MiDriveFile, values: Partial<MiDriveFile>, updater: MiUser) { - const alwaysMarkNsfw = (await this.roleService.getUserPolicies(file.userId)).alwaysMarkNsfw; + const profile = file.userId ? await this.userProfilesRepository.findOneBy({ userId: file.userId }) : null; + const alwaysMarkNsfw = file.userId ? (await this.roleService.getUserPolicies(file.userId)).alwaysMarkNsfw || (profile !== null && profile!.alwaysMarkNsfw) : false; if (values.name != null && !this.driveFileEntityService.validateFileName(values.name)) { throw new DriveService.InvalidFileNameError(); diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 6aa63d7d55..5725c795ed 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; @@ -62,8 +62,8 @@ export class FanoutTimelineEndpointService { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); - const shouldPrepend = ps.sinceId && !ps.untilId; - const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; + const ascending = ps.sinceId && !ps.untilId; + const idCompare: (a: string, b: string) => number = ascending ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); @@ -101,7 +101,7 @@ export class FanoutTimelineEndpointService { if (ps.excludePureRenotes) { const parentFilter = filter; - filter = (note) => !isPureRenote(note) && parentFilter(note); + filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note); } if (ps.me) { @@ -122,9 +122,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (note.mentions.some(mention => userIdsWhoMeMuting.has(mention))) return false; - if (isPureRenote(note) && note.renote && note.renote.mentions.some(mention => userIdsWhoMeMuting.has(mention))) return false; - if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; return parentFilter(note); @@ -150,9 +148,7 @@ export class FanoutTimelineEndpointService { if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { // 十分Redisからとれた - const result = redisTimeline.slice(0, ps.limit); - if (shouldPrepend) result.reverse(); - return result; + return redisTimeline.slice(0, ps.limit); } } @@ -160,8 +156,7 @@ export class FanoutTimelineEndpointService { const remainingToRead = ps.limit - redisTimeline.length; let dbUntil: string | null; let dbSince: string | null; - if (shouldPrepend) { - redisTimeline.reverse(); + if (ascending) { dbUntil = ps.untilId; dbSince = noteIds[noteIds.length - 1]; } else { @@ -169,7 +164,7 @@ export class FanoutTimelineEndpointService { dbSince = ps.sinceId; } const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); - return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb]; + return [...redisTimeline, ...gotFromDb]; } return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 66db2067d9..e73e1d3a9d 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -40,6 +40,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { firstRetrievedAt: new Date(parsed.firstRetrievedAt), latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null, }; }, }); diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index bc270bd28f..aa16468ecb 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -51,21 +51,35 @@ export class FetchInstanceMetadataService { } @bindThis - public async tryLock(host: string): Promise<boolean> { - const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET'); - return mutex !== '1'; + // public for test + public async tryLock(host: string): Promise<string | null> { + // TODO: マイグレーションなのであとで消す (2024.3.1) + this.redisClient.del(`fetchInstanceMetadata:mutex:${host}`); + + return await this.redisClient.set( + `fetchInstanceMetadata:mutex:v2:${host}`, '1', + 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 + 'GET' // 古い値を返す(なかったらnull) + ); } @bindThis - public unlock(host: string): Promise<'OK'> { - return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0'); + // public for test + public unlock(host: string): Promise<number> { + return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`); } @bindThis public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> { const host = instance.host; - // Acquire mutex to ensure no parallel runs - if (!await this.tryLock(host)) return; + + // finallyでunlockされてしまうのでtry内でロックチェックをしない + // (returnであってもfinallyは実行される) + if (!force && await this.tryLock(host) === '1') { + // 1が返ってきていたらロックされているという意味なので、何もしない + return; + } + try { if (!force) { const _instance = await this.federatedInstanceService.fetch(host); @@ -140,7 +154,7 @@ export class FetchInstanceMetadataService { throw new Error('No wellknown links'); } - const links = wellknown.links as any[]; + const links = wellknown.links as ({ rel: string, href: string; })[]; const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 2d3312f247..9a9eae3cae 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -8,11 +8,13 @@ import * as crypto from 'node:crypto'; import * as stream from 'node:stream/promises'; import { Injectable } from '@nestjs/common'; import * as fileType from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; -import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { encode } from 'blurhash'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; export type FileInfo = { @@ -43,8 +45,12 @@ const TYPE_SVG = { @Injectable() export class FileInfoService { + private logger: Logger; + constructor( + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('file-info'); } /** @@ -148,6 +154,34 @@ export class FileInfoService { } /** + * ビデオファイルにビデオトラックがあるかどうかチェック + * (ない場合:m4a, webmなど) + * + * @param path ファイルパス + * @returns ビデオトラックがあるかどうか(エラー発生時は常に`true`を返す) + */ + @bindThis + private hasVideoTrackOnVideoFile(path: string): Promise<boolean> { + const sublogger = this.logger.createSubLogger('ffprobe'); + sublogger.info(`Checking the video file. File path: ${path}`); + return new Promise((resolve) => { + try { + FFmpeg.ffprobe(path, (err, metadata) => { + if (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err); + resolve(true); + return; + } + resolve(metadata.streams.some((stream) => stream.codec_type === 'video')); + }); + } catch (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error); + resolve(true); + } + }); + } + + /** * Detect MIME Type and extension */ @bindThis @@ -169,6 +203,20 @@ export class FileInfoService { return TYPE_SVG; } + if ((type.mime.startsWith('video') || type.mime === 'application/ogg') && !(await this.hasVideoTrackOnVideoFile(path))) { + const newMime = `audio/${type.mime.split('/')[1]}`; + if (newMime === 'audio/mp4') { + return { + mime: 'audio/mp4', + ext: 'm4a', + }; + } + return { + mime: newMime, + ext: type.ext, + }; + } + return { mime: this.fixMime(type.mime), ext: type.ext, diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index b7c9064cef..625df1feaa 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,10 +6,11 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window } from 'happy-dom'; +import { Window, XMLSerializer } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; @@ -33,6 +34,8 @@ export class MfmService { // some AP servers like Pixelfed use br tags as well as newlines html = html.replace(/<br\s?\/?>\r?\n/gi, '\n'); + const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x))); + const dom = parse5.parseFragment(html); let text = ''; @@ -85,7 +88,7 @@ export class MfmService { const href = node.attrs.find(x => x.name === 'href'); // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { @@ -129,9 +132,18 @@ export class MfmService { case 'h1': { - text += '【'; + text += '**【'; appendChildren(node.childNodes); - text += '】\n'; + text += '】**\n'; + break; + } + + case 'h2': + case 'h3': + { + text += '**'; + appendChildren(node.childNodes); + text += '**\n'; break; } @@ -200,8 +212,6 @@ export class MfmService { } case 'p': - case 'h2': - case 'h3': case 'h4': case 'h5': case 'h6': @@ -244,6 +254,8 @@ export class MfmService { const doc = window.document; + const body = doc.createElement('p'); + function appendChildren(children: mfm.MfmNode[], targetElement: any): void { if (children) { for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child); @@ -454,15 +466,15 @@ export class MfmService { }, }; - appendChildren(nodes, doc.body); + appendChildren(nodes, body); - return `<p>${doc.body.innerHTML}</p>`; + return new XMLSerializer().serializeToString(body); } - // the toMastoHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version + // the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version @bindThis - public async toMastoHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { + public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { if (nodes == null) { return null; } @@ -478,8 +490,8 @@ export class MfmService { } const handlers: { - [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any; - } = { + [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any; + } = { async bold(node) { const el = doc.createElement('span'); el.textContent = '**'; @@ -637,7 +649,7 @@ export class MfmService { search: (node) => { const a = doc.createElement('a'); - a.setAttribute('href', `https"google.com/${node.props.query}`); + a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); a.textContent = node.props.content; return a; }, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 631d7074bd..41efa76f3f 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -282,7 +282,7 @@ export class NoteCreateService implements OnApplicationShutdown { data.visibility = 'home'; } - if (data.renote) { + if (this.isRenote(data)) { switch (data.renote.visibility) { case 'public': // public noteは無条件にrenote可能 @@ -309,7 +309,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -495,7 +495,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -627,6 +627,14 @@ export class NoteCreateService implements OnApplicationShutdown { userHost: user.host, }); + // 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"); + } + if (data.renote?.id === insert.id) { + throw new Error("A note can't renote itself"); + } + if (data.uri != null) insert.uri = data.uri; if (data.url != null) insert.url = data.url; @@ -662,6 +670,7 @@ export class NoteCreateService implements OnApplicationShutdown { noteVisibility: insert.visibility, userId: user.id, userHost: user.host, + channelId: insert.channelId, }); await transactionalEntityManager.insert(MiPoll, poll); @@ -855,7 +864,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // If it is renote - if (data.renote) { + if (this.isRenote(data)) { const type = this.isQuote(data) ? 'quote' : 'renote'; // Notify @@ -1055,9 +1064,20 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); + private isRenote(note: Option): note is Option & { renote: MiNote } { + return note.renote != null; + } + + @bindThis + private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } + ) { + // NOTE: SYNC WITH misc/is-quote.ts + return note.text != null || + note.reply != null || + note.cw != null || + note.poll != null || + (note.files != null && note.files.length > 0); } @bindThis @@ -1133,7 +1153,7 @@ export class NoteCreateService implements OnApplicationShutdown { private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { if (data.localOnly) return null; - const content = data.renote && !this.isQuote(data) + 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); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 471ade92c7..af65750a01 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -86,7 +86,7 @@ export class NoteDeleteService { let renote: MiNote | null = null; // if deleted note is renote - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index a01dfec664..0cb58d04a2 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -298,7 +298,11 @@ export class NoteEditService implements OnApplicationShutdown { data.visibility = 'home'; } - if (data.renote) { + if (this.isRenote(data)) { + if (data.renote.id === oldnote.id) { + throw new Error("A note can't renote itself"); + } + switch (data.renote.visibility) { case 'public': // public noteは無条件にrenote可能 @@ -325,7 +329,7 @@ export class NoteEditService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -342,7 +346,7 @@ export class NoteEditService implements OnApplicationShutdown { } // ローカルのみをRenoteしたらローカルのみにする - if (data.renote && data.renote.localOnly && data.channel == null) { + if (this.isRenote(data) && data.renote.localOnly && data.channel == null) { data.localOnly = true; } @@ -524,6 +528,7 @@ export class NoteEditService implements OnApplicationShutdown { noteVisibility: note.visibility, userId: user.id, userHost: user.host, + channelId: data.channel ? data.channel.id : null, }); if (!oldnote.hasPoll) { @@ -689,7 +694,7 @@ export class NoteEditService implements OnApplicationShutdown { } // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && data.renote.userHost !== null) { + if (this.isRenote(data) && data.renote.userHost !== null) { const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); } @@ -699,6 +704,24 @@ export class NoteEditService implements OnApplicationShutdown { dm.addFollowersRecipe(); } + if (['public', 'home'].includes(note.visibility)) { + // Send edit event to all users who replied to, + // renoted a post or reacted to a note. + const noteId = note.id; + const users = await this.usersRepository.createQueryBuilder() + .where( + 'id IN (SELECT "userId" FROM note WHERE "replyId" = :noteId OR "renoteId" = :noteId UNION SELECT "userId" FROM note_reaction WHERE "noteId" = :noteId)', + { noteId }, + ) + .andWhere('host IS NOT NULL') + .getMany(); + for (const u of users) { + // User was verified to be remote by checking + // whether host IS NOT NULL in SQL query. + dm.addDirectRecipe(u as MiRemoteUser); + } + } + if (['public'].includes(note.visibility)) { this.relayService.deliverToRelays(user, noteActivity); } @@ -732,9 +755,20 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); + private isRenote(note: Option): note is Option & { renote: MiNote } { + return note.renote != null; + } + + @bindThis + private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } + ) { + // NOTE: SYNC WITH misc/is-quote.ts + return note.text != null || + note.reply != null || + note.cw != null || + note.poll != null || + (note.files != null && note.files.length > 0); } @bindThis @@ -783,7 +817,7 @@ export class NoteEditService implements OnApplicationShutdown { const user = await this.usersRepository.findOneBy({ id: note.userId }); if (user == null) throw new Error('user not found'); - const content = data.renote && !this.isQuote(data) + 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); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 3b706d9854..6a845b951d 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -101,7 +101,7 @@ export class PushNotificationService implements OnApplicationShutdown { type, body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body, userId, - dateTime: (new Date()).getTime(), + dateTime: Date.now(), }), { proxy: this.config.proxy, }).catch((err: any) => { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 2059a6e784..f5a753afc7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -209,45 +209,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { try { switch (value.type) { + // ~かつ~ case 'and': { return value.values.every(v => this.evalCond(user, roles, v)); } + // ~または~ case 'or': { return value.values.some(v => this.evalCond(user, roles, v)); } + // ~ではない case 'not': { return !this.evalCond(user, roles, value.value); } + // マニュアルロールがアサインされている case 'roleAssignedTo': { return roles.some(r => r.id === value.roleId); } + // ローカルユーザのみ case 'isLocal': { return this.userEntityService.isLocalUser(user); } + // リモートユーザのみ case 'isRemote': { return this.userEntityService.isRemoteUser(user); } + // サスペンド済みユーザである + case 'isSuspended': { + return user.isSuspended; + } + // 鍵アカウントユーザである + case 'isLocked': { + return user.isLocked; + } + // botユーザである + case 'isBot': { + return user.isBot; + } + // 猫である + case 'isCat': { + return user.isCat; + } + // 「ユーザを見つけやすくする」が有効なアカウント + case 'isExplorable': { + return user.isExplorable; + } + // ユーザが作成されてから指定期間経過した case 'createdLessThan': { return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000)); } + // ユーザが作成されてから指定期間経っていない case 'createdMoreThan': { return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000)); } + // フォロワー数が指定値以下 case 'followersLessThanOrEq': { return user.followersCount <= value.value; } + // フォロワー数が指定値以上 case 'followersMoreThanOrEq': { return user.followersCount >= value.value; } + // フォロー数が指定値以下 case 'followingLessThanOrEq': { return user.followingCount <= value.value; } + // フォロー数が指定値以上 case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + // ノート数が指定値以下 case 'notesLessThanOrEq': { return user.notesCount <= value.value; } + // ノート数が指定値以上 case 'notesMoreThanOrEq': { return user.notesCount >= value.value; } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 42fbed2110..ec9f4484a4 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -10,7 +10,7 @@ import { generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; -import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers'; +import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -49,7 +49,7 @@ export class WebAuthnService { const instance = await this.metaService.fetch(); return { origin: this.config.url, - rpId: this.config.host, + rpId: this.config.hostname, rpName: instance.name ?? this.config.host, rpIcon: instance.iconUrl ?? undefined, }; @@ -65,13 +65,12 @@ export class WebAuthnService { const registrationOptions = await generateRegistrationOptions({ rpName: relyingParty.rpName, rpID: relyingParty.rpId, - userID: userId, + userID: isoUint8Array.fromUTF8String(userId), userName: userName, userDisplayName: userDisplayName, attestationType: 'indirect', - excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), authenticatorSelection: { @@ -87,7 +86,7 @@ export class WebAuthnService { @bindThis public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{ - credentialID: Uint8Array; + credentialID: string; credentialPublicKey: Uint8Array; attestationObject: Uint8Array; fmt: AttestationFormat; @@ -144,6 +143,7 @@ export class WebAuthnService { @bindThis public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> { + const relyingParty = await this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -153,9 +153,9 @@ export class WebAuthnService { } const authenticationOptions = await generateAuthenticationOptions({ - allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + rpID: relyingParty.rpId, + allowCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), userVerification: 'preferred', @@ -219,7 +219,7 @@ export class WebAuthnService { expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, authenticator: { - credentialID: Buffer.from(key.id, 'base64url'), + credentialID: key.id, credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), counter: key.counter, transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 6ff03b22e1..cf66816566 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -28,6 +28,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserR import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -36,9 +37,8 @@ import { ApResolverService } from './ApResolverService.js'; import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js'; @Injectable() export class ApInboxService { @@ -90,13 +90,15 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> { + public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> { + let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { + const results = [] as [string, string | void][]; const resolver = this.apResolverService.createResolver(); for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { const act = await resolver.resolve(item); try { - await this.performOneActivity(actor, act); + results.push([getApId(item), await this.performOneActivity(actor, act)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -105,8 +107,13 @@ export class ApInboxService { } } } + + const hasReason = results.some(([, reason]) => (reason != null && !reason.startsWith('ok'))); + if (hasReason) { + result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); + } } else { - await this.performOneActivity(actor, activity); + result = await this.performOneActivity(actor, activity); } // ついでにリモートユーザーの情報が古かったら更新しておく @@ -117,42 +124,43 @@ export class ApInboxService { }); } } + return result; } @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<void> { + public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> { if (actor.isSuspended) return; if (isCreate(activity)) { - await this.create(actor, activity); + return await this.create(actor, activity); } else if (isDelete(activity)) { - await this.delete(actor, activity); + return await this.delete(actor, activity); } else if (isUpdate(activity)) { - await this.update(actor, activity); + return await this.update(actor, activity); } else if (isFollow(activity)) { - await this.follow(actor, activity); + return await this.follow(actor, activity); } else if (isAccept(activity)) { - await this.accept(actor, activity); + return await this.accept(actor, activity); } else if (isReject(activity)) { - await this.reject(actor, activity); + return await this.reject(actor, activity); } else if (isAdd(activity)) { - await this.add(actor, activity).catch(err => this.logger.error(err)); + return await this.add(actor, activity); } else if (isRemove(activity)) { - await this.remove(actor, activity).catch(err => this.logger.error(err)); + return await this.remove(actor, activity); } else if (isAnnounce(activity)) { - await this.announce(actor, activity); + return await this.announce(actor, activity); } else if (isLike(activity)) { - await this.like(actor, activity); + return await this.like(actor, activity); } else if (isUndo(activity)) { - await this.undo(actor, activity); + return await this.undo(actor, activity); } else if (isBlock(activity)) { - await this.block(actor, activity); + return await this.block(actor, activity); } else if (isFlag(activity)) { - await this.flag(actor, activity); + return await this.flag(actor, activity); } else if (isMove(activity)) { - await this.move(actor, activity); + return await this.move(actor, activity); } else { - this.logger.warn(`unrecognized activity type: ${activity.type}`); + return `unrecognized activity type: ${activity.type}`; } } @@ -234,38 +242,49 @@ export class ApInboxService { } @bindThis - private async add(actor: MiRemoteUser, activity: IAdd): Promise<void> { + private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return 'target is null'; } if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); + if (note == null) return 'note not found'; await this.notePiningService.addPinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis - private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<void> { + private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> { const uri = getApId(activity); this.logger.info(`Announce: ${uri}`); + const resolver = this.apResolverService.createResolver(); + + if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + + const target = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + return e; + }); + + if (isPost(target)) return await this.announceNote(actor, activity, target); - await this.announceNote(actor, activity, targetUri); + return `skip: unknown object type ${getApType(target)}`; } @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> { const uri = getApId(activity); if (actor.isSuspended) { @@ -288,24 +307,21 @@ export class ApInboxService { // Announce対象をresolve let renote; try { - renote = await this.apNoteService.resolveNote(targetUri); - if (renote == null) throw new Error('announce target is null'); + renote = await this.apNoteService.resolveNote(target); + if (renote == null) return 'announce target is null'; } catch (err) { // 対象が4xxならスキップ if (err instanceof StatusError) { if (!err.isRetryable) { - this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); - return; + return `Ignored announce target ${target.id} - ${err.statusCode}`; } - - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); + return `Error in announce target ${target.id} - ${err.statusCode}`; } throw err; } if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - this.logger.warn('skip: invalid actor for this activity'); - return; + return 'skip: invalid actor for this activity'; } this.logger.info(`Creating the (Re)Note: ${uri}`); @@ -314,8 +330,7 @@ export class ApInboxService { const createdAt = activity.published ? new Date(activity.published) : null; if (createdAt && createdAt < this.idService.parse(renote.id).date) { - this.logger.warn('skip: malformed createdAt'); - return; + return 'skip: malformed createdAt'; } await this.noteCreateService.create(actor, { @@ -349,11 +364,15 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise<void> { + private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); + if (!activity.object) return 'skip: activity has no object property'; + const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + // copy audiences between activity <=> object. if (typeof activity.object === 'object') { const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); @@ -380,7 +399,7 @@ export class ApInboxService { if (isPost(object)) { await this.createNote(resolver, actor, object, false, activity); } else { - this.logger.warn(`Unknown type: ${getApType(object)}`); + return `Unknown type: ${getApType(object)}`; } } @@ -422,7 +441,7 @@ export class ApInboxService { @bindThis private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } // 削除対象objectのtype @@ -581,29 +600,29 @@ export class ApInboxService { } @bindThis - private async remove(actor: MiRemoteUser, activity: IRemove): Promise<void> { + private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return 'target is null'; } if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); + if (note == null) return 'note not found'; await this.notePiningService.removePinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } const uri = activity.id ?? activity; @@ -614,7 +633,7 @@ export class ApInboxService { const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); - throw e; + return e; }); // don't queue because the sender may attempt again when timeout diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 38175b2059..90784fdc1d 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -29,7 +29,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; import { MetaService } from '../MetaService.js'; -import { LdSignatureService } from './LdSignatureService.js'; +import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; @@ -61,7 +61,7 @@ export class ApRendererService { private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, - private ldSignatureService: LdSignatureService, + private jsonLdService: JsonLdService, private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, @@ -173,6 +173,7 @@ export class ApRendererService { url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, summary: file.comment, + sensitive: file.isSensitive, }; } @@ -794,9 +795,9 @@ export class ApRendererService { public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> { const keypair = await this.userKeypairService.getUserKeypair(user.id); - const ldSignature = this.ldSignatureService.use(); - ldSignature.debug = false; - activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + const jsonLd = this.jsonLdService.use(); + jsonLd.debug = false; + activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/JsonLdService.ts index 2ec6dc4585..100d4fa19f 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/JsonLdService.ts @@ -7,14 +7,14 @@ import * as crypto from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; -import { CONTEXT, CONTEXTS } from './misc/contexts.js'; +import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; import type { JsonLdDocument } from 'jsonld'; -import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; +import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js'; -// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 +// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017 -class LdSignature { +class JsonLd { public debug = false; public preLoad = true; public loderTimeout = 5000; @@ -101,8 +101,6 @@ class LdSignature { @bindThis public async normalize(data: JsonLdDocument): Promise<string> { const customLoader = this.getLoader(); - // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically - // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 return (await import('jsonld')).default.normalize(data, { documentLoader: customLoader, }); @@ -114,11 +112,11 @@ class LdSignature { if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`); if (this.preLoad) { - if (url in CONTEXTS) { + if (url in PRELOADED_CONTEXTS) { if (this.debug) console.debug(`HIT: ${url}`); return { contextUrl: undefined, - document: CONTEXTS[url], + document: PRELOADED_CONTEXTS[url], documentUrl: url, }; } @@ -135,7 +133,7 @@ class LdSignature { } @bindThis - private async fetchDocument(url: string): Promise<JsonLd> { + private async fetchDocument(url: string): Promise<JsonLdObject> { const json = await this.httpRequestService.send( url, { @@ -156,7 +154,7 @@ class LdSignature { } }); - return json as JsonLd; + return json as JsonLdObject; } @bindThis @@ -168,14 +166,14 @@ class LdSignature { } @Injectable() -export class LdSignatureService { +export class JsonLdService { constructor( private httpRequestService: HttpRequestService, ) { } @bindThis - public use(): LdSignature { - return new LdSignature(this.httpRequestService); + public use(): JsonLd { + return new JsonLd(this.httpRequestService); } } diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts index 78ba891a2e..34e4907267 100644 --- a/packages/backend/src/core/activitypub/misc/check-against-url.ts +++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts @@ -4,16 +4,24 @@ */ import type { IObject } from '../type.js'; +function getHrefFrom(one: IObject|string): string | undefined { + if (typeof(one) === 'string') return one; + return one.href; +} + export function assertActivityMatchesUrls(activity: IObject, urls: string[]) { const idOk = activity.id !== undefined && urls.includes(activity.id); + if (idOk) return; - // technically `activity.url` could be an `ApObject = IObject | - // string | (IObject | string)[]`, but if it's a complicated thing - // and the `activity.id` doesn't match, I think we're fine - // rejecting the activity - const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url); + const url = activity.url; + if (url) { + // `activity.url` can be an `ApObject = IObject | string | (IObject + // | string)[]`, we have to look inside it + const activityUrls = Array.isArray(url) ? url.map(getHrefFrom) : [getHrefFrom(url)]; + const goodUrl = activityUrls.find(u => u && urls.includes(u)); - if (!idOk && !urlOk) { - throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`); + if (goodUrl) return; } + + throw new Error(`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 4ff114bbf5..815b20b910 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -570,7 +570,7 @@ const extension_context_definition = { export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition]; -export const CONTEXTS: Record<string, JsonLd> = { +export const PRELOADED_CONTEXTS: Record<string, JsonLd> = { 'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/security/v1': security_v1, 'https://www.w3.org/ns/activitystreams': activitystreams, diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 1017a5018a..3d98c5b764 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -18,7 +18,7 @@ import { checkHttps } from '@/misc/check-https.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import type { IObject } from '../type.js'; +import { isDocument, type IObject } from '../type.js'; @Injectable() export class ApImageService { @@ -41,7 +41,7 @@ export class ApImageService { * Imageを作成します。 */ @bindThis - public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> { + public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); @@ -49,16 +49,18 @@ export class ApImageService { const image = await this.apResolverService.createResolver().resolve(value); + if (!isDocument(image)) return null; + if (image.url == null) { - throw new Error('invalid image: url not provided'); + return null; } if (typeof image.url !== 'string') { - throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); + return null; } if (!checkHttps(image.url)) { - throw new Error('invalid image: unexpected schema of url: ' + image.url); + return null; } this.logger.info(`Creating the Image: ${image.url}`); @@ -94,12 +96,11 @@ export class ApImageService { /** * Imageを解決します。 * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + * ImageをリモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> { - // TODO + public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { + // TODO: Misskeyに対象のImageが登録されていればそれを返す // リモートサーバーからフェッチしてきて登録 return await this.createImage(actor, value); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 6d9dc86c16..4827baad84 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -4,7 +4,6 @@ */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { PollsRepository, EmojisRepository, NotesRepository } from '@/models/_.js'; @@ -87,20 +86,20 @@ export class ApNoteService { const expectHost = this.utilityService.extractDbHost(uri); if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${getApType(object)}`); } if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { - return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); if (object.attributedTo && actualHost !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { - return new Error('invalid Note: published timestamp is malformed'); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); } return null; @@ -134,7 +133,7 @@ export class ApNoteService { value, object, }); - throw new Error('invalid note'); + throw err; } const note = object as IPost; @@ -214,15 +213,13 @@ export class ApNoteService { } // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit<MiDriveFile>(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } // リプライ const reply: MiNote | null = note.inReplyTo @@ -251,7 +248,7 @@ export class ApNoteService { > => { if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' }; try { - const res = await this.resolveNote(uri); + const res = await this.resolveNote(uri, { resolver }); if (res == null) return { status: 'permerror' }; return { status: 'ok', res }; } catch (e) { @@ -441,15 +438,13 @@ export class ApNoteService { } // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit<MiDriveFile>(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } // リプライ const reply: MiNote | null = note.inReplyTo @@ -478,7 +473,7 @@ export class ApNoteService { > => { if (!/^https?:/.test(uri)) return { status: 'permerror' }; try { - const res = await this.resolveNote(uri); + const res = await this.resolveNote(uri, { resolver }); if (res == null) return { status: 'permerror' }; return { status: 'ok', res }; } catch (e) { diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 716515840c..8edd8a1aba 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -25,6 +25,7 @@ export interface IObject { endTime?: Date; icon?: any; image?: any; + mediaType?: string; url?: ApObject | string; href?: string; tag?: IObject | IObject[]; @@ -246,14 +247,14 @@ export interface IKey extends IObject { } export interface IApDocument extends IObject { - type: 'Document'; - name: string | null; - mediaType: string; + type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; } -export interface IApImage extends IObject { +export const isDocument = (object: IObject): object is IApDocument => + ['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object)); + +export interface IApImage extends IApDocument { type: 'Image'; - name: string | null; } export interface ICreate extends IActivity { @@ -333,3 +334,4 @@ export const isAnnounce = (object: IObject): object is IAnnounce => getApType(ob export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; +export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note'; diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index 5e4555ee96..28e441d72c 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -47,7 +47,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') .select('instance.host') - .where('instance.isSuspended = true'); + .where("instance.suspensionState != 'none'"); const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') @@ -89,7 +89,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') + .andWhere("instance.suspensionState = 'none'") .andWhere('instance.isNotResponding = false') .getRawOne() .then(x => parseInt(x.count, 10)), @@ -97,7 +97,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') + .andWhere("instance.suspensionState = 'none'") .andWhere('instance.isNotResponding = false') .getRawOne() .then(x => parseInt(x.count, 10)), diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index aa0cb9dc2b..f10e30ef10 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -459,13 +459,15 @@ export default abstract class Chart<T extends Schema> { } } - // bake unique count + // bake cardinality for (const [k, v] of Object.entries(finalDiffs)) { if (this.schema[k].uniqueIncrement) { const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>; const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>; - queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; - queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; + const cardinalityOfHour = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; + const cardinalityOfDay = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; + queryForHour[name] = cardinalityOfHour; + queryForDay[name] = cardinalityOfDay; } } @@ -637,7 +639,7 @@ export default abstract class Chart<T extends Schema> { // 要求された範囲にログがひとつもなかったら if (logs.length === 0) { // もっとも新しいログを持ってくる - // (すくなくともひとつログが無いと隙間埋めできないため) + // (すくなくともひとつログが無いと補間できないため) const recentLog = await repository.findOne({ where: group ? { group: group, @@ -654,7 +656,7 @@ export default abstract class Chart<T extends Schema> { // 要求された範囲の最も古い箇所に位置するログが存在しなかったら } else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) { // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する - // (隙間埋めできないため) + // (補間できないため) const outdatedLog = await repository.findOne({ where: { date: LessThan(Chart.dateToTimestamp(gt)), @@ -683,7 +685,7 @@ export default abstract class Chart<T extends Schema> { if (log) { chart.unshift(this.convertRawRecord(log)); } else { - // 隙間埋め + // 補間 const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current)); const data = latest ? this.convertRawRecord(latest) : null; chart.unshift(this.getNewLog(data)); diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 49f256d870..b0e1d1ab36 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -10,6 +10,8 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,6 +28,11 @@ export class AbuseUserReportEntityService { @bindThis public async pack( src: MiAbuseUserReport['id'] | MiAbuseUserReport, + hint?: { + packedReporter?: Packed<'UserDetailedNotMe'>, + packedTargetUser?: Packed<'UserDetailedNotMe'>, + packedAssignee?: Packed<'UserDetailedNotMe'>, + }, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); @@ -37,13 +44,13 @@ export class AbuseUserReportEntityService { reporterId: report.reporterId, targetUserId: report.targetUserId, assigneeId: report.assigneeId, - reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { schema: 'UserDetailedNotMe', }), - targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { schema: 'UserDetailedNotMe', }), - assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, @@ -51,9 +58,24 @@ export class AbuseUserReportEntityService { } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiAbuseUserReport[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); + const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); + const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(isNotNull); + const _userMap = await this.userEntityService.packMany( + [..._reporters, ..._targetUsers, ..._assignees], + null, + { schema: 'UserDetailedNotMe' }, + ).then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + reports.map(report => { + const packedReporter = _userMap.get(report.reporterId); + const packedTargetUser = _userMap.get(report.targetUserId); + const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; + return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); + }), + ); } } diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts new file mode 100644 index 0000000000..90b04d0229 --- /dev/null +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class AnnouncementEntityService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private idService: IdService, + ) { + } + + @bindThis + public async pack( + src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null }, + me?: { id: MiUser['id'] } | null | undefined, + ): Promise<Packed<'Announcement'>> { + const announcement = typeof src === 'object' + ? src + : await this.announcementsRepository.findOneByOrFail({ + id: src, + }) as MiAnnouncement & { isRead?: boolean | null }; + + if (me && announcement.isRead === undefined) { + announcement.isRead = await this.announcementReadsRepository + .countBy({ + announcementId: announcement.id, + userId: me.id, + }) + .then((count: number) => count > 0); + } + + return { + id: announcement.id, + createdAt: this.idService.parse(announcement.id).date.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forYou: announcement.userId === me?.id, + needConfirmationToRead: announcement.needConfirmationToRead, + silence: announcement.silence, + isRead: announcement.isRead !== null ? announcement.isRead : undefined, + }; + } + + @bindThis + public async packMany( + announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[], + me?: { id: MiUser['id'] } | null | undefined, + ) : Promise<Packed<'Announcement'>[]> { + return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 64d6a3c978..e770028af3 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -38,11 +38,12 @@ export class AntennaEntityService { users: antenna.users, caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, - notify: antenna.notify, + excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, isActive: antenna.isActive, hasUnreadNote: false, // TODO + notify: false, // 後方互換性のため }; } } diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c8c1520ceb..1e699032e2 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -29,6 +29,9 @@ export class BlockingEntityService { public async pack( src: MiBlocking['id'] | MiBlocking, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + blockee?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Blocking'>> { const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); @@ -36,17 +39,20 @@ export class BlockingEntityService { id: blocking.id, createdAt: this.idService.parse(blocking.id).date.toISOString(), blockeeId: blocking.blockeeId, - blockee: this.userEntityService.pack(blocking.blockeeId, me, { + blockee: hint?.blockee ?? this.userEntityService.pack(blocking.blockeeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - blockings: any[], + public async packMany( + blockings: MiBlocking[], me: { id: MiUser['id'] }, ) { - return Promise.all(blockings.map(x => this.pack(x, me))); + const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId); + const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) }))); } } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 26fcd6714d..3855a28436 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; +import type { ClipNotesRepository, ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; @@ -20,6 +20,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, @@ -32,6 +35,9 @@ export class ClipEntityService { public async pack( src: MiClip['id'] | MiClip, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Clip'>> { const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); @@ -41,21 +47,25 @@ export class ClipEntityService { createdAt: this.idService.parse(clip.id).date.toISOString(), lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, - user: this.userEntityService.pack(clip.user ?? clip.userId), + user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, + notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, }); } @bindThis - public packMany( + public async packMany( clips: MiClip[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x, me))); + const _users = clips.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) }))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 8affe2b3bf..02ff2e7754 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -222,6 +222,9 @@ export class DriveFileEntityService { public async packNullable( src: MiDriveFile['id'] | MiDriveFile, options?: PackOptions, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'DriveFile'> | null> { const opts = Object.assign({ detail: false, @@ -248,8 +251,8 @@ export class DriveFileEntityService { folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { detail: true, }) : null, - userId: opts.withUser ? file.userId : null, - user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, + userId: file.userId, + user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); } @@ -258,7 +261,10 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { - const items = await Promise.all(files.map(f => this.packNullable(f, options))); + const _user = files.map(({ user, userId }) => user ?? userId).filter(isNotNull); + const _userMap = await this.userEntityService.packMany(_user) + .then(users => new Map(users.map(user => [user.id, user]))); + const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); return items.filter(isNotNull); } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index db4cf6d360..d110f7afc6 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -33,6 +33,9 @@ export class FlashEntityService { public async pack( src: MiFlash['id'] | MiFlash, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Flash'>> { const meId = me ? me.id : null; const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); @@ -42,7 +45,7 @@ export class FlashEntityService { createdAt: this.idService.parse(flash.id).date.toISOString(), updatedAt: flash.updatedAt.toISOString(), userId: flash.userId, - user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 title: flash.title, summary: flash.summary, script: flash.script, @@ -52,11 +55,14 @@ export class FlashEntityService { } @bindThis - public packMany( - flashs: MiFlash[], + public async packMany( + flashes: MiFlash[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(flashs.map(x => this.pack(x, me))); + const _users = flashes.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); } } diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts index 763b75101f..0101ec8aa7 100644 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -10,6 +10,7 @@ import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiFollowRequest } from '@/models/FollowRequest.js'; import { bindThis } from '@/decorators.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,14 +27,36 @@ export class FollowRequestEntityService { public async pack( src: MiFollowRequest['id'] | MiFollowRequest, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedFollower?: Packed<'UserLite'>, + packedFollowee?: Packed<'UserLite'>, + }, ) { const request = typeof src === 'object' ? src : await this.followRequestsRepository.findOneByOrFail({ id: src }); return { id: request.id, - follower: await this.userEntityService.pack(request.followerId, me), - followee: await this.userEntityService.pack(request.followeeId, me), + follower: hint?.packedFollower ?? await this.userEntityService.pack(request.followerId, me), + followee: hint?.packedFollowee ?? await this.userEntityService.pack(request.followeeId, me), }; } + + @bindThis + public async packMany( + requests: MiFollowRequest[], + me?: { id: MiUser['id'] } | null | undefined, + ) { + const _followers = requests.map(({ follower, followerId }) => follower ?? followerId); + const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId); + const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + requests.map(req => { + const packedFollower = _userMap.get(req.followerId); + const packedFollowee = _userMap.get(req.followeeId); + return this.pack(req, me, { packedFollower, packedFollowee }); + }), + ); + } } diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index 24cd33e3f7..d2dbaf2270 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -78,6 +78,10 @@ export class FollowingEntityService { populateFollowee?: boolean; populateFollower?: boolean; }, + hint?: { + packedFollowee?: Packed<'UserDetailedNotMe'>, + packedFollower?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Following'>> { const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); @@ -88,25 +92,35 @@ export class FollowingEntityService { createdAt: this.idService.parse(following.id).date.toISOString(), followeeId: following.followeeId, followerId: following.followerId, - followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { + followee: opts.populateFollowee ? hint?.packedFollowee ?? this.userEntityService.pack(following.followee ?? following.followeeId, me, { schema: 'UserDetailedNotMe', }) : undefined, - follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { + follower: opts.populateFollower ? hint?.packedFollower ?? this.userEntityService.pack(following.follower ?? following.followerId, me, { schema: 'UserDetailedNotMe', }) : undefined, }); } @bindThis - public packMany( - followings: any[], + public async packMany( + followings: MiFollowing[], me?: { id: MiUser['id'] } | null | undefined, opts?: { populateFollowee?: boolean; populateFollower?: boolean; }, ) { - return Promise.all(followings.map(x => this.pack(x, me, opts))); + const _followees = opts?.populateFollowee ? followings.map(({ followee, followeeId }) => followee ?? followeeId) : []; + const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : []; + const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + followings.map(following => { + const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined; + const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined; + return this.pack(following, me, opts, { packedFollowee, packedFollower }); + }), + ); } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index 101182a9e5..9746a4c1af 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -35,6 +35,9 @@ export class GalleryPostEntityService { public async pack( src: MiGalleryPost['id'] | MiGalleryPost, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'GalleryPost'>> { const meId = me ? me.id : null; const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); @@ -44,7 +47,7 @@ export class GalleryPostEntityService { createdAt: this.idService.parse(post.id).date.toISOString(), updatedAt: post.updatedAt.toISOString(), userId: post.userId, - user: this.userEntityService.pack(post.user ?? post.userId, me), + user: hint?.packedUser ?? this.userEntityService.pack(post.user ?? post.userId, me), title: post.title, description: post.description, fileIds: post.fileIds, @@ -58,11 +61,14 @@ export class GalleryPostEntityService { } @bindThis - public packMany( + public async packMany( posts: MiGalleryPost[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(posts.map(x => this.pack(x, me))); + const _users = posts.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) }))); } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index b4a518a1c6..002a93397d 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -39,7 +39,8 @@ export class InstanceEntityService { followingCount: instance.followingCount, followersCount: instance.followersCount, isNotResponding: instance.isNotResponding, - isSuspended: instance.isSuspended, + isSuspended: instance.suspensionState !== 'none', + suspensionState: instance.suspensionState, isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts index 891543bc0f..26f57e1299 100644 --- a/packages/backend/src/core/entities/InviteCodeEntityService.ts +++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts @@ -12,6 +12,7 @@ import type { MiUser } from '@/models/User.js'; import type { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -29,6 +30,10 @@ export class InviteCodeEntityService { public async pack( src: MiRegistrationTicket['id'] | MiRegistrationTicket, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedCreatedBy?: Packed<'UserLite'>, + packedUsedBy?: Packed<'UserLite'>, + }, ): Promise<Packed<'InviteCode'>> { const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({ where: { @@ -42,18 +47,28 @@ export class InviteCodeEntityService { code: target.code, expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null, createdAt: this.idService.parse(target.id).date.toISOString(), - createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null, - usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null, + createdBy: target.createdBy ? hints?.packedCreatedBy ?? await this.userEntityService.pack(target.createdBy, me) : null, + usedBy: target.usedBy ? hints?.packedUsedBy ?? await this.userEntityService.pack(target.usedBy, me) : null, usedAt: target.usedAt ? target.usedAt.toISOString() : null, used: !!target.usedAt, }); } @bindThis - public packMany( - targets: any[], + public async packMany( + tickets: MiRegistrationTicket[], me: { id: MiUser['id'] }, ) { - return Promise.all(targets.map(x => this.pack(x, me))); + const _createdBys = tickets.map(({ createdBy, createdById }) => createdBy ?? createdById).filter(isNotNull); + const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(isNotNull); + const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + tickets.map(ticket => { + const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined; + const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined; + return this.pack(ticket, me, { packedCreatedBy, packedUsedBy }); + }), + ); } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 7e3926c431..34d46e50e5 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -67,6 +67,7 @@ export class MetaEntityService { impressumUrl: instance.impressumUrl, donationUrl: instance.donationUrl, privacyPolicyUrl: instance.privacyPolicyUrl, + inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, approvalRequiredForSignup: instance.approvalRequiredForSignup, @@ -114,6 +115,7 @@ export class MetaEntityService { policies: { ...DEFAULT_POLICIES, ...instance.policies }, mediaProxy: this.config.mediaProxy, + enableUrlPreview: instance.urlPreviewEnabled, }; return packed; diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts index 205e147bd1..bf1b2a002c 100644 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -8,9 +8,10 @@ import { DI } from '@/di-symbols.js'; import type { ModerationLogsRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { } from '@/models/Blocking.js'; -import type { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiModerationLog } from '@/models/ModerationLog.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -27,6 +28,9 @@ export class ModerationLogEntityService { @bindThis public async pack( src: MiModerationLog['id'] | MiModerationLog, + hint?: { + packedUser?: Packed<'UserDetailedNotMe'>, + }, ) { const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); @@ -36,17 +40,20 @@ export class ModerationLogEntityService { type: log.type, info: log.info, userId: log.userId, - user: this.userEntityService.pack(log.user ?? log.userId, null, { + user: hint?.packedUser ?? this.userEntityService.pack(log.user ?? log.userId, null, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiModerationLog[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _users = reports.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) }))); } } diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 0a52f429a2..d361a20271 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -30,6 +30,9 @@ export class MutingEntityService { public async pack( src: MiMuting['id'] | MiMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Muting'>> { const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); @@ -38,18 +41,21 @@ export class MutingEntityService { createdAt: this.idService.parse(muting.id).date.toISOString(), expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 86a8670f29..ca755ea286 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -307,6 +307,7 @@ export class NoteEntityService implements OnModuleInit { _hint_?: { myReactions: Map<MiNote['id'], string | null>; packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; + packedUsers: Map<MiUser['id'], Packed<'UserLite'>> }; }, ): Promise<Packed<'Note'>> { @@ -336,13 +337,14 @@ export class NoteEntityService implements OnModuleInit { .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, - user: this.userEntityService.pack(note.user ?? note.userId, me), + user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, cw: note.cw, visibility: note.visibility, @@ -351,6 +353,7 @@ export class NoteEntityService implements OnModuleInit { visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, + reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), reactions: this.reactionService.convertLegacyReactions(note.reactions), reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, @@ -464,12 +467,20 @@ export class NoteEntityService implements OnModuleInit { // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); + const users = [ + ...notes.map(({ user, userId }) => user ?? userId), + ...notes.map(({ replyUserId }) => replyUserId).filter(isNotNull), + ...notes.map(({ renoteUserId }) => renoteUserId).filter(isNotNull), + ]; + const packedUsers = await this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, packedFiles, + packedUsers, }, }))); } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 3f4fa3cf96..46ec13704c 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -52,6 +52,9 @@ export class NoteReactionEntityService implements OnModuleInit { options?: { withNote: boolean; }, + hints?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'NoteReaction'>> { const opts = Object.assign({ withNote: false, @@ -62,7 +65,7 @@ export class NoteReactionEntityService implements OnModuleInit { return { id: reaction.id, createdAt: this.idService.parse(reaction.id).date.toISOString(), - user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + user: hints?.packedUser ?? await this.userEntityService.pack(reaction.user ?? reaction.userId, me), type: this.reactionService.convertLegacyReaction(reaction.reaction), ...(opts.withNote ? { note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), @@ -81,7 +84,9 @@ export class NoteReactionEntityService implements OnModuleInit { const opts = Object.assign({ withNote: false, }, options); - - return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts))); + const _users = reactions.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); } } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 65c69a49a7..142d9e81db 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -40,6 +40,9 @@ export class PageEntityService { public async pack( src: MiPage['id'] | MiPage, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Page'>> { const meId = me ? me.id : null; const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); @@ -91,7 +94,7 @@ export class PageEntityService { createdAt: this.idService.parse(page.id).date.toISOString(), updatedAt: page.updatedAt.toISOString(), userId: page.userId, - user: this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 content: page.content, variables: page.variables, title: page.title, @@ -110,11 +113,14 @@ export class PageEntityService { } @bindThis - public packMany( + public async packMany( pages: MiPage[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(pages.map(x => this.pack(x, me))); + const _users = pages.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) }))); } } diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts index 0b05a5db80..e4e154109a 100644 --- a/packages/backend/src/core/entities/RenoteMutingEntityService.ts +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -30,6 +30,9 @@ export class RenoteMutingEntityService { public async pack( src: MiRenoteMuting['id'] | MiRenoteMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'> + }, ): Promise<Packed<'RenoteMuting'>> { const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); @@ -37,18 +40,21 @@ export class RenoteMutingEntityService { id: muting.id, createdAt: this.idService.parse(muting.id).date.toISOString(), muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiRenoteMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index 32cbe631e4..df042e75c1 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -28,13 +28,15 @@ export class ReversiGameEntityService { @bindThis public async packDetail( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise<Packed<'ReversiGameDetailed'>> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -49,10 +51,10 @@ export class ReversiGameEntityService { user2Ready: game.user2Ready, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -68,22 +70,35 @@ export class ReversiGameEntityService { } @bindThis - public packDetailMany( - xs: MiReversiGame[], + public async packDetailMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packDetail(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packDetail(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } @bindThis public async packLite( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise<Packed<'ReversiGameLite'>> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -94,10 +109,10 @@ export class ReversiGameEntityService { isEnded: game.isEnded, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -111,10 +126,21 @@ export class ReversiGameEntityService { } @bindThis - public packLiteMany( - xs: MiReversiGame[], + public async packLiteMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packLite(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packLite(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8f5d986fac..9e234318b5 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -14,9 +15,32 @@ import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, listenbrainzSchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js'; -import { MiNotification } from '@/models/Notification.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js'; +import { + birthdaySchema, + descriptionSchema, + listenbrainzSchema, + localUsernameSchema, + locationSchema, + nameSchema, + passwordSchema, +} from '@/models/User.js'; +import type { + BlockingsRepository, + DriveFilesRepository, + FollowingsRepository, + FollowRequestsRepository, + MiFollowing, + MiUserNotePining, + MiUserProfile, + MutingsRepository, + NoteUnreadsRepository, + RenoteMutingsRepository, + UserMemoRepository, + UserNotePiningsRepository, + UserProfilesRepository, + UserSecurityKeysRepository, + UsersRepository, +} from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -46,6 +70,19 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { return !isLocalUser(user); } +export type UserRelation = { + id: MiUser['id'] + following: MiFollowing | null, + isFollowing: boolean + isFollowed: boolean + hasPendingFollowRequestFromYou: boolean + hasPendingFollowRequestToYou: boolean + isBlocking: boolean + isBlocked: boolean + isMuted: boolean + isRenoteMuted: boolean +} + @Injectable() export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; @@ -101,12 +138,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - @Inject(DI.userMemosRepository) private userMemosRepository: UserMemoRepository, ) { @@ -139,7 +170,7 @@ export class UserEntityService implements OnModuleInit { public isRemoteUser = isRemoteUser; @bindThis - public async getRelation(me: MiUser['id'], target: MiUser['id']) { + public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise<UserRelation> { const [ following, isFollowed, @@ -213,6 +244,80 @@ export class UserEntityService implements OnModuleInit { } @bindThis + public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise<Map<MiUser['id'], UserRelation>> { + const [ + followers, + followees, + followersRequests, + followeesRequests, + blockers, + blockees, + muters, + renoteMuters, + ] = await Promise.all([ + this.followingsRepository.findBy({ followerId: me }) + .then(f => new Map(f.map(it => [it.followeeId, it]))), + this.followingsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followeeId') + .where('f.followerId = :me', { me }) + .getRawMany<{ f_followeeId: string }>() + .then(it => it.map(it => it.f_followeeId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockeeId') + .where('b.blockerId = :me', { me }) + .getRawMany<{ b_blockeeId: string }>() + .then(it => it.map(it => it.b_blockeeId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockerId') + .where('b.blockeeId = :me', { me }) + .getRawMany<{ b_blockerId: string }>() + .then(it => it.map(it => it.b_blockerId)), + this.mutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), + this.renoteMutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), + ]); + + return new Map( + targets.map(target => { + const following = followers.get(target) ?? null; + + return [ + target, + { + id: target, + following: following, + isFollowing: following != null, + isFollowed: followees.includes(target), + hasPendingFollowRequestFromYou: followersRequests.includes(target), + hasPendingFollowRequestToYou: followeesRequests.includes(target), + isBlocking: blockers.includes(target), + isBlocked: blockees.includes(target), + isMuted: muters.includes(target), + isRenoteMuted: renoteMuters.includes(target), + }, + ]; + }), + ); + } + + @bindThis public async getHasUnreadAntenna(userId: MiUser['id']): Promise<boolean> { /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); @@ -304,6 +409,9 @@ export class UserEntityService implements OnModuleInit { schema?: S, includeSecrets?: boolean, userProfile?: MiUserProfile, + userRelations?: Map<MiUser['id'], UserRelation>, + userMemos?: Map<MiUser['id'], string | null>, + pinNotes?: Map<MiUser['id'], MiUserNotePining[]>, }, ): Promise<Packed<S>> { const opts = Object.assign({ @@ -344,13 +452,41 @@ export class UserEntityService implements OnModuleInit { const isMe = meId === user.id; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; - const relation = meId && !isMe && isDetailed ? await this.getRelation(meId, user.id) : null; - const pins = isDetailed ? await this.userNotePiningsRepository.createQueryBuilder('pin') - .where('pin.userId = :userId', { userId: user.id }) - .innerJoinAndSelect('pin.note', 'note') - .orderBy('pin.id', 'DESC') - .getMany() : []; - const profile = isDetailed ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; + const profile = isDetailed + ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) + : null; + + let relation: UserRelation | null = null; + if (meId && !isMe && isDetailed) { + if (opts.userRelations) { + relation = opts.userRelations.get(user.id) ?? null; + } else { + relation = await this.getRelation(meId, user.id); + } + } + + let memo: string | null = null; + if (isDetailed && meId) { + if (opts.userMemos) { + memo = opts.userMemos.get(user.id) ?? null; + } else { + memo = await this.userMemosRepository.findOneBy({ userId: meId, targetUserId: user.id }) + .then(row => row?.memo ?? null); + } + } + + let pins: MiUserNotePining[] = []; + if (isDetailed) { + if (opts.pinNotes) { + pins = opts.pinNotes.get(user.id) ?? []; + } else { + pins = await this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('pin.note', 'note') + .orderBy('pin.id', 'DESC') + .getMany(); + } + } const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; @@ -452,9 +588,7 @@ export class UserEntityService implements OnModuleInit { twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ - userId: user.id, - }).then(result => result >= 1) + ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) : false, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, @@ -466,10 +600,7 @@ export class UserEntityService implements OnModuleInit { isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, }))), - memo: meId == null ? null : await this.userMemosRepository.findOneBy({ - userId: meId, - targetUserId: user.id, - }).then(row => row?.memo ?? null), + memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), @@ -552,7 +683,7 @@ export class UserEntityService implements OnModuleInit { return await awaitAll(packed); } - public packMany<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>( + public async packMany<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>( users: (MiUser['id'] | MiUser)[], me?: { id: MiUser['id'] } | null | undefined, options?: { @@ -560,6 +691,69 @@ export class UserEntityService implements OnModuleInit { includeSecrets?: boolean, }, ): Promise<Packed<S>[]> { - return Promise.all(users.map(u => this.pack(u, me, options))); + // -- IDのみの要素を補完して完全なエンティティ一覧を作る + + const _users = users.filter((user): user is MiUser => typeof user !== 'string'); + if (_users.length !== users.length) { + _users.push( + ...await this.usersRepository.findBy({ + id: In(users.filter((user): user is string => typeof user === 'string')), + }), + ); + } + const _userIds = _users.map(u => u.id); + + // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 + + let profilesMap: Map<MiUser['id'], MiUserProfile> = new Map(); + let userRelations: Map<MiUser['id'], UserRelation> = new Map(); + let userMemos: Map<MiUser['id'], string | null> = new Map(); + let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map(); + + if (options?.schema !== 'UserLite') { + profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) + .then(profiles => new Map(profiles.map(p => [p.userId, p]))); + + const meId = me ? me.id : null; + if (meId) { + userMemos = await this.userMemosRepository.findBy({ userId: meId }) + .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))); + + if (_userIds.length > 0) { + userRelations = await this.getRelations(meId, _userIds); + pinNotes = await this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId IN (:...userIds)', { userIds: _userIds }) + .innerJoinAndSelect('pin.note', 'note') + .getMany() + .then(pinsNotes => { + const map = new Map<MiUser['id'], MiUserNotePining[]>(); + for (const note of pinsNotes) { + const notes = map.get(note.userId) ?? []; + notes.push(note); + map.set(note.userId, notes); + } + for (const [, notes] of map.entries()) { + // pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく + notes.sort((a, b) => b.id.localeCompare(a.id)); + } + return map; + }); + } + } + } + + return Promise.all( + _users.map(u => this.pack( + u, + me, + { + ...options, + userProfile: profilesMap.get(u.id), + userRelations: userRelations, + userMemos: userMemos, + pinNotes: pinNotes, + }, + )), + ); } } diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 09cab24521..b77249c5cb 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -50,11 +50,14 @@ export class UserListEntityService { public async packMembershipsMany( memberships: MiUserListMembership[], ) { + const _users = memberships.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users) + .then(users => new Map(users.map(u => [u.id, u]))); return Promise.all(memberships.map(async x => ({ id: x.id, createdAt: this.idService.parse(x.id).date.toISOString(), userId: x.userId, - user: await this.userEntityService.pack(x.userId), + user: _userMap.get(x.userId) ?? await this.userEntityService.pack(x.userId), withReplies: x.withReplies, }))); } diff --git a/packages/backend/src/misc/fastify-hook-handlers.ts b/packages/backend/src/misc/fastify-hook-handlers.ts index 49a48f6a6b..3e1c099e00 100644 --- a/packages/backend/src/misc/fastify-hook-handlers.ts +++ b/packages/backend/src/misc/fastify-hook-handlers.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import type { onRequestHookHandler } from 'fastify'; export const handleRequestRedirectToOmitSearch: onRequestHookHandler = (request, reply, done) => { diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index 62a8ab8ace..342e0f8602 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -8,9 +8,8 @@ * https://en.wikipedia.org/wiki/Identicon */ -import * as p from 'pureimage'; +import { createCanvas } from '@napi-rs/canvas'; import gen from 'random-seed'; -import type { WriteStream } from 'node:fs'; const size = 128; // px const n = 5; // resolution @@ -45,9 +44,9 @@ const sideN = Math.floor(n / 2); /** * Generate buffer of an identicon by seed */ -export function genIdenticon(seed: string, stream: WriteStream): Promise<void> { +export async function genIdenticon(seed: string): Promise<Buffer> { const rand = gen.create(seed); - const canvas = p.make(size, size, undefined); + const canvas = createCanvas(size, size); const ctx = canvas.getContext('2d'); const bgColors = colors[rand(colors.length)]; @@ -101,5 +100,5 @@ export function genIdenticon(seed: string, stream: WriteStream): Promise<void> { } } - return p.encodePNGToStream(canvas, stream); + return await canvas.encode('png'); } diff --git a/packages/backend/src/misc/is-pure-renote.ts b/packages/backend/src/misc/is-pure-renote.ts deleted file mode 100644 index 994d981522..0000000000 --- a/packages/backend/src/misc/is-pure-renote.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MiNote } from '@/models/Note.js'; - -export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable<MiNote['renoteId']> } { - if (!note.renoteId) return false; - - if (note.text) return false; // it's quoted with text - if (note.fileIds.length !== 0) return false; // it's quoted with files - if (note.hasPoll) return false; // it's quoted with poll - return true; -} diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts deleted file mode 100644 index 75b29f63f4..0000000000 --- a/packages/backend/src/misc/is-quote.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiNote } from '@/models/Note.js'; - -// eslint-disable-next-line import/no-default-export -export default function(note: MiNote): boolean { - // sync with NoteCreateService.isQuote - return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); -} diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts new file mode 100644 index 0000000000..48f821806c --- /dev/null +++ b/packages/backend/src/misc/is-renote.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { MiNote } from '@/models/Note.js'; +import type { Packed } from '@/misc/json-schema.js'; + +type Renote = + MiNote & { + renoteId: NonNullable<MiNote['renoteId']> + }; + +type Quote = + Renote & ({ + text: NonNullable<MiNote['text']> + } | { + cw: NonNullable<MiNote['cw']> + } | { + replyId: NonNullable<MiNote['replyId']> + reply: NonNullable<MiNote['reply']> + } | { + hasPoll: true + }); + +export function isRenote(note: MiNote): note is Renote { + return note.renoteId != null; +} + +export function isQuote(note: Renote): note is Quote { + // NOTE: SYNC WITH NoteCreateService.isQuote + return note.text != null || + note.cw != null || + note.replyId != null || + note.hasPoll || + note.fileIds.length > 0; +} + +type PackedRenote = + Packed<'Note'> & { + renoteId: NonNullable<Packed<'Note'>['renoteId']> + }; + +type PackedQuote = + PackedRenote & ({ + text: NonNullable<Packed<'Note'>['text']> + } | { + cw: NonNullable<Packed<'Note'>['cw']> + } | { + replyId: NonNullable<Packed<'Note'>['replyId']> + } | { + poll: NonNullable<Packed<'Note'>['poll']> + } | { + fileIds: NonNullable<Packed<'Note'>['fileIds']> + }); + +export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { + return note.renoteId != null; +} + +export function isQuotePacked(note: PackedRenote): note is PackedQuote { + return note.text != null || + note.cw != null || + note.replyId != null || + note.poll != null || + (note.fileIds != null && note.fileIds.length > 0); +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 46b0bb2fab..41e5bfe9e4 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -48,6 +48,7 @@ import { packedRoleCondFormulaValueCreatedSchema, packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaValueSchema, + packedRoleCondFormulaValueUserSettingBooleanSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; @@ -97,6 +98,7 @@ export const refs = { RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, RoleCondFormulaValueNot: packedRoleCondFormulaValueNot, RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema, + RoleCondFormulaValueUserSettingBooleanSchema: packedRoleCondFormulaValueUserSettingBooleanSchema, RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema, RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema, RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, @@ -226,7 +228,7 @@ export type SchemaTypeDef<p extends Schema> = p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] : never ) : - p['items'] extends NonNullable<Schema> ? SchemaTypeDef<p['items']>[] : + p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] : any[] ) : p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> : diff --git a/packages/backend/src/misc/loader.ts b/packages/backend/src/misc/loader.ts index 25f7b54d31..7f29b9db10 100644 --- a/packages/backend/src/misc/loader.ts +++ b/packages/backend/src/misc/loader.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + export type FetchFunction<K, V> = (key: K) => Promise<V>; type ResolveReject<V> = Parameters<ConstructorParameters<typeof Promise<V>>[0]>; diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts index 0c05255674..ffe61670ee 100644 --- a/packages/backend/src/misc/sql-like-escape.ts +++ b/packages/backend/src/misc/sql-like-escape.ts @@ -4,5 +4,5 @@ */ export function sqlLikeEscape(s: string) { - return s.replace(/([%_])/g, '\\$1'); + return s.replace(/([%_\\])/g, '\\$1'); } diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 332a899768..33e6f48189 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -75,6 +75,11 @@ export class MiAntenna { @Column('boolean', { default: false, }) + public excludeBots: boolean; + + @Column('boolean', { + default: false, + }) public withReplies: boolean; @Column('boolean') @@ -85,9 +90,6 @@ export class MiAntenna { }) public expression: string | null; - @Column('boolean') - public notify: boolean; - @Index() @Column('boolean', { default: true, diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index 7dd4e5b10c..dd625f95d3 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -81,13 +81,22 @@ export class MiInstance { public isNotResponding: boolean; /** - * このインスタンスへの配信を停止するか + * このインスタンスと不通になった日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public notRespondingSince: Date | null; + + /** + * このインスタンスへの配信状態 */ @Index() - @Column('boolean', { - default: false, + @Column('enum', { + default: 'none', + enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'], }) - public isSuspended: boolean; + public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'; @Column('varchar', { length: 64, nullable: true, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index dd2e78cde2..fb021f0f6b 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -287,12 +287,6 @@ export class MiMeta { }) public enableBotTrending: boolean; - @Column('varchar', { - length: 1024, - nullable: true, - }) - public summalyProxy: string | null; - @Column('boolean', { default: false, }) @@ -410,6 +404,12 @@ export class MiMeta { public donationUrl: string | null; @Column('varchar', { + length: 1024, + nullable: true, + }) + public inquiryUrl: string | null; + + @Column('varchar', { length: 8192, nullable: true, }) @@ -631,4 +631,36 @@ export class MiMeta { length: 256, array: true, default: '{}', }) public bubbleInstances: string[]; + + @Column('boolean', { + default: true, + }) + public urlPreviewEnabled: boolean; + + @Column('integer', { + default: 10000, + }) + public urlPreviewTimeout: number; + + @Column('bigint', { + default: 1024 * 1024 * 10, + }) + public urlPreviewMaximumContentLength: number; + + @Column('boolean', { + default: true, + }) + public urlPreviewRequireContentLength: boolean; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public urlPreviewSummaryProxyUrl: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public urlPreviewUserAgent: string | null; } diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts index 9ec989dd50..cfd9ad748e 100644 --- a/packages/backend/src/models/NoteEdit.ts +++ b/packages/backend/src/models/NoteEdit.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from "typeorm"; import { id } from './util/id.js'; import { MiNote } from './Note.js'; diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts index c2693dbb19..ca985c8b24 100644 --- a/packages/backend/src/models/Poll.ts +++ b/packages/backend/src/models/Poll.ts @@ -8,6 +8,7 @@ import { noteVisibilities } from '@/types.js'; import { id } from './util/id.js'; import { MiNote } from './Note.js'; import type { MiUser } from './User.js'; +import type { MiChannel } from "@/models/Channel.js"; @Entity('poll') export class MiPoll { @@ -58,6 +59,14 @@ export class MiPoll { comment: '[Denormalized]', }) public userHost: string | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public channelId: MiChannel['id'] | null; //#endregion constructor(data: Partial<MiPoll>) { diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index c03335dd63..6b29a0ce8c 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index 058abe3118..a173971b2c 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -6,69 +6,149 @@ import { Entity, Column, PrimaryColumn } from 'typeorm'; import { id } from './util/id.js'; +/** + * ~かつ~ + * 複数の条件を同時に満たす場合のみ成立とする + */ type CondFormulaValueAnd = { type: 'and'; values: RoleCondFormulaValue[]; }; +/** + * ~または~ + * 複数の条件のうち、いずれかを満たす場合のみ成立とする + */ type CondFormulaValueOr = { type: 'or'; values: RoleCondFormulaValue[]; }; +/** + * ~ではない + * 条件を満たさない場合のみ成立とする + */ type CondFormulaValueNot = { type: 'not'; value: RoleCondFormulaValue; }; +/** + * ローカルユーザーのみ成立とする + */ type CondFormulaValueIsLocal = { type: 'isLocal'; }; +/** + * リモートユーザーのみ成立とする + */ type CondFormulaValueIsRemote = { type: 'isRemote'; }; +/** + * 既に指定のマニュアルロールにアサインされている場合のみ成立とする + */ type CondFormulaValueRoleAssignedTo = { type: 'roleAssignedTo'; roleId: string; }; +/** + * サスペンド済みアカウントの場合のみ成立とする + */ +type CondFormulaValueIsSuspended = { + type: 'isSuspended'; +}; + +/** + * 鍵アカウントの場合のみ成立とする + */ +type CondFormulaValueIsLocked = { + type: 'isLocked'; +}; + +/** + * botアカウントの場合のみ成立とする + */ +type CondFormulaValueIsBot = { + type: 'isBot'; +}; + +/** + * 猫アカウントの場合のみ成立とする + */ +type CondFormulaValueIsCat = { + type: 'isCat'; +}; + +/** + * 「ユーザを見つけやすくする」が有効なアカウントの場合のみ成立とする + */ +type CondFormulaValueIsExplorable = { + type: 'isExplorable'; +}; + +/** + * ユーザが作成されてから指定期間経過した場合のみ成立とする + */ type CondFormulaValueCreatedLessThan = { type: 'createdLessThan'; sec: number; }; +/** + * ユーザが作成されてから指定期間経っていない場合のみ成立とする + */ type CondFormulaValueCreatedMoreThan = { type: 'createdMoreThan'; sec: number; }; +/** + * フォロワー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowersLessThanOrEq = { type: 'followersLessThanOrEq'; value: number; }; +/** + * フォロワー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowersMoreThanOrEq = { type: 'followersMoreThanOrEq'; value: number; }; +/** + * フォロー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowingLessThanOrEq = { type: 'followingLessThanOrEq'; value: number; }; +/** + * フォロー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowingMoreThanOrEq = { type: 'followingMoreThanOrEq'; value: number; }; +/** + * 投稿数が指定値以下の場合のみ成立とする + */ type CondFormulaValueNotesLessThanOrEq = { type: 'notesLessThanOrEq'; value: number; }; +/** + * 投稿数が指定値以上の場合のみ成立とする + */ type CondFormulaValueNotesMoreThanOrEq = { type: 'notesMoreThanOrEq'; value: number; @@ -80,6 +160,11 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueNot | CondFormulaValueIsLocal | CondFormulaValueIsRemote | + CondFormulaValueIsSuspended | + CondFormulaValueIsLocked | + CondFormulaValueIsBot | + CondFormulaValueIsCat | + CondFormulaValueIsExplorable | CondFormulaValueRoleAssignedTo | CondFormulaValueCreatedLessThan | CondFormulaValueCreatedMoreThan | diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index 74622b6193..b5b9a5b42c 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -72,9 +72,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, - notify: { + excludeBots: { type: 'boolean', optional: false, nullable: false, + default: false, }, withReplies: { type: 'boolean', @@ -94,5 +95,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + notify: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts index ca4886c978..c4e7055cd8 100644 --- a/packages/backend/src/models/json-schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -52,5 +52,9 @@ export const packedClipSchema = { type: 'boolean', optional: true, nullable: false, }, + notesCount: { + type: 'integer', + optional: true, 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 7b8ab22831..a602126dc8 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -45,6 +45,11 @@ export const packedFederationInstanceSchema = { type: 'boolean', optional: false, nullable: false, }, + suspensionState: { + type: 'string', + nullable: false, optional: false, + enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'], + }, isBlocked: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 9db3f7f809..7edd877f80 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -223,6 +223,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: false, }, + enableUrlPreview: { + type: 'boolean', + optional: false, nullable: false, + }, backgroundImageUrl: { type: 'string', optional: false, nullable: true, @@ -239,6 +243,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + inquiryUrl: { + type: 'string', + optional: false, nullable: true, + }, serverRules: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts index a58e2aa1de..ba936f866b 100644 --- a/packages/backend/src/models/json-schema/note-edit.ts +++ b/packages/backend/src/models/json-schema/note-edit.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + export const packedNoteEdit = { type: "object", properties: { diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index bb4ccc7ee4..2641161c8b 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -223,6 +223,10 @@ export const packedNoteSchema = { }], }, }, + reactionCount: { + type: 'number', + optional: false, nullable: false, + }, renoteCount: { type: 'number', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 7eba1d5443..08580d22de 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -57,6 +57,20 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = { }, } as const; +export const packedRoleCondFormulaValueUserSettingBooleanSchema = { + type: 'object', + properties: { + id: { + type: 'string', optional: false, + }, + type: { + type: 'string', + nullable: false, optional: false, + enum: ['isSuspended', 'isLocked', 'isBot', 'isCat', 'isExplorable'], + }, + }, +} as const; + export const packedRoleCondFormulaValueAssignedRoleSchema = { type: 'object', properties: { @@ -136,6 +150,9 @@ export const packedRoleCondFormulaValueSchema = { ref: 'RoleCondFormulaValueIsLocalOrRemote', }, { + ref: 'RoleCondFormulaValueUserSettingBooleanSchema', + }, + { ref: 'RoleCondFormulaValueAssignedRole', }, { diff --git a/packages/backend/src/models/json-schema/signin.ts b/packages/backend/src/models/json-schema/signin.ts index d27d2490c5..45732a742b 100644 --- a/packages/backend/src/models/json-schema/signin.ts +++ b/packages/backend/src/models/json-schema/signin.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + export const packedSigninSchema = { type: 'object', properties: { diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 4fa414b0b5..81842b221f 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -73,7 +73,6 @@ export class CleanRemoteFilesProcessorService { }); await job.updateProgress(100 / total * deletedCount); - } this.logger.succ(`All cached remote files processed. Total deleted: ${deletedCount}, Failed: ${errorCount}.`); diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5fed070929..d665945861 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Bull from 'bullmq'; +import { Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; @@ -62,7 +63,7 @@ export class DeliverProcessorService { if (suspendedHosts == null) { suspendedHosts = await this.instancesRepository.find({ where: { - isSuspended: true, + suspensionState: Not('none'), }, }); this.suspendedHostsCache.set(suspendedHosts); @@ -79,6 +80,7 @@ export class DeliverProcessorService { if (i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: false, + notRespondingSince: null, }); } @@ -98,6 +100,20 @@ export class DeliverProcessorService { if (!i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: true, + notRespondingSince: new Date(), + }); + } else if (i.notRespondingSince) { + // 1週間以上不通ならサスペンド + if (i.suspensionState === 'none' && i.notRespondingSince.getTime() <= Date.now() - 1000 * 60 * 60 * 24 * 7) { + this.federatedInstanceService.update(i.id, { + suspensionState: 'autoSuspendedForNotResponding', + }); + } + } else { + // isNotRespondingがtrueでnotRespondingSinceがnullの場合はnotRespondingSinceをセット + // notRespondingSinceは新たな機能なので、それ以前のデータにはnotRespondingSinceがない場合がある + this.federatedInstanceService.update(i.id, { + notRespondingSince: new Date(), }); } @@ -116,7 +132,7 @@ export class DeliverProcessorService { if (job.data.isSharedInbox && res.statusCode === 410) { this.federatedInstanceService.fetch(host).then(i => { this.federatedInstanceService.update(i.id, { - isSuspended: true, + suspensionState: 'goneSuspended', }); }); throw new Bull.UnrecoverableError(`${host} is gone`); diff --git a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts index f6a4e8e2ec..33a2362c4a 100644 --- a/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAccountDataProcessorService.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { In, IsNull, MoreThan, Not } from 'typeorm'; @@ -287,7 +292,7 @@ export class ExportAccountDataProcessorService { const mutings = await this.mutingsRepository.findBy({ muterId: user.id, }); - + while (true) { const followings = await this.followingsRepository.find({ where: { @@ -353,7 +358,7 @@ export class ExportAccountDataProcessorService { let followersCursor: MiFollowing['id'] | null = null; let exportedFollowersCount = 0; - + while (true) { const followers = await this.followingsRepository.find({ where: { @@ -680,7 +685,6 @@ export class ExportAccountDataProcessorService { localOnly: antenna.localOnly, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, })); if (antennas.length - 1 !== index) { @@ -749,9 +753,9 @@ export class ExportAccountDataProcessorService { cleanup(); archiveCleanup(); if (profile.email) { - this.emailService.sendEmail(profile.email, - 'Your data archive is ready', - `Click the following link to download the archive: ${driveFile.url}<br/>It is also available in your drive.`, + this.emailService.sendEmail(profile.email, + 'Your data archive is ready', + `Click the following link to download the archive: ${driveFile.url}<br/>It is also available in your drive.`, `Click the following link to download the archive: ${driveFile.url}\r\n\r\nIt is also available in your drive.`, ); } diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index af48bad417..88c4ea29c0 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -81,9 +81,9 @@ export class ExportAntennasProcessorService { }) : null, caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, + excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, })); if (antennas.length - 1 !== index) { write(', '); diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 951b560597..e5b7c5ac52 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -44,11 +44,11 @@ const validate = new Ajv().compile({ } }, caseSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, + excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, - required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], }); @Injectable() @@ -88,9 +88,9 @@ export class ImportAntennasProcessorService { users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean), caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, + excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); this.logger.succ('Antenna created: ' + result.id); this.globalEventService.publishInternalEvent('antennaCreated', result); diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index 7cef858c51..f89dc46722 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import * as fs from 'node:fs'; import * as fsp from 'node:fs/promises'; import * as crypto from 'node:crypto'; @@ -19,12 +24,16 @@ import { IdService } from '@/core/IdService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbNoteImportToDbJobData, DbNoteImportJobData, DbNoteWithParentImportToDbJobData } from '../types.js'; +import type { Config } from '@/config.js'; @Injectable() export class ImportNotesProcessorService { private logger: Logger; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -74,6 +83,11 @@ export class ImportNotesProcessorService { } @bindThis + private downloadUrl(url: string, path:string): Promise<{filename: string}> { + return this.downloadService.downloadUrl(url, path, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); + } + + @bindThis private async recreateChain(idFieldPath: string[], replyFieldPath: string[], arr: any[], includeOrphans: boolean): Promise<any[]> { type NotesMap = { [id: string]: any; @@ -176,7 +190,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -206,7 +220,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -239,7 +253,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -297,7 +311,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(path, '', 'utf-8'); - await this.downloadService.downloadUrl(file.url, path); + await this.downloadUrl(file.url, path); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -349,7 +363,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.url, filePath); + await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } @@ -488,7 +502,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.url, filePath); + await this.downloadUrl(file.url, 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 2b5b7c5619..641b8b8607 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -22,7 +22,7 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js'; +import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -39,7 +39,7 @@ export class InboxProcessorService { private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, - private ldSignatureService: LdSignatureService, + private jsonLdService: JsonLdService, private apPersonService: ApPersonService, private apDbResolverService: ApDbResolverService, private instanceChart: InstanceChart, @@ -111,7 +111,7 @@ export class InboxProcessorService { // また、signatureのsignerは、activity.actorと一致する必要がある if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { let renewKeyFailed = true; - + if (!httpSignatureValidated) { authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); @@ -122,20 +122,21 @@ export class InboxProcessorService { } // 一致しなくても、でもLD-Signatureがありそうならそっちも見る - if (activity.signature && renewKeyFailed) { - if (activity.signature.type !== 'RsaSignature2017') { - throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`); + const ldSignature = activity.signature; + if (ldSignature) { + if (ldSignature.type !== 'RsaSignature2017') { + throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); } - // activity.signature.creator: https://example.oom/users/user#main-key + // ldSignature.creator: https://example.oom/users/user#main-key // みたいになっててUserを引っ張れば公開キーも入ることを期待する - if (activity.signature.creator) { - const candicate = activity.signature.creator.replace(/#.*/, ''); + if (ldSignature.creator) { + const candicate = ldSignature.creator.replace(/#.*/, ''); await this.apPersonService.resolvePerson(candicate).catch(() => null); } // keyIdからLD-Signatureのユーザーを取得 - authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator); + authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator); if (authUser == null) { throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); } @@ -144,9 +145,10 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); } + const jsonLd = this.jsonLdService.use(); + // LD-Signature検証 - const ldSignature = this.ldSignatureService.use(); - const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + const verified = await jsonLd.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); if (!verified) { throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } @@ -154,7 +156,7 @@ export class InboxProcessorService { // アクティビティを正規化 delete activity.signature; try { - activity = await ldSignature.compact(activity) as IActivity; + activity = await jsonLd.compact(activity) as IActivity; } catch (e) { throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); } @@ -191,6 +193,8 @@ export class InboxProcessorService { this.federatedInstanceService.update(i.id, { latestRequestReceivedAt: new Date(), isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined, }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); @@ -205,13 +209,22 @@ export class InboxProcessorService { // アクティビティを処理 try { - await this.apInboxService.performActivity(authUser.user, activity); + const result = await this.apInboxService.performActivity(authUser.user, activity); + if (result && !result.startsWith('ok')) { + this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`); + return result; + } } catch (e) { if (e instanceof IdentifiableError) { if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') { return 'blocked notes with prohibited words'; } - if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended'; + if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') { + return 'actor has been suspended'; + } + if (e.id === 'd450b8a9-48e4-4dab-ae36-f4db763fda7c') { // invalid Note + return e.message; + } } throw e; } diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 64bce07a98..398ad54f6a 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -33,7 +33,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; import type Logger from '@/logger.js'; @@ -107,7 +107,7 @@ export class ActivityPubServerService { */ @bindThis private async packActivity(note: MiNote): Promise<any> { - if (isPureRenote(note)) { + 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); } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 3cb353f2b3..e0b187f3cf 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -212,6 +212,8 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition( 'inline', @@ -254,6 +256,7 @@ export class FileServerService { return fs.createReadStream(file.path); } else { reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', file.filename)); @@ -528,9 +531,7 @@ export class FileServerService { if (!file.storedInternal) { if (!(file.isLink && file.uri)) return '204'; const result = await this.downloadAndDetectTypeFromUrl(file.uri); - if (!file.size) { - file.size = (await fs.promises.stat(result.path)).size; - } + file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので return { ...result, url: file.uri, diff --git a/packages/backend/src/server/HealthServerService.ts b/packages/backend/src/server/HealthServerService.ts new file mode 100644 index 0000000000..2c3ed85925 --- /dev/null +++ b/packages/backend/src/server/HealthServerService.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DataSource } from 'typeorm'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import { readyRef } from '@/boot/ready.js'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import type { MeiliSearch } from 'meilisearch'; + +@Injectable() +export class HealthServerService { + constructor( + @Inject(DI.redis) + private redis: Redis.Redis, + + @Inject(DI.redisForPub) + private redisForPub: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.meilisearch) + private meilisearch: MeiliSearch | null, + ) {} + + @bindThis + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/', async (request, reply) => { + reply.code(await Promise.all([ + new Promise<void>((resolve, reject) => readyRef.value ? resolve() : reject()), + this.redis.ping(), + this.redisForPub.ping(), + this.redisForSub.ping(), + this.redisForTimelines.ping(), + this.db.query('SELECT 1'), + ...(this.meilisearch ? [this.meilisearch.health()] : []), + ]).then(() => 200, () => 503)); + reply.header('Cache-Control', 'no-store'); + }); + + done(); + } +} diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index d4f4c8b752..716bb0944b 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -36,12 +36,12 @@ export class NodeinfoServerService { @bindThis public getLinks() { return [{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', - href: this.config.url + nodeinfo2_1path - }, { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', - href: this.config.url + nodeinfo2_0path, - }]; + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', + href: this.config.url + nodeinfo2_1path, + }, { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: this.config.url + nodeinfo2_0path, + }]; } @bindThis @@ -107,6 +107,7 @@ export class NodeinfoServerService { langs: meta.langs, tosUrl: meta.termsOfServiceUrl, privacyPolicyUrl: meta.privacyPolicyUrl, + inquiryUrl: meta.inquiryUrl, impressumUrl: meta.impressumUrl, donationUrl: meta.donationUrl, repositoryUrl: meta.repositoryUrl, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index e0c4768ffc..39c8f67b8e 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -8,6 +8,7 @@ import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; +import { HealthServerService } from './HealthServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ServerService } from './ServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; @@ -58,6 +59,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js ClientServerService, ClientLoggerService, FeedService, + HealthServerService, UrlPreviewService, ActivityPubServerService, FileServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 5a456e09ad..9eddf434f7 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -18,7 +18,6 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { genIdenticon } from '@/misc/gen-identicon.js'; -import { createTemp } from '@/misc/create-temp.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -29,6 +28,7 @@ import { ApiServerService } from './api/ApiServerService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; +import { HealthServerService } from './HealthServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js'; @@ -64,6 +64,7 @@ export class ServerService implements OnApplicationShutdown { private wellKnownServerService: WellKnownServerService, private nodeinfoServerService: NodeinfoServerService, private fileServerService: FileServerService, + private healthServerService: HealthServerService, private clientServerService: ClientServerService, private globalEventService: GlobalEventService, private loggerService: LoggerService, @@ -111,6 +112,7 @@ export class ServerService implements OnApplicationShutdown { fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' }); + fastify.register(this.healthServerService.createServer, { prefix: '/healthz' }); fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => { const path = request.params.path; @@ -122,12 +124,20 @@ export class ServerService implements OnApplicationShutdown { return; } - const name = path.split('@')[0].replace(/\.webp$/i, ''); - const host = path.split('@')[1]?.replace(/\.webp$/i, ''); + const emojiPath = path.replace(/\.webp$/i, ''); + const pathChunks = emojiPath.split('@'); + + if (pathChunks.length > 2) { + reply.code(400); + return; + } + + const name = pathChunks.shift(); + const host = pathChunks.pop(); const emoji = await this.emojisRepository.findOneBy({ // `@.` is the spec of ReactionService.decodeReaction - host: (host == null || host === '.') ? IsNull() : host, + host: (host === undefined || host === '.') ? IsNull() : host, name: name, }); @@ -186,9 +196,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if ((await this.metaService.fetch()).enableIdenticonGeneration) { - const [temp, cleanup] = await createTemp(); - await genIdenticon(request.params.x, fs.createWriteStream(temp)); - return fs.createReadStream(temp).on('close', () => cleanup()); + return await genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 9836689872..271ef80554 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -17,6 +18,7 @@ import { MetaService } from '@/core/MetaService.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -38,6 +40,9 @@ export class ApiCallService implements OnApplicationShutdown { private userIpHistoriesClearIntervalId: NodeJS.Timeout; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, @@ -88,6 +93,48 @@ export class ApiCallService implements OnApplicationShutdown { } } + #onExecError(ep: IEndpoint, data: any, err: Error): void { + if (err instanceof ApiError || err instanceof AuthenticationError) { + throw err; + } else { + const errId = randomUUID(); + this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, + }, + }); + console.error(err, errId); + + if (this.config.sentryForBackend) { + Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + extra: { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, + }, + }, + }); + } + + throw new ApiError(null, { + e: { + message: err.message, + code: err.name, + id: errId, + }, + }); + } + } + @bindThis public handleRequest( endpoint: IEndpoint & { exec: any }, @@ -362,31 +409,11 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError || err instanceof AuthenticationError) { - throw err; - } else { - const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }); - console.error(err, errId); - throw new ApiError(null, { - e: { - message: err.message, - code: err.name, - id: errId, - }, - }); - } - }); + if (this.config.sentryForBackend) { + return await Sentry.startSpan({ name: 'API: ' + ep.name }, () => ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err))); + } else { + return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err)); + } } @bindThis diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index e99244cdd0..4a5935f930 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -137,7 +137,7 @@ export class ApiServerService { const instances = await this.instancesRepository.find({ select: ['host'], where: { - isSuspended: false, + suspensionState: 'none', }, }); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index f2945f477c..f44635fba0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -88,6 +88,7 @@ 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___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'; @@ -473,6 +474,7 @@ const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', us 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 $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 }; @@ -862,6 +864,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_updateDefaultPolicies, $admin_roles_users, $announcements, + $announcements_show, $antennas_create, $antennas_delete, $antennas_list, @@ -1245,6 +1248,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_updateDefaultPolicies, $admin_roles_users, $announcements, + $announcements_show, $antennas_create, $antennas_delete, $antennas_list, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f83d2cacff..89f60933ef 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -88,6 +88,7 @@ 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___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'; @@ -471,6 +472,7 @@ const eps = [ ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], ['admin/roles/users', ep___admin_roles_users], ['announcements', ep___announcements], + ['announcements/show', ep___announcements_show], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], ['antennas/list', ep___antennas_list], 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 f968813197..acd2494131 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -90,7 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const addedEmoji = await this.customEmojiService.add({ driveFile, name: nameNfc, - category: emoji.category?.normalize('NFC'), + category: emoji.category?.normalize('NFC') ?? null, aliases: emoji.aliases?.map(a => a.normalize('NFC')), host: null, license: emoji.license, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 5e21111f9f..f35a6667f4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -66,6 +66,7 @@ export const paramDef = { properties: { query: { type: 'string', nullable: true, default: null }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', minimum: 1, nullable: true, default: null }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, }, @@ -91,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //const emojis = await q.limit(ps.limit).getMany(); - emojis = await q.orderBy('length(emoji.name)', 'ASC').getMany(); + emojis = await q.orderBy('length(emoji.name)', 'ASC').addOrderBy('id', 'DESC').getMany(); const queryarry = ps.query.match(/:([\p{Letter}\p{Number}\p{Mark}_+-]*):/ug); if (queryarry) { @@ -105,9 +106,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- emoji.aliases.some(a => a.includes(queryNfc)) || emoji.category?.includes(queryNfc)); } - emojis.splice(ps.limit + 1); + emojis = emojis.slice((ps.offset ?? 0), ((ps.offset ?? 0) + ps.limit)); } else { - emojis = await q.limit(ps.limit).getMany(); + emojis = await q.take(ps.limit).skip(ps.offset ?? 0).getMany(); } return this.emojiEntityService.packDetailedMany(emojis); 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 4ababae9f2..8b142910a6 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 @@ -47,13 +47,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new Error('instance not found'); } + const isSuspendedBefore = instance.suspensionState !== 'none'; + let suspensionState: undefined | 'manuallySuspended' | 'none'; + + if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) { + suspensionState = ps.isSuspended ? 'manuallySuspended' : 'none'; + } + await this.federatedInstanceService.update(instance.id, { - isSuspended: ps.isSuspended, + suspensionState, isNSFW: ps.isNSFW, moderationNote: ps.moderationNote, }); - if (ps.isSuspended != null && instance.isSuspended !== ps.isSuspended) { + if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) { if (ps.isSuspended) { this.moderationLogService.log(me, 'suspendRemoteInstance', { id: instance.id, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 34454c276e..ca4d63b834 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -458,6 +458,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + inquiryUrl: { + type: 'string', + optional: false, nullable: true, + }, repositoryUrl: { type: 'string', optional: false, nullable: true, @@ -465,6 +469,8 @@ export const meta = { summalyProxy: { type: 'string', optional: false, nullable: true, + deprecated: true, + description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.', }, themeColor: { type: 'string', @@ -482,6 +488,30 @@ export const meta = { type: 'string', optional: false, nullable: false, }, + urlPreviewEnabled: { + type: 'boolean', + optional: false, nullable: false, + }, + urlPreviewTimeout: { + type: 'number', + optional: false, nullable: false, + }, + urlPreviewMaximumContentLength: { + type: 'number', + optional: false, nullable: false, + }, + urlPreviewRequireContentLength: { + type: 'boolean', + optional: false, nullable: false, + }, + urlPreviewUserAgent: { + type: 'string', + optional: false, nullable: true, + }, + urlPreviewSummaryProxyUrl: { + type: 'string', + optional: false, nullable: true, + }, }, }, } as const; @@ -519,6 +549,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- impressumUrl: instance.impressumUrl, donationUrl: instance.donationUrl, privacyPolicyUrl: instance.privacyPolicyUrl, + inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, approvalRequiredForSignup: instance.approvalRequiredForSignup, @@ -569,7 +600,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, enableBotTrending: instance.enableBotTrending, proxyAccountId: instance.proxyAccountId, - summalyProxy: instance.summalyProxy, email: instance.email, smtpSecure: instance.smtpSecure, smtpHost: instance.smtpHost, @@ -616,6 +646,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax, perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax, notesPerOneAd: instance.notesPerOneAd, + summalyProxy: instance.urlPreviewSummaryProxyUrl, + urlPreviewEnabled: instance.urlPreviewEnabled, + urlPreviewTimeout: instance.urlPreviewTimeout, + urlPreviewMaximumContentLength: instance.urlPreviewMaximumContentLength, + urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, + urlPreviewUserAgent: instance.urlPreviewUserAgent, + urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, }; }); } 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 2a47abe03c..d3fa4251dd 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie 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, UserProfilesRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 45758d4f50..198166bec2 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -89,10 +89,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .limit(ps.limit) .getMany(); + const _users = assigns.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(assigns.map(async assign => ({ id: assign.id, createdAt: this.idService.parse(assign.id).date.toISOString(), - user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), + user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), expiresAt: assign.expiresAt?.toISOString() ?? null, }))); }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 685da928e3..5f16519403 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -16,7 +16,7 @@ export const meta = { requireCredential: true, requireModerator: true, - kind: 'read:admin:show-users', + kind: 'read:admin:show-user', res: { type: 'array', 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 007bed5c03..7e6045049a 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie 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'; 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 013e7771ba..26588365e1 100644 --- a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie 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, UserProfilesRepository } from '@/models/_.js'; 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 5e514ccda6..f92be0d8e0 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie 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'; 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 7fea7d969e..015a1e1f7c 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -93,7 +93,6 @@ export const paramDef = { type: 'string', }, }, - summalyProxy: { type: 'string', nullable: true }, deeplAuthKey: { type: 'string', nullable: true }, deeplIsPro: { type: 'boolean' }, deeplFreeMode: { type: 'boolean' }, @@ -114,6 +113,7 @@ export const paramDef = { impressumUrl: { type: 'string', nullable: true }, donationUrl: { type: 'string', nullable: true }, privacyPolicyUrl: { type: 'string', nullable: true }, + inquiryUrl: { type: 'string', nullable: true }, useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, @@ -158,6 +158,16 @@ export const paramDef = { type: 'string', }, }, + summalyProxy: { + type: 'string', nullable: true, + description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.', + }, + urlPreviewEnabled: { type: 'boolean' }, + urlPreviewTimeout: { type: 'integer' }, + urlPreviewMaximumContentLength: { type: 'integer' }, + urlPreviewRequireContentLength: { type: 'boolean' }, + urlPreviewUserAgent: { type: 'string', nullable: true }, + urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, }, required: [], } as const; @@ -357,10 +367,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.langs = ps.langs.filter(Boolean); } - if (ps.summalyProxy !== undefined) { - set.summalyProxy = ps.summalyProxy; - } - if (ps.enableEmail !== undefined) { set.enableEmail = ps.enableEmail; } @@ -425,6 +431,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.privacyPolicyUrl = ps.privacyPolicyUrl; } + if (ps.inquiryUrl !== undefined) { + set.inquiryUrl = ps.inquiryUrl; + } + if (ps.useObjectStorage !== undefined) { set.useObjectStorage = ps.useObjectStorage; } @@ -609,6 +619,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- set.bannedEmailDomains = ps.bannedEmailDomains; } + if (ps.urlPreviewEnabled !== undefined) { + set.urlPreviewEnabled = ps.urlPreviewEnabled; + } + + if (ps.urlPreviewTimeout !== undefined) { + set.urlPreviewTimeout = ps.urlPreviewTimeout; + } + + if (ps.urlPreviewMaximumContentLength !== undefined) { + set.urlPreviewMaximumContentLength = ps.urlPreviewMaximumContentLength; + } + + if (ps.urlPreviewRequireContentLength !== undefined) { + set.urlPreviewRequireContentLength = ps.urlPreviewRequireContentLength; + } + + if (ps.urlPreviewUserAgent !== undefined) { + const value = (ps.urlPreviewUserAgent ?? '').trim(); + set.urlPreviewUserAgent = value === '' ? null : ps.urlPreviewUserAgent; + } + + if (ps.summalyProxy !== undefined || ps.urlPreviewSummaryProxyUrl !== undefined) { + const value = ((ps.urlPreviewSummaryProxyUrl ?? ps.summalyProxy) ?? '').trim(); + set.urlPreviewSummaryProxyUrl = value === '' ? null : value; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 3b12f5b62c..ff8dd73605 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { DI } from '@/di-symbols.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js'; +import type { AnnouncementsRepository } from '@/models/_.js'; export const meta = { tags: ['meta'], @@ -44,11 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - private queryService: QueryService, - private announcementService: AnnouncementService, + private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) @@ -60,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const announcements = await query.limit(ps.limit).getMany(); - return this.announcementService.packMany(announcements, me); + return this.announcementEntityService.packMany(announcements, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/announcements/show.ts b/packages/backend/src/server/api/endpoints/announcements/show.ts new file mode 100644 index 0000000000..6312a0a54c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/announcements/show.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EntityNotFoundError } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Announcement', + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'b57b5e1d-4f49-404a-9edb-46b00268f121', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + announcementId: { type: 'string', format: 'misskey:id' }, + }, + required: ['announcementId'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private announcementService: AnnouncementService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + return await this.announcementService.getAnnouncement(ps.announcementId, me); + } catch (err) { + if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement); + throw err; + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 191de8f833..6b7bacb054 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -64,11 +64,11 @@ export const paramDef = { } }, caseSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, + excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, - required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @Injectable() @@ -124,9 +124,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- users: ps.users, caseSensitive: ps.caseSensitive, localOnly: ps.localOnly, + excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - notify: ps.notify, }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 459729f61f..0c30bca9e0 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -63,11 +63,11 @@ export const paramDef = { } }, caseSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, + excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, - required: ['antennaId', 'name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], + required: ['antennaId'], } as const; @Injectable() @@ -83,8 +83,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { - throw new Error('either keywords or excludeKeywords is required.'); + if (ps.keywords && ps.excludeKeywords) { + if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { + throw new Error('either keywords or excludeKeywords is required.'); + } } // Fetch the antenna const antenna = await this.antennasRepository.findOneBy({ @@ -98,7 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- let userList; - if (ps.src === 'list' && ps.userListId) { + if ((ps.src === 'list' || antenna.src === 'list') && ps.userListId) { userList = await this.userListsRepository.findOneBy({ id: ps.userListId, userId: me.id, @@ -112,15 +114,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- await this.antennasRepository.update(antenna.id, { name: ps.name, src: ps.src, - userListId: userList ? userList.id : null, + userListId: ps.userListId !== undefined ? userList ? userList.id : null : undefined, keywords: ps.keywords, excludeKeywords: ps.excludeKeywords, users: ps.users, caseSensitive: ps.caseSensitive, localOnly: ps.localOnly, + excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - notify: ps.notify, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8c55673590..9369481649 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -16,6 +16,7 @@ import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; +import { Brackets } from 'typeorm'; export const meta = { tags: ['notes', 'channels'], @@ -51,6 +52,12 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default + withRenotes: { type: 'boolean', default: true }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', + }, }, required: ['channelId'], } as const; @@ -89,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (me) this.activeUsersChart.read(me); if (!serverSettings.enableFanoutTimeline) { - return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); + return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); } return await this.fanoutTimelineEndpointService.timeline({ @@ -100,9 +107,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- me, useDbFallback: true, redisTimelines: [`channelTimeline:${channel.id}`], - excludePureRenotes: false, + excludePureRenotes: !ps.withRenotes, + excludeNoFiles: ps.withFiles, dbFallback: async (untilId, sinceId, limit) => { - return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); + return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me); }, }); }); @@ -112,7 +120,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- untilId: string | null, sinceId: string | null, limit: number, - channelId: string + channelId: string, + withFiles: boolean, + withRenotes: boolean, }, me: MiLocalUser | null) { //#region fallback to database const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -128,6 +138,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } + + if (ps.withRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts index 595a6957b2..502d42f9e0 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -54,7 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- folderId: ps.folderId ?? IsNull(), }); - return await Promise.all(files.map(file => this.driveFileEntityService.pack(file, { self: true }))); + return await this.driveFileEntityService.packMany(files, { self: true }); }); } } diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 4064a415a4..c3f2247b69 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -125,9 +125,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (typeof ps.suspended === 'boolean') { if (ps.suspended) { - query.andWhere('instance.isSuspended = TRUE'); + query.andWhere('instance.suspensionState != \'none\''); } else { - query.andWhere('instance.isSuspended = FALSE'); + query.andWhere('instance.suspensionState = \'none\''); } } diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index 2085b06365..ba48b0119e 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -20,13 +20,188 @@ export const meta = { res: { type: 'object', properties: { + image: { + type: 'object', + optional: true, + properties: { + link: { + type: 'string', + optional: true, + }, + url: { + type: 'string', + optional: false, + }, + title: { + type: 'string', + optional: true, + }, + }, + }, + paginationLinks: { + type: 'object', + optional: true, + properties: { + self: { + type: 'string', + optional: true, + }, + first: { + type: 'string', + optional: true, + }, + next: { + type: 'string', + optional: true, + }, + last: { + type: 'string', + optional: true, + }, + prev: { + type: 'string', + optional: true, + }, + }, + }, + link: { + type: 'string', + optional: true, + }, + title: { + type: 'string', + optional: true, + }, items: { type: 'array', + optional: false, items: { type: 'object', + properties: { + link: { + type: 'string', + optional: true, + }, + guid: { + type: 'string', + optional: true, + }, + title: { + type: 'string', + optional: true, + }, + pubDate: { + type: 'string', + optional: true, + }, + creator: { + type: 'string', + optional: true, + }, + summary: { + type: 'string', + optional: true, + }, + content: { + type: 'string', + optional: true, + }, + isoDate: { + type: 'string', + optional: true, + }, + categories: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + contentSnippet: { + type: 'string', + optional: true, + }, + enclosure: { + type: 'object', + optional: true, + properties: { + url: { + type: 'string', + optional: false, + }, + length: { + type: 'number', + optional: true, + }, + type: { + type: 'string', + optional: true, + }, + }, + }, + }, + }, + }, + feedUrl: { + type: 'string', + optional: true, + }, + description: { + type: 'string', + optional: true, + }, + itunes: { + type: 'object', + optional: true, + additionalProperties: true, + properties: { + image: { + type: 'string', + optional: true, + }, + owner: { + type: 'object', + optional: true, + properties: { + name: { + type: 'string', + optional: true, + }, + email: { + type: 'string', + optional: true, + }, + }, + }, + author: { + type: 'string', + optional: true, + }, + summary: { + type: 'string', + optional: true, + }, + explicit: { + type: 'string', + optional: true, + }, + categories: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + keywords: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, }, - } - } + }, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index 584d167a29..361496e17e 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -44,6 +44,7 @@ export const paramDef = { permissions: { type: 'array', items: { type: 'string', } }, + visibility: { type: 'string', enum: ['public', 'private'], default: 'public' }, }, required: ['title', 'summary', 'script', 'permissions'], } as const; @@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- summary: ps.summary, script: ps.script, permissions: ps.permissions, + visibility: ps.visibility, }).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0])); return await this.flashEntityService.pack(flash); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index 88f559138b..fa59e38976 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .limit(ps.limit) .getMany(); - return await Promise.all(requests.map(req => this.followRequestEntityService.pack(req))); + return await this.followRequestEntityService.packMany(requests, me); }); } } 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 74ee90b3dd..a4e61a0e8f 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 @@ -97,10 +97,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { } const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential); + const keyId = keyInfo.credentialID; - const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url'); await this.userSecurityKeysRepository.insert({ - id: credentialId, + id: keyId, userId: me.id, name: ps.name, publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'), @@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { })); return { - id: credentialId, + id: keyId, name: ps.name, }; }); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8ddbe5663e..2606108539 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 390dd9cd71..d5e824df27 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 51a9cdf5a5..0f5800404e 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-notes.ts b/packages/backend/src/server/api/endpoints/i/import-notes.ts index 4e00163550..91ef12c3e3 100644 --- a/packages/backend/src/server/api/endpoints/i/import-notes.ts +++ b/packages/backend/src/server/api/endpoints/i/import-notes.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -59,7 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); - + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); if ((await this.roleService.getUserPolicies(me.id)).canImportNotes === false) { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index a3b67301a7..bacdd5c88f 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -74,7 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 594e8b95c8..5e97b90f99 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -7,7 +7,7 @@ import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -84,27 +84,51 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); + let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; + let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; - if (notificationsRes.length === 0) { - return []; - } + let notifications: MiNotification[]; + for (;;) { + let notificationsRes: [id: string, fields: string[]][]; - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; + // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 + if (sinceTime && !untilTime) { + notificationsRes = await this.redisClient.xrange( + `notificationTimeline:${me.id}`, + '(' + sinceTime, + '+', + 'COUNT', ps.limit); + } else { + notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + untilTime ? '(' + untilTime : '+', + sinceTime ? '(' + sinceTime : '-', + 'COUNT', ps.limit); + } - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } + if (notificationsRes.length === 0) { + return []; + } - if (notifications.length === 0) { - return []; + notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length !== 0) { + // 通知が1件以上ある場合は返す + break; + } + + // フィルタしたことで通知が0件になった場合、次のページを取得する + if (ps.sinceId && !ps.untilId) { + sinceTime = notificationsRes[notificationsRes.length - 1][0]; + } else { + untilTime = notificationsRes[notificationsRes.length - 1][0]; + } } // Mark all as read diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts b/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts index bb471284c9..3aa256077e 100644 --- a/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts +++ b/packages/backend/src/server/api/endpoints/i/registry/get-unsecure.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie 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 { RegistryItemsRepository } from '@/models/_.js'; diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 08a8301bd1..7332026d84 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -16,6 +16,7 @@ import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { UserAuthService } from '@/core/UserAuthService.js'; +import { MetaService } from '@/core/MetaService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -40,6 +41,12 @@ export const meta = { code: 'UNAVAILABLE', id: 'a2defefb-f220-8849-0af6-17f816099323', }, + + emailRequired: { + message: 'Email address is required.', + code: 'EMAIL_REQUIRED', + id: '324c7a88-59f2-492f-903f-89134f93e47e', + }, }, res: { @@ -67,6 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + private metaService: MetaService, private userEntityService: UserEntityService, private emailService: EmailService, private userAuthService: UserAuthService, @@ -98,6 +106,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (!res.available) { throw new ApiError(meta.errors.unavailable); } + } else if ((await this.metaService.fetch()).emailRequiredForSignup) { + throw new ApiError(meta.errors.emailRequired); } await this.userProfilesRepository.update(me.id, { diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 06edb28578..aa2f85845f 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -530,26 +530,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private async verifyLink(url: string, user: MiLocalUser) { if (!safeForSql(url)) return; - const html = await this.httpRequestService.getHtml(url); + try { + const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc = window.document; + const { window } = new JSDOM(html); + const doc = window.document; - const myLink = `${this.config.url}/@${user.username}`; + const myLink = `${this.config.url}/@${user.username}`; - const aEls = Array.from(doc.getElementsByTagName('a')); - const linkEls = Array.from(doc.getElementsByTagName('link')); + const aEls = Array.from(doc.getElementsByTagName('a')); + const linkEls = Array.from(doc.getElementsByTagName('link')); - const includesMyLink = aEls.some(a => a.href === myLink); - const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); + const includesMyLink = aEls.some(a => a.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); - if (includesMyLink || includesRelMeLinks) { - await this.userProfilesRepository.createQueryBuilder('profile').update() - .where('userId = :userId', { userId: user.id }) - .set({ - verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている - }) - .execute(); + if (includesMyLink || includesRelMeLinks) { + await this.userProfilesRepository.createQueryBuilder('profile').update() + .where('userId = :userId', { userId: user.id }) + .set({ + verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている + }) + .execute(); + } + + window.close(); + } catch (err) { + // なにもしない } } } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 95ebda2f21..626f03b758 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -16,7 +16,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import { DI } from '@/di-symbols.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -32,6 +32,7 @@ export const meta = { limit: { duration: ms('1hour'), max: 300, + minInterval: ms('1sec'), }, kind: 'write:notes', @@ -286,7 +287,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isPureRenote(renote)) { + } else if (isRenote(renote) && !isQuote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -332,7 +333,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isPureRenote(reply)) { + } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 3caeda288b..835cbc14fa 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -11,7 +11,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEditService } from '@/core/NoteEditService.js'; import { DI } from '@/di-symbols.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; @@ -336,7 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (renote == null) { throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isPureRenote(renote)) { + } else if (isRenote(renote) && !isQuote(renote)) { throw new ApiError(meta.errors.cannotReRenote); } @@ -386,7 +386,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (reply == null) { throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isPureRenote(reply)) { + } else if (isRenote(reply) && !isQuote(reply)) { throw new ApiError(meta.errors.cannotReplyToPureRenote); } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { throw new ApiError(meta.errors.cannotReplyToInvisibleNote); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index ba38573065..4fd6f8682d 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -32,6 +32,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, + excludeChannels: { type: 'boolean', default: false }, }, required: [], } as const; @@ -86,6 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- query.setParameters(mutingQuery.getParameters()); //#endregion + //#region exclude channels + if (ps.excludeChannels) { + query.andWhere('poll.channelId IS NULL'); + } + //#endregion + const polls = await query .orderBy('poll.noteId', 'DESC') .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 3beb5064ae..7e334df93e 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -79,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const reactions = await query.limit(ps.limit).getMany(); - return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); + return await this.noteReactionEntityService.packMany(reactions, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index a935f761b7..d6ef655291 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -21,7 +21,7 @@ export const meta = { res: { type: 'object', - optional: false, nullable: false, + optional: true, nullable: false, properties: { sourceLang: { type: 'string' }, text: { type: 'string' }, @@ -39,6 +39,11 @@ export const meta = { code: 'NO_SUCH_NOTE', id: 'bea9b03f-36e0-49c5-a4db-627a029f8971', }, + cannotTranslateInvisibleNote: { + message: 'Cannot translate invisible note.', + code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE', + id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d', + }, }, } as const; @@ -72,21 +77,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }); if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) { - return 204; // TODO: 良い感じのエラー返す + throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } if (note.text == null) { - return 204; + return; } const instance = await this.metaService.fetch(); if (instance.deeplAuthKey == null && !instance.deeplFreeMode) { - return 204; // TODO: 良い感じのエラー返す + throw new ApiError(meta.errors.unavailable); } if (instance.deeplFreeMode && !instance.deeplFreeInstance) { - return 204; + throw new ApiError(meta.errors.unavailable); } let targetLang = ps.targetLang; diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 85d100ce1c..48d350af59 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -92,9 +92,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .limit(ps.limit) .getMany(); + const _users = assigns.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(assigns.map(async assign => ({ id: assign.id, - user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), + user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), }))); }); } diff --git a/packages/backend/src/server/api/endpoints/sponsors.ts b/packages/backend/src/server/api/endpoints/sponsors.ts index 50e1c594f2..b6ccb9b2f9 100644 --- a/packages/backend/src/server/api/endpoints/sponsors.ts +++ b/packages/backend/src/server/api/endpoints/sponsors.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 5d52ebba76..6b3389f0b2 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -6,6 +6,7 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js'; +import { birthdaySchema } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; @@ -66,7 +67,7 @@ export const paramDef = { description: 'The local host is represented with `null`.', }, - birthday: { type: 'string', nullable: true }, + birthday: { ...birthdaySchema, nullable: true }, }, anyOf: [ { required: ['userId'] }, @@ -127,9 +128,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (ps.birthday) { try { - const d = new Date(ps.birthday); - d.setHours(0, 0, 0, 0); - const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; + const birthday = ps.birthday.substring(5, 10); const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); birthdayUserQuery.select('user_profile.userId') .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 02aa037466..9248a2fa68 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -118,12 +118,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); // Extract top replied users - const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); + const topRepliedUserIds = repliedUsersSorted.slice(0, ps.limit); // Make replies object (includes weights) - const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ - user: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), - weight: repliedUsers[user] / peak, + const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); + const repliesObj = await Promise.all(topRepliedUserIds.map(async (userId) => ({ + user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: 'UserDetailed' }), + weight: repliedUsers[userId] / peak, }))); return repliesObj; diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 6a5b2262fa..1d75437b81 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -132,11 +132,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; - - const relations = await Promise.all(ids.map(id => this.userEntityService.getRelation(me.id, id))); - - return Array.isArray(ps.userId) ? relations : relations[0]; + return Array.isArray(ps.userId) + ? await this.userEntityService.getRelations(me.id, ps.userId).then(it => [...it.values()]) + : await this.userEntityService.getRelation(me.id, ps.userId).then(it => [it]); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index bd81989cb9..062326e28d 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -110,14 +110,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }); // リクエストされた通りに並べ替え + // 順番は保持されるけど数は減ってる可能性がある const _users: MiUser[] = []; for (const id of ps.userIds) { - _users.push(users.find(x => x.id === id)!); + const user = users.find(x => x.id === id); + if (user != null) _users.push(user); } - return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, { - schema: 'UserDetailed', - }))); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return _users.map(u => _userMap.get(u.id)!); } else { // Lookup user if (typeof ps.host === 'string' && typeof ps.username === 'string') { diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts index 5706a17786..8c9cca1730 100644 --- a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts +++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; import megalodon, { Entity, MegalodonInterface } from 'megalodon'; import querystring from 'querystring'; @@ -89,7 +94,7 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); - + fastify.get('/v1/instance', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -113,7 +118,7 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); - + fastify.get('/v1/announcements', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -126,7 +131,7 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); - + fastify.post<{ Body: { id: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -177,8 +182,8 @@ export class MastodonApiServerService { /* console.error(e); */ reply.code(401).send(e.response.data); } - }); - + }); + fastify.get('/v1/filters', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -192,7 +197,7 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); - + fastify.get('/v1/trends', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -238,7 +243,7 @@ export class MastodonApiServerService { reply.code(401).send(e.response.data); } }); - + fastify.get('/v1/preferences', async (_request, reply) => { const BASE_URL = `${_request.protocol}://${_request.hostname}`; const accessTokens = _request.headers.authorization; @@ -291,18 +296,18 @@ export class MastodonApiServerService { user: { id: tokeninfo.userId, host: null }, path: avatar.path, name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined, - sensitive: false, + sensitive: false, }); if (upload.type.startsWith('image/')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (_request.body as any).avatar = upload.id; } - } else if (tokeninfo && header) { + } else if (tokeninfo && header) { const upload = await this.driveService.addFile({ user: { id: tokeninfo.userId, host: null }, path: header.path, name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined, - sensitive: false, + sensitive: false, }); if (upload.type.startsWith('image/')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -324,7 +329,7 @@ export class MastodonApiServerService { }; }); // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); + (_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0); } const data = await client.updateCredentials(_request.body!); diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 326d3a1d5c..405701e826 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; import { Entity } from 'megalodon'; import mfm from '@transfem-org/sfm-js'; @@ -110,7 +115,7 @@ export class MastoConverters { private async encodeField(f: Entity.Field): Promise<Entity.Field> { return { name: f.name, - value: await this.mfmService.toMastoHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), + value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), verified_at: null, }; } @@ -146,8 +151,8 @@ export class MastoConverters { display_name: user.name ?? user.username, locked: user.isLocked, created_at: this.idService.parse(user.id).date.toISOString(), - followers_count: user.followersCount, - following_count: user.followingCount, + followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0, + following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0, statuses_count: user.notesCount, note: profile?.description ?? '', url: user.uri ?? acctUrl, @@ -179,7 +184,7 @@ export class MastoConverters { const files = this.driveFileEntityService.packManyByIds(edit.fileIds); const item = { account: noteUser, - content: this.mfmService.toMastoHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), + 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), @@ -240,7 +245,7 @@ export class MastoConverters { }); const content = note.text !== null - ? quoteUri.then(quoteUri => this.mfmService.toMastoHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) + ? quoteUri.then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) .then(p => p ?? escapeMFM(note.text!)) : ''; diff --git a/packages/backend/src/server/api/mastodon/endpoints.ts b/packages/backend/src/server/api/mastodon/endpoints.ts index 5a75823891..085314059b 100644 --- a/packages/backend/src/server/api/mastodon/endpoints.ts +++ b/packages/backend/src/server/api/mastodon/endpoints.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { ApiAuthMastodon } from './endpoints/auth.js'; import { ApiAccountMastodon } from './endpoints/account.js'; import { ApiSearchMastodon } from './endpoints/search.js'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 07d9efb8c1..6fcfb0019c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { MastoConverters, convertRelationship } from '../converters.js'; import { argsToBools, limitToInt } from './timeline.js'; import type { MegalodonInterface } from 'megalodon'; @@ -104,7 +109,7 @@ export class ApiAccountMastodon { public async getFollowers() { try { const data = await this.client.getAccountFollowers( - (this.request.params as any).id, + (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))); @@ -118,7 +123,7 @@ export class ApiAccountMastodon { public async getFollowing() { try { const data = await this.client.getAccountFollowing( - (this.request.params as any).id, + (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))); diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index 80a5e89ca3..a447bdb1b7 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; @@ -65,7 +70,7 @@ export async function ApiAuthMastodon(request: FastifyRequest, client: Megalodon client_id: Buffer.from(appData.url || '').toString('base64'), client_secret: appData.clientSecret, }; - + return returns; } catch (e: any) { console.error(e); diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 212c79b251..ce6809d230 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { convertFilter } from '../converters.js'; import type { MegalodonInterface } from 'megalodon'; import type { FastifyRequest } from 'fastify'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index efb39ef939..79d2c62a24 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Entity } from 'megalodon'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import type { Config } from '@/config.js'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index c4628b58c4..0eefb5894c 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { convertNotification } from '../converters.js'; import type { MegalodonInterface, Entity } from 'megalodon'; import type { FastifyRequest } from 'fastify'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 500129c901..946e796e2a 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { MastoConverters } from '../converters.js'; import { limitToInt } from './timeline.js'; import type { MegalodonInterface } from 'megalodon'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index fe77646af4..ddc99639fa 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import querystring from 'querystring'; import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; import { convertAttachment, convertPoll, convertStatusSource, MastoConverters } from '../converters.js'; diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index f81b63b9ac..3eb4898713 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { ParsedUrlQuery } from 'querystring'; import { convertConversation, convertList, MastoConverters } from '../converters.js'; import { getClient } from '../MastodonApiServerService.js'; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index b5f91ff542..436f9a44bb 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -93,7 +93,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); const info = { - operationId: endpoint.name, + operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない summary: endpoint.name, description: desc, externalDocs: { diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 44a143538b..49b0ae1d5b 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -4,6 +4,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 type { Packed } from '@/misc/json-schema.js'; import type Connection from './Connection.js'; /** @@ -54,6 +58,24 @@ export default abstract class Channel { return this.connection.subscriber; } + /* + * ミュートとブロックされてるを処理する + */ + protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる + if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; + + // 流れてきたNoteがミュートしているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; + // 流れてきたNoteがブロックされているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true; + + // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの + if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + + return false; + } + constructor(id: string, connection: Connection) { this.id = id; this.connection = connection; diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 135d162e63..4a1d2dd109 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -4,7 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -40,12 +39,7 @@ class AntennaChannel extends Channel { if (data.type === 'note') { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; this.connection.cacheNote(note); 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 2d85d65ba5..01be2d2089 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -4,15 +4,13 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { MiMeta } from '@/models/Meta.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { MiChannelService } from '../channel.js'; class BubbleTimelineChannel extends Channel { @@ -54,36 +52,18 @@ class BubbleTimelineChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; - + if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; if (note.channelId != null) return; - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && note.renoteId && !note.text) { + 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; diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 90ee1ecda5..865e4fed19 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -4,10 +4,10 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.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 Channel, { type MiChannelService } from '../channel.js'; class ChannelChannel extends Channel { @@ -15,6 +15,8 @@ class ChannelChannel extends Channel { public static shouldShare = false; public static requireCredential = false as const; private channelId: string; + private withFiles: boolean; + private withRenotes: boolean; constructor( private noteEntityService: NoteEntityService, @@ -29,6 +31,8 @@ class ChannelChannel extends Channel { @bindThis public async init(params: any) { this.channelId = params.channelId as string; + this.withFiles = params.withFiles ?? false; + this.withRenotes = params.withRenotes ?? true; // Subscribe stream this.subscriber.on('notesStream', this.onNote); @@ -38,14 +42,13 @@ class ChannelChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - if (this.user && note.renoteId && !note.text) { + 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; 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 fc25724782..0a894147a2 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -4,14 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class GlobalTimelineChannel extends Channel { @@ -55,31 +53,13 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && note.renoteId && !note.text) { + 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; diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 377b1a0162..57bada5d9c 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -5,10 +5,10 @@ import { Injectable } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { isUserRelated } from '@/misc/is-user-related.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 Channel, { type MiChannelService } from '../channel.js'; class HashtagChannel extends Channel { @@ -43,14 +43,9 @@ class HashtagChannel extends Channel { const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); if (!matched) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + 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; 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 0a4852ee8d..84ff241469 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -4,12 +4,10 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.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 Channel, { type MiChannelService } from '../channel.js'; class HomeTimelineChannel extends Channel { @@ -51,9 +49,6 @@ class HomeTimelineChannel extends Channel { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; - if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } else if (note.visibility === 'specified') { @@ -74,7 +69,7 @@ class HomeTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; // 純粋なリノート(引用リノートでないリノート)の場合 - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) { + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; @@ -83,17 +78,9 @@ class HomeTimelineChannel extends Channel { } } - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (this.user && note.renoteId && !note.text) { + 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; 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 02786e9e16..b83d3ec817 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -4,14 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -74,8 +72,7 @@ class HybridTimelineChannel extends Channel { if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances)) && !this.following[note.userId]) return; + if (this.isNoteMutedOrBlocked(note)) return; if (note.reply) { const reply = note.reply; @@ -90,17 +87,7 @@ class HybridTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { 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 71b5675402..48cc76c497 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -4,13 +4,12 @@ */ import { Injectable } from '@nestjs/common'; -import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class LocalTimelineChannel extends Channel { @@ -66,19 +65,11 @@ class LocalTimelineChannel extends Channel { if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && note.renote.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - if (note.mentions?.some(mention => this.userIdsWhoMeMuting.has(mention))) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + 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; 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 80aab4b35e..6a4ad22460 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -4,8 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -46,12 +44,7 @@ class RoleTimelineChannel extends Channel { } if (note.visibility !== 'public') return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; this.send('note', note); } else { 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 f7bb106c03..14b30a157c 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -5,12 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { @@ -106,25 +105,17 @@ class UserListChannel extends Channel { } } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + 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; } } - // 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する - if (isInstanceMuted(note, this.userMutedInstances)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index cb41c4f338..702e306feb 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -202,9 +202,18 @@ export class ClientServerService { // Authenticate fastify.addHook('onRequest', async (request, reply) => { + if (request.routeOptions.url == null) { + reply.code(404).send('Not found'); + return; + } + // %71ueueとかでリクエストされたら困るため const url = decodeURI(request.routeOptions.url); if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { + if (!url.startsWith(bullBoardPath + '/static/')) { + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + } + const token = request.cookies.token; if (token == null) { reply.code(401).send('Login required'); @@ -447,7 +456,7 @@ export class ClientServerService { //#endregion - const renderBase = async (reply: FastifyReply) => { + const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => { const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=30'); return await reply.view('base', { @@ -456,6 +465,7 @@ export class ClientServerService { title: meta.name ?? 'Misskey', desc: meta.description, ...await this.generateCommonPugData(meta), + ...data, }); }; @@ -474,7 +484,9 @@ export class ClientServerService { }; // Atom - fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { @@ -487,7 +499,9 @@ export class ClientServerService { }); // RSS - fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { @@ -500,7 +514,9 @@ export class ClientServerService { }); // JSON - fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { @@ -753,6 +769,18 @@ export class ClientServerService { }); //#endregion + //region noindex pages + // Tags + fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => { + return await renderBase(reply, { noindex: true }); + }); + + // User with Tags + fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => { + return await renderBase(reply, { noindex: true }); + }); + //endregion + fastify.get('/_info_card_', async (request, reply) => { const meta = await this.metaService.fetch(true); diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index c6a96e94cb..8f8f08a305 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { summaly } from '@misskey-dev/summaly'; +import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; @@ -14,6 +15,7 @@ import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { ApiError } from '@/server/api/error.js'; +import { MiMeta } from '@/models/Meta.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -62,24 +64,25 @@ export class UrlPreviewService { const meta = await this.metaService.fetch(); - this.logger.info(meta.summalyProxy + if (!meta.urlPreviewEnabled) { + reply.code(403); + return { + error: new ApiError({ + message: 'URL preview is disabled', + code: 'URL_PREVIEW_DISABLED', + id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', + }), + }; + } + + this.logger.info(meta.urlPreviewSummaryProxyUrl ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); + try { - const summary = meta.summalyProxy ? - await this.httpRequestService.getJson<ReturnType<typeof summaly>>(`${meta.summalyProxy}?${query({ - url: url, - lang: lang ?? 'ja-JP', - })}`) - : - await summaly(url, { - followRedirects: false, - lang: lang ?? 'ja-JP', - agent: this.config.proxy ? { - http: this.httpRequestService.httpAgent, - https: this.httpRequestService.httpsAgent, - } : undefined, - }); + const summary = meta.urlPreviewSummaryProxyUrl + ? await this.fetchSummaryFromProxy(url, meta, lang) + : await this.fetchSummary(url, meta, lang); this.logger.succ(`Got preview of ${url}: ${summary.title}`); @@ -100,6 +103,7 @@ export class UrlPreviewService { return summary; } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); + reply.code(422); reply.header('Cache-Control', 'max-age=86400, immutable'); return { @@ -111,4 +115,37 @@ export class UrlPreviewService { }; } } + + private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { + const agent = this.config.proxy + ? { + http: this.httpRequestService.httpAgent, + https: this.httpRequestService.httpsAgent, + } + : undefined; + + return summaly(url, { + followRedirects: false, + lang: lang ?? 'ja-JP', + agent: agent, + userAgent: meta.urlPreviewUserAgent ?? undefined, + operationTimeout: meta.urlPreviewTimeout, + contentLengthLimit: meta.urlPreviewMaximumContentLength, + contentLengthRequired: meta.urlPreviewRequireContentLength, + }); + } + + private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> { + const proxy = meta.urlPreviewSummaryProxyUrl!; + const queryStr = query({ + url: url, + lang: lang ?? 'ja-JP', + userAgent: meta.urlPreviewUserAgent ?? undefined, + operationTimeout: meta.urlPreviewTimeout, + contentLengthLimit: meta.urlPreviewMaximumContentLength, + contentLengthRequired: meta.urlPreviewRequireContentLength, + }); + + return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`); + } } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 6b9dc0a103..0543cc2b65 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -86,8 +86,8 @@ //#endregion //#region Script - function importAppScript() { - import(`/vite/${CLIENT_ENTRY}`) + async function importAppScript() { + await import(`/vite/${CLIENT_ENTRY}`) .catch(async e => { console.error(e); renderError('APP_IMPORT', e); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 0f555e0637..4007632291 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -40,8 +40,8 @@ html link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/phosphor-icons/bold/style.css') - link(rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css') + link(rel='stylesheet' href=`/assets/phosphor-icons/bold/style.css?version=${version}`) + link(rel='stylesheet' href=`/static-assets/fonts/sharkey-icons/style.css?version=${version}`) link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists @@ -55,6 +55,9 @@ html block title = title || 'Sharkey' + if noindex + meta(name='robots' content='noindex') + block desc meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug index 47c89ae2d7..a7cd0293cd 100644 --- a/packages/backend/src/server/web/views/note.pug +++ b/packages/backend/src/server/web/views/note.pug @@ -2,7 +2,7 @@ extends ./base block vars - const user = note.user; - - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`; - const url = `${config.url}/notes/${note.id}`; - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - const images = note.cw ? [] : (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive) @@ -28,7 +28,7 @@ block og // FIXME: add embed player for Twitter if images.length meta(property='twitter:card' content='summary_large_image') - each image in images + each image in images meta(property='og:image' content= image.url) else meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug index 08bb08ffe7..03c50eca8a 100644 --- a/packages/backend/src/server/web/views/page.pug +++ b/packages/backend/src/server/web/views/page.pug @@ -3,7 +3,7 @@ extends ./base block vars - const user = page.user; - const title = page.title; - - const url = `${config.url}/@${user.username}/${page.name}`; + - const url = `${config.url}/@${user.username}/pages/${page.name}`; block title = `${title} | ${instanceName}` diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug index 83d57349a6..2b0a7bab5c 100644 --- a/packages/backend/src/server/web/views/user.pug +++ b/packages/backend/src/server/web/views/user.pug @@ -1,7 +1,7 @@ extends ./base block vars - - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`; - const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`; block title |