From beeafbc6d385e0c2b7bec764cc496564ca5da81a Mon Sep 17 00:00:00 2001 From: GrapeApple0 <84321396+GrapeApple0@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:07:01 +0000 Subject: [PATCH] =?UTF-8?q?wip:=20=E6=9C=80=E4=BD=8E=E9=99=90=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/1718271407902-noteEdit.js | 18 + packages/backend/src/core/CoreModule.ts | 6 + packages/backend/src/core/NoteEditService.ts | 515 ++++++++++++++++++ .../src/core/activitypub/ApInboxService.ts | 5 +- .../src/core/activitypub/ApRendererService.ts | 19 +- .../core/activitypub/models/ApNoteService.ts | 196 ++++++- packages/backend/src/core/activitypub/type.ts | 1 + packages/backend/src/models/Note.ts | 6 + packages/backend/src/models/NoteHistory.ts | 88 +++ .../backend/src/models/json-schema/note.ts | 5 + packages/backend/src/types.ts | 9 + packages/misskey-js/etc/misskey-js.api.md | 5 +- packages/misskey-js/src/autogen/types.ts | 2 + packages/misskey-js/src/consts.ts | 9 + packages/misskey-js/src/entities.ts | 3 + 15 files changed, 883 insertions(+), 4 deletions(-) create mode 100644 packages/backend/migration/1718271407902-noteEdit.js create mode 100644 packages/backend/src/core/NoteEditService.ts create mode 100644 packages/backend/src/models/NoteHistory.ts diff --git a/packages/backend/migration/1718271407902-noteEdit.js b/packages/backend/migration/1718271407902-noteEdit.js new file mode 100644 index 0000000000..4016f82b13 --- /dev/null +++ b/packages/backend/migration/1718271407902-noteEdit.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteEdit1718271407902 { + name = 'NoteEdit1718271407902' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "updatedAt"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index b5b34487ec..ad81784839 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -41,6 +41,7 @@ import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; +import { NoteEditService } from './NoteEditService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; @@ -182,6 +183,7 @@ const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService } const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; +const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; @@ -328,6 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ModerationLogService, NoteCreateService, NoteDeleteService, + NoteEditService, NotePiningService, NoteReadService, NotificationService, @@ -470,6 +473,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ModerationLogService, $NoteCreateService, $NoteDeleteService, + $NoteEditService, $NotePiningService, $NoteReadService, $NotificationService, @@ -613,6 +617,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ModerationLogService, NoteCreateService, NoteDeleteService, + NoteEditService, NotePiningService, NoteReadService, NotificationService, @@ -754,6 +759,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ModerationLogService, $NoteCreateService, $NoteDeleteService, + $NoteEditService, $NotePiningService, $NoteReadService, $NotificationService, diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts new file mode 100644 index 0000000000..01d50dc159 --- /dev/null +++ b/packages/backend/src/core/NoteEditService.ts @@ -0,0 +1,515 @@ +/* + * 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 { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { In, LessThan } from 'typeorm'; +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 { ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, NotesRepository, UserProfilesRepository, UsersRepository, PollsRepository, DriveFilesRepository } from '@/models/_.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import { concat } from '@/misc/prelude/array.js'; +import { IdService } from '@/core/IdService.js'; +import type { MiUser, MiRemoteUser } from '@/models/User.js'; +import type { IPoll } from '@/models/Poll.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.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 NotesChart from '@/core/chart/charts/notes.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.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 { 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 { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { bindThis } from '@/decorators.js'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +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; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | MiUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; +}; + +@Injectable() +export class NoteEditService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + + public static ContainsProhibitedWordsError = class extends Error { }; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private queueService: QueueService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private remoteUserResolveService: RemoteUserResolveService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private roleService: RoleService, + private metaService: MetaService, + private searchService: SearchService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private utilityService: UtilityService, + private userBlockingService: UserBlockingService, + private moderationLogService: ModerationLogService, + private userWebhookService: UserWebhookService, + ) { } + + @bindThis + public async edit(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + isCat: MiUser['isCat']; + }, targetId: MiNote['id'], data: Option, silent = false, editor?: MiUser): Promise { + const targetNote = await this.notesRepository.findOneByOrFail({ id: targetId }); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (targetNote == null) { + throw new Error('No such note'); + } + + // if ((await this.roleService.getUserPolicies(user.id)).canEditNote !== true) { + // throw new Error('Not allow edit note'); + // } + + if (data.reply == null) data.reply = targetNote.reply; + if (data.channel == null) data.channel = targetNote.channel; + + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + 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.createdAt == null) data.createdAt = this.idService.parse(targetId).date; + if (data.renote == null && targetNote.renoteId) data.renote = await this.notesRepository.findOneByOrFail({ id: targetNote.renoteId }); + if (data.reply == null && targetNote.replyId) data.reply = await this.notesRepository.findOneByOrFail({ id: targetNote.replyId }); + if (data.poll == null) data.poll = targetNote.hasPoll ? await this.pollsRepository.findOneByOrFail({ noteId: targetId }) : null; + if (data.files == null) data.files = await this.driveFilesRepository.findBy({ id: In(targetNote.fileIds) }); + if (data.name == null) data.name = targetNote.name; + if (data.reactionAcceptance == null) data.reactionAcceptance = targetNote.reactionAcceptance; + const meta = await this.metaService.fetch(); + + if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) { + throw new NoteEditService.ContainsProhibitedWordsError(); + } + + let changeVisibilityToHome = false; + + const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); + + if (targetNote.visibility === 'public' && data.channel == null) { + const sensitiveWords = meta.sensitiveWords; + if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { + changeVisibilityToHome = true; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + changeVisibilityToHome = true; + } + } else if (inSilencedInstance) { + changeVisibilityToHome = true; + } + + // Check blocking + if (data.renote && !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'); + } + } + } + } + + if (data.text) { + if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) { + data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH); + } + data.text = data.text.trim(); + } else { + data.text = 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); + } + + 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 })); + } + + const note = new MiNote({ + id: targetNote.id, + updatedAt: data.createdAt ?? new Date(), + visibility: changeVisibilityToHome ? 'home' : targetNote.visibility, + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + 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, + userId: user.id, + reactionAcceptance: data.reactionAcceptance, + 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, + }); + + if (data.uri != null) note.uri = data.uri; + if (data.url != null) note.url = data.url; + + // Append mentions data + 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]; + })); + } + + // 投稿を作成 + try { + await this.notesRepository.update({ id: note.id }, note); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + const err = new Error('Duplicated note'); + err.name = 'duplicated'; + throw err; + } + + console.error(e); + + throw e; + } + + setImmediate('post updated', { signal: this.#shutdownController.signal }).then( + async () => this.postNoteEdited((await this.notesRepository.findOneByOrFail({ id: note.id })), user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); + if (editor && (note.userId !== editor.id)) { + const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); + this.moderationLogService.log(editor, 'editNote', { + noteId: note.id, + noteUserId: note.userId, + noteUserUsername: user.username, + noteUserHost: user.host, + note: note, + beforeNote: targetNote, + }); + } + return note; + } + + @bindThis + private async postNoteEdited(note: MiNote, user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + const meta = await this.metaService.fetch(); + + this.notesChart.update(note, true); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, true); + } + + // Register host + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetch(user.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + this.queueService.endedPollNotificationQueue.add(note.id, { + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + // Pack the note + const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); + + this.globalEventService.publishNotesStream(noteObj); + + this.roleService.addNoteToRoleTimeline(noteObj); + + this.userWebhookService.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, + }); + } + }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + (async () => { + const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user.id); + 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 (data.renote && 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'].includes(note.visibility)) { + this.relayService.deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + this.channelsRepository.update(data.channel.id, { + lastNotedAt: new Date(), + }); + } + + // Register to search database + this.index(note); + } + + @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); + } + + @bindThis + private async renderNoteOrRenoteActivity(data: Option, note: MiNote, userId: string) { + const content = data.renote && !this.isQuote(data) + ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) + : this.apRendererService.renderNoteUpdate(await this.apRendererService.renderNote(note, false, true), note, { id: userId }); + + 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 + 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 + public dispose(): void { + this.#shutdownController.abort(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index de3178b482..76a6b2a5b1 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -12,8 +12,8 @@ import { ReactionService } from '@/core/ReactionService.js'; import { RelayService } from '@/core/RelayService.js'; import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; @@ -771,6 +771,9 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); return 'ok: Question updated'; + } else if (getApType(object) === 'Note') { + await this.apNoteService.updateNote(object, resolver); + return 'ok: Note updated'; } else { return `skip: Unknown type: ${getApType(object)}`; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 4fc724b548..6372e5febf 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -313,7 +313,7 @@ export class ApRendererService { } @bindThis - public async renderNote(note: MiNote, dive = true): Promise { + public async renderNote(note: MiNote, dive = true, updated = false): Promise { const getPromisedFiles = async (ids: string[]): Promise => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -429,6 +429,7 @@ export class ApRendererService { attributedTo, summary: summary ?? undefined, content: content ?? undefined, + updated: updated ? note.updatedAt.toISOString() : undefined, ...(noMisskeyContent ? {} : { _misskey_content: text, source: { @@ -594,6 +595,22 @@ export class ApRendererService { }; } + @bindThis + public renderNoteUpdate(object: IObject, note: MiNote, user: { id: MiUser['id'] }): IUpdate { + const activity: IUpdate = { + id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: this.userEntityService.genLocalUserUri(note.userId), + type: 'Update', + published: new Date().toISOString(), + object, + }; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; + } + @bindThis public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate { return { diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index c6e6b3a1e8..162dc35361 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -5,8 +5,9 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import promiseLimit from 'promise-limit'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { PollsRepository, EmojisRepository, NotesRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -16,6 +17,7 @@ import { MetaService } from '@/core/MetaService.js'; import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteEditService } from '@/core/NoteEditService.js'; import type Logger from '@/logger.js'; import { IdService } from '@/core/IdService.js'; import { PollService } from '@/core/PollService.js'; @@ -47,6 +49,9 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -70,6 +75,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteEditService: NoteEditService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -325,6 +331,194 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, resolver?: Resolver, silent = false) { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('uri is null'); + + if (uri.startsWith(`${this.config.url}/`)) return; + + //#region このサーバーに既に登録されているか + const targetNote = await this.notesRepository.findOneBy({ uri }); + if (targetNote === null) return; + //#endregion + + // eslint-disable-next-line no-param-reassign + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(value); + + const entryUri = getApId(value); + const err = this.validateNote(object, entryUri); + if (err) { + this.logger.error(err.message, { + resolver: { history: resolver.getHistory() }, + value, + object, + }); + throw new Error('invalid note'); + } + + const note = object as IPost; + + this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + + if (note.id && !checkHttps(note.id)) { + throw new Error('unexpected schema of note.id: ' + note.id); + } + + const url = getOneApHrefNullable(note.url); + + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of note url: ' + url); + } + + this.logger.info(`Updating the Note: ${note.id}`); + + // 投稿者をフェッチ + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // Audience (to, cc) が指定されてなかった場合 + if (visibility === 'specified' && visibleUsers.length === 0) { + if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している + // こちらから匿名GET出来たものならばpublic + visibility = 'public'; + } + } + + const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); + const apHashtags = extractApHashtags(note.tag); + + // 添付ファイル + 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 + ? await this.resolveNote(note.inReplyTo, { resolver }) + .then(x => { + if (x == null) { + this.logger.warn('Specified inReplyTo, but not found'); + throw new Error('inReplyTo not found'); + } + + return x; + }) + .catch(async err => { + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + throw err; + }) + : null; + + // 引用 + let quote: MiNote | undefined | null = null; + + if (note._misskey_quote ?? note.quoteUrl) { + const tryResolveNote = async (uri: string): Promise< + | { status: 'ok'; res: MiNote } + | { status: 'permerror' | 'temperror' } + > => { + if (!/^https?:/.test(uri)) return { status: 'permerror' }; + try { + const res = await this.resolveNote(uri); + if (res == null) return { status: 'permerror' }; + return { status: 'ok', res }; + } catch (e) { + return { + status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror', + }; + } + }; + + const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); + const results = await Promise.all(uris.map(tryResolveNote)); + + quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); + if (!quote) { + if (results.some(x => x.status === 'temperror')) { + throw new Error('quote resolve failed'); + } + } + } + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + let text: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { + text = note.source.content; + } else if (typeof note._misskey_content !== 'undefined') { + text = note._misskey_content; + } else if (typeof note.content === 'string') { + text = this.apMfmService.htmlToMfm(note.content, note.tag); + } + + // vote + if (reply && reply.hasPoll) { + const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id }); + + const tryCreateVote = async (name: string, index: number): Promise => { + if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { + this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + } else if (index >= 0) { + this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + await this.pollService.vote(actor, reply, index); + + // リモートフォロワーにUpdate配信 + this.pollService.deliverQuestionUpdate(reply.id); + } + return null; + }; + + if (note.name) { + return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); + } + } + + const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return []; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + await this.noteEditService.edit(actor, targetNote.id, { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: url, + }, silent); + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5b6c6c8ca6..178dbf3a9a 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -117,6 +117,7 @@ export interface IPost extends IObject { content: string; mediaType: string; }; + updated?: string; _misskey_quote?: string; _misskey_content?: string; quoteUrl?: string; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab..f3ad343884 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -15,6 +15,12 @@ export class MiNote { @PrimaryColumn(id()) public id: string; + @Column('timestamp with time zone', { + nullable: true, + comment: 'The updated date of the Note.', + }) + public updatedAt: Date; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/NoteHistory.ts b/packages/backend/src/models/NoteHistory.ts new file mode 100644 index 0000000000..4e321142c8 --- /dev/null +++ b/packages/backend/src/models/NoteHistory.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiNote } from './Note.js'; +import type { MiDriveFile } from './DriveFile.js'; + +@Entity('note') +export class MiNoteHistory { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + nullable: true, + comment: 'The updated date of the Note.', + }) + public updatedAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public targetId: MiNote['id'] | null; + + // TODO: varcharにしたい + @Column('text', { + nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public cw: string | null; + + @Index('IDX_NOTE_FILE_IDS', { synchronize: false }) + @Column({ + ...id(), + array: true, default: '{}', + }) + public fileIds: MiDriveFile['id'][]; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public attachedFileTypes: string[]; + + @Index('IDX_NOTE_MENTIONS', { synchronize: false }) + @Column({ + ...id(), + array: true, default: '{}', + }) + public mentions: MiUser['id'][]; + + @Column('text', { + default: '[]', + }) + public mentionedRemoteUsers: string; + + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public emojis: string[]; + + @Index('IDX_NOTE_TAGS', { synchronize: false }) + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public tags: string[]; + + @Column('boolean', { + default: false, + }) + public hasPoll: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 2641161c8b..e46254fd31 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -17,6 +17,11 @@ export const packedNoteSchema = { optional: false, nullable: false, format: 'date-time', }, + updatedAt: { + type: 'string', + optional: true, nullable: true, + format: 'date-time', + }, deletedAt: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index ecbbee4eff..ba2d05d581 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -68,6 +68,7 @@ export const moderationLogTypes = [ 'promoteQueue', 'deleteDriveFile', 'deleteNote', + 'editNote', 'createGlobalAnnouncement', 'createUserAnnouncement', 'updateGlobalAnnouncement', @@ -169,6 +170,14 @@ export type ModerationLogPayloads = { fileUserUsername: string | null; fileUserHost: string | null; }; + editNote: { + noteId: string; + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + note: any; + beforeNote: any; + }; deleteNote: { noteId: string; noteUserId: string; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index bea89f2a7c..cfb1518a00 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2390,6 +2390,9 @@ type ModerationLog = { } | { type: 'deleteDriveFile'; info: ModerationLogPayloads['deleteDriveFile']; +} | { + type: 'editNote'; + info: ModerationLogPayloads['editNote']; } | { type: 'deleteNote'; info: ModerationLogPayloads['deleteNote']; @@ -2477,7 +2480,7 @@ type ModerationLog = { }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "editNote", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index bdcc1dfd77..f5903c9ea4 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4036,6 +4036,8 @@ export type components = { /** Format: date-time */ createdAt: string; /** Format: date-time */ + updatedAt?: string | null; + /** Format: date-time */ deletedAt?: string | null; text: string | null; cw?: string | null; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 03b9069290..b2c8eea571 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -110,6 +110,7 @@ export const moderationLogTypes = [ 'clearQueue', 'promoteQueue', 'deleteDriveFile', + 'editNote', 'deleteNote', 'createGlobalAnnouncement', 'createUserAnnouncement', @@ -206,6 +207,14 @@ export type ModerationLogPayloads = { fileUserUsername: string | null; fileUserHost: string | null; }; + editNote: { + noteId: string; + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + note: any; + beforeNote: any; + }; deleteNote: { noteId: string; noteUserId: string; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 7a84cb6a1a..f7c442287a 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -65,6 +65,9 @@ export type ModerationLog = { } | { type: 'deleteDriveFile'; info: ModerationLogPayloads['deleteDriveFile']; +} | { + type: 'editNote'; + info: ModerationLogPayloads['editNote']; } | { type: 'deleteNote'; info: ModerationLogPayloads['deleteNote'];