From 5dc938cb93b8432202d1cea497e3e3b92a9423a1 Mon Sep 17 00:00:00 2001 From: fly_mc Date: Wed, 20 Nov 2024 23:37:16 +0800 Subject: [PATCH 1/2] backend: fixes --- packages/backend/src/core/AntennaService.ts | 8 +- packages/backend/src/core/DriveService.ts | 40 +- .../backend/src/core/GlobalEventService.ts | 4 + .../backend/src/core/NoteCreateService.ts | 54 +- .../backend/src/core/NoteDeleteService.ts | 33 +- packages/backend/src/core/NoteEditService.ts | 1070 +++++++++++++++++ packages/backend/src/core/NoteReadService.ts | 6 +- packages/backend/src/core/ReactionService.ts | 26 +- .../backend/src/core/UserSuspendService.ts | 1 + .../src/core/activitypub/ApMfmService.ts | 2 +- .../src/core/activitypub/ApResolverService.ts | 2 +- packages/backend/src/misc/get-note-summary.ts | 6 +- packages/backend/src/models/Note.ts | 1 + .../src/models/json-schema/note-edit.ts | 59 + .../src/queue/QueueProcessorService.ts | 2 +- 15 files changed, 1271 insertions(+), 43 deletions(-) create mode 100644 packages/backend/src/core/NoteEditService.ts create mode 100644 packages/backend/src/models/json-schema/note-edit.ts diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68..ec9ace417 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -136,13 +136,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/DriveService.ts b/packages/backend/src/core/DriveService.ts index 79ca3baad..66d8f9075 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -227,25 +227,33 @@ export class DriveService { const thumbnailAccessKey = 'thumbnail-' + randomUUID(); const webpublicAccessKey = 'webpublic-' + randomUUID(); - const url = this.internalStorageService.saveFromPath(accessKey, path); - - let thumbnailUrl: string | null = null; - let webpublicUrl: string | null = null; + // Ugly type is just to help TS figure out that 2nd / 3rd promises are optional. + const promises: [Promise, ...(Promise | undefined)[]] = [ + this.internalStorageService.saveFromPath(accessKey, path), + ]; if (alts.thumbnail) { - thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); - this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + promises.push(this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data)); } if (alts.webpublic) { - webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + promises.push(this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data)); + } + + const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises); + + if (thumbnailUrl) { + this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (webpublicUrl) { this.registerLogger.info(`web stored: ${webpublicAccessKey}`); } file.storedInternal = true; file.url = url; - file.thumbnailUrl = thumbnailUrl; - file.webpublicUrl = webpublicUrl; + file.thumbnailUrl = thumbnailUrl ?? null; + file.webpublicUrl = webpublicUrl ?? null; file.accessKey = accessKey; file.thumbnailAccessKey = thumbnailAccessKey; file.webpublicAccessKey = webpublicAccessKey; @@ -741,19 +749,19 @@ export class DriveService { @bindThis public async deleteFileSync(file: MiDriveFile, isExpired = false, deleter?: MiUser) { + const promises = []; + if (file.storedInternal) { - this.internalStorageService.del(file.accessKey!); + promises.push(this.internalStorageService.del(file.accessKey!)); if (file.thumbnailUrl) { - this.internalStorageService.del(file.thumbnailAccessKey!); + promises.push(this.internalStorageService.del(file.thumbnailAccessKey!)); } if (file.webpublicUrl) { - this.internalStorageService.del(file.webpublicAccessKey!); + promises.push(this.internalStorageService.del(file.webpublicAccessKey!)); } } else if (!file.isLink) { - const promises = []; - promises.push(this.deleteObjectStorageFile(file.accessKey!)); if (file.thumbnailUrl) { @@ -763,10 +771,10 @@ export class DriveService { if (file.webpublicUrl) { promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!)); } - - await Promise.all(promises); } + await Promise.all(promises); + this.deletePostProcess(file, isExpired, deleter); } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 04dc5682f..46b5d18dc 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -138,6 +138,10 @@ export interface NoteEventTypes { reaction: string; userId: MiUser['id']; }; + replied: { + id: MiNote['id']; + userId: MiUser['id']; + }; } type NoteStreamEventTypes = { [key in keyof NoteEventTypes]: { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 82245d847..b108320bb 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -52,8 +52,10 @@ import { FeaturedService } from '@/core/FeaturedService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; @@ -217,6 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private cacheService: CacheService, ) { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); } @@ -450,6 +453,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; @@ -630,6 +641,10 @@ export class NoteCreateService implements OnApplicationShutdown { // If has in reply to note if (data.reply) { + this.globalEventService.publishNoteStream(data.reply.id, 'replied', { + id: note.id, + userId: user.id, + }); // 通知 if (data.reply.userHost === null) { const isThreadMuted = await this.noteThreadMutingsRepository.exists({ @@ -639,7 +654,15 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - if (!isThreadMuted) { + const [ + userIdsWhoMeMuting, + ] = data.reply.userId ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(data.reply.userId), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (!isThreadMuted && !muted) { nm.push(data.reply.userId, 'reply'); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); @@ -659,7 +682,24 @@ export class NoteCreateService implements OnApplicationShutdown { // Notify if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: data.renote.userId, + threadId: data.renote.threadId ?? data.renote.id, + }, + }); + + const [ + userIdsWhoMeMuting, + ] = data.renote.userId ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(data.renote.userId), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (!isThreadMuted && !muted) { + nm.push(data.renote.userId, type); + } } // Publish event @@ -788,7 +828,15 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - if (isThreadMuted) { + const [ + userIdsWhoMeMuting, + ] = u.id ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(u.id), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (isThreadMuted || muted) { continue; } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 4ecd2592b..f00cc739b 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -68,6 +68,13 @@ export class NoteDeleteService { await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); } + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => { + if (!renote) return; + if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1); + }); + } + if (!quiet) { this.globalEventService.publishNoteStream(note.id, 'deleted', { deletedAt: deletedAt, @@ -106,15 +113,25 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (this.meta.enableStatsForFederatedInstances) { - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + if (note.renoteId && note.text) { + // Decrement notes count (user) + this.decNotesCountOfUser(user); + } else if (!note.renoteId) { + // Decrement notes count (user) + this.decNotesCountOfUser(user); + } + + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetch(user.host).then(async i => { + if (note.renoteId && note.text) { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); - } + } else if (!note.renoteId) { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + } + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } + }); } } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts new file mode 100644 index 000000000..e910c53ba --- /dev/null +++ b/packages/backend/src/core/NoteEditService.ts @@ -0,0 +1,1070 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setImmediate } from 'node:timers/promises'; +import * as mfm from 'mfm-js'; +import { DataSource, In, IsNull, LessThan } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import type { IMentionedRemoteUsers } from '@/models/Note.js'; +import { MiNote } from '@/models/Note.js'; +import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiApp } from '@/models/App.js'; +import { concat } from '@/misc/prelude/array.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import { MiPoll, type IPoll } from '@/models/Poll.js'; +import type { MiChannel } from '@/models/Channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; +import { SearchService } from '@/core/SearchService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isReply } from '@/misc/is-reply.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { LatestNoteService } from '@/core/LatestNoteService.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited'; + +class NotificationManager { + private notifier: { id: MiUser['id']; }; + private note: MiNote; + private queue: { + target: MiLocalUser['id']; + reason: NotificationType; + }[]; + + constructor( + private mutingsRepository: MutingsRepository, + private notificationService: NotificationService, + notifier: { id: MiUser['id']; }, + note: MiNote, + ) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + @bindThis + public push(notifiee: MiLocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason !== 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee, + }); + } + } + + @bindThis + public async notify() { + for (const x of this.queue) { + if (x.reason === 'renote') { + this.notificationService.createNotification(x.target, 'renote', { + noteId: this.note.id, + targetNoteId: this.note.renoteId!, + }, this.notifier.id); + } else { + this.notificationService.createNotification(x.target, x.reason, { + noteId: this.note.id, + }, this.notifier.id); + } + } + } +} + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; + updatedAt?: Date | null; + editcount?: boolean | null; +}; + +@Injectable() +export class NoteEditService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + private updateNotesCountQueue: CollapsedQueue; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.noteEditRepository) + private noteEditRepository: NoteEditRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private queueService: QueueService, + private fanoutTimelineService: FanoutTimelineService, + private noteReadService: NoteReadService, + private notificationService: NotificationService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private webhookService: UserWebhookService, + private remoteUserResolveService: RemoteUserResolveService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private roleService: RoleService, + private searchService: SearchService, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private utilityService: UtilityService, + private userBlockingService: UserBlockingService, + private cacheService: CacheService, + private latestNoteService: LatestNoteService, + private noteCreateService: NoteCreateService, + ) { + this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); + } + + @bindThis + public async edit(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + noindex: MiUser['noindex']; + }, editid: MiNote['id'], data: Option, silent = false): Promise { + if (!editid) { + throw new Error('fail'); + } + + const oldnote = await this.notesRepository.findOneBy({ + id: editid, + }); + + if (oldnote == null) { + throw new Error('no such note'); + } + + if (oldnote.userId !== user.id) { + throw new Error('not the author'); + } + + // we never want to change the replyId, so fetch the original "parent" + if (oldnote.replyId) { + data.reply = await this.notesRepository.findOneBy({ id: oldnote.replyId }); + } else { + data.reply = undefined; + } + + // changing visibility on an edit is ill-defined, let's try to + // keep the same visibility as the original note + data.visibility = oldnote.visibility; + data.localOnly = oldnote.localOnly; + + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } + + if (data.visibility == null) data.visibility = 'public'; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + if (data.updatedAt == null) data.updatedAt = new Date(); + + if (data.visibility === 'public' && data.channel == null) { + const sensitiveWords = this.meta.sensitiveWords; + if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { + data.visibility = 'home'; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + data.visibility = 'home'; + } + } + + const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ + cw: data.cw, + text: data.text, + pollChoices: data.poll?.choices, + }, this.meta.prohibitedWords); + + if (hasProhibitedWords) { + throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); + } + + const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host); + + if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { + data.visibility = 'home'; + } + + 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可能 + break; + case 'home': + // home noteはhome以下にrenote可能 + if (data.visibility === 'public') { + data.visibility = 'home'; + } + break; + case 'followers': + // 他人のfollowers noteはreject + if (data.renote.userId !== user.id) { + throw new Error('Renote target is not public or home'); + } + + // Renote対象がfollowersならfollowersにする + data.visibility = 'followers'; + break; + case 'specified': + // specified / direct noteはreject + throw new Error('Renote target is not public or home'); + } + } + + // Check blocking + 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); + if (blocked) { + throw new Error('blocked'); + } + } + } + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (this.isRenote(data) && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + const maxTextLength = user.host == null + ? this.config.maxNoteLength + : this.config.maxRemoteNoteLength; + + if (data.text) { + if (data.text.length > maxTextLength) { + data.text = data.text.slice(0, maxTextLength); + } + data.text = data.text.trim(); + if (data.text === '') { + data.text = null; + } + } else { + data.text = null; + } + + const maxCwLength = user.host == null + ? this.config.maxCwLength + : this.config.maxRemoteCwLength; + + if (data.cw) { + if (data.cw.length > maxCwLength) { + data.cw = data.cw.slice(0, maxCwLength); + } + data.cw = data.cw.trim(); + if (data.cw === '') { + data.cw = null; + } + } else { + data.cw = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); + } + + // if the host is media-silenced, custom emojis are not allowed + if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { + mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + } + + if (user.host && !data.cw) { + await this.federatedInstanceService.fetch(user.host).then(async i => { + if (i.isNSFW) { + data.cw = 'Instance is marked as NSFW'; + } + }); + } + + if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) { + throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions'); + } + + const update: Partial = {}; + if (data.text !== oldnote.text) { + update.text = data.text; + } + if (data.cw !== oldnote.cw) { + update.cw = data.cw; + } + if (oldnote.hasPoll !== !!data.poll) { + update.hasPoll = !!data.poll; + } + + // technically we should check if the two sets of files are + // different, or if their descriptions have changed. In practice + // this is good enough. + const filesChanged = oldnote.fileIds?.length || data.files?.length; + + const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); + + const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; + + if (Object.keys(update).length > 0 || filesChanged) { + const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id }); + + await this.noteEditRepository.insert({ + id: this.idService.gen(), + noteId: oldnote.id, + oldText: oldnote.text || undefined, + newText: update.text || undefined, + cw: update.cw || undefined, + fileIds: undefined, + oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date, + updatedAt: new Date(), + }); + + const note = new MiNote({ + id: oldnote.id, + updatedAt: data.updatedAt ? data.updatedAt : new Date(), + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: oldnote.replyId, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw ?? null, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + reactions: oldnote.reactions, + userId: user.id, + localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, + visibility: data.visibility as any, + visibleUserIds: data.visibility === 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + reactionAndUserPairCache: oldnote.reactionAndUserPairCache, + }); + + if (data.uri != null) note.uri = data.uri; + if (data.url != null) note.url = data.url; + + if (mentionedUsers.length > 0) { + note.mentions = mentionedUsers.map(u => u.id); + const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) }); + note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId === u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url ?? undefined, + username: u.username, + host: u.host, + } as IMentionedRemoteUsers[0]; + })); + } + + if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.update(MiNote, oldnote.id, note); + + const poll = new MiPoll({ + noteId: note.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: note.visibility, + userId: user.id, + userHost: user.host, + channelId: data.channel ? data.channel.id : null, + }); + + if (!oldnote.hasPoll) { + await transactionalEntityManager.insert(MiPoll, poll); + } else { + await transactionalEntityManager.update(MiPoll, oldnote.id, poll); + } + }); + } else { + await this.notesRepository.update(oldnote.id, note); + } + + setImmediate('post edited', { signal: this.#shutdownController.signal }).then( + () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); + + return note; + } else { + return oldnote; + } + } + + @bindThis + private async postNoteEdited(note: MiNote, oldNote: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + noindex: MiUser['noindex']; + }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + // Register host + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetch(user.host).then(async i => { + this.updateNotesCountQueue.enqueue(i.id, 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } + + // ハッシュタグ更新 + this.pushToTl(note, user); + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + this.queueService.endedPollNotificationQueue.remove(note.id); + this.queueService.endedPollNotificationQueue.add(note.id, { + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); + if (data.poll != null) { + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: note.cw, + text: note.text!, + }); + } else { + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: note.cw, + text: note.text!, + }); + } + + this.roleService.addNoteToRoleTimeline(noteObj); + + this.webhookService.getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + this.queueService.userWebhookDeliver(webhook, 'note', { + note: noteObj, + }); + } + }); + + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); + + //await this.createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // 通知 + if (data.reply.userHost === null) { + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: data.reply.userId, + threadId: data.reply.threadId ?? data.reply.id, + }, + }); + + const [ + userIdsWhoMeMuting, + ] = data.reply.userId ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(data.reply.userId), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (!isThreadMuted && !muted) { + nm.push(data.reply.userId, 'edited'); + this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('edited')); + for (const webhook of webhooks) { + this.queueService.userWebhookDeliver(webhook, 'edited', { + note: noteObj, + }); + } + } + } + } + + nm.notify(); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + (async () => { + const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as MiRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + 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); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + 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); + } + + trackPromise(dm.execute()); + })(); + } + //#endregion + } + + if (data.channel) { + this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); + this.channelsRepository.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + this.notesRepository.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Update the Latest Note index / following feed + this.latestNoteService.handleUpdatedNoteBG(oldNote, note); + + // Register to search database + if (!user.noindex) this.index(note); + } + + @bindThis + 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 + private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: u.id, + threadId: note.threadId ?? note.id, + }, + }); + + const [ + userIdsWhoMeMuting, + ] = u.id ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(u.id), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (isThreadMuted || muted) { + continue; + } + + const detailPackedNote = await this.noteEntityService.pack(note, u, { + detail: true, + }); + + this.globalEventService.publishMainStream(u.id, 'edited', detailPackedNote); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('edited')); + for (const webhook of webhooks) { + this.queueService.userWebhookDeliver(webhook, 'edited', { + note: detailPackedNote, + }); + } + + // Create notification + nm.push(u.id, 'edited'); + } + } + + @bindThis + private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + if (data.localOnly) return null; + const user = await this.usersRepository.findOneBy({ id: note.userId }); + if (user == null) throw new Error('user not found'); + + const content = this.isRenote(data) && !this.isQuote(data) + ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) + : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); + + return this.apRendererService.addContext(content); + } + + @bindThis + private index(note: MiNote) { + if (note.text == null && note.cw == null) return; + + this.searchService.indexNote(note); + } + + @bindThis + private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + let mentionedUsers = (await Promise.all(mentions.map(m => + this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), + ))).filter(x => x !== null) as MiUser[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id), + ); + + return mentionedUsers; + } + + @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + if (!this.meta.enableFanoutTimeline) return; + + const r = this.redisForTimelines.pipeline(); + + if (note.channelId) { + this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); + + this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); + + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + } + } + } else { + // TODO: キャッシュ? + // eslint-disable-next-line prefer-const + let [followings, userListMemberships] = await Promise.all([ + this.followingsRepository.find({ + where: { + followeeId: user.id, + followerHost: IsNull(), + isFollowerHibernated: false, + }, + select: ['followerId', 'withReplies'], + }), + this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'userListUserId', 'withReplies'], + }), + ]); + + if (note.visibility === 'followers') { + // TODO: 重そうだから何とかしたい Set 使う? + userListMemberships = userListMemberships.filter(x => x.userListUserId === user.id || followings.some(f => f.followerId === x.userListUserId)); + } + + // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする + for (const following of followings) { + // 基本的にvisibleUserIdsには自身のidが含まれている前提であること + if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; + + // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 + if (isReply(note, following.followerId)) { + if (!following.withReplies) continue; + } + + this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + } + } + + for (const userListMembership of userListMemberships) { + // ダイレクトのとき、そのリストが対象外のユーザーの場合 + if ( + note.visibility === 'specified' && + note.userId !== userListMembership.userListUserId && + !note.visibleUserIds.some(v => v === userListMembership.userListUserId) + ) continue; + + // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 + if (isReply(note, userListMembership.userListUserId)) { + if (!userListMembership.withReplies) continue; + } + + this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r); + } + } + + // 自分自身のHTL + if (note.userHost == null) { + if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); + } + } + } + + // 自分自身以外への返信 + if (isReply(note)) { + this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); + + if (note.visibility === 'public' && note.userHost == null) { + this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); + if (note.replyUserHost == null) { + this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); + } + } + } else { + this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r); + } + + if (note.visibility === 'public' && note.userHost == null) { + this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); + if (note.fileIds.length > 0) { + this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); + } + } + } + + if (Math.random() < 0.1) { + process.nextTick(() => { + this.checkHibernation(followings); + }); + } + } + + r.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }); + } + } + + @bindThis + private collapseNotesCount(oldValue: number, newValue: number) { + return oldValue + newValue; + } + + @bindThis + private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { + await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); + } + + @bindThis + public async dispose(): Promise { + this.#shutdownController.abort(); + await this.updateNotesCountQueue.performAllNow(); + } + + @bindThis + public async onApplicationShutdown(signal?: string | undefined): Promise { + await this.dispose(); + } +} diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 181c9f764..320b23cc1 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -66,7 +66,11 @@ export class NoteReadService implements OnApplicationShutdown { noteUserId: note.userId, }; - await this.noteUnreadsRepository.insert(unread); + /* we may be called from NoteEditService, for a note that's + already present in the `note_unread` table: `upsert` makes sure + we don't throw a "duplicate key" error, while still updating + the other columns if they've changed */ + await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']); // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 6f9fe5393..0179b0680 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository, MiMeta } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -64,8 +64,8 @@ type DecodedReaction = { host?: string | null; }; -const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; -const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; +const isCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@\.)?:$/u; +const decodeCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@([\w.-]+))?:$/u; @Injectable() export class ReactionService { @@ -82,6 +82,9 @@ export class ReactionService { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, @@ -256,10 +259,19 @@ export class ReactionService { // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.notificationService.createNotification(note.userId, 'reaction', { - noteId: note.id, - reaction: reaction, - }, user.id); + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: note.userId, + threadId: note.threadId ?? note.id, + }, + }); + + if (!isThreadMuted) { + this.notificationService.createNotification(note.userId, 'reaction', { + noteId: note.id, + reaction: reaction, + }, user.id); + } } //#region 配信 diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e3..ec40899c9 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -38,6 +38,7 @@ export class UserSuspendService { @bindThis public async suspend(user: MiUser, moderator: MiUser): Promise { + await this.usersRepository.update(user.id, { isSuspended: true, }); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 4036d2794..06fddf1db 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -35,7 +35,7 @@ export class ApMfmService { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers)); + const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); return { content, diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index b6026c4b8..2b91fa5b0 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -16,12 +16,12 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; -import { fromTuple } from '@/misc/from-tuple.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { fromTuple } from '@/misc/from-tuple.js'; export class Resolver { private history: Set; diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 3d6637242..60dddee9a 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -24,12 +24,12 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { if (note.cw != null) { summary += `CW: ${note.cw}`; } else if (note.text) { - summary += note.text ? note.text : ''; + summary += note.text; } // ファイルが添付されているとき - if ((note.files ?? []).length !== 0) { - summary += ` (📎${note.files!.length})`; + if (note.files && note.files.length !== 0) { + summary += ` (📎${note.files.length})`; } // 投票が添付されているとき diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 3b764c227..a0def1a59 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -16,6 +16,7 @@ export class MiNote { public id: string; @Column('timestamp with time zone', { + comment: 'The update time of the Note.', default: null, }) public updatedAt: Date | null; diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts new file mode 100644 index 000000000..ba936f866 --- /dev/null +++ b/packages/backend/src/models/json-schema/note-edit.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedNoteEdit = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + updatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + note: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + noteId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + oldText: { + type: "string", + optional: true, + nullable: true, + }, + newText: { + type: "string", + optional: true, + nullable: true, + }, + cw: { + type: "string", + optional: true, + nullable: true, + }, + fileIds: { + type: "array", + optional: true, + nullable: true, + items: { + type: "string", + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 28a74bbb4..fa30f8478 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -69,7 +69,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts.attempts ?? 0; + const maxAttempts = job.opts ? job.opts.attempts : 0; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; } From 5459bc395b19808a1fa797ffa2e89367b007ac96 Mon Sep 17 00:00:00 2001 From: fly_mc Date: Wed, 20 Nov 2024 23:40:42 +0800 Subject: [PATCH 2/2] removed unsed files --- packages/backend/src/core/NoteEditService.ts | 1070 ----------------- .../src/models/json-schema/note-edit.ts | 59 - 2 files changed, 1129 deletions(-) delete mode 100644 packages/backend/src/core/NoteEditService.ts delete mode 100644 packages/backend/src/models/json-schema/note-edit.ts diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts deleted file mode 100644 index e910c53ba..000000000 --- a/packages/backend/src/core/NoteEditService.ts +++ /dev/null @@ -1,1070 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setImmediate } from 'node:timers/promises'; -import * as mfm from 'mfm-js'; -import { DataSource, In, IsNull, LessThan } from 'typeorm'; -import * as Redis from 'ioredis'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { extractMentions } from '@/misc/extract-mentions.js'; -import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; -import { extractHashtags } from '@/misc/extract-hashtags.js'; -import type { IMentionedRemoteUsers } from '@/models/Note.js'; -import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; -import { concat } from '@/misc/prelude/array.js'; -import { IdService } from '@/core/IdService.js'; -import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { MiPoll, type IPoll } from '@/models/Poll.js'; -import type { MiChannel } from '@/models/Channel.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { RelayService } from '@/core/RelayService.js'; -import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import InstanceChart from '@/core/chart/charts/instance.js'; -import ActiveUsersChart from '@/core/chart/charts/active-users.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { NotificationService } from '@/core/NotificationService.js'; -import { UserWebhookService } from '@/core/UserWebhookService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; -import { SearchService } from '@/core/SearchService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { isReply } from '@/misc/is-reply.js'; -import { trackPromise } from '@/misc/promise-tracker.js'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { LatestNoteService } from '@/core/LatestNoteService.js'; -import { CollapsedQueue } from '@/misc/collapsed-queue.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; - -type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'edited'; - -class NotificationManager { - private notifier: { id: MiUser['id']; }; - private note: MiNote; - private queue: { - target: MiLocalUser['id']; - reason: NotificationType; - }[]; - - constructor( - private mutingsRepository: MutingsRepository, - private notificationService: NotificationService, - notifier: { id: MiUser['id']; }, - note: MiNote, - ) { - this.notifier = notifier; - this.note = note; - this.queue = []; - } - - @bindThis - public push(notifiee: MiLocalUser['id'], reason: NotificationType) { - // 自分自身へは通知しない - if (this.notifier.id === notifiee) return; - - const exist = this.queue.find(x => x.target === notifiee); - - if (exist) { - // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする - if (reason !== 'mention') { - exist.reason = reason; - } - } else { - this.queue.push({ - reason: reason, - target: notifiee, - }); - } - } - - @bindThis - public async notify() { - for (const x of this.queue) { - if (x.reason === 'renote') { - this.notificationService.createNotification(x.target, 'renote', { - noteId: this.note.id, - targetNoteId: this.note.renoteId!, - }, this.notifier.id); - } else { - this.notificationService.createNotification(x.target, x.reason, { - noteId: this.note.id, - }, this.notifier.id); - } - } - } -} - -type MinimumUser = { - id: MiUser['id']; - host: MiUser['host']; - username: MiUser['username']; - uri: MiUser['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: MiNote | null; - renote?: MiNote | null; - files?: MiDriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - reactionAcceptance?: MiNote['reactionAcceptance']; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: MiChannel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: MiApp | null; - updatedAt?: Date | null; - editcount?: boolean | null; -}; - -@Injectable() -export class NoteEditService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - private updateNotesCountQueue: CollapsedQueue; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.meta) - private meta: MiMeta, - - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.noteEditRepository) - private noteEditRepository: NoteEditRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private queueService: QueueService, - private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, - private notificationService: NotificationService, - private relayService: RelayService, - private federatedInstanceService: FederatedInstanceService, - private webhookService: UserWebhookService, - private remoteUserResolveService: RemoteUserResolveService, - private apDeliverManagerService: ApDeliverManagerService, - private apRendererService: ApRendererService, - private roleService: RoleService, - private searchService: SearchService, - private activeUsersChart: ActiveUsersChart, - private instanceChart: InstanceChart, - private utilityService: UtilityService, - private userBlockingService: UserBlockingService, - private cacheService: CacheService, - private latestNoteService: LatestNoteService, - private noteCreateService: NoteCreateService, - ) { - this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount); - } - - @bindThis - public async edit(user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, editid: MiNote['id'], data: Option, silent = false): Promise { - if (!editid) { - throw new Error('fail'); - } - - const oldnote = await this.notesRepository.findOneBy({ - id: editid, - }); - - if (oldnote == null) { - throw new Error('no such note'); - } - - if (oldnote.userId !== user.id) { - throw new Error('not the author'); - } - - // we never want to change the replyId, so fetch the original "parent" - if (oldnote.replyId) { - data.reply = await this.notesRepository.findOneBy({ id: oldnote.replyId }); - } else { - data.reply = undefined; - } - - // changing visibility on an edit is ill-defined, let's try to - // keep the same visibility as the original note - data.visibility = oldnote.visibility; - data.localOnly = oldnote.localOnly; - - // チャンネル外にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { - if (data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } else { - data.channel = null; - } - } - - // チャンネル内にリプライしたら対象のスコープに合わせる - // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) - if (data.reply && (data.channel == null) && data.reply.channelId) { - data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); - } - - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.channel != null) data.visibility = 'public'; - if (data.channel != null) data.visibleUsers = []; - if (data.channel != null) data.localOnly = true; - if (data.updatedAt == null) data.updatedAt = new Date(); - - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = this.meta.sensitiveWords; - if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { - data.visibility = 'home'; - } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { - data.visibility = 'home'; - } - } - - const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ - cw: data.cw, - text: data.text, - pollChoices: data.poll?.choices, - }, this.meta.prohibitedWords); - - if (hasProhibitedWords) { - throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); - } - - const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host); - - if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { - data.visibility = 'home'; - } - - 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可能 - break; - case 'home': - // home noteはhome以下にrenote可能 - if (data.visibility === 'public') { - data.visibility = 'home'; - } - break; - case 'followers': - // 他人のfollowers noteはreject - if (data.renote.userId !== user.id) { - throw new Error('Renote target is not public or home'); - } - - // Renote対象がfollowersならfollowersにする - data.visibility = 'followers'; - break; - case 'specified': - // specified / direct noteはreject - throw new Error('Renote target is not public or home'); - } - } - - // Check blocking - 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); - if (blocked) { - throw new Error('blocked'); - } - } - } - } - - // 返信対象がpublicではないならhomeにする - if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { - data.visibility = 'home'; - } - - // ローカルのみをRenoteしたらローカルのみにする - if (this.isRenote(data) && data.renote.localOnly && data.channel == null) { - data.localOnly = true; - } - - // ローカルのみにリプライしたらローカルのみにする - if (data.reply && data.reply.localOnly && data.channel == null) { - data.localOnly = true; - } - - const maxTextLength = user.host == null - ? this.config.maxNoteLength - : this.config.maxRemoteNoteLength; - - if (data.text) { - if (data.text.length > maxTextLength) { - data.text = data.text.slice(0, maxTextLength); - } - data.text = data.text.trim(); - if (data.text === '') { - data.text = null; - } - } else { - data.text = null; - } - - const maxCwLength = user.host == null - ? this.config.maxCwLength - : this.config.maxRemoteCwLength; - - if (data.cw) { - if (data.cw.length > maxCwLength) { - data.cw = data.cw.slice(0, maxCwLength); - } - data.cw = data.cw.trim(); - if (data.cw === '') { - data.cw = null; - } - } else { - data.cw = null; - } - - let tags = data.apHashtags; - let emojis = data.apEmojis; - let mentionedUsers = data.apMentions; - - // Parse MFM if needed - if (!tags || !emojis || !mentionedUsers) { - const tokens = data.text ? mfm.parse(data.text)! : []; - const cwTokens = data.cw ? mfm.parse(data.cw)! : []; - const choiceTokens = data.poll && data.poll.choices - ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) - : []; - - const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); - - tags = data.apHashtags ?? extractHashtags(combinedTokens); - - emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); - - mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens); - } - - // if the host is media-silenced, custom emojis are not allowed - if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; - - tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); - - if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { - mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - if (!mentionedUsers.some(x => x.id === u.id)) { - mentionedUsers.push(u); - } - } - - if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); - } - } - - if (user.host && !data.cw) { - await this.federatedInstanceService.fetch(user.host).then(async i => { - if (i.isNSFW) { - data.cw = 'Instance is marked as NSFW'; - } - }); - } - - if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) { - throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions'); - } - - const update: Partial = {}; - if (data.text !== oldnote.text) { - update.text = data.text; - } - if (data.cw !== oldnote.cw) { - update.cw = data.cw; - } - if (oldnote.hasPoll !== !!data.poll) { - update.hasPoll = !!data.poll; - } - - // technically we should check if the two sets of files are - // different, or if their descriptions have changed. In practice - // this is good enough. - const filesChanged = oldnote.fileIds?.length || data.files?.length; - - const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); - - const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; - - if (Object.keys(update).length > 0 || filesChanged) { - const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id }); - - await this.noteEditRepository.insert({ - id: this.idService.gen(), - noteId: oldnote.id, - oldText: oldnote.text || undefined, - newText: update.text || undefined, - cw: update.cw || undefined, - fileIds: undefined, - oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date, - updatedAt: new Date(), - }); - - const note = new MiNote({ - id: oldnote.id, - updatedAt: data.updatedAt ? data.updatedAt : new Date(), - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: oldnote.replyId, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - threadId: data.reply - ? data.reply.threadId - ? data.reply.threadId - : data.reply.id - : null, - name: data.name, - text: data.text, - hasPoll: data.poll != null, - cw: data.cw ?? null, - tags: tags.map(tag => normalizeForSearch(tag)), - emojis, - reactions: oldnote.reactions, - userId: user.id, - localOnly: data.localOnly!, - reactionAcceptance: data.reactionAcceptance, - visibility: data.visibility as any, - visibleUserIds: data.visibility === 'specified' - ? data.visibleUsers - ? data.visibleUsers.map(u => u.id) - : [] - : [], - - attachedFileTypes: data.files ? data.files.map(file => file.type) : [], - - // 以下非正規化データ - replyUserId: data.reply ? data.reply.userId : null, - replyUserHost: data.reply ? data.reply.userHost : null, - renoteUserId: data.renote ? data.renote.userId : null, - renoteUserHost: data.renote ? data.renote.userHost : null, - userHost: user.host, - reactionAndUserPairCache: oldnote.reactionAndUserPairCache, - }); - - if (data.uri != null) note.uri = data.uri; - if (data.url != null) note.url = data.url; - - if (mentionedUsers.length > 0) { - note.mentions = mentionedUsers.map(u => u.id); - const profiles = await this.userProfilesRepository.findBy({ userId: In(note.mentions) }); - note.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { - const profile = profiles.find(p => p.userId === u.id); - const url = profile != null ? profile.url : null; - return { - uri: u.uri, - url: url ?? undefined, - username: u.username, - host: u.host, - } as IMentionedRemoteUsers[0]; - })); - } - - if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) { - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.update(MiNote, oldnote.id, note); - - const poll = new MiPoll({ - noteId: note.id, - choices: data.poll!.choices, - expiresAt: data.poll!.expiresAt, - multiple: data.poll!.multiple, - votes: new Array(data.poll!.choices.length).fill(0), - noteVisibility: note.visibility, - userId: user.id, - userHost: user.host, - channelId: data.channel ? data.channel.id : null, - }); - - if (!oldnote.hasPoll) { - await transactionalEntityManager.insert(MiPoll, poll); - } else { - await transactionalEntityManager.update(MiPoll, oldnote.id, poll); - } - }); - } else { - await this.notesRepository.update(oldnote.id, note); - } - - setImmediate('post edited', { signal: this.#shutdownController.signal }).then( - () => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); - - return note; - } else { - return oldnote; - } - } - - @bindThis - private async postNoteEdited(note: MiNote, oldNote: MiNote, user: { - id: MiUser['id']; - username: MiUser['username']; - host: MiUser['host']; - isBot: MiUser['isBot']; - noindex: MiUser['noindex']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.updateNotesCountQueue.enqueue(i.id, 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); - } - - // ハッシュタグ更新 - this.pushToTl(note, user); - - if (data.poll && data.poll.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - this.queueService.endedPollNotificationQueue.remove(note.id); - this.queueService.endedPollNotificationQueue.add(note.id, { - noteId: note.id, - }, { - delay, - removeOnComplete: true, - }); - } - - if (!silent) { - if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - - // Pack the note - const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); - if (data.poll != null) { - this.globalEventService.publishNoteStream(note.id, 'updated', { - cw: note.cw, - text: note.text!, - }); - } else { - this.globalEventService.publishNoteStream(note.id, 'updated', { - cw: note.cw, - text: note.text!, - }); - } - - this.roleService.addNoteToRoleTimeline(noteObj); - - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); - - const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); - - //await this.createMentionedEvents(mentionedUsers, note, nm); - - // If has in reply to note - if (data.reply) { - // 通知 - if (data.reply.userHost === null) { - const isThreadMuted = await this.noteThreadMutingsRepository.exists({ - where: { - userId: data.reply.userId, - threadId: data.reply.threadId ?? data.reply.id, - }, - }); - - const [ - userIdsWhoMeMuting, - ] = data.reply.userId ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(data.reply.userId), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); - - if (!isThreadMuted && !muted) { - nm.push(data.reply.userId, 'edited'); - this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('edited')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'edited', { - note: noteObj, - }); - } - } - } - } - - nm.notify(); - - //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { - (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); - const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); - - // メンションされたリモートユーザーに配送 - for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { - dm.addDirectRecipe(u as MiRemoteUser); - } - - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && data.reply.userHost !== null) { - const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); - if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - 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); - } - - // フォロワーに配送 - if (['public', 'home', 'followers'].includes(note.visibility)) { - 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); - } - - trackPromise(dm.execute()); - })(); - } - //#endregion - } - - if (data.channel) { - this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); - this.channelsRepository.update(data.channel.id, { - lastNotedAt: new Date(), - }); - - this.notesRepository.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then(count => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1) { - this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); - } - }); - } - - // Update the Latest Note index / following feed - this.latestNoteService.handleUpdatedNoteBG(oldNote, note); - - // Register to search database - if (!user.noindex) this.index(note); - } - - @bindThis - 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 - private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) { - for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { - const isThreadMuted = await this.noteThreadMutingsRepository.exists({ - where: { - userId: u.id, - threadId: note.threadId ?? note.id, - }, - }); - - const [ - userIdsWhoMeMuting, - ] = u.id ? await Promise.all([ - this.cacheService.userMutingsCache.fetch(u.id), - ]) : [new Set()]; - - const muted = isUserRelated(note, userIdsWhoMeMuting); - - if (isThreadMuted || muted) { - continue; - } - - const detailPackedNote = await this.noteEntityService.pack(note, u, { - detail: true, - }); - - this.globalEventService.publishMainStream(u.id, 'edited', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('edited')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'edited', { - note: detailPackedNote, - }); - } - - // Create notification - nm.push(u.id, 'edited'); - } - } - - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { - if (data.localOnly) return null; - const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('user not found'); - - const content = this.isRenote(data) && !this.isQuote(data) - ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); - - return this.apRendererService.addContext(content); - } - - @bindThis - private index(note: MiNote) { - if (note.text == null && note.cw == null) return; - - this.searchService.indexNote(note); - } - - @bindThis - private async extractMentionedUsers(user: { host: MiUser['host']; }, tokens: mfm.MfmNode[]): Promise { - if (tokens == null) return []; - - const mentions = extractMentions(tokens); - let mentionedUsers = (await Promise.all(mentions.map(m => - this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), - ))).filter(x => x !== null) as MiUser[]; - - // Drop duplicate users - mentionedUsers = mentionedUsers.filter((u, i, self) => - i === self.findIndex(u2 => u.id === u2.id), - ); - - return mentionedUsers; - } - - @bindThis - private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - if (!this.meta.enableFanoutTimeline) return; - - const r = this.redisForTimelines.pipeline(); - - if (note.channelId) { - this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); - - const channelFollowings = await this.channelFollowingsRepository.find({ - where: { - followeeId: note.channelId, - }, - select: ['followerId'], - }); - - for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); - } - } - } else { - // TODO: キャッシュ? - // eslint-disable-next-line prefer-const - let [followings, userListMemberships] = await Promise.all([ - this.followingsRepository.find({ - where: { - followeeId: user.id, - followerHost: IsNull(), - isFollowerHibernated: false, - }, - select: ['followerId', 'withReplies'], - }), - this.userListMembershipsRepository.find({ - where: { - userId: user.id, - }, - select: ['userListId', 'userListUserId', 'withReplies'], - }), - ]); - - if (note.visibility === 'followers') { - // TODO: 重そうだから何とかしたい Set 使う? - userListMemberships = userListMemberships.filter(x => x.userListUserId === user.id || followings.some(f => f.followerId === x.userListUserId)); - } - - // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする - for (const following of followings) { - // 基本的にvisibleUserIdsには自身のidが含まれている前提であること - if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue; - - // 「自分自身への返信 or そのフォロワーへの返信」のどちらでもない場合 - if (isReply(note, following.followerId)) { - if (!following.withReplies) continue; - } - - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); - } - } - - for (const userListMembership of userListMemberships) { - // ダイレクトのとき、そのリストが対象外のユーザーの場合 - if ( - note.visibility === 'specified' && - note.userId !== userListMembership.userListUserId && - !note.visibleUserIds.some(v => v === userListMembership.userListUserId) - ) continue; - - // 「自分自身への返信 or そのリストの作成者への返信」のどちらでもない場合 - if (isReply(note, userListMembership.userListUserId)) { - if (!userListMembership.withReplies) continue; - } - - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r); - } - } - - // 自分自身のHTL - if (note.userHost == null) { - if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); - } - } - } - - // 自分自身以外への返信 - if (isReply(note)) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); - if (note.replyUserHost == null) { - this.fanoutTimelineService.push(`localTimelineWithReplyTo:${note.replyUserId}`, note.id, 300 / 10, r); - } - } - } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r); - } - - if (note.visibility === 'public' && note.userHost == null) { - this.fanoutTimelineService.push('localTimeline', note.id, 1000, r); - if (note.fileIds.length > 0) { - this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r); - } - } - } - - if (Math.random() < 0.1) { - process.nextTick(() => { - this.checkHibernation(followings); - }); - } - } - - r.exec(); - } - - @bindThis - public async checkHibernation(followings: MiFollowing[]) { - if (followings.length === 0) return; - - const shuffle = (array: MiFollowing[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - }; - - // ランダムに最大1000件サンプリング - const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); - - const hibernatedUsers = await this.usersRepository.find({ - where: { - id: In(samples.map(x => x.followerId)), - lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), - }, - select: ['id'], - }); - - if (hibernatedUsers.length > 0) { - this.usersRepository.update({ - id: In(hibernatedUsers.map(x => x.id)), - }, { - isHibernated: true, - }); - - this.followingsRepository.update({ - followerId: In(hibernatedUsers.map(x => x.id)), - }, { - isFollowerHibernated: true, - }); - } - } - - @bindThis - private collapseNotesCount(oldValue: number, newValue: number) { - return oldValue + newValue; - } - - @bindThis - private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { - await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); - } - - @bindThis - public async dispose(): Promise { - this.#shutdownController.abort(); - await this.updateNotesCountQueue.performAllNow(); - } - - @bindThis - public async onApplicationShutdown(signal?: string | undefined): Promise { - await this.dispose(); - } -} diff --git a/packages/backend/src/models/json-schema/note-edit.ts b/packages/backend/src/models/json-schema/note-edit.ts deleted file mode 100644 index ba936f866..000000000 --- a/packages/backend/src/models/json-schema/note-edit.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: marie and other Sharkey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export const packedNoteEdit = { - type: "object", - properties: { - id: { - type: "string", - optional: false, - nullable: false, - format: "id", - example: "xxxxxxxxxx", - }, - updatedAt: { - type: "string", - optional: false, - nullable: false, - format: "date-time", - }, - note: { - type: "object", - optional: false, - nullable: false, - ref: "Note", - }, - noteId: { - type: "string", - optional: false, - nullable: false, - format: "id", - }, - oldText: { - type: "string", - optional: true, - nullable: true, - }, - newText: { - type: "string", - optional: true, - nullable: true, - }, - cw: { - type: "string", - optional: true, - nullable: true, - }, - fileIds: { - type: "array", - optional: true, - nullable: true, - items: { - type: "string", - format: "id", - }, - }, - }, -} as const;