diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1b59bd6278..ddd3d91ad9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -715,6 +715,46 @@ export class NoteCreateService implements OnApplicationShutdown { this.index(note); } + @bindThis + public async appendNoteVisibleUser(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + isCat: MiUser['isCat']; + }, note: MiNote, additionalUserId: MiLocalUser['id']) { + if (note.visibility !== 'specified') return; + if (note.visibleUserIds.includes(additionalUserId)) return; + + const additionalUser = await this.usersRepository.findOneByOrFail({ id: additionalUserId, host: IsNull() }); + + // ノートのvisibleUserIdsを更新 + await this.notesRepository.update(note.id, { + visibleUserIds: () => `array_append("visibleUserIds", '${additionalUser.id}')`, + }); + + // 新しい対象ユーザーにだけ処理が行われるようにする + note.visibleUserIds = [additionalUser.id]; + + // FanoutTimelineに追加 + this.pushToTl(note, user); + + // 未読として追加 + this.noteReadService.insertNoteUnread(additionalUser.id, note, { + isSpecified: true, + isMentioned: false, + }); + + // ストリームに流す + const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); + this.globalEventService.publishNotesStream(noteObj); + + // 通知を作成 + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); + await this.createMentionedEvents([additionalUser], note, nm); + nm.notify(); + } + @bindThis private isQuote(note: Option): note is Option & { renote: MiNote } { // sync with misc/is-quote.ts diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 9e1b5ca78a..62103039d3 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -97,7 +97,7 @@ export class PollService { if (note.localOnly) return; const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('note not found'); + if (user == null) throw new Error('user not found'); if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 125d8e3bce..892c19d3c9 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -137,8 +137,9 @@ export class QueueService { } @bindThis - public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + public inbox(user: ThinUser | null, activity: IActivity, signature: httpSignature.IParsedSignature) { const data = { + user: user ?? undefined, activity: activity, signature, }; diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 85a7f549a0..546e1fb756 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -26,7 +26,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import type { MiRemoteUser } from '@/models/User.js'; +import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -87,13 +87,13 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performActivity(actor: MiRemoteUser, activity: IObject, additionalTo?: MiLocalUser['id']): Promise { if (isCollectionOrOrderedCollection(activity)) { const resolver = this.apResolverService.createResolver(); for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { const act = await resolver.resolve(item); try { - await this.performOneActivity(actor, act); + await this.performOneActivity(actor, act, additionalTo); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -103,7 +103,7 @@ export class ApInboxService { } } } else { - await this.performOneActivity(actor, activity); + await this.performOneActivity(actor, activity, additionalTo); } // ついでにリモートユーザーの情報が古かったら更新しておく @@ -117,15 +117,15 @@ export class ApInboxService { } @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performOneActivity(actor: MiRemoteUser, activity: IObject, additionalTo?: MiLocalUser['id']): Promise { if (actor.isSuspended) return; if (isCreate(activity)) { - await this.create(actor, activity); + await this.create(actor, activity, additionalTo); } else if (isDelete(activity)) { await this.delete(actor, activity); } else if (isUpdate(activity)) { - await this.update(actor, activity); + await this.update(actor, activity, additionalTo); } else if (isFollow(activity)) { await this.follow(actor, activity); } else if (isAccept(activity)) { @@ -346,7 +346,7 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise { + private async create(actor: MiRemoteUser, activity: ICreate, additionalTo?: MiLocalUser['id']): Promise { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); @@ -375,14 +375,14 @@ export class ApInboxService { }); if (isPost(object)) { - await this.createNote(resolver, actor, object, false, activity); + await this.createNote(resolver, actor, object, false, activity, additionalTo); } else { this.logger.warn(`Unknown type: ${getApType(object)}`); } } @bindThis - private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { + private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate, additionalTo?: MiLocalUser['id']): Promise { const uri = getApId(note); if (typeof note === 'object') { @@ -401,9 +401,14 @@ export class ApInboxService { try { const exist = await this.apNoteService.fetchNote(note); - if (exist) return 'skip: note exists'; + if (additionalTo && exist && !await this.noteEntityService.isVisibleForMe(exist, additionalTo)) { + await this.noteCreateService.appendNoteVisibleUser(actor, exist, additionalTo); + return 'ok: note visible user appended'; + } else if (exist) { + return 'skip: note exists'; + } - await this.apNoteService.createNote(note, resolver, silent); + await this.apNoteService.createNote(note, resolver, silent, additionalTo); return 'ok'; } catch (err) { if (err instanceof StatusError && !err.isRetryable) { @@ -731,7 +736,7 @@ export class ApInboxService { } @bindThis - private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + private async update(actor: MiRemoteUser, activity: IUpdate, additionalTo?: MiLocalUser['id']): Promise { if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } @@ -751,6 +756,27 @@ export class ApInboxService { } else if (getApType(object) === 'Question') { await this.apQuestionService.updateQuestion(object, resolver).catch(err => this.logger.error(`err: failed to update question: ${err}`, { error: err })); return 'ok: Question updated'; + } else if (additionalTo && isPost(object)) { + const uri = getApId(object); + const unlock = await this.appLockService.getApLock(uri); + + try { + const exist = await this.apNoteService.fetchNote(object); + if (exist && !await this.noteEntityService.isVisibleForMe(exist, additionalTo)) { + await this.noteCreateService.appendNoteVisibleUser(actor, exist, additionalTo); + return 'ok: note visible user appended'; + } else { + return 'skip: nothing to do'; + } + } catch (err) { + if (err instanceof StatusError && !err.isRetryable) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } } else { return `skip: Unknown type: ${getApType(object)}`; } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 1d00bf0cca..9c3f3cd897 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -5,11 +5,11 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; -import { In } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.js'; +import type { UsersRepository, PollsRepository, EmojisRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; -import type { MiRemoteUser } from '@/models/User.js'; +import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; @@ -46,6 +46,9 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -113,7 +116,7 @@ export class ApNoteService { * Noteを作成します。 */ @bindThis - public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + public async createNote(value: string | IObject, resolver?: Resolver, silent = false, additionalTo?: MiLocalUser['id']): Promise { // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); @@ -163,6 +166,13 @@ export class ApNoteService { let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; + if (additionalTo) { + const additionalUser = await this.usersRepository.findOneBy({ id: additionalTo, host: IsNull() }); + if (additionalUser && !visibleUsers.some(x => x.id === additionalUser.id)) { + visibleUsers.push(additionalUser); + } + } + // Audience (to, cc) が指定されてなかった場合 if (visibility === 'specified' && visibleUsers.length === 0) { if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 50e7a1631e..76dde37bc6 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -180,7 +180,7 @@ export class InboxProcessorService { }); // アクティビティを処理 - await this.apInboxService.performActivity(authUser.user, activity); + await this.apInboxService.performActivity(authUser.user, activity, job.data.user?.id); return 'ok'; } } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 22ea8c1be1..8484f00bc2 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -26,6 +26,7 @@ export type DeliverJobData = { }; export type InboxJobData = { + user?: ThinUser; activity: IActivity; signature: httpSignature.IParsedSignature; }; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index a1dcc4f484..63e719293e 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -100,7 +100,8 @@ export class ActivityPubServerService { } @bindThis - private inbox(request: FastifyRequest, reply: FastifyReply) { + private async inbox(request: FastifyRequest, reply: FastifyReply) { + const userId = (request.params as { user: string; } | undefined)?.user; let signature; try { @@ -162,14 +163,23 @@ export class ActivityPubServerService { } } + const user = userId ? await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }) : null; + + if (userId && user == null) { + reply.code(404); + return; + } + const activity = request.body as IActivity; if (!activity.type || !signature.keyId) { reply.code(400); return; } - this.queueService.inbox(activity, signature); - + await this.queueService.inbox(user, activity, signature); reply.code(202); } @@ -553,7 +563,7 @@ export class ActivityPubServerService { //#region Routing // inbox (limit: 64kb) fastify.post('/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); - fastify.post('/users/:user/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + fastify.post<{ Params: { user: string; }; }>('/users/:user/inbox', { config: { rawBody: true }, bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {