diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32cf3f3e26eb..b936741c3568 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -732,6 +732,45 @@ export class NoteCreateService implements OnApplicationShutdown { return note.renote != null; } + 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 & { renote: MiNote }): note is Option & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 6c96ab16cff0..9f6924b60818 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 80827a500b56..d734257601d2 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -155,8 +155,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 e2164fec1d93..5a3927377733 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 { GlobalEventService } from '@/core/GlobalEventService.js'; import { AbuseReportService } from '@/core/AbuseReportService.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'; @@ -88,7 +88,7 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performActivity(actor: MiRemoteUser, activity: IObject, additionalTo?: MiLocalUser['id']): Promise { let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; @@ -96,7 +96,7 @@ export class ApInboxService { for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { const act = await resolver.resolve(item); try { - results.push([getApId(item), await this.performOneActivity(actor, act)]); + results.push([getApId(item), await this.performOneActivity(actor, act, additionalTo)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -111,7 +111,7 @@ export class ApInboxService { result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); } } else { - result = await this.performOneActivity(actor, activity); + result = await this.performOneActivity(actor, activity, additionalTo); } // ついでにリモートユーザーの情報が古かったら更新しておく @@ -126,15 +126,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)) { - return await this.create(actor, activity); + return await this.create(actor, activity, additionalTo); } else if (isDelete(activity)) { return await this.delete(actor, activity); } else if (isUpdate(activity)) { - return await this.update(actor, activity); + return await this.update(actor, activity, additionalTo); } else if (isFollow(activity)) { return await this.follow(actor, activity); } else if (isAccept(activity)) { @@ -362,7 +362,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}`); @@ -395,14 +395,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 { return `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') { @@ -421,9 +421,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) { @@ -750,7 +755,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'; } @@ -770,6 +775,27 @@ 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 (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 fc7aa1e0b972..f9938172972c 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -4,11 +4,11 @@ */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -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(); @@ -198,6 +201,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 fa7009f8f5d9..22633db6ac29 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -204,7 +204,7 @@ export class InboxProcessorService { // アクティビティを処理 try { - const result = await this.apInboxService.performActivity(authUser.user, activity); + const result = await this.apInboxService.performActivity(authUser.user, activity, job.data.user?.id); if (result && !result.startsWith('ok')) { this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`); return result; diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index a4077a0547ea..913cf90141af 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -25,6 +25,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 3255d64621db..3f26ed9c2ae3 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,8 +163,23 @@ export class ActivityPubServerService { } } - this.queueService.inbox(request.body as IActivity, signature); + 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; + } + await this.queueService.inbox(user, activity, signature); reply.code(202); } @@ -547,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) => {