From 857608590680ee8e0dc785a132eb721b3f102472 Mon Sep 17 00:00:00 2001 From: Nya Candy <dev@candinya.com> Date: Fri, 26 Jan 2024 09:28:05 +0800 Subject: [PATCH] feat(wip): ap update note --- .../backend/src/core/NoteUpdateService.ts | 146 ++++++++++++++++++ .../src/core/activitypub/ApInboxService.ts | 3 + .../src/core/activitypub/ApRendererService.ts | 12 ++ .../core/activitypub/models/ApNoteService.ts | 46 +++++- .../src/server/api/endpoints/notes/update.ts | 22 +-- 5 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 packages/backend/src/core/NoteUpdateService.ts diff --git a/packages/backend/src/core/NoteUpdateService.ts b/packages/backend/src/core/NoteUpdateService.ts new file mode 100644 index 000000000..5bb50916c --- /dev/null +++ b/packages/backend/src/core/NoteUpdateService.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets, In } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; +import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; +import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.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 { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { SearchService } from '@/core/SearchService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class NoteUpdateService { + 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, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private globalEventService: GlobalEventService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private metaService: MetaService, + private searchService: SearchService, + private moderationLogService: ModerationLogService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private instanceChart: InstanceChart, + private idService: IdService, + ) {} + + /** + * Update note + * @param user Note creator + * @param note Note to update + * @param ps New note info + */ + async update(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, ps: Pick<MiNote, 'text' | 'cw'>, quiet = false, updater?: MiUser) { + const newNote = { + ...note, + ...ps, // Overwrite updated fields + }; + + if (!quiet) { + this.globalEventService.publishNoteStream(note.id, 'updated', { + cw: ps.cw, + text: ps.text ?? '', // prevent null + }); + + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + const content = this.apRendererService.renderUpdateNote(await this.apRendererService.renderNote(newNote, false), newNote); + this.deliverToConcerned(user, note, content); + } + } + + this.searchService.indexNote(newNote); + + await this.notesRepository.update({ id: note.id }, { + updatedAt: new Date(), + history: [...(note.history || []), { + createdAt: (note.updatedAt || this.idService.parse(note.id).date).toISOString(), + cw: note.cw, + text: note.text, + }], + cw: ps.cw, + text: ps.text, + }); + + // Currently not implemented + // if (updater && (note.userId !== updater.id)) { + // const user = await this.usersRepository.findOneByOrFail({ id: note.userId }); + // this.moderationLogService.log(updater, 'updateNote', { + // noteId: note.id, + // noteUserId: note.userId, + // noteUserUsername: user.username, + // noteUserHost: user.host, + // note: note, + // }); + // } + } + + @bindThis + private async getMentionedRemoteUsers(note: MiNote) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as MiRemoteUser[]; + } + + @bindThis + private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } +} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1..28b64cf45 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -770,6 +770,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 (isPost(object)) { + await this.apNoteService.updateNote(object, resolver); + return 'ok: Post 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 98e944f34..72945c43d 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -593,6 +593,18 @@ export class ApRendererService { }; } + @bindThis + public renderUpdateNote(object: string | IPost, note: { id: MiNote['id'], userId: MiNote['userId'] }): IUpdate { + return { + id: `${this.config.url}/notes/${note.id}#updates/${new Date().getTime()}`, + actor: this.userEntityService.genLocalUserUri(note.userId), + type: 'Update', + to: ['https://www.w3.org/ns/activitystreams#Public'], + object, + published: new Date().toISOString(), + }; + } + @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 5b75da22a..2bd744f86 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -6,7 +6,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; 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 +16,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 { NoteUpdateService } from '@/core/NoteUpdateService.js'; import type Logger from '@/logger.js'; import { IdService } from '@/core/IdService.js'; import { PollService } from '@/core/PollService.js'; @@ -52,6 +53,9 @@ export class ApNoteService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -69,6 +73,7 @@ export class ApNoteService { private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, + private noteUpdateService: NoteUpdateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, ) { @@ -325,6 +330,45 @@ export class ApNoteService { } } + @bindThis + public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<void> { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('uri is null'); + + // Is from local + if (uri.startsWith(`${this.config.url}/`)) throw new Error('uri points local'); + + const originNote = await this.notesRepository.findOneBy({ uri }); + if (originNote == null) throw new Error('Note is not registered'); + + // Process new note + const note = value as IPost; + + // Fetch note author + if (note.attributedTo == null) { + throw new Error('invalid note.attributedTo: ' + note.attributedTo); + } + + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser; + + const cw = note.summary || null; + + // Text parsing + 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); + } + + await this.noteUpdateService.update(actor, originNote, { + cw, + text, + }, silent); + } + /** * Noteを解決します。 * diff --git a/packages/backend/src/server/api/endpoints/notes/update.ts b/packages/backend/src/server/api/endpoints/notes/update.ts index 0b940f101..179b69a2f 100644 --- a/packages/backend/src/server/api/endpoints/notes/update.ts +++ b/packages/backend/src/server/api/endpoints/notes/update.ts @@ -13,6 +13,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { ApiError } from '../../error.js'; import { IdService } from "@/core/IdService.js"; +import { NoteUpdateService } from '@/core/NoteUpdateService.js'; export const meta = { tags: ['notes'], @@ -58,12 +59,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private getterService: GetterService, - private globalEventService: GlobalEventService, - private idService: IdService, + private noteUpdateService: NoteUpdateService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.getterService.getNote(ps.noteId).catch(err => { @@ -75,21 +72,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchNote); } - await this.notesRepository.update({ id: note.id }, { - updatedAt: new Date(), - history: [...(note.history || []), { - createdAt: (note.updatedAt || this.idService.parse(note.id).date).toISOString(), - cw: note.cw, - text: note.text, - }], - cw: ps.cw, + await this.noteUpdateService.update(await this.usersRepository.findOneByOrFail({ id: note.userId }), note, { text: ps.text, - }); - - this.globalEventService.publishNoteStream(note.id, 'updated', { cw: ps.cw, - text: ps.text, - }); + }, false, me); }); } }