From 22ccb0fa716a84560c8599781647baaaeb8e80bd Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 4 Dec 2022 10:16:03 +0900 Subject: refactor --- packages/backend/src/core/AccountUpdateService.ts | 6 +- packages/backend/src/core/AntennaService.ts | 2 +- packages/backend/src/core/CaptchaService.ts | 2 +- packages/backend/src/core/CoreModule.ts | 48 +- .../backend/src/core/CreateNotificationService.ts | 4 +- packages/backend/src/core/CustomEmojiService.ts | 4 +- packages/backend/src/core/DriveService.ts | 6 +- .../backend/src/core/FederatedInstanceService.ts | 2 +- .../src/core/FetchInstanceMetadataService.ts | 2 +- packages/backend/src/core/HashtagService.ts | 2 +- packages/backend/src/core/InstanceActorService.ts | 2 +- packages/backend/src/core/MessagingService.ts | 12 +- packages/backend/src/core/NoteCreateService.ts | 16 +- packages/backend/src/core/NoteDeleteService.ts | 8 +- packages/backend/src/core/NotePiningService.ts | 6 +- packages/backend/src/core/NoteReadService.ts | 2 +- packages/backend/src/core/NotificationService.ts | 2 +- packages/backend/src/core/PollService.ts | 6 +- packages/backend/src/core/ProxyAccountService.ts | 2 +- .../backend/src/core/PushNotificationService.ts | 2 +- packages/backend/src/core/QueueModule.ts | 112 ++++ packages/backend/src/core/QueueService.ts | 4 +- packages/backend/src/core/ReactionService.ts | 10 +- packages/backend/src/core/RelayService.ts | 2 +- packages/backend/src/core/RemoteLoggerService.ts | 14 + .../backend/src/core/RemoteUserResolveService.ts | 132 ++++ packages/backend/src/core/S3Service.ts | 2 +- packages/backend/src/core/SignupService.ts | 2 +- packages/backend/src/core/UserBlockingService.ts | 8 +- packages/backend/src/core/UserCacheService.ts | 2 +- packages/backend/src/core/UserFollowingService.ts | 4 +- packages/backend/src/core/UserListService.ts | 4 +- packages/backend/src/core/UserSuspendService.ts | 4 +- packages/backend/src/core/WebfingerService.ts | 48 ++ .../src/core/activitypub/ApAudienceService.ts | 104 +++ .../src/core/activitypub/ApDbResolverService.ts | 179 +++++ .../core/activitypub/ApDeliverManagerService.ts | 199 ++++++ .../backend/src/core/activitypub/ApInboxService.ts | 740 +++++++++++++++++++++ .../src/core/activitypub/ApLoggerService.ts | 14 + .../backend/src/core/activitypub/ApMfmService.ts | 30 + .../src/core/activitypub/ApRendererService.ts | 703 ++++++++++++++++++++ .../src/core/activitypub/ApRequestService.ts | 182 +++++ .../src/core/activitypub/ApResolverService.ts | 195 ++++++ .../src/core/activitypub/LdSignatureService.ts | 147 ++++ .../backend/src/core/activitypub/misc/contexts.ts | 526 +++++++++++++++ .../src/core/activitypub/models/ApImageService.ts | 90 +++ .../core/activitypub/models/ApMentionService.ts | 39 ++ .../src/core/activitypub/models/ApNoteService.ts | 403 +++++++++++ .../src/core/activitypub/models/ApPersonService.ts | 594 +++++++++++++++++ .../core/activitypub/models/ApQuestionService.ts | 109 +++ .../backend/src/core/activitypub/models/icon.ts | 5 + .../src/core/activitypub/models/identifier.ts | 5 + .../backend/src/core/activitypub/models/tag.ts | 19 + packages/backend/src/core/activitypub/type.ts | 296 +++++++++ .../backend/src/core/chart/charts/active-users.ts | 2 +- .../backend/src/core/chart/charts/ap-request.ts | 2 +- packages/backend/src/core/chart/charts/drive.ts | 2 +- .../backend/src/core/chart/charts/federation.ts | 2 +- packages/backend/src/core/chart/charts/hashtag.ts | 2 +- packages/backend/src/core/chart/charts/instance.ts | 2 +- packages/backend/src/core/chart/charts/notes.ts | 2 +- .../src/core/chart/charts/per-user-drive.ts | 2 +- .../src/core/chart/charts/per-user-following.ts | 2 +- .../src/core/chart/charts/per-user-notes.ts | 2 +- .../src/core/chart/charts/per-user-reactions.ts | 2 +- .../backend/src/core/chart/charts/test-grouped.ts | 2 +- .../src/core/chart/charts/test-intersection.ts | 2 +- .../backend/src/core/chart/charts/test-unique.ts | 2 +- packages/backend/src/core/chart/charts/test.ts | 2 +- packages/backend/src/core/chart/charts/users.ts | 2 +- .../src/core/entities/InstanceEntityService.ts | 2 +- packages/backend/src/core/queue/QueueModule.ts | 112 ---- .../backend/src/core/remote/RemoteLoggerService.ts | 14 - .../backend/src/core/remote/ResolveUserService.ts | 132 ---- .../backend/src/core/remote/WebfingerService.ts | 48 -- .../core/remote/activitypub/ApAudienceService.ts | 104 --- .../core/remote/activitypub/ApDbResolverService.ts | 179 ----- .../remote/activitypub/ApDeliverManagerService.ts | 199 ------ .../src/core/remote/activitypub/ApInboxService.ts | 740 --------------------- .../src/core/remote/activitypub/ApLoggerService.ts | 14 - .../src/core/remote/activitypub/ApMfmService.ts | 30 - .../core/remote/activitypub/ApRendererService.ts | 703 -------------------- .../core/remote/activitypub/ApRequestService.ts | 182 ----- .../core/remote/activitypub/ApResolverService.ts | 195 ------ .../core/remote/activitypub/LdSignatureService.ts | 147 ---- .../src/core/remote/activitypub/misc/contexts.ts | 526 --------------- .../remote/activitypub/models/ApImageService.ts | 90 --- .../remote/activitypub/models/ApMentionService.ts | 39 -- .../remote/activitypub/models/ApNoteService.ts | 403 ----------- .../remote/activitypub/models/ApPersonService.ts | 594 ----------------- .../remote/activitypub/models/ApQuestionService.ts | 109 --- .../src/core/remote/activitypub/models/icon.ts | 5 - .../core/remote/activitypub/models/identifier.ts | 5 - .../src/core/remote/activitypub/models/tag.ts | 19 - .../backend/src/core/remote/activitypub/type.ts | 296 --------- 95 files changed, 4991 insertions(+), 4991 deletions(-) create mode 100644 packages/backend/src/core/QueueModule.ts create mode 100644 packages/backend/src/core/RemoteLoggerService.ts create mode 100644 packages/backend/src/core/RemoteUserResolveService.ts create mode 100644 packages/backend/src/core/WebfingerService.ts create mode 100644 packages/backend/src/core/activitypub/ApAudienceService.ts create mode 100644 packages/backend/src/core/activitypub/ApDbResolverService.ts create mode 100644 packages/backend/src/core/activitypub/ApDeliverManagerService.ts create mode 100644 packages/backend/src/core/activitypub/ApInboxService.ts create mode 100644 packages/backend/src/core/activitypub/ApLoggerService.ts create mode 100644 packages/backend/src/core/activitypub/ApMfmService.ts create mode 100644 packages/backend/src/core/activitypub/ApRendererService.ts create mode 100644 packages/backend/src/core/activitypub/ApRequestService.ts create mode 100644 packages/backend/src/core/activitypub/ApResolverService.ts create mode 100644 packages/backend/src/core/activitypub/LdSignatureService.ts create mode 100644 packages/backend/src/core/activitypub/misc/contexts.ts create mode 100644 packages/backend/src/core/activitypub/models/ApImageService.ts create mode 100644 packages/backend/src/core/activitypub/models/ApMentionService.ts create mode 100644 packages/backend/src/core/activitypub/models/ApNoteService.ts create mode 100644 packages/backend/src/core/activitypub/models/ApPersonService.ts create mode 100644 packages/backend/src/core/activitypub/models/ApQuestionService.ts create mode 100644 packages/backend/src/core/activitypub/models/icon.ts create mode 100644 packages/backend/src/core/activitypub/models/identifier.ts create mode 100644 packages/backend/src/core/activitypub/models/tag.ts create mode 100644 packages/backend/src/core/activitypub/type.ts delete mode 100644 packages/backend/src/core/queue/QueueModule.ts delete mode 100644 packages/backend/src/core/remote/RemoteLoggerService.ts delete mode 100644 packages/backend/src/core/remote/ResolveUserService.ts delete mode 100644 packages/backend/src/core/remote/WebfingerService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApAudienceService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApDbResolverService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApInboxService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApLoggerService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApMfmService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApRendererService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApRequestService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/ApResolverService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/LdSignatureService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/misc/contexts.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/ApImageService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/ApMentionService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/ApNoteService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/ApPersonService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/icon.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/identifier.ts delete mode 100644 packages/backend/src/core/remote/activitypub/models/tag.ts delete mode 100644 packages/backend/src/core/remote/activitypub/type.ts (limited to 'packages/backend/src/core') diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index 6fe0e05c6d..a5ab4fdfce 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -3,10 +3,10 @@ import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; -import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { RelayService } from '@/core/RelayService.js'; -import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; @Injectable() export class AccountUpdateService { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index af76767f31..8046ba5311 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -11,7 +11,7 @@ import { Cache } from '@/misc/cache.js'; import type { Packed } from '@/misc/schema.js'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; -import { UtilityService } from './UtilityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index b2bc24ac2c..b60271812c 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import { HttpRequestService } from './HttpRequestService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; type CaptchaResponse = { success: boolean; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index da07728d22..085addaa05 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -94,25 +94,25 @@ import { UserEntityService } from './entities/UserEntityService.js'; import { UserGroupEntityService } from './entities/UserGroupEntityService.js'; import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js'; import { UserListEntityService } from './entities/UserListEntityService.js'; -import { ApAudienceService } from './remote/activitypub/ApAudienceService.js'; -import { ApDbResolverService } from './remote/activitypub/ApDbResolverService.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; -import { ApInboxService } from './remote/activitypub/ApInboxService.js'; -import { ApLoggerService } from './remote/activitypub/ApLoggerService.js'; -import { ApMfmService } from './remote/activitypub/ApMfmService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { ApRequestService } from './remote/activitypub/ApRequestService.js'; -import { ApResolverService } from './remote/activitypub/ApResolverService.js'; -import { LdSignatureService } from './remote/activitypub/LdSignatureService.js'; -import { RemoteLoggerService } from './remote/RemoteLoggerService.js'; -import { ResolveUserService } from './remote/ResolveUserService.js'; -import { WebfingerService } from './remote/WebfingerService.js'; -import { ApImageService } from './remote/activitypub/models/ApImageService.js'; -import { ApMentionService } from './remote/activitypub/models/ApMentionService.js'; -import { ApNoteService } from './remote/activitypub/models/ApNoteService.js'; -import { ApPersonService } from './remote/activitypub/models/ApPersonService.js'; -import { ApQuestionService } from './remote/activitypub/models/ApQuestionService.js'; -import { QueueModule } from './queue/QueueModule.js'; +import { ApAudienceService } from './activitypub/ApAudienceService.js'; +import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; +import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; +import { ApInboxService } from './activitypub/ApInboxService.js'; +import { ApLoggerService } from './activitypub/ApLoggerService.js'; +import { ApMfmService } from './activitypub/ApMfmService.js'; +import { ApRendererService } from './activitypub/ApRendererService.js'; +import { ApRequestService } from './activitypub/ApRequestService.js'; +import { ApResolverService } from './activitypub/ApResolverService.js'; +import { LdSignatureService } from './activitypub/LdSignatureService.js'; +import { RemoteLoggerService } from './RemoteLoggerService.js'; +import { RemoteUserResolveService } from './RemoteUserResolveService.js'; +import { WebfingerService } from './WebfingerService.js'; +import { ApImageService } from './activitypub/models/ApImageService.js'; +import { ApMentionService } from './activitypub/models/ApMentionService.js'; +import { ApNoteService } from './activitypub/models/ApNoteService.js'; +import { ApPersonService } from './activitypub/models/ApPersonService.js'; +import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; +import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; import type { Provider } from '@nestjs/common'; @@ -226,7 +226,7 @@ const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService }; const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService }; const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService }; -const $ResolveUserService: Provider = { provide: 'ResolveUserService', useExisting: ResolveUserService }; +const $RemoteUserResolveService: Provider = { provide: 'RemoteUserResolveService', useExisting: RemoteUserResolveService }; const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService }; const $ApImageService: Provider = { provide: 'ApImageService', useExisting: ApImageService }; const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: ApMentionService }; @@ -346,7 +346,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApResolverService, LdSignatureService, RemoteLoggerService, - ResolveUserService, + RemoteUserResolveService, WebfingerService, ApImageService, ApMentionService, @@ -462,7 +462,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApResolverService, $LdSignatureService, $RemoteLoggerService, - $ResolveUserService, + $RemoteUserResolveService, $WebfingerService, $ApImageService, $ApMentionService, @@ -578,7 +578,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApResolverService, LdSignatureService, RemoteLoggerService, - ResolveUserService, + RemoteUserResolveService, WebfingerService, ApImageService, ApMentionService, @@ -693,7 +693,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApResolverService, $LdSignatureService, $RemoteLoggerService, - $ResolveUserService, + $RemoteUserResolveService, $WebfingerService, $ApImageService, $ApMentionService, diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts index feb82dcbf9..504661c3bd 100644 --- a/packages/backend/src/core/CreateNotificationService.ts +++ b/packages/backend/src/core/CreateNotificationService.ts @@ -5,8 +5,8 @@ import type { Notification } from '@/models/entities/Notification.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; -import { NotificationEntityService } from './entities/NotificationEntityService.js'; -import { PushNotificationService } from './PushNotificationService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; @Injectable() export class CreateNotificationService { diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index e1355fff07..3319f3efa8 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -10,8 +10,8 @@ import { Cache } from '@/misc/cache.js'; import { query } from '@/misc/prelude/url.js'; import type { Note } from '@/models/entities/Note.js'; import type { EmojisRepository } from '@/models/index.js'; -import { UtilityService } from './UtilityService.js'; -import { ReactionService } from './ReactionService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { ReactionService } from '@/core/ReactionService.js'; /** * 添付用絵文字情報 diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index e0bdd29c0f..1d2ba5df8c 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -28,9 +28,9 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import { DownloadService } from '@/core/DownloadService.js'; import { S3Service } from '@/core/S3Service.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { FileInfoService } from './FileInfoService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; import type S3 from 'aws-sdk/clients/s3.js'; type AddFileArgs = { diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index b98a41f757..a05c95a2ae 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -4,7 +4,7 @@ import type { Instance } from '@/models/entities/Instance.js'; import { Cache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; -import { UtilityService } from './UtilityService.js'; +import { UtilityService } from '@/core/UtilityService.js'; @Injectable() export class FederatedInstanceService { diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 184404123c..b92ebe6059 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -9,7 +9,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { HttpRequestService } from './HttpRequestService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index 83950aa890..5ca058e9a4 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -6,7 +6,7 @@ import { IdService } from '@/core/IdService.js'; import type { Hashtag } from '@/models/entities/Hashtag.js'; import HashtagChart from '@/core/chart/charts/hashtag.js'; import type { HashtagsRepository, UsersRepository } from '@/models/index.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; @Injectable() export class HashtagService { diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index fa906df4a2..f35a28147d 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -4,7 +4,7 @@ import type { ILocalUser } from '@/models/entities/User.js'; import type { UsersRepository } from '@/models/index.js'; import { Cache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; -import { CreateSystemUserService } from './CreateSystemUserService.js'; +import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; const ACTOR_USERNAME = 'instance.actor' as const; diff --git a/packages/backend/src/core/MessagingService.ts b/packages/backend/src/core/MessagingService.ts index 0603da0651..9de28ad8db 100644 --- a/packages/backend/src/core/MessagingService.ts +++ b/packages/backend/src/core/MessagingService.ts @@ -11,12 +11,12 @@ import { QueueService } from '@/core/QueueService.js'; import { toArray } from '@/misc/prelude/array.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js'; -import { IdService } from './IdService.js'; -import { GlobalEventService } from './GlobalEventService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; -import { PushNotificationService } from './PushNotificationService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; @Injectable() export class MessagingService { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a23e105674..cf1566a5e8 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -34,12 +34,12 @@ import { WebhookService } from '@/core/WebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; import { QueueService } from '@/core/QueueService.js'; -import { NoteEntityService } from './entities/NoteEntityService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { NoteReadService } from './NoteReadService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { ResolveUserService } from './remote/ResolveUserService.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.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'; const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -179,7 +179,7 @@ export class NoteCreateService { private hashtagService: HashtagService, private antennaService: AntennaService, private webhookService: WebhookService, - private resolveUserService: ResolveUserService, + private remoteUserResolveService: RemoteUserResolveService, private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private notesChart: NotesChart, @@ -726,7 +726,7 @@ export class NoteCreateService { const mentions = extractMentions(tokens); let mentionedUsers = (await Promise.all(mentions.map(m => - this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null), + this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), ))).filter(x => x != null) as User[]; // Drop duplicate users diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index ccc583c5b6..ce6e755a7e 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -11,10 +11,10 @@ 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 './remote/activitypub/ApRendererService.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { NoteEntityService } from './entities/NoteEntityService.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'; @Injectable() export class NoteDeleteService { diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts index 8c4a761ba6..a04b52fe4c 100644 --- a/packages/backend/src/core/NotePiningService.ts +++ b/packages/backend/src/core/NotePiningService.ts @@ -8,9 +8,9 @@ import { IdService } from '@/core/IdService.js'; import type { UserNotePining } from '@/models/entities/UserNotePining.js'; import { RelayService } from '@/core/RelayService.js'; import type { Config } from '@/config.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @Injectable() export class NotePiningService { diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 2c84e1d4d5..e0feaa957d 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -8,7 +8,7 @@ import type { Note } from '@/models/entities/Note.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NotificationService } from './NotificationService.js'; import { AntennaService } from './AntennaService.js'; diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 2606ca4de0..8bbc95b02d 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -5,7 +5,7 @@ import type { NotificationsRepository } from '@/models/index.js'; import type { UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from './GlobalEventService.js'; import { PushNotificationService } from './PushNotificationService.js'; diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index e3e12b5320..287ce8ada4 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -8,9 +8,9 @@ import type { CacheableUser } from '@/models/entities/User.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { CreateNotificationService } from '@/core/CreateNotificationService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; @Injectable() export class PollService { diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts index 07d8d0dbd5..4cbdadd029 100644 --- a/packages/backend/src/core/ProxyAccountService.ts +++ b/packages/backend/src/core/ProxyAccountService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository } from '@/models/index.js'; import type { ILocalUser, User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from './MetaService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class ProxyAccountService { diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 5eaaed00eb..98e0841799 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -5,7 +5,7 @@ import type { Config } from '@/config.js'; import type { Packed } from '@/misc/schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { SwSubscriptionsRepository } from '@/models/index.js'; -import { MetaService } from './MetaService.js'; +import { MetaService } from '@/core/MetaService.js'; // Defined also packages/sw/types.ts#L14-L21 type pushNotificationsTypes = { diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts new file mode 100644 index 0000000000..edd843977b --- /dev/null +++ b/packages/backend/src/core/QueueModule.ts @@ -0,0 +1,112 @@ +import { Module } from '@nestjs/common'; +import Bull from 'bull'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { Provider } from '@nestjs/common'; +import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js'; + +function q(config: Config, name: string, limitPerSec = -1) { + return new Bull(name, { + redis: { + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + db: config.redis.db ?? 0, + }, + prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + limiter: limitPerSec > 0 ? { + max: limitPerSec, + duration: 1000, + } : undefined, + settings: { + backoffStrategies: { + apBackoff, + }, + }, + }); +} + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function apBackoff(attemptsMade: number, err: Error) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} + +export type SystemQueue = Bull.Queue>; +export type EndedPollNotificationQueue = Bull.Queue; +export type DeliverQueue = Bull.Queue; +export type InboxQueue = Bull.Queue; +export type DbQueue = Bull.Queue; +export type ObjectStorageQueue = Bull.Queue; +export type WebhookDeliverQueue = Bull.Queue; + +const $system: Provider = { + provide: 'queue:system', + useFactory: (config: Config) => q(config, 'system'), + inject: [DI.config], +}; + +const $endedPollNotification: Provider = { + provide: 'queue:endedPollNotification', + useFactory: (config: Config) => q(config, 'endedPollNotification'), + inject: [DI.config], +}; + +const $deliver: Provider = { + provide: 'queue:deliver', + useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128), + inject: [DI.config], +}; + +const $inbox: Provider = { + provide: 'queue:inbox', + useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16), + inject: [DI.config], +}; + +const $db: Provider = { + provide: 'queue:db', + useFactory: (config: Config) => q(config, 'db'), + inject: [DI.config], +}; + +const $objectStorage: Provider = { + provide: 'queue:objectStorage', + useFactory: (config: Config) => q(config, 'objectStorage'), + inject: [DI.config], +}; + +const $webhookDeliver: Provider = { + provide: 'queue:webhookDeliver', + useFactory: (config: Config) => q(config, 'webhookDeliver', 64), + inject: [DI.config], +}; + +@Module({ + imports: [ + ], + providers: [ + $system, + $endedPollNotification, + $deliver, + $inbox, + $db, + $objectStorage, + $webhookDeliver, + ], + exports: [ + $system, + $endedPollNotification, + $deliver, + $inbox, + $db, + $objectStorage, + $webhookDeliver, + ], +}) +export class QueueModule {} diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index d9ad26747f..a27d68ee19 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; -import type { IActivity } from '@/core/remote/activitypub/type.js'; +import type { IActivity } from '@/core/activitypub/type.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; import type { ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index d5b3c0e799..7a9724e7dd 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -12,11 +12,11 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; import { emojiRegex } from '@/misc/emoji-regex.js'; -import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; -import { NoteEntityService } from './entities/NoteEntityService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { MetaService } from './MetaService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from './UtilityService.js'; const legacies: Record = { diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 3c67e0573f..7951edddcb 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -7,7 +7,7 @@ import { Cache } from '@/misc/cache.js'; import type { Relay } from '@/models/entities/Relay.js'; import { QueueService } from '@/core/QueueService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { DI } from '@/di-symbols.js'; import { deepClone } from '@/misc/clone.js'; diff --git a/packages/backend/src/core/RemoteLoggerService.ts b/packages/backend/src/core/RemoteLoggerService.ts new file mode 100644 index 0000000000..68246466c8 --- /dev/null +++ b/packages/backend/src/core/RemoteLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class RemoteLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('remote', 'cyan'); + } +} diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts new file mode 100644 index 0000000000..809b50f6e9 --- /dev/null +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -0,0 +1,132 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import chalk from 'chalk'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import type { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { WebfingerService } from '@/core/WebfingerService.js'; +import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; + +@Injectable() +export class RemoteUserResolveService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private utilityService: UtilityService, + private webfingerService: WebfingerService, + private remoteLoggerService: RemoteLoggerService, + private apPersonService: ApPersonService, + ) { + this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); + } + + public async resolveUser(username: string, host: string | null): Promise { + const usernameLower = username.toLowerCase(); + + if (host == null) { + this.logger.info(`return local user: ${usernameLower}`); + return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + host = this.utilityService.toPuny(host); + + if (this.config.host === host) { + this.logger.info(`return local user: ${usernameLower}`); + return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + const user = await this.usersRepository.findOneBy({ usernameLower, host }) as IRemoteUser | null; + + const acctLower = `${usernameLower}@${host}`; + + if (user == null) { + const self = await this.resolveSelf(acctLower); + + this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); + return await this.apPersonService.createPerson(self.href); + } + + // ユーザー情報が古い場合は、WebFilgerからやりなおして返す + if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する + await this.usersRepository.update(user.id, { + lastFetchedAt: new Date(), + }); + + this.logger.info(`try resync: ${acctLower}`); + const self = await this.resolveSelf(acctLower); + + if (user.uri !== self.href) { + // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. + this.logger.info(`uri missmatch: ${acctLower}`); + this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); + + // validate uri + const uri = new URL(self.href); + if (uri.hostname !== host) { + throw new Error('Invalid uri'); + } + + await this.usersRepository.update({ + usernameLower, + host: host, + }, { + uri: self.href, + }); + } else { + this.logger.info(`uri is fine: ${acctLower}`); + } + + await this.apPersonService.updatePerson(self.href); + + this.logger.info(`return resynced remote user: ${acctLower}`); + return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + this.logger.info(`return existing remote user: ${acctLower}`); + return user; + } + + private async resolveSelf(acctLower: string) { + this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + const finger = await this.webfingerService.webfinger(acctLower).catch(err => { + this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); + throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`); + }); + const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); + if (!self) { + this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); + throw new Error('self link not found'); + } + return self; + } +} diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 723a79dc59..1374ee06c8 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -4,7 +4,7 @@ import S3 from 'aws-sdk/clients/s3.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Meta } from '@/models/entities/Meta.js'; -import { HttpRequestService } from './HttpRequestService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; @Injectable() export class S3Service { diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 2239d5fd83..1e34d9e4f8 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -12,7 +12,7 @@ import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import UsersChart from './chart/charts/users.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UtilityService } from './UtilityService.js'; @Injectable() diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index b7a434684e..3399bb510f 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -10,10 +10,10 @@ import { DI } from '@/di-symbols.js'; import logger from '@/logger.js'; import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; import Logger from '@/logger.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { WebhookService } from './WebhookService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { LoggerService } from './LoggerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { WebhookService } from '@/core/WebhookService.js'; @Injectable() export class UserBlockingService { diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts index b7166010ee..25a600a8da 100644 --- a/packages/backend/src/core/UserCacheService.ts +++ b/packages/backend/src/core/UserCacheService.ts @@ -4,7 +4,7 @@ import type { UsersRepository } from '@/models/index.js'; import { Cache } from '@/misc/cache.js'; import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 31e08c1366..2f51e2a9df 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -14,8 +14,8 @@ import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { DI } from '@/di-symbols.js'; import type { BlockingsRepository, FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import Logger from '../logger.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; const logger = new Logger('following/create'); diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index b1d01a1565..1d1ead5a1f 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -7,8 +7,8 @@ import { IdService } from '@/core/IdService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from './entities/UserEntityService.js'; -import { ProxyAccountService } from './ProxyAccountService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ProxyAccountService } from '@/core/ProxyAccountService.js'; @Injectable() export class UserListService { diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 82c2e98236..02f686bab6 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -6,8 +6,8 @@ import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { ApRendererService } from './remote/activitypub/ApRendererService.js'; -import { UserEntityService } from './entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; @Injectable() export class UserSuspendService { diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts new file mode 100644 index 0000000000..d2a88be583 --- /dev/null +++ b/packages/backend/src/core/WebfingerService.ts @@ -0,0 +1,48 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { query as urlQuery } from '@/misc/prelude/url.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; + +type ILink = { + href: string; + rel?: string; +}; + +type IWebFinger = { + links: ILink[]; + subject: string; +}; + +@Injectable() +export class WebfingerService { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + public async webfinger(query: string): Promise { + const url = this.genUrl(query); + + return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json') as IWebFinger; + } + + private genUrl(query: string): string { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + } + + throw new Error(`Invalid query (${query})`); + } +} diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts new file mode 100644 index 0000000000..744017aa3a --- /dev/null +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -0,0 +1,104 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; +import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import type { ApObject } from './type.js'; +import type { Resolver } from './ApResolverService.js'; + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +type AudienceInfo = { + visibility: Visibility, + mentionedUsers: CacheableUser[], + visibleUsers: CacheableUser[], +}; + +@Injectable() +export class ApAudienceService { + constructor( + private apPersonService: ApPersonService, + ) { + } + + public async parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { + const toGroups = this.groupingAudience(getApIds(to), actor); + const ccGroups = this.groupingAudience(getApIds(cc), actor); + + const others = unique(concat([toGroups.other, ccGroups.other])); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), + )).filter((x): x is CacheableUser => x != null); + + if (toGroups.public.length > 0) { + return { + visibility: 'public', + mentionedUsers, + visibleUsers: [], + }; + } + + if (ccGroups.public.length > 0) { + return { + visibility: 'home', + mentionedUsers, + visibleUsers: [], + }; + } + + if (toGroups.followers.length > 0) { + return { + visibility: 'followers', + mentionedUsers, + visibleUsers: [], + }; + } + + return { + visibility: 'specified', + mentionedUsers, + visibleUsers: mentionedUsers, + }; + } + + private groupingAudience(ids: string[], actor: CacheableRemoteUser) { + const groups = { + public: [] as string[], + followers: [] as string[], + other: [] as string[], + }; + + for (const id of ids) { + if (this.isPublic(id)) { + groups.public.push(id); + } else if (this.isFollowers(id, actor)) { + groups.followers.push(id); + } else { + groups.other.push(id); + } + } + + groups.other = unique(groups.other); + + return groups; + } + + private isPublic(id: string) { + return [ + 'https://www.w3.org/ns/activitystreams#Public', + 'as#Public', + 'Public', + ].includes(id); + } + + private isFollowers(id: string, actor: CacheableRemoteUser) { + return ( + id === (actor.followersUri ?? `${actor.uri}/followers`) + ); + } +} diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts new file mode 100644 index 0000000000..77d200c3c8 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -0,0 +1,179 @@ +import { Inject, Injectable } from '@nestjs/common'; +import escapeRegexp from 'escape-regexp'; +import { DI } from '@/di-symbols.js'; +import type { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserPublickey } from '@/models/entities/UserPublickey.js'; +import { UserCacheService } from '@/core/UserCacheService.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { getApId } from './type.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import type { IObject } from './type.js'; + +export type UriParseResult = { + /** wether the URI was generated by us */ + local: true; + /** id in DB */ + id: string; + /** hint of type, e.g. "notes", "users" */ + type: string; + /** any remaining text after type and id, not including the slash after id. undefined if empty */ + rest?: string; +} | { + /** wether the URI was generated by us */ + local: false; + /** uri in DB */ + uri: string; +}; + +@Injectable() +export class ApDbResolverService { + private publicKeyCache: Cache; + private publicKeyByUserIdCache: Cache; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userPublickeysRepository) + private userPublickeysRepository: UserPublickeysRepository, + + private userCacheService: UserCacheService, + private apPersonService: ApPersonService, + ) { + this.publicKeyCache = new Cache(Infinity); + this.publicKeyByUserIdCache = new Cache(Infinity); + } + + public parseUri(value: string | IObject): UriParseResult { + const uri = getApId(value); + + // the host part of a URL is case insensitive, so use the 'i' flag. + const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); + const matchLocal = uri.match(localRegex); + + if (matchLocal) { + return { + local: true, + type: matchLocal[1], + id: matchLocal[2], + rest: matchLocal[3], + }; + } else { + return { + local: false, + uri, + }; + } + } + + /** + * AP Note => Misskey Note in DB + */ + public async getNoteFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await this.notesRepository.findOneBy({ + id: parsed.id, + }); + } else { + return await this.notesRepository.findOneBy({ + uri: parsed.uri, + }); + } + } + + public async getMessageFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await this.messagingMessagesRepository.findOneBy({ + id: parsed.id, + }); + } else { + return await this.messagingMessagesRepository.findOneBy({ + uri: parsed.uri, + }); + } + } + + /** + * AP Person => Misskey User in DB + */ + public async getUserFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'users') return null; + + return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + id: parsed.id, + }).then(x => x ?? undefined)) ?? null; + } else { + return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + uri: parsed.uri, + })); + } + } + + /** + * AP KeyId => Misskey User and Key + */ + public async getAuthUserFromKeyId(keyId: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey; + } | null> { + const key = await this.publicKeyCache.fetch(keyId, async () => { + const key = await this.userPublickeysRepository.findOneBy({ + keyId, + }); + + if (key == null) return null; + + return key; + }, key => key != null); + + if (key == null) return null; + + return { + user: await this.userCacheService.userByIdCache.fetch(key.userId, () => this.usersRepository.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser, + key, + }; + } + + /** + * AP Actor id => Misskey User and Key + */ + public async getAuthUserFromApId(uri: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null> { + const user = await this.apPersonService.resolvePerson(uri) as CacheableRemoteUser; + + if (user == null) return null; + + const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null); + + return { + user, + key, + }; + } +} diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts new file mode 100644 index 0000000000..6fc75a0397 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -0,0 +1,199 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { QueueService } from '@/core/QueueService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +interface IRecipe { + type: string; +} + +interface IFollowersRecipe extends IRecipe { + type: 'Followers'; +} + +interface IDirectRecipe extends IRecipe { + type: 'Direct'; + to: IRemoteUser; +} + +const isFollowers = (recipe: any): recipe is IFollowersRecipe => + recipe.type === 'Followers'; + +const isDirect = (recipe: any): recipe is IDirectRecipe => + recipe.type === 'Direct'; + +@Injectable() +export class ApDeliverManagerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private queueService: QueueService, + ) { + } + + /** + * Deliver activity to followers + * @param activity Activity + * @param from Followee + */ + public async deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) { + const manager = new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + actor, + activity, + ); + manager.addFollowersRecipe(); + await manager.execute(); + } + + /** + * Deliver activity to user + * @param activity Activity + * @param to Target user + */ + public async deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) { + const manager = new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + actor, + activity, + ); + manager.addDirectRecipe(to); + await manager.execute(); + } + + public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) { + return new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + + actor, + activity, + ); + } +} + +class DeliverManager { + private actor: { id: User['id']; host: null; }; + private activity: any; + private recipes: IRecipe[] = []; + + /** + * Constructor + * @param actor Actor + * @param activity Activity to deliver + */ + constructor( + private userEntityService: UserEntityService, + private followingsRepository: FollowingsRepository, + private queueService: QueueService, + + actor: { id: User['id']; host: null; }, + activity: any, + ) { + this.actor = actor; + this.activity = activity; + } + + /** + * Add recipe for followers deliver + */ + public addFollowersRecipe() { + const deliver = { + type: 'Followers', + } as IFollowersRecipe; + + this.addRecipe(deliver); + } + + /** + * Add recipe for direct deliver + * @param to To + */ + public addDirectRecipe(to: IRemoteUser) { + const recipe = { + type: 'Direct', + to, + } as IDirectRecipe; + + this.addRecipe(recipe); + } + + /** + * Add recipe + * @param recipe Recipe + */ + public addRecipe(recipe: IRecipe) { + this.recipes.push(recipe); + } + + /** + * Execute delivers + */ + public async execute() { + if (!this.userEntityService.isLocalUser(this.actor)) return; + + const inboxes = new Set(); + + /* + build inbox list + + Process follower recipes first to avoid duplication when processing + direct recipes later. + */ + if (this.recipes.some(r => isFollowers(r))) { + // followers deliver + // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう + // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? + const followers = await this.followingsRepository.find({ + where: { + followeeId: this.actor.id, + followerHost: Not(IsNull()), + }, + select: { + followerSharedInbox: true, + followerInbox: true, + }, + }) as { + followerSharedInbox: string | null; + followerInbox: string; + }[]; + + for (const following of followers) { + const inbox = following.followerSharedInbox ?? following.followerInbox; + inboxes.add(inbox); + } + } + + this.recipes.filter((recipe): recipe is IDirectRecipe => + // followers recipes have already been processed + isDirect(recipe) + // check that shared inbox has not been added yet + && !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox)) + // check that they actually have an inbox + && recipe.to.inbox != null, + ) + .forEach(recipe => inboxes.add(recipe.to.inbox!)); + + // deliver + for (const inbox of inboxes) { + this.queueService.deliver(this.actor, this.activity, inbox); + } + } +} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts new file mode 100644 index 0000000000..3da384ec2d --- /dev/null +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -0,0 +1,740 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +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 { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import type Logger from '@/logger.js'; +import { MetaService } from '@/core/MetaService.js'; +import { IdService } from '@/core/IdService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { ApNoteService } from './models/ApNoteService.js'; +import { ApLoggerService } from './ApLoggerService.js'; +import { ApDbResolverService } from './ApDbResolverService.js'; +import { ApResolverService } from './ApResolverService.js'; +import { ApAudienceService } from './ApAudienceService.js'; +import { ApPersonService } from './models/ApPersonService.js'; +import { ApQuestionService } from './models/ApQuestionService.js'; +import type { Resolver } from './ApResolverService.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate } from './type.js'; + +@Injectable() +export class ApInboxService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private utilityService: UtilityService, + private idService: IdService, + private metaService: MetaService, + private userFollowingService: UserFollowingService, + private apAudienceService: ApAudienceService, + private reactionService: ReactionService, + private relayService: RelayService, + private notePiningService: NotePiningService, + private userBlockingService: UserBlockingService, + private noteCreateService: NoteCreateService, + private noteDeleteService: NoteDeleteService, + private appLockService: AppLockService, + private apResolverService: ApResolverService, + private apDbResolverService: ApDbResolverService, + private apLoggerService: ApLoggerService, + private apNoteService: ApNoteService, + private apPersonService: ApPersonService, + private apQuestionService: ApQuestionService, + private queueService: QueueService, + private messagingService: MessagingService, + ) { + this.logger = this.apLoggerService.logger; + } + + public async performActivity(actor: CacheableRemoteUser, activity: IObject) { + 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); + } catch (err) { + if (err instanceof Error || typeof err === 'string') { + this.logger.error(err); + } + } + } + } else { + await this.performOneActivity(actor, activity); + } + + // ついでにリモートユーザーの情報が古かったら更新しておく + if (actor.uri) { + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + setImmediate(() => { + this.apPersonService.updatePerson(actor.uri!); + }); + } + } + } + + public async performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { + if (actor.isSuspended) return; + + if (isCreate(activity)) { + await this.create(actor, activity); + } else if (isDelete(activity)) { + await this.delete(actor, activity); + } else if (isUpdate(activity)) { + await this.update(actor, activity); + } else if (isRead(activity)) { + await this.read(actor, activity); + } else if (isFollow(activity)) { + await this.follow(actor, activity); + } else if (isAccept(activity)) { + await this.accept(actor, activity); + } else if (isReject(activity)) { + await this.reject(actor, activity); + } else if (isAdd(activity)) { + await this.add(actor, activity).catch(err => this.logger.error(err)); + } else if (isRemove(activity)) { + await this.remove(actor, activity).catch(err => this.logger.error(err)); + } else if (isAnnounce(activity)) { + await this.announce(actor, activity); + } else if (isLike(activity)) { + await this.like(actor, activity); + } else if (isUndo(activity)) { + await this.undo(actor, activity); + } else if (isBlock(activity)) { + await this.block(actor, activity); + } else if (isFlag(activity)) { + await this.flag(actor, activity); + } else { + this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); + } + } + + private async follow(actor: CacheableRemoteUser, activity: IFollow): Promise { + const followee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (followee == null) { + return 'skip: followee not found'; + } + + if (followee.host != null) { + return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; + } + + await this.userFollowingService.follow(actor, followee, activity.id); + return 'ok'; + } + + private async like(actor: CacheableRemoteUser, activity: ILike): Promise { + const targetUri = getApId(activity.object); + + const note = await this.apNoteService.fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); + + return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { + if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + return 'skip: already reacted'; + } else { + throw err; + } + }).then(() => 'ok'); + } + + private async read(actor: CacheableRemoteUser, activity: IRead): Promise { + const id = await getApId(activity.object); + + if (!this.utilityService.isSelfHost(this.utilityService.extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split('/').pop(); + + const message = await this.messagingMessagesRepository.findOneBy({ id: messageId }); + if (message == null) { + return 'skip: message not found'; + } + + if (actor.id !== message.recipientId) { + return 'skip: actor is not a message recipient'; + } + + await this.messagingService.readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; + } + + private async accept(actor: CacheableRemoteUser, activity: IAccept): Promise { + const uri = activity.id ?? activity; + + this.logger.info(`Accept: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(err => { + this.logger.error(`Resolution failed: ${err}`); + throw err; + }); + + if (isFollow(object)) return await this.acceptFollow(actor, object); + + return `skip: Unknown Accept type: ${getApType(object)}`; + } + + private async acceptFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); + + if (follower == null) { + return 'skip: follower not found'; + } + + if (follower.host != null) { + return 'skip: follower is not a local user'; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await this.relayService.relayAccepted(match[1]); + } + + await this.userFollowingService.acceptFollowRequest(actor, follower); + return 'ok'; + } + + private async add(actor: CacheableRemoteUser, activity: IAdd): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await this.apNoteService.resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await this.notePiningService.addPinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); + } + + private async announce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { + const uri = getApId(activity); + + this.logger.info(`Announce: ${uri}`); + + const targetUri = getApId(activity.object); + + this.announceNote(actor, activity, targetUri); + } + + private async announceNote(actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { + const uri = getApId(activity); + + if (actor.isSuspended) { + return; + } + + // アナウンス先をブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) return; + + const unlock = await this.appLockService.getApLock(uri); + + try { + // 既に同じURIを持つものが登録されていないかチェック + const exist = await this.apNoteService.fetchNote(uri); + if (exist) { + return; + } + + // Announce対象をresolve + let renote; + try { + renote = await this.apNoteService.resolveNote(targetUri); + if (renote == null) throw new Error('announce target is null'); + } catch (err) { + // 対象が4xxならスキップ + if (err instanceof StatusError) { + if (err.isClientError) { + this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); + return; + } + + this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`); + } + throw err; + } + + if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { + this.logger.warn('skip: invalid actor for this activity'); + return; + } + + this.logger.info(`Creating the (Re)Note: ${uri}`); + + const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); + + await this.noteCreateService.create(actor, { + createdAt: activity.published ? new Date(activity.published) : null, + renote, + visibility: activityAudience.visibility, + visibleUsers: activityAudience.visibleUsers, + uri, + }); + } finally { + unlock(); + } + } + + private async block(actor: CacheableRemoteUser, activity: IBlock): Promise { + // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず + + const blockee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (blockee == null) { + return 'skip: blockee not found'; + } + + if (blockee.host != null) { + return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません'; + } + + await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id })); + return 'ok'; + } + + private async create(actor: CacheableRemoteUser, activity: ICreate): Promise { + const uri = getApId(activity); + + this.logger.info(`Create: ${uri}`); + + // copy audiences between activity <=> object. + if (typeof activity.object === 'object') { + const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); + const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); + + activity.to = to; + activity.cc = cc; + activity.object.to = to; + activity.object.cc = cc; + } + + // If there is no attributedTo, use Activity actor. + if (typeof activity.object === 'object' && !activity.object.attributedTo) { + activity.object.attributedTo = activity.actor; + } + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isPost(object)) { + this.createNote(resolver, actor, object, false, activity); + } else { + this.logger.warn(`Unknown type: ${getApType(object)}`); + } + } + + private async createNote(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return 'skip: actor.uri !== note.attributedTo'; + } + + if (typeof note.id === 'string') { + if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { + return 'skip: host in actor.uri !== note.id'; + } + } + } + + const unlock = await this.appLockService.getApLock(uri); + + try { + const exist = await this.apNoteService.fetchNote(note); + if (exist) return 'skip: note exists'; + + await this.apNoteService.createNote(note, resolver, silent); + return 'ok'; + } catch (err) { + if (err instanceof StatusError && err.isClientError) { + return `skip ${err.statusCode}`; + } else { + throw err; + } + } finally { + unlock(); + } + } + + private async delete(actor: CacheableRemoteUser, activity: IDelete): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + // 削除対象objectのtype + let formerType: string | undefined; + + if (typeof activity.object === 'string') { + // typeが不明だけど、どうせ消えてるのでremote resolveしない + formerType = undefined; + } else { + const object = activity.object as IObject; + if (isTombstone(object)) { + formerType = toSingle(object.formerType); + } else { + formerType = toSingle(object.type); + } + } + + const uri = getApId(activity.object); + + // type不明でもactorとobjectが同じならばそれはPersonに違いない + if (!formerType && actor.uri === uri) { + formerType = 'Person'; + } + + // それでもなかったらおそらくNote + if (!formerType) { + formerType = 'Note'; + } + + if (validPost.includes(formerType)) { + return await this.deleteNote(actor, uri); + } else if (validActor.includes(formerType)) { + return await this.deleteActor(actor, uri); + } else { + return `Unknown type ${formerType}`; + } + } + + private async deleteActor(actor: CacheableRemoteUser, uri: string): Promise { + this.logger.info(`Deleting the Actor: ${uri}`); + + if (actor.uri !== uri) { + return `skip: delete actor ${actor.uri} !== ${uri}`; + } + + const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); + if (user.isDeleted) { + this.logger.info('skip: already deleted'); + } + + const job = await this.queueService.createDeleteAccountJob(actor); + + await this.usersRepository.update(actor.id, { + isDeleted: true, + }); + + return `ok: queued ${job.name} ${job.id}`; + } + + private async deleteNote(actor: CacheableRemoteUser, uri: string): Promise { + this.logger.info(`Deleting the Note: ${uri}`); + + const unlock = await this.appLockService.getApLock(uri); + + try { + const note = await this.apDbResolverService.getNoteFromApId(uri); + + if (note == null) { + const message = await this.apDbResolverService.getMessageFromApId(uri); + if (message == null) return 'message not found'; + + if (message.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await this.messagingService.deleteMessage(message); + + return 'ok: message deleted'; + } + + if (note.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await this.noteDeleteService.delete(actor, note); + return 'ok: note deleted'; + } finally { + unlock(); + } + } + + private async flag(actor: CacheableRemoteUser, activity: IFlag): Promise { + // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので + // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する + const uris = getApIds(activity.object); + + const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!); + const users = await this.usersRepository.findBy({ + id: In(userIds), + }); + if (users.length < 1) return 'skip'; + + await this.abuseUserReportsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + targetUserId: users[0].id, + targetUserHost: users[0].host, + reporterId: actor.id, + reporterHost: actor.host, + comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, + }); + + return 'ok'; + } + + private async reject(actor: CacheableRemoteUser, activity: IReject): Promise { + const uri = activity.id ?? activity; + + this.logger.info(`Reject: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await this.rejectFollow(actor, object); + + return `skip: Unknown Reject type: ${getApType(object)}`; + } + + private async rejectFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); + + if (follower == null) { + return 'skip: follower not found'; + } + + if (!this.userEntityService.isLocalUser(follower)) { + return 'skip: follower is not a local user'; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await this.relayService.relayRejected(match[1]); + } + + await this.userFollowingService.remoteReject(actor, follower); + return 'ok'; + } + + private async remove(actor: CacheableRemoteUser, activity: IRemove): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await this.apNoteService.resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await this.notePiningService.removePinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); + } + + private async undo(actor: CacheableRemoteUser, activity: IUndo): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id ?? activity; + + this.logger.info(`Undo: ${uri}`); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await this.undoFollow(actor, object); + if (isBlock(object)) return await this.undoBlock(actor, object); + if (isLike(object)) return await this.undoLike(actor, object); + if (isAnnounce(object)) return await this.undoAnnounce(actor, object); + if (isAccept(object)) return await this.undoAccept(actor, object); + + return `skip: unknown object type ${getApType(object)}`; + } + + private async undoAccept(actor: CacheableRemoteUser, activity: IAccept): Promise { + const follower = await this.apDbResolverService.getUserFromApId(activity.object); + if (follower == null) { + return 'skip: follower not found'; + } + + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: actor.id, + }); + + if (following) { + await this.userFollowingService.unfollow(follower, actor); + return 'ok: unfollowed'; + } + + return 'skip: フォローされていない'; + } + + private async undoAnnounce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { + const uri = getApId(activity); + + const note = await this.notesRepository.findOneBy({ + uri, + userId: actor.id, + }); + + if (!note) return 'skip: no such Announce'; + + await this.noteDeleteService.delete(actor, note); + return 'ok: deleted'; + } + + private async undoBlock(actor: CacheableRemoteUser, activity: IBlock): Promise { + const blockee = await this.apDbResolverService.getUserFromApId(activity.object); + + if (blockee == null) { + return 'skip: blockee not found'; + } + + if (blockee.host != null) { + return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません'; + } + + await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee); + return 'ok'; + } + + private async undoFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { + const followee = await this.apDbResolverService.getUserFromApId(activity.object); + if (followee == null) { + return 'skip: followee not found'; + } + + if (followee.host != null) { + return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; + } + + const req = await this.followRequestsRepository.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + const following = await this.followingsRepository.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + if (req) { + await this.userFollowingService.cancelFollowRequest(followee, actor); + return 'ok: follow request canceled'; + } + + if (following) { + await this.userFollowingService.unfollow(actor, followee); + return 'ok: unfollowed'; + } + + return 'skip: リクエストもフォローもされていない'; + } + + private async undoLike(actor: CacheableRemoteUser, activity: ILike): Promise { + const targetUri = getApId(activity.object); + + const note = await this.apNoteService.fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await this.reactionService.delete(actor, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; + throw e; + }); + + return 'ok'; + } + + private async update(actor: CacheableRemoteUser, activity: IUpdate): Promise { + if ('actor' in activity && actor.uri !== activity.actor) { + return 'skip: invalid actor'; + } + + this.logger.debug('Update'); + + const resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isActor(object)) { + await this.apPersonService.updatePerson(actor.uri!, resolver, object); + return 'ok: Person updated'; + } else if (getApType(object) === 'Question') { + await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); + return 'ok: Question updated'; + } else { + return `skip: Unknown type: ${getApType(object)}`; + } + } +} diff --git a/packages/backend/src/core/activitypub/ApLoggerService.ts b/packages/backend/src/core/activitypub/ApLoggerService.ts new file mode 100644 index 0000000000..a742cc42da --- /dev/null +++ b/packages/backend/src/core/activitypub/ApLoggerService.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; + +@Injectable() +export class ApLoggerService { + public logger: Logger; + + constructor( + private remoteLoggerService: RemoteLoggerService, + ) { + this.logger = this.remoteLoggerService.logger.createSubLogger('ap', 'magenta'); + } +} diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts new file mode 100644 index 0000000000..8804fde64a --- /dev/null +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { MfmService } from '@/core/MfmService.js'; +import type { Note } from '@/models/entities/Note.js'; +import { extractApHashtagObjects } from './models/tag.js'; +import type { IObject } from './type.js'; + +@Injectable() +export class ApMfmService { + constructor( + @Inject(DI.config) + private config: Config, + + private mfmService: MfmService, + ) { + } + + public htmlToMfm(html: string, tag?: IObject | IObject[]) { + const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); + + return this.mfmService.fromHtml(html, hashtagNames); + } + + public getNoteHtml(note: Note) { + if (!note.text) return ''; + return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); + } +} diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts new file mode 100644 index 0000000000..38a92567c3 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -0,0 +1,703 @@ +import { createPublicKey } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import type { Relay } from '@/models/entities/Relay.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import type { Poll } from '@/models/entities/Poll.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { PollVote } from '@/models/entities/PollVote.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { MfmService } from '@/core/MfmService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; +import { LdSignatureService } from './LdSignatureService.js'; +import { ApMfmService } from './ApMfmService.js'; +import type { IActivity, IObject } from './type.js'; +import type { IIdentifier } from './models/identifier.js'; + +@Injectable() +export class ApRendererService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private ldSignatureService: LdSignatureService, + private userKeypairStoreService: UserKeypairStoreService, + private apMfmService: ApMfmService, + private mfmService: MfmService, + ) { + } + + public renderAccept(object: any, user: { id: User['id']; host: null }) { + return { + type: 'Accept', + actor: `${this.config.url}/users/${user.id}`, + object, + }; + } + + public renderAdd(user: ILocalUser, target: any, object: any) { + return { + type: 'Add', + actor: `${this.config.url}/users/${user.id}`, + target, + object, + }; + } + + public renderAnnounce(object: any, note: Note) { + const attributedTo = `${this.config.url}/users/${note.userId}`; + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`]; + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else { + return null; + } + + return { + id: `${this.config.url}/notes/${note.id}/activity`, + actor: `${this.config.url}/users/${note.userId}`, + type: 'Announce', + published: note.createdAt.toISOString(), + to, + cc, + object, + }; + } + + /** + * Renders a block into its ActivityPub representation. + * + * @param block The block to be rendered. The blockee relation must be loaded. + */ + public renderBlock(block: Blocking) { + if (block.blockee?.uri == null) { + throw new Error('renderBlock: missing blockee uri'); + } + + return { + type: 'Block', + id: `${this.config.url}/blocks/${block.id}`, + actor: `${this.config.url}/users/${block.blockerId}`, + object: block.blockee.uri, + }; + } + + public renderCreate(object: any, note: Note) { + const activity = { + id: `${this.config.url}/notes/${note.id}/activity`, + actor: `${this.config.url}/users/${note.userId}`, + type: 'Create', + published: note.createdAt.toISOString(), + object, + } as any; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; + } + + public renderDelete(object: any, user: { id: User['id']; host: null }) { + return { + type: 'Delete', + actor: `${this.config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; + } + + public renderDocument(file: DriveFile) { + return { + type: 'Document', + mediaType: file.type, + url: this.driveFileEntityService.getPublicUrl(file), + name: file.comment, + }; + } + + public renderEmoji(emoji: Emoji) { + return { + id: `${this.config.url}/emojis/${emoji.name}`, + type: 'Emoji', + name: `:${emoji.name}:`, + updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, + icon: { + type: 'Image', + mediaType: emoji.type ?? 'image/png', + url: emoji.publicUrl ?? emoji.originalUrl, // ?? emoji.originalUrl してるのは後方互換性のため + }, + }; + } + + // to anonymise reporters, the reporting actor must be a system user + // object has to be a uri or array of uris + public renderFlag(user: ILocalUser, object: [string], content: string) { + return { + type: 'Flag', + actor: `${this.config.url}/users/${user.id}`, + content, + object, + }; + } + + public renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${this.config.url}/activities/follow-relay/${relay.id}`, + type: 'Follow', + actor: `${this.config.url}/users/${relayActor.id}`, + object: 'https://www.w3.org/ns/activitystreams#Public', + }; + + return follow; + } + + /** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ + public async renderFollowUser(id: User['id']) { + const user = await this.usersRepository.findOneByOrFail({ id: id }); + return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + } + + public renderFollow( + follower: { id: User['id']; host: User['host']; uri: User['host'] }, + followee: { id: User['id']; host: User['host']; uri: User['host'] }, + requestId?: string, + ) { + const follow = { + id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, + type: 'Follow', + actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri, + object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri, + } as any; + + return follow; + } + + public renderHashtag(tag: string) { + return { + type: 'Hashtag', + href: `${this.config.url}/tags/${encodeURIComponent(tag)}`, + name: `#${tag}`, + }; + } + + public renderImage(file: DriveFile) { + return { + type: 'Image', + url: this.driveFileEntityService.getPublicUrl(file), + sensitive: file.isSensitive, + name: file.comment, + }; + } + + public renderKey(user: ILocalUser, key: UserKeypair, postfix?: string) { + return { + id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, + type: 'Key', + owner: `${this.config.url}/users/${user.id}`, + publicKeyPem: createPublicKey(key.publicKey).export({ + type: 'spki', + format: 'pem', + }), + }; + } + + public async renderLike(noteReaction: NoteReaction, note: { uri: string | null }) { + const reaction = noteReaction.reaction; + + const object = { + type: 'Like', + id: `${this.config.url}/likes/${noteReaction.id}`, + actor: `${this.config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, + content: reaction, + _misskey_reaction: reaction, + } as any; + + if (reaction.startsWith(':')) { + const name = reaction.replace(/:/g, ''); + const emoji = await this.emojisRepository.findOneBy({ + name, + host: IsNull(), + }); + + if (emoji) object.tag = [this.renderEmoji(emoji)]; + } + + return object; + } + + public renderMention(mention: User) { + return { + type: 'Mention', + href: this.userEntityService.isRemoteUser(mention) ? mention.uri : `${this.config.url}/users/${(mention as ILocalUser).id}`, + name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, + }; + } + + public async renderNote(note: Note, dive = true, isTalk = false): Promise { + const getPromisedFiles = async (ids: string[]) => { + if (!ids || ids.length === 0) return []; + const items = await this.driveFilesRepository.findBy({ id: In(ids) }); + return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; + }; + + let inReplyTo; + let inReplyToNote: Note | null; + + if (note.replyId) { + inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); + + if (inReplyToNote != null) { + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); + + if (inReplyToUser != null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await this.renderNote(inReplyToNote, false); + } else { + inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + let quote; + + if (note.renoteId) { + const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); + + if (renote) { + quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; + } + } + + const attributedTo = `${this.config.url}/users/${note.userId}`; + + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } + + const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ + id: In(note.mentions), + }) : []; + + const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); + const mentionTags = mentionedUsers.map(u => this.renderMention(u)); + + const files = await getPromisedFiles(note.fileIds); + + const text = note.text ?? ''; + let poll: Poll | null = null; + + if (note.hasPoll) { + poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + } + + let apText = text; + + if (quote) { + apText += `\n\nRE: ${quote}`; + } + + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { + text: apText, + })); + + const emojis = await this.getEmojis(note.emojis); + const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + + const tag = [ + ...hashtagTags, + ...mentionTags, + ...apemojis, + ]; + + const asPoll = poll ? { + type: 'Question', + content: this.apMfmService.getNoteHtml(Object.assign({}, note, { + text: text, + })), + [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + type: 'Note', + name: text, + replies: { + type: 'Collection', + totalItems: poll!.votes[i], + }, + })), + } : {}; + + const asTalk = isTalk ? { + _misskey_talk: true, + } : {}; + + return { + id: `${this.config.url}/notes/${note.id}`, + type: 'Note', + attributedTo, + summary: summary ?? undefined, + content: content ?? undefined, + _misskey_content: text, + source: { + content: text, + mediaType: 'text/x.misskeymarkdown', + }, + _misskey_quote: quote, + quoteUrl: quote, + published: note.createdAt.toISOString(), + to, + cc, + inReplyTo, + attachment: files.map(x => this.renderDocument(x)), + sensitive: note.cw != null || files.some(file => file.isSensitive), + tag, + ...asPoll, + ...asTalk, + }; + } + + public async renderPerson(user: ILocalUser) { + const id = `${this.config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); + + const [avatar, banner, profile] = await Promise.all([ + user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), + user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), + this.userProfilesRepository.findOneByOrFail({ userId: user.id }), + ]); + + const attachment: { + type: 'PropertyValue', + name: string, + value: string, + identifier?: IIdentifier, + }[] = []; + + if (profile.fields) { + for (const field of profile.fields) { + attachment.push({ + type: 'PropertyValue', + name: field.name, + value: (field.value != null && field.value.match(/^https?:/)) + ? `${new URL(field.value).href}` + : field.value, + }); + } + } + + const emojis = await this.getEmojis(user.emojis); + const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); + + const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); + + const tag = [ + ...apemojis, + ...hashtagTags, + ]; + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const person = { + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, + featured: `${id}/collections/featured`, + sharedInbox: `${this.config.url}/inbox`, + endpoints: { sharedInbox: `${this.config.url}/inbox` }, + url: `${this.config.url}/@${user.username}`, + preferredUsername: user.username, + name: user.name, + summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, + icon: avatar ? this.renderImage(avatar) : null, + image: banner ? this.renderImage(banner) : null, + tag, + manuallyApprovesFollowers: user.isLocked, + discoverable: !!user.isExplorable, + publicKey: this.renderKey(user, keypair, '#main-key'), + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined, + } as any; + + if (profile.birthday) { + person['vcard:bday'] = profile.birthday; + } + + if (profile.location) { + person['vcard:Address'] = profile.location; + } + + return person; + } + + public async renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { + const question = { + type: 'Question', + id: `${this.config.url}/questions/${note.id}`, + actor: `${this.config.url}/users/${user.id}`, + content: note.text ?? '', + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + name: text, + _misskey_votes: poll.votes[i], + replies: { + type: 'Collection', + totalItems: poll.votes[i], + }, + })), + }; + + return question; + } + + public renderRead(user: { id: User['id'] }, message: MessagingMessage) { + return { + type: 'Read', + actor: `${this.config.url}/users/${user.id}`, + object: message.uri, + }; + } + + public renderReject(object: any, user: { id: User['id'] }) { + return { + type: 'Reject', + actor: `${this.config.url}/users/${user.id}`, + object, + }; + } + + public renderRemove(user: { id: User['id'] }, target: any, object: any) { + return { + type: 'Remove', + actor: `${this.config.url}/users/${user.id}`, + target, + object, + }; + } + + public renderTombstone(id: string) { + return { + id, + type: 'Tombstone', + }; + } + + public renderUndo(object: any, user: { id: User['id'] }) { + if (object == null) return null; + const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; + + return { + type: 'Undo', + ...(id ? { id } : {}), + actor: `${this.config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; + } + + public renderUpdate(object: any, user: { id: User['id'] }) { + const activity = { + id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: `${this.config.url}/users/${user.id}`, + type: 'Update', + to: ['https://www.w3.org/ns/activitystreams#Public'], + object, + published: new Date().toISOString(), + } as any; + + return activity; + } + + public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser) { + return { + id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, + actor: `${this.config.url}/users/${user.id}`, + type: 'Create', + to: [pollOwner.uri], + published: new Date().toISOString(), + object: { + id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, + type: 'Note', + attributedTo: `${this.config.url}/users/${user.id}`, + to: [pollOwner.uri], + inReplyTo: note.uri, + name: poll.choices[vote.choice], + }, + }; + } + + public renderActivity(x: any): IActivity | null { + if (x == null) return null; + + if (typeof x === 'object' && x.id == null) { + x.id = `${this.config.url}/${uuid()}`; + } + + return Object.assign({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + discoverable: 'toot:discoverable', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: 'https://misskey-hub.net/ns#', + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', + }, + ], + }, x); + } + + public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const ldSignature = this.ldSignatureService.use(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + + return activity; + } + + /** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ + public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems, + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; + } + + /** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) + */ + public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; + } + + private async getEmojis(names: string[]): Promise { + if (names == null || names.length === 0) return []; + + const emojis = await Promise.all( + names.map(name => this.emojisRepository.findOneBy({ + name, + host: IsNull(), + })), + ); + + return emojis.filter(emoji => emoji != null) as Emoji[]; + } +} diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts new file mode 100644 index 0000000000..baad46d668 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -0,0 +1,182 @@ +import * as crypto from 'node:crypto'; +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { User } from '@/models/entities/User.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; + +type Request = { + url: string; + method: string; + headers: Record; +}; + +type Signed = { + request: Request; + signingString: string; + signature: string; + signatureHeader: string; +}; + +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; + +@Injectable() +export class ApRequestService { + constructor( + @Inject(DI.config) + private config: Config, + + private userKeypairStoreService: UserKeypairStoreService, + private httpRequestService: HttpRequestService, + ) { + } + + private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; + + const request: Request = { + url: u.href, + method: 'POST', + headers: this.objectAssignWithLcKey({ + 'Date': new Date().toUTCString(), + 'Host': u.hostname, + 'Content-Type': 'application/activity+json', + 'Digest': digestHeader, + }, args.additionalHeaders), + }; + + const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: 'GET', + headers: this.objectAssignWithLcKey({ + 'Accept': 'application/activity+json, application/ld+json', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).hostname, + }, args.additionalHeaders), + }; + + const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; + } + + private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { + const signingString = this.genSigningString(request, includeHeaders); + const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); + const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; + + request.headers = this.objectAssignWithLcKey(request.headers, { + Signature: signatureHeader, + }); + + return { + request, + signingString, + signature, + signatureHeader, + }; + } + + private genSigningString(request: Request, includeHeaders: string[]): string { + request.headers = this.lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map(x => x.toLowerCase())) { + if (key === '(request-target)') { + results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join('\n'); + } + + private lcObjectKey(src: Record): Record { + const dst: Record = {}; + for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; + } + + private objectAssignWithLcKey(a: Record, b: Record): Record { + return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b)); + } + + public async signedPost(user: { id: User['id'] }, url: string, object: any) { + const body = JSON.stringify(object); + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const req = this.createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, + url, + body, + additionalHeaders: { + 'User-Agent': this.config.userAgent, + }, + }); + + await this.httpRequestService.getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + }); + } + + /** + * Get AP object with http-signature + * @param user http-signature user + * @param url URL to fetch + */ + public async signedGet(url: string, user: { id: User['id'] }) { + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + const req = this.createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${this.config.url}/users/${user.id}#main-key`, + }, + url, + additionalHeaders: { + 'User-Agent': this.config.userAgent, + }, + }); + + const res = await this.httpRequestService.getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + }); + + return await res.json(); + } +} diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts new file mode 100644 index 0000000000..bcdb9383d1 --- /dev/null +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -0,0 +1,195 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import { MetaService } from '@/core/MetaService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { DI } from '@/di-symbols.js'; +import { UtilityService } from '@/core/UtilityService.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'; + +@Injectable() +export class ApResolverService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + private utilityService: UtilityService, + private instanceActorService: InstanceActorService, + private metaService: MetaService, + private apRequestService: ApRequestService, + private httpRequestService: HttpRequestService, + private apRendererService: ApRendererService, + private apDbResolverService: ApDbResolverService, + ) { + } + + public createResolver(): Resolver { + return new Resolver( + this.config, + this.usersRepository, + this.notesRepository, + this.pollsRepository, + this.noteReactionsRepository, + this.utilityService, + this.instanceActorService, + this.metaService, + this.apRequestService, + this.httpRequestService, + this.apRendererService, + this.apDbResolverService, + ); + } +} + +export class Resolver { + private history: Set; + private user?: ILocalUser; + + constructor( + private config: Config, + private usersRepository: UsersRepository, + private notesRepository: NotesRepository, + private pollsRepository: PollsRepository, + private noteReactionsRepository: NoteReactionsRepository, + private utilityService: UtilityService, + private instanceActorService: InstanceActorService, + private metaService: MetaService, + private apRequestService: ApRequestService, + private httpRequestService: HttpRequestService, + private apRendererService: ApRendererService, + private apDbResolverService: ApDbResolverService, + private recursionLimit = 100 + ) { + this.history = new Set(); + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection(value: string | IObject): Promise { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + if (isCollectionOrOrderedCollection(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + + public async resolve(value: string | IObject): Promise { + if (value == null) { + throw new Error('resolvee is null (or undefined)'); + } + + if (typeof value !== 'string') { + return value; + } + + if (value.includes('#')) { + // URLs with fragment parts cannot be resolved correctly because + // the fragment part does not get transmitted over HTTP(S). + // Avoid strange behaviour by not trying to resolve these at all. + throw new Error(`cannot resolve URL with fragment: ${value}`); + } + + if (this.history.has(value)) { + throw new Error('cannot resolve already resolved one'); + } + + if (this.history.size > this.recursionLimit) { + throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); + } + + this.history.add(value); + + const host = this.utilityService.extractDbHost(value); + if (this.utilityService.isSelfHost(host)) { + return await this.resolveLocal(value); + } + + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(host)) { + throw new Error('Instance is blocked'); + } + + if (this.config.signToActivityPubGet && !this.user) { + this.user = await this.instanceActorService.getInstanceActor(); + } + + const object = (this.user + ? await this.apRequestService.signedGet(value, this.user) + : await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject; + + if (object == null || ( + Array.isArray(object['@context']) ? + !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : + object['@context'] !== 'https://www.w3.org/ns/activitystreams' + )) { + throw new Error('invalid response'); + } + + return object; + } + + private resolveLocal(url: string): Promise { + const parsed = this.apDbResolverService.parseUri(url); + if (!parsed.local) throw new Error('resolveLocal: not local'); + + switch (parsed.type) { + case 'notes': + return this.notesRepository.findOneByOrFail({ id: parsed.id }) + .then(note => { + if (parsed.rest === 'activity') { + // this refers to the create activity and not the note itself + return this.apRendererService.renderActivity(this.apRendererService.renderCreate(this.apRendererService.renderNote(note), note)); + } else { + return this.apRendererService.renderNote(note); + } + }); + case 'users': + return this.usersRepository.findOneByOrFail({ id: parsed.id }) + .then(user => this.apRendererService.renderPerson(user as ILocalUser)); + case 'questions': + // Polls are indexed by the note they are attached to. + return Promise.all([ + this.notesRepository.findOneByOrFail({ id: parsed.id }), + this.pollsRepository.findOneByOrFail({ noteId: parsed.id }), + ]) + .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)); + case 'likes': + return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(reaction => + this.apRendererService.renderActivity(this.apRendererService.renderLike(reaction, { uri: null }))!); + case 'follows': + // rest should be + if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); + + return Promise.all( + [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), + ) + .then(([follower, followee]) => this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee, url))); + default: + throw new Error(`resolveLocal: type ${parsed.type} unhandled`); + } + } +} diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/LdSignatureService.ts new file mode 100644 index 0000000000..ea39f15b2b --- /dev/null +++ b/packages/backend/src/core/activitypub/LdSignatureService.ts @@ -0,0 +1,147 @@ +import * as crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import fetch from 'node-fetch'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { CONTEXTS } from './misc/contexts.js'; + +// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 + +@Injectable() +export class LdSignatureService { + constructor( + private httpRequestService: HttpRequestService, + ) { + } + + public use(): LdSignature { + return new LdSignature(this.httpRequestService); + } +} + +class LdSignature { + public debug = false; + public preLoad = true; + public loderTimeout = 10 * 1000; + + constructor( + private httpRequestService: HttpRequestService, + ) { + } + + public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { + const options = { + type: 'RsaSignature2017', + creator, + domain, + nonce: crypto.randomBytes(16).toString('hex'), + created: (created ?? new Date()).toISOString(), + } as { + type: string; + creator: string; + domain?: string; + nonce: string; + created: string; + }; + + if (!domain) { + delete options.domain; + } + + const toBeSigned = await this.createVerifyData(data, options); + + const signer = crypto.createSign('sha256'); + signer.update(toBeSigned); + signer.end(); + + const signature = signer.sign(privateKey); + + return { + ...data, + signature: { + ...options, + signatureValue: signature.toString('base64'), + }, + }; + } + + public async verifyRsaSignature2017(data: any, publicKey: string): Promise { + const toBeSigned = await this.createVerifyData(data, data.signature); + const verifier = crypto.createVerify('sha256'); + verifier.update(toBeSigned); + return verifier.verify(publicKey, data.signature.signatureValue, 'base64'); + } + + public async createVerifyData(data: any, options: any) { + const transformedOptions = { + ...options, + '@context': 'https://w3id.org/identity/v1', + }; + delete transformedOptions['type']; + delete transformedOptions['id']; + delete transformedOptions['signatureValue']; + const canonizedOptions = await this.normalize(transformedOptions); + const optionsHash = this.sha256(canonizedOptions.toString()); + const transformedData = { ...data }; + delete transformedData['signature']; + const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + const documentHash = this.sha256(cannonidedData.toString()); + const verifyData = `${optionsHash}${documentHash}`; + return verifyData; + } + + public async normalize(data: any) { + const customLoader = this.getLoader(); + return 42; + } + + private getLoader() { + return async (url: string): Promise => { + if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`; + + if (this.preLoad) { + if (url in CONTEXTS) { + if (this.debug) console.debug(`HIT: ${url}`); + return { + contextUrl: null, + document: CONTEXTS[url], + documentUrl: url, + }; + } + } + + if (this.debug) console.debug(`MISS: ${url}`); + const document = await this.fetchDocument(url); + return { + contextUrl: null, + document: document, + documentUrl: url, + }; + }; + } + + private async fetchDocument(url: string) { + const json = await fetch(url, { + headers: { + Accept: 'application/ld+json, application/json', + }, + // TODO + //timeout: this.loderTimeout, + agent: u => u.protocol === 'http:' ? this.httpRequestService.httpAgent : this.httpRequestService.httpsAgent, + }).then(res => { + if (!res.ok) { + throw `${res.status} ${res.statusText}`; + } else { + return res.json(); + } + }); + + return json; + } + + public sha256(data: string): string { + const hash = crypto.createHash('sha256'); + hash.update(data); + return hash.digest('hex'); + } +} diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts new file mode 100644 index 0000000000..aee0d3629c --- /dev/null +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -0,0 +1,526 @@ +/* eslint:disable:quotemark indent */ +const id_v1 = { + '@context': { + 'id': '@id', + 'type': '@type', + + 'cred': 'https://w3id.org/credentials#', + 'dc': 'http://purl.org/dc/terms/', + 'identity': 'https://w3id.org/identity#', + 'perm': 'https://w3id.org/permissions#', + 'ps': 'https://w3id.org/payswarm#', + 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'rdfs': 'http://www.w3.org/2000/01/rdf-schema#', + 'sec': 'https://w3id.org/security#', + 'schema': 'http://schema.org/', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + + 'Group': 'https://www.w3.org/ns/activitystreams#Group', + + 'claim': { '@id': 'cred:claim', '@type': '@id' }, + 'credential': { '@id': 'cred:credential', '@type': '@id' }, + 'issued': { '@id': 'cred:issued', '@type': 'xsd:dateTime' }, + 'issuer': { '@id': 'cred:issuer', '@type': '@id' }, + 'recipient': { '@id': 'cred:recipient', '@type': '@id' }, + 'Credential': 'cred:Credential', + 'CryptographicKeyCredential': 'cred:CryptographicKeyCredential', + + 'about': { '@id': 'schema:about', '@type': '@id' }, + 'address': { '@id': 'schema:address', '@type': '@id' }, + 'addressCountry': 'schema:addressCountry', + 'addressLocality': 'schema:addressLocality', + 'addressRegion': 'schema:addressRegion', + 'comment': 'rdfs:comment', + 'created': { '@id': 'dc:created', '@type': 'xsd:dateTime' }, + 'creator': { '@id': 'dc:creator', '@type': '@id' }, + 'description': 'schema:description', + 'email': 'schema:email', + 'familyName': 'schema:familyName', + 'givenName': 'schema:givenName', + 'image': { '@id': 'schema:image', '@type': '@id' }, + 'label': 'rdfs:label', + 'name': 'schema:name', + 'postalCode': 'schema:postalCode', + 'streetAddress': 'schema:streetAddress', + 'title': 'dc:title', + 'url': { '@id': 'schema:url', '@type': '@id' }, + 'Person': 'schema:Person', + 'PostalAddress': 'schema:PostalAddress', + 'Organization': 'schema:Organization', + + 'identityService': { '@id': 'identity:identityService', '@type': '@id' }, + 'idp': { '@id': 'identity:idp', '@type': '@id' }, + 'Identity': 'identity:Identity', + + 'paymentProcessor': 'ps:processor', + 'preferences': { '@id': 'ps:preferences', '@type': '@vocab' }, + + 'cipherAlgorithm': 'sec:cipherAlgorithm', + 'cipherData': 'sec:cipherData', + 'cipherKey': 'sec:cipherKey', + 'digestAlgorithm': 'sec:digestAlgorithm', + 'digestValue': 'sec:digestValue', + 'domain': 'sec:domain', + 'expires': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, + 'initializationVector': 'sec:initializationVector', + 'member': { '@id': 'schema:member', '@type': '@id' }, + 'memberOf': { '@id': 'schema:memberOf', '@type': '@id' }, + 'nonce': 'sec:nonce', + 'normalizationAlgorithm': 'sec:normalizationAlgorithm', + 'owner': { '@id': 'sec:owner', '@type': '@id' }, + 'password': 'sec:password', + 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, + 'privateKeyPem': 'sec:privateKeyPem', + 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, + 'publicKeyPem': 'sec:publicKeyPem', + 'publicKeyService': { '@id': 'sec:publicKeyService', '@type': '@id' }, + 'revoked': { '@id': 'sec:revoked', '@type': 'xsd:dateTime' }, + 'signature': 'sec:signature', + 'signatureAlgorithm': 'sec:signatureAlgorithm', + 'signatureValue': 'sec:signatureValue', + 'CryptographicKey': 'sec:Key', + 'EncryptedMessage': 'sec:EncryptedMessage', + 'GraphSignature2012': 'sec:GraphSignature2012', + 'LinkedDataSignature2015': 'sec:LinkedDataSignature2015', + + 'accessControl': { '@id': 'perm:accessControl', '@type': '@id' }, + 'writePermission': { '@id': 'perm:writePermission', '@type': '@id' }, + }, +}; + +const security_v1 = { + '@context': { + 'id': '@id', + 'type': '@type', + + 'dc': 'http://purl.org/dc/terms/', + 'sec': 'https://w3id.org/security#', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + + 'EcdsaKoblitzSignature2016': 'sec:EcdsaKoblitzSignature2016', + 'Ed25519Signature2018': 'sec:Ed25519Signature2018', + 'EncryptedMessage': 'sec:EncryptedMessage', + 'GraphSignature2012': 'sec:GraphSignature2012', + 'LinkedDataSignature2015': 'sec:LinkedDataSignature2015', + 'LinkedDataSignature2016': 'sec:LinkedDataSignature2016', + 'CryptographicKey': 'sec:Key', + + 'authenticationTag': 'sec:authenticationTag', + 'canonicalizationAlgorithm': 'sec:canonicalizationAlgorithm', + 'cipherAlgorithm': 'sec:cipherAlgorithm', + 'cipherData': 'sec:cipherData', + 'cipherKey': 'sec:cipherKey', + 'created': { '@id': 'dc:created', '@type': 'xsd:dateTime' }, + 'creator': { '@id': 'dc:creator', '@type': '@id' }, + 'digestAlgorithm': 'sec:digestAlgorithm', + 'digestValue': 'sec:digestValue', + 'domain': 'sec:domain', + 'encryptionKey': 'sec:encryptionKey', + 'expiration': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, + 'expires': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, + 'initializationVector': 'sec:initializationVector', + 'iterationCount': 'sec:iterationCount', + 'nonce': 'sec:nonce', + 'normalizationAlgorithm': 'sec:normalizationAlgorithm', + 'owner': { '@id': 'sec:owner', '@type': '@id' }, + 'password': 'sec:password', + 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, + 'privateKeyPem': 'sec:privateKeyPem', + 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, + 'publicKeyBase58': 'sec:publicKeyBase58', + 'publicKeyPem': 'sec:publicKeyPem', + 'publicKeyWif': 'sec:publicKeyWif', + 'publicKeyService': { '@id': 'sec:publicKeyService', '@type': '@id' }, + 'revoked': { '@id': 'sec:revoked', '@type': 'xsd:dateTime' }, + 'salt': 'sec:salt', + 'signature': 'sec:signature', + 'signatureAlgorithm': 'sec:signingAlgorithm', + 'signatureValue': 'sec:signatureValue', + }, +}; + +const activitystreams = { + '@context': { + '@vocab': '_:', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + 'as': 'https://www.w3.org/ns/activitystreams#', + 'ldp': 'http://www.w3.org/ns/ldp#', + 'vcard': 'http://www.w3.org/2006/vcard/ns#', + 'id': '@id', + 'type': '@type', + 'Accept': 'as:Accept', + 'Activity': 'as:Activity', + 'IntransitiveActivity': 'as:IntransitiveActivity', + 'Add': 'as:Add', + 'Announce': 'as:Announce', + 'Application': 'as:Application', + 'Arrive': 'as:Arrive', + 'Article': 'as:Article', + 'Audio': 'as:Audio', + 'Block': 'as:Block', + 'Collection': 'as:Collection', + 'CollectionPage': 'as:CollectionPage', + 'Relationship': 'as:Relationship', + 'Create': 'as:Create', + 'Delete': 'as:Delete', + 'Dislike': 'as:Dislike', + 'Document': 'as:Document', + 'Event': 'as:Event', + 'Follow': 'as:Follow', + 'Flag': 'as:Flag', + 'Group': 'as:Group', + 'Ignore': 'as:Ignore', + 'Image': 'as:Image', + 'Invite': 'as:Invite', + 'Join': 'as:Join', + 'Leave': 'as:Leave', + 'Like': 'as:Like', + 'Link': 'as:Link', + 'Mention': 'as:Mention', + 'Note': 'as:Note', + 'Object': 'as:Object', + 'Offer': 'as:Offer', + 'OrderedCollection': 'as:OrderedCollection', + 'OrderedCollectionPage': 'as:OrderedCollectionPage', + 'Organization': 'as:Organization', + 'Page': 'as:Page', + 'Person': 'as:Person', + 'Place': 'as:Place', + 'Profile': 'as:Profile', + 'Question': 'as:Question', + 'Reject': 'as:Reject', + 'Remove': 'as:Remove', + 'Service': 'as:Service', + 'TentativeAccept': 'as:TentativeAccept', + 'TentativeReject': 'as:TentativeReject', + 'Tombstone': 'as:Tombstone', + 'Undo': 'as:Undo', + 'Update': 'as:Update', + 'Video': 'as:Video', + 'View': 'as:View', + 'Listen': 'as:Listen', + 'Read': 'as:Read', + 'Move': 'as:Move', + 'Travel': 'as:Travel', + 'IsFollowing': 'as:IsFollowing', + 'IsFollowedBy': 'as:IsFollowedBy', + 'IsContact': 'as:IsContact', + 'IsMember': 'as:IsMember', + 'subject': { + '@id': 'as:subject', + '@type': '@id', + }, + 'relationship': { + '@id': 'as:relationship', + '@type': '@id', + }, + 'actor': { + '@id': 'as:actor', + '@type': '@id', + }, + 'attributedTo': { + '@id': 'as:attributedTo', + '@type': '@id', + }, + 'attachment': { + '@id': 'as:attachment', + '@type': '@id', + }, + 'bcc': { + '@id': 'as:bcc', + '@type': '@id', + }, + 'bto': { + '@id': 'as:bto', + '@type': '@id', + }, + 'cc': { + '@id': 'as:cc', + '@type': '@id', + }, + 'context': { + '@id': 'as:context', + '@type': '@id', + }, + 'current': { + '@id': 'as:current', + '@type': '@id', + }, + 'first': { + '@id': 'as:first', + '@type': '@id', + }, + 'generator': { + '@id': 'as:generator', + '@type': '@id', + }, + 'icon': { + '@id': 'as:icon', + '@type': '@id', + }, + 'image': { + '@id': 'as:image', + '@type': '@id', + }, + 'inReplyTo': { + '@id': 'as:inReplyTo', + '@type': '@id', + }, + 'items': { + '@id': 'as:items', + '@type': '@id', + }, + 'instrument': { + '@id': 'as:instrument', + '@type': '@id', + }, + 'orderedItems': { + '@id': 'as:items', + '@type': '@id', + '@container': '@list', + }, + 'last': { + '@id': 'as:last', + '@type': '@id', + }, + 'location': { + '@id': 'as:location', + '@type': '@id', + }, + 'next': { + '@id': 'as:next', + '@type': '@id', + }, + 'object': { + '@id': 'as:object', + '@type': '@id', + }, + 'oneOf': { + '@id': 'as:oneOf', + '@type': '@id', + }, + 'anyOf': { + '@id': 'as:anyOf', + '@type': '@id', + }, + 'closed': { + '@id': 'as:closed', + '@type': 'xsd:dateTime', + }, + 'origin': { + '@id': 'as:origin', + '@type': '@id', + }, + 'accuracy': { + '@id': 'as:accuracy', + '@type': 'xsd:float', + }, + 'prev': { + '@id': 'as:prev', + '@type': '@id', + }, + 'preview': { + '@id': 'as:preview', + '@type': '@id', + }, + 'replies': { + '@id': 'as:replies', + '@type': '@id', + }, + 'result': { + '@id': 'as:result', + '@type': '@id', + }, + 'audience': { + '@id': 'as:audience', + '@type': '@id', + }, + 'partOf': { + '@id': 'as:partOf', + '@type': '@id', + }, + 'tag': { + '@id': 'as:tag', + '@type': '@id', + }, + 'target': { + '@id': 'as:target', + '@type': '@id', + }, + 'to': { + '@id': 'as:to', + '@type': '@id', + }, + 'url': { + '@id': 'as:url', + '@type': '@id', + }, + 'altitude': { + '@id': 'as:altitude', + '@type': 'xsd:float', + }, + 'content': 'as:content', + 'contentMap': { + '@id': 'as:content', + '@container': '@language', + }, + 'name': 'as:name', + 'nameMap': { + '@id': 'as:name', + '@container': '@language', + }, + 'duration': { + '@id': 'as:duration', + '@type': 'xsd:duration', + }, + 'endTime': { + '@id': 'as:endTime', + '@type': 'xsd:dateTime', + }, + 'height': { + '@id': 'as:height', + '@type': 'xsd:nonNegativeInteger', + }, + 'href': { + '@id': 'as:href', + '@type': '@id', + }, + 'hreflang': 'as:hreflang', + 'latitude': { + '@id': 'as:latitude', + '@type': 'xsd:float', + }, + 'longitude': { + '@id': 'as:longitude', + '@type': 'xsd:float', + }, + 'mediaType': 'as:mediaType', + 'published': { + '@id': 'as:published', + '@type': 'xsd:dateTime', + }, + 'radius': { + '@id': 'as:radius', + '@type': 'xsd:float', + }, + 'rel': 'as:rel', + 'startIndex': { + '@id': 'as:startIndex', + '@type': 'xsd:nonNegativeInteger', + }, + 'startTime': { + '@id': 'as:startTime', + '@type': 'xsd:dateTime', + }, + 'summary': 'as:summary', + 'summaryMap': { + '@id': 'as:summary', + '@container': '@language', + }, + 'totalItems': { + '@id': 'as:totalItems', + '@type': 'xsd:nonNegativeInteger', + }, + 'units': 'as:units', + 'updated': { + '@id': 'as:updated', + '@type': 'xsd:dateTime', + }, + 'width': { + '@id': 'as:width', + '@type': 'xsd:nonNegativeInteger', + }, + 'describes': { + '@id': 'as:describes', + '@type': '@id', + }, + 'formerType': { + '@id': 'as:formerType', + '@type': '@id', + }, + 'deleted': { + '@id': 'as:deleted', + '@type': 'xsd:dateTime', + }, + 'inbox': { + '@id': 'ldp:inbox', + '@type': '@id', + }, + 'outbox': { + '@id': 'as:outbox', + '@type': '@id', + }, + 'following': { + '@id': 'as:following', + '@type': '@id', + }, + 'followers': { + '@id': 'as:followers', + '@type': '@id', + }, + 'streams': { + '@id': 'as:streams', + '@type': '@id', + }, + 'preferredUsername': 'as:preferredUsername', + 'endpoints': { + '@id': 'as:endpoints', + '@type': '@id', + }, + 'uploadMedia': { + '@id': 'as:uploadMedia', + '@type': '@id', + }, + 'proxyUrl': { + '@id': 'as:proxyUrl', + '@type': '@id', + }, + 'liked': { + '@id': 'as:liked', + '@type': '@id', + }, + 'oauthAuthorizationEndpoint': { + '@id': 'as:oauthAuthorizationEndpoint', + '@type': '@id', + }, + 'oauthTokenEndpoint': { + '@id': 'as:oauthTokenEndpoint', + '@type': '@id', + }, + 'provideClientKey': { + '@id': 'as:provideClientKey', + '@type': '@id', + }, + 'signClientKey': { + '@id': 'as:signClientKey', + '@type': '@id', + }, + 'sharedInbox': { + '@id': 'as:sharedInbox', + '@type': '@id', + }, + 'Public': { + '@id': 'as:Public', + '@type': '@id', + }, + 'source': 'as:source', + 'likes': { + '@id': 'as:likes', + '@type': '@id', + }, + 'shares': { + '@id': 'as:shares', + '@type': '@id', + }, + 'alsoKnownAs': { + '@id': 'as:alsoKnownAs', + '@type': '@id', + }, + }, +}; + +export const CONTEXTS: Record = { + 'https://w3id.org/identity/v1': id_v1, + 'https://w3id.org/security/v1': security_v1, + 'https://www.w3.org/ns/activitystreams': activitystreams, +}; diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts new file mode 100644 index 0000000000..9bf87f19d4 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { DriveFilesRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { MetaService } from '@/core/MetaService.js'; +import { truncate } from '@/misc/truncate.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import { DriveService } from '@/core/DriveService.js'; +import type Logger from '@/logger.js'; +import { ApResolverService } from '../ApResolverService.js'; +import { ApLoggerService } from '../ApLoggerService.js'; + +@Injectable() +export class ApImageService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private metaService: MetaService, + private apResolverService: ApResolverService, + private driveService: DriveService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + /** + * Imageを作成します。 + */ + public async createImage(actor: CacheableRemoteUser, value: any): Promise { + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const image = await this.apResolverService.createResolver().resolve(value) as any; + + if (image.url == null) { + throw new Error('invalid image: url not privided'); + } + + this.logger.info(`Creating the Image: ${image.url}`); + + const instance = await this.metaService.fetch(); + + let file = await this.driveService.uploadFromUrl({ + url: image.url, + user: actor, + uri: image.url, + sensitive: image.sensitive, + isLink: !instance.cacheRemoteFiles, + comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), + }); + + if (file.isLink) { + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 + // URLを更新する + if (file.url !== image.url) { + await this.driveFilesRepository.update({ id: file.id }, { + url: image.url, + uri: image.url, + }); + + file = await this.driveFilesRepository.findOneByOrFail({ id: file.id }); + } + } + + return file; + } + + /** + * Imageを解決します。 + * + * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolveImage(actor: CacheableRemoteUser, value: any): Promise { + // TODO + + // リモートサーバーからフェッチしてきて登録 + return await this.createImage(actor, value); + } +} diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts new file mode 100644 index 0000000000..1275e24c62 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import { toArray, unique } from '@/misc/prelude/array.js'; +import type { CacheableUser } from '@/models/entities/User.js'; +import { isMention } from '../type.js'; +import { ApResolverService, Resolver } from '../ApResolverService.js'; +import { ApPersonService } from './ApPersonService.js'; +import type { IObject, IApMention } from '../type.js'; + +@Injectable() +export class ApMentionService { + constructor( + @Inject(DI.config) + private config: Config, + + private apResolverService: ApResolverService, + private apPersonService: ApPersonService, + ) { + } + + public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) { + const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); + + const limit = promiseLimit(2); + const mentionedUsers = (await Promise.all( + hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), + )).filter((x): x is CacheableUser => x != null); + + return mentionedUsers; + } + + public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { + if (tags == null) return []; + return toArray(tags).filter(isMention); + } +} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts new file mode 100644 index 0000000000..7cf6725a38 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -0,0 +1,403 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DI } from '@/di-symbols.js'; +import type { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { CacheableRemoteUser } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { MetaService } from '@/core/MetaService.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import type Logger from '@/logger.js'; +import { IdService } from '@/core/IdService.js'; +import { PollService } from '@/core/PollService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ApLoggerService } from '../ApLoggerService.js'; +import { ApMfmService } from '../ApMfmService.js'; +import { ApDbResolverService } from '../ApDbResolverService.js'; +import { ApResolverService } from '../ApResolverService.js'; +import { ApAudienceService } from '../ApAudienceService.js'; +import { ApPersonService } from './ApPersonService.js'; +import { extractApHashtags } from './tag.js'; +import { ApMentionService } from './ApMentionService.js'; +import { ApQuestionService } from './ApQuestionService.js'; +import { ApImageService } from './ApImageService.js'; +import type { Resolver } from '../ApResolverService.js'; +import type { IObject, IPost } from '../type.js'; + +@Injectable() +export class ApNoteService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private idService: IdService, + private apMfmService: ApMfmService, + private apResolverService: ApResolverService, + + // 循環参照のため / for circular dependency + @Inject(forwardRef(() => ApPersonService)) + private apPersonService: ApPersonService, + + private utilityService: UtilityService, + private apAudienceService: ApAudienceService, + private apMentionService: ApMentionService, + private apImageService: ApImageService, + private apQuestionService: ApQuestionService, + private metaService: MetaService, + private messagingService: MessagingService, + private appLockService: AppLockService, + private pollService: PollService, + private noteCreateService: NoteCreateService, + private apDbResolverService: ApDbResolverService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + public validateNote(object: any, uri: string) { + const expectHost = this.utilityService.extractDbHost(uri); + + if (object == null) { + return new Error('invalid Note: object is null'); + } + + if (!validPost.includes(getApType(object))) { + return new Error(`invalid Note: invalid object type ${getApType(object)}`); + } + + if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { + return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); + } + + if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + } + + return null; + } + + /** + * Noteをフェッチします。 + * + * Misskeyに対象のNoteが登録されていればそれを返します。 + */ + public async fetchNote(object: string | IObject): Promise { + return await this.apDbResolverService.getNoteFromApId(object); + } + + /** + * Noteを作成します。 + */ + public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object: any = 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: value, + object: object, + }); + throw new Error('invalid note'); + } + + const note: IPost = object; + + this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + + this.logger.info(`Creating the Note: ${note.id}`); + + // 投稿者をフェッチ + const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser; + + // 投稿者が凍結されていたらスキップ + 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'; + } + } + + let isMessaging = note._misskey_talk && visibility === 'specified'; + + const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); + const apHashtags = await extractApHashtags(note.tag); + + // 添付ファイル + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + // Noteがsensitiveなら添付もsensitiveにする + const limit = promiseLimit(2); + + note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; + const files = note.attachment + .map(attach => attach.sensitive = note.sensitive) + ? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise))) + .filter(image => image != null) + : []; + + // リプライ + const reply: Note | null = note.inReplyTo + ? await this.resolveNote(note.inReplyTo, resolver).then(x => { + if (x == null) { + this.logger.warn('Specified inReplyTo, but nout found'); + throw new Error('inReplyTo not found'); + } else { + return x; + } + }).catch(async err => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(this.config.url + '/')) { + const id = uri.split('/').pop(); + const talk = await this.messagingMessagesRepository.findOneBy({ id }); + if (talk) { + isMessaging = true; + return null; + } + } + + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); + throw err; + }) + : null; + + // 引用 + let quote: Note | undefined | null; + + if (note._misskey_quote || note.quoteUrl) { + const tryResolveNote = async (uri: string): Promise<{ + status: 'ok'; + res: Note | null; + } | { + status: 'permerror' | 'temperror'; + }> => { + if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + try { + const res = await this.resolveNote(uri); + if (res) { + return { + status: 'ok', + res, + }; + } else { + return { + status: 'permerror', + }; + } + } 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(uri => tryResolveNote(uri))); + + quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + if (!quote) { + if (results.some(x => x.status === 'temperror')) { + throw '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 [] as Emoji[]; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + + if (isMessaging) { + for (const recipient of visibleUsers) { + await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id); + return null; + } + } + + return await this.noteCreateService.create(actor, { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + localOnly: false, + visibility, + visibleUsers, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: getOneApHrefNullable(note.url), + }, silent); + } + + /** + * Noteを解決します。 + * + * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolveNote(value: string | IObject, resolver?: Resolver): Promise { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('missing uri'); + + // ブロックしてたら中断 + const meta = await this.metaService.fetch(); + if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) throw { statusCode: 451 }; + + const unlock = await this.appLockService.getApLock(uri); + + try { + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + if (uri.startsWith(this.config.url)) { + throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); + } + + // リモートサーバーからフェッチしてきて登録 + // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが + // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 + return await this.createNote(uri, resolver, true); + } finally { + unlock(); + } + } + + public async extractEmojis(tags: IObject | IObject[], host: string): Promise { + host = this.utilityService.toPuny(host); + + if (!tags) return []; + + const eomjiTags = toArray(tags).filter(isEmoji); + + return await Promise.all(eomjiTags.map(async tag => { + const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + tag.icon = toSingle(tag.icon); + + const exists = await this.emojisRepository.findOneBy({ + host, + name, + }); + + if (exists) { + if ((tag.updated != null && exists.updatedAt == null) + || (tag.id != null && exists.uri == null) + || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) + || (tag.icon!.url !== exists.originalUrl) + ) { + await this.emojisRepository.update({ + host, + name, + }, { + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + }); + + return await this.emojisRepository.findOneBy({ + host, + name, + }) as Emoji; + } + + return exists; + } + + this.logger.info(`register emoji host=${host}, name=${name}`); + + return await this.emojisRepository.insert({ + id: this.idService.genId(), + host, + name, + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + aliases: [], + } as Partial).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + })); + } +} diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts new file mode 100644 index 0000000000..f9d6f42ef6 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -0,0 +1,594 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import promiseLimit from 'promise-limit'; +import { DataSource } from 'typeorm'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { CacheableUser, IRemoteUser } from '@/models/entities/User.js'; +import { User } from '@/models/entities/User.js'; +import { truncate } from '@/misc/truncate.js'; +import type { UserCacheService } from '@/core/UserCacheService.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type Logger from '@/logger.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { IdService } from '@/core/IdService.js'; +import type { MfmService } from '@/core/MfmService.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { toArray } from '@/misc/prelude/array.js'; +import type { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { UserPublickey } from '@/models/entities/UserPublickey.js'; +import type UsersChart from '@/core/chart/charts/users.js'; +import type InstanceChart from '@/core/chart/charts/instance.js'; +import type { HashtagService } from '@/core/HashtagService.js'; +import { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { StatusError } from '@/misc/status-error.js'; +import type { UtilityService } from '@/core/UtilityService.js'; +import type { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; +import { extractApHashtags } from './tag.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { ApNoteService } from './ApNoteService.js'; +import type { ApMfmService } from '../ApMfmService.js'; +import type { ApResolverService, Resolver } from '../ApResolverService.js'; +import type { ApLoggerService } from '../ApLoggerService.js'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type { ApImageService } from './ApImageService.js'; +import type { IActor, IObject, IApPropertyValue } from '../type.js'; + +const nameLength = 128; +const summaryLength = 2048; + +const services: { + [x: string]: (id: string, username: string) => any +} = { + 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), + 'misskey:authentication:github': (id, login) => ({ id, login }), + 'misskey:authentication:discord': (id, name) => $discord(id, name), +}; + +const $discord = (id: string, name: string) => { + if (typeof name !== 'string') { + name = 'unknown#0000'; + } + const [username, discriminator] = name.split('#'); + return { id, username, discriminator }; +}; + +function addService(target: { [x: string]: any }, source: IApPropertyValue) { + const service = services[source.name]; + + if (typeof source.value !== 'string') { + source.value = 'unknown'; + } + + const [id, username] = source.value.split('@'); + + if (service) { + target[source.name.split(':')[2]] = service(id, username); + } +} + +@Injectable() +export class ApPersonService implements OnModuleInit { + private utilityService: UtilityService; + private userEntityService: UserEntityService; + private idService: IdService; + private globalEventService: GlobalEventService; + private federatedInstanceService: FederatedInstanceService; + private fetchInstanceMetadataService: FetchInstanceMetadataService; + private userCacheService: UserCacheService; + private apResolverService: ApResolverService; + private apNoteService: ApNoteService; + private apImageService: ApImageService; + private apMfmService: ApMfmService; + private mfmService: MfmService; + private hashtagService: HashtagService; + private usersChart: UsersChart; + private instanceChart: InstanceChart; + private apLoggerService: ApLoggerService; + private logger: Logger; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userPublickeysRepository) + private userPublickeysRepository: UserPublickeysRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + //private utilityService: UtilityService, + //private userEntityService: UserEntityService, + //private idService: IdService, + //private globalEventService: GlobalEventService, + //private federatedInstanceService: FederatedInstanceService, + //private fetchInstanceMetadataService: FetchInstanceMetadataService, + //private userCacheService: UserCacheService, + //private apResolverService: ApResolverService, + //private apNoteService: ApNoteService, + //private apImageService: ApImageService, + //private apMfmService: ApMfmService, + //private mfmService: MfmService, + //private hashtagService: HashtagService, + //private usersChart: UsersChart, + //private instanceChart: InstanceChart, + //private apLoggerService: ApLoggerService, + ) { + } + + onModuleInit() { + this.utilityService = this.moduleRef.get('UtilityService'); + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.idService = this.moduleRef.get('IdService'); + this.globalEventService = this.moduleRef.get('GlobalEventService'); + this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); + this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); + this.userCacheService = this.moduleRef.get('UserCacheService'); + this.apResolverService = this.moduleRef.get('ApResolverService'); + this.apNoteService = this.moduleRef.get('ApNoteService'); + this.apImageService = this.moduleRef.get('ApImageService'); + this.apMfmService = this.moduleRef.get('ApMfmService'); + this.mfmService = this.moduleRef.get('MfmService'); + this.hashtagService = this.moduleRef.get('HashtagService'); + this.usersChart = this.moduleRef.get('UsersChart'); + this.instanceChart = this.moduleRef.get('InstanceChart'); + this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.logger = this.apLoggerService.logger; + } + + /** + * Validate and convert to actor object + * @param x Fetched object + * @param uri Fetch target URI + */ + private validateActor(x: IObject, uri: string): IActor { + const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + + if (x == null) { + throw new Error('invalid Actor: object is null'); + } + + if (!isActor(x)) { + throw new Error(`invalid Actor type '${x.type}'`); + } + + if (!(typeof x.id === 'string' && x.id.length > 0)) { + throw new Error('invalid Actor: wrong id'); + } + + if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { + throw new Error('invalid Actor: wrong inbox'); + } + + if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { + throw new Error('invalid Actor: wrong username'); + } + + // These fields are only informational, and some AP software allows these + // fields to be very long. If they are too long, we cut them off. This way + // we can at least see these users and their activities. + if (x.name) { + if (!(typeof x.name === 'string' && x.name.length > 0)) { + throw new Error('invalid Actor: wrong name'); + } + x.name = truncate(x.name, nameLength); + } + if (x.summary) { + if (!(typeof x.summary === 'string' && x.summary.length > 0)) { + throw new Error('invalid Actor: wrong summary'); + } + x.summary = truncate(x.summary, summaryLength); + } + + const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + if (idHost !== expectHost) { + throw new Error('invalid Actor: id has different host'); + } + + if (x.publicKey) { + if (typeof x.publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); + } + + const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); + } + } + + return x; + } + + /** + * Personをフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返します。 + */ + public async fetchPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + const cached = this.userCacheService.uriPersonCache.get(uri); + if (cached) return cached; + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(this.config.url + '/')) { + const id = uri.split('/').pop(); + const u = await this.usersRepository.findOneBy({ id }); + if (u) this.userCacheService.uriPersonCache.set(uri, u); + return u; + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.usersRepository.findOneBy({ uri }); + + if (exist) { + this.userCacheService.uriPersonCache.set(uri, exist); + return exist; + } + //#endregion + + return null; + } + + /** + * Personを作成します。 + */ + public async createPerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + if (uri.startsWith(this.config.url)) { + throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); + } + + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = await resolver.resolve(uri) as any; + + const person = this.validateActor(object, uri); + + this.logger.info(`Creating the Person: ${person.id}`); + + const host = this.utilityService.toPuny(new URL(object.id).hostname); + + const { fields } = this.analyzeAttachments(person.attachment ?? []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const isBot = getApType(object) === 'Service'; + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + // Create user + let user: IRemoteUser; + try { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + user = await transactionalEntityManager.save(new User({ + id: this.idService.genId(), + avatarId: null, + bannerId: null, + createdAt: new Date(), + lastFetchedAt: new Date(), + name: truncate(person.name, nameLength), + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + username: person.preferredUsername, + usernameLower: person.preferredUsername!.toLowerCase(), + host, + inbox: person.inbox, + sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured ? getApId(person.featured) : undefined, + uri: person.id, + tags, + isBot, + isCat: (person as any).isCat === true, + showTimelineReplies: false, + })) as IRemoteUser; + + await transactionalEntityManager.save(new UserProfile({ + userId: user.id, + description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + url: getOneApHrefNullable(person.url), + fields, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] ?? null, + userHost: host, + })); + + if (person.publicKey) { + await transactionalEntityManager.save(new UserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + })); + } + }); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + const u = await this.usersRepository.findOneBy({ + uri: person.id, + }); + + if (u) { + user = u as IRemoteUser; + } else { + throw new Error('already registered'); + } + } else { + this.logger.error(e instanceof Error ? e : new Error(e as string)); + throw e; + } + } + + // Register host + this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); + this.instanceChart.newUser(i.host); + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + }); + + this.usersChart.update(user!, true); + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(user!, tags); + + //#region アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : this.apImageService.resolveImage(user!, img).catch(() => null), + )); + + const avatarId = avatar ? avatar.id : null; + const bannerId = banner ? banner.id : null; + + await this.usersRepository.update(user!.id, { + avatarId, + bannerId, + }); + + user!.avatarId = avatarId; + user!.bannerId = bannerId; + //#endregion + + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { + this.logger.info(`extractEmojis: ${err}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + await this.usersRepository.update(user!.id, { + emojis: emojiNames, + }); + //#endregion + + await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + + return user!; + } + + /** + * Personの情報を更新します。 + * Misskeyに対象のPersonが登録されていなければ無視します。 + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + */ + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(this.config.url + '/')) { + return; + } + + //#region このサーバーに既に登録されているか + const exist = await this.usersRepository.findOneBy({ uri }) as IRemoteUser; + + if (exist == null) { + return; + } + //#endregion + + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const object = hint ?? await resolver.resolve(uri); + + const person = this.validateActor(object, uri); + + this.logger.info(`Updating the Person: ${person.id}`); + + // アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image, + ].map(img => + img == null + ? Promise.resolve(null) + : this.apImageService.resolveImage(exist, img).catch(() => null), + )); + + // カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { + this.logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + const { fields } = this.analyzeAttachments(person.attachment ?? []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured, + emojis: emojiNames, + name: truncate(person.name, nameLength), + tags, + isBot: getApType(object) === 'Service', + isCat: (person as any).isCat === true, + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + } as Partial; + + if (avatar) { + updates.avatarId = avatar.id; + } + + if (banner) { + updates.bannerId = banner.id; + } + + // Update user + await this.usersRepository.update(exist.id, updates); + + if (person.publicKey) { + await this.userPublickeysRepository.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + }); + } + + await this.userProfilesRepository.update({ userId: exist.id }, { + url: getOneApHrefNullable(person.url), + fields, + description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] ?? null, + }); + + this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(exist, tags); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await this.followingsRepository.update({ + followerId: exist.id, + }, { + followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), + }); + + await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + } + + /** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolvePerson(uri: string, resolver?: Resolver): Promise { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await this.fetchPerson(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + if (resolver == null) resolver = this.apResolverService.createResolver(); + return await this.createPerson(uri, resolver); + } + + public analyzeAttachments(attachments: IObject | IObject[] | undefined) { + const fields: { + name: string, + value: string + }[] = []; + const services: { [x: string]: any } = {}; + + if (Array.isArray(attachments)) { + for (const attachment of attachments.filter(isPropertyValue)) { + if (isPropertyValue(attachment.identifier)) { + addService(services, attachment.identifier); + } else { + fields.push({ + name: attachment.name, + value: this.mfmService.fromHtml(attachment.value), + }); + } + } + } + + return { fields, services }; + } + + public async updateFeatured(userId: User['id'], resolver?: Resolver) { + const user = await this.usersRepository.findOneByOrFail({ id: userId }); + if (!this.userEntityService.isRemoteUser(user)) return; + if (!user.featured) return; + + this.logger.info(`Updating the featured: ${user.uri}`); + + if (resolver == null) resolver = this.apResolverService.createResolver(); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; + const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); + + // Resolve and regist Notes + const limit = promiseLimit(2); + const featuredNotes = await Promise.all(items + .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも + .slice(0, 5) + .map(item => limit(() => this.apNoteService.resolveNote(item, resolver)))); + + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); + + // とりあえずidを別の時間で生成して順番を維持 + let td = 0; + for (const note of featuredNotes.filter(note => note != null)) { + td -= 1000; + transactionalEntityManager.insert(UserNotePining, { + id: this.idService.genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id, + }); + } + }); + } +} diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts new file mode 100644 index 0000000000..5793b98353 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { NotesRepository, PollsRepository } from '@/models/index.js'; +import type { Config } from '@/config.js'; +import type { IPoll } from '@/models/entities/Poll.js'; +import type Logger from '@/logger.js'; +import { isQuestion } from '../type.js'; +import { ApLoggerService } from '../ApLoggerService.js'; +import { ApResolverService } from '../ApResolverService.js'; +import type { Resolver } from '../ApResolverService.js'; +import type { IObject, IQuestion } from '../type.js'; + +@Injectable() +export class ApQuestionService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + private apResolverService: ApResolverService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { + if (resolver == null) resolver = this.apResolverService.createResolver(); + + const question = await resolver.resolve(source); + + if (!isQuestion(question)) { + throw new Error('invalid type'); + } + + const multiple = !question.oneOf; + const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; + + if (multiple && !question.anyOf) { + throw new Error('invalid question'); + } + + const choices = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.name!); + + const votes = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); + + return { + choices, + votes, + multiple, + expiresAt, + }; + } + + /** + * Update votes of Question + * @param uri URI of AP Question object + * @returns true if updated + */ + public async updateQuestion(value: any, resolver?: Resolver) { + const uri = typeof value === 'string' ? value : value.id; + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); + + //#region このサーバーに既に登録されているか + const note = await this.notesRepository.findOneBy({ uri }); + if (note == null) throw new Error('Question is not registed'); + + const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + if (poll == null) throw new Error('Question is not registed'); + //#endregion + + // resolve new Question object + if (resolver == null) resolver = this.apResolverService.createResolver(); + const question = await resolver.resolve(value) as IQuestion; + this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); + + if (question.type !== 'Question') throw new Error('object is not a Question'); + + const apChoices = question.oneOf ?? question.anyOf; + + let changed = false; + + for (const choice of poll.choices) { + const oldCount = poll.votes[poll.choices.indexOf(choice)]; + const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + + if (oldCount !== newCount) { + changed = true; + poll.votes[poll.choices.indexOf(choice)] = newCount; + } + } + + await this.pollsRepository.update({ noteId: note.id }, { + votes: poll.votes, + }); + + return changed; + } +} diff --git a/packages/backend/src/core/activitypub/models/icon.ts b/packages/backend/src/core/activitypub/models/icon.ts new file mode 100644 index 0000000000..50794a937d --- /dev/null +++ b/packages/backend/src/core/activitypub/models/icon.ts @@ -0,0 +1,5 @@ +export type IIcon = { + type: string; + mediaType?: string; + url?: string; +}; diff --git a/packages/backend/src/core/activitypub/models/identifier.ts b/packages/backend/src/core/activitypub/models/identifier.ts new file mode 100644 index 0000000000..f6c3bb8c88 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/identifier.ts @@ -0,0 +1,5 @@ +export type IIdentifier = { + type: string; + name: string; + value: string; +}; diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts new file mode 100644 index 0000000000..803846a0b0 --- /dev/null +++ b/packages/backend/src/core/activitypub/models/tag.ts @@ -0,0 +1,19 @@ +import { toArray } from '@/misc/prelude/array.js'; +import { isHashtag } from '../type.js'; +import type { IObject, IApHashtag } from '../type.js'; + +export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { + if (tags == null) return []; + + const hashtags = extractApHashtagObjects(tags); + + return hashtags.map(tag => { + const m = tag.name.match(/^#(.+)/); + return m ? m[1] : null; + }).filter((x): x is string => x != null); +} + +export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { + if (tags == null) return []; + return toArray(tags).filter(isHashtag); +} diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts new file mode 100644 index 0000000000..dcc5110aa5 --- /dev/null +++ b/packages/backend/src/core/activitypub/type.ts @@ -0,0 +1,296 @@ +export type obj = { [x: string]: any }; +export type ApObject = IObject | string | (IObject | string)[]; + +export interface IObject { + '@context': string | string[] | obj | obj[]; + type: string | string[]; + id?: string; + summary?: string; + published?: string; + cc?: ApObject; + to?: ApObject; + attributedTo: ApObject; + attachment?: any[]; + inReplyTo?: any; + replies?: ICollection; + content?: string; + name?: string; + startTime?: Date; + endTime?: Date; + icon?: any; + image?: any; + url?: ApObject; + href?: string; + tag?: IObject | IObject[]; + sensitive?: boolean; +} + +/** + * Get array of ActivityStreams Objects id + */ +export function getApIds(value: ApObject | undefined): string[] { + if (value == null) return []; + const array = Array.isArray(value) ? value : [value]; + return array.map(x => getApId(x)); +} + +/** + * Get first ActivityStreams Object id + */ +export function getOneApId(value: ApObject): string { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApId(firstOne); +} + +/** + * Get ActivityStreams Object id + */ +export function getApId(value: string | IObject): string { + if (typeof value === 'string') return value; + if (typeof value.id === 'string') return value.id; + throw new Error('cannot detemine id'); +} + +/** + * Get ActivityStreams Object type + */ +export function getApType(value: IObject): string { + if (typeof value.type === 'string') return value.type; + if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; + throw new Error('cannot detect type'); +} + +export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApHrefNullable(firstOne); +} + +export function getApHrefNullable(value: string | IObject | undefined): string | undefined { + if (typeof value === 'string') return value; + if (typeof value?.href === 'string') return value.href; + return undefined; +} + +export interface IActivity extends IObject { + //type: 'Activity'; + actor: IObject | string; + object: IObject | string; + target?: IObject | string; + /** LD-Signature */ + signature?: { + type: string; + created: Date; + creator: string; + domain?: string; + nonce?: string; + signatureValue: string; + }; +} + +export interface ICollection extends IObject { + type: 'Collection'; + totalItems: number; + items: ApObject; +} + +export interface IOrderedCollection extends IObject { + type: 'OrderedCollection'; + totalItems: number; + orderedItems: ApObject; +} + +export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; + +export const isPost = (object: IObject): object is IPost => + validPost.includes(getApType(object)); + +export interface IPost extends IObject { + type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; + source?: { + content: string; + mediaType: string; + }; + _misskey_quote?: string; + _misskey_content?: string; + quoteUrl?: string; + _misskey_talk?: boolean; +} + +export interface IQuestion extends IObject { + type: 'Note' | 'Question'; + source?: { + content: string; + mediaType: string; + }; + _misskey_quote?: string; + quoteUrl?: string; + oneOf?: IQuestionChoice[]; + anyOf?: IQuestionChoice[]; + endTime?: Date; + closed?: Date; +} + +export const isQuestion = (object: IObject): object is IQuestion => + getApType(object) === 'Note' || getApType(object) === 'Question'; + +interface IQuestionChoice { + name?: string; + replies?: ICollection; + _misskey_votes?: number; +} +export interface ITombstone extends IObject { + type: 'Tombstone'; + formerType?: string; + deleted?: Date; +} + +export const isTombstone = (object: IObject): object is ITombstone => + getApType(object) === 'Tombstone'; + +export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; + +export const isActor = (object: IObject): object is IActor => + validActor.includes(getApType(object)); + +export interface IActor extends IObject { + type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; + name?: string; + preferredUsername?: string; + manuallyApprovesFollowers?: boolean; + discoverable?: boolean; + inbox: string; + sharedInbox?: string; // 後方互換性のため + publicKey?: { + id: string; + publicKeyPem: string; + }; + followers?: string | ICollection | IOrderedCollection; + following?: string | ICollection | IOrderedCollection; + featured?: string | IOrderedCollection; + outbox: string | IOrderedCollection; + endpoints?: { + sharedInbox?: string; + }; + 'vcard:bday'?: string; + 'vcard:Address'?: string; +} + +export const isCollection = (object: IObject): object is ICollection => + getApType(object) === 'Collection'; + +export const isOrderedCollection = (object: IObject): object is IOrderedCollection => + getApType(object) === 'OrderedCollection'; + +export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => + isCollection(object) || isOrderedCollection(object); + +export interface IApPropertyValue extends IObject { + type: 'PropertyValue'; + identifier: IApPropertyValue; + name: string; + value: string; +} + +export const isPropertyValue = (object: IObject): object is IApPropertyValue => + object && + getApType(object) === 'PropertyValue' && + typeof object.name === 'string' && + typeof (object as any).value === 'string'; + +export interface IApMention extends IObject { + type: 'Mention'; + href: string; +} + +export const isMention = (object: IObject): object is IApMention => + getApType(object) === 'Mention' && + typeof object.href === 'string'; + +export interface IApHashtag extends IObject { + type: 'Hashtag'; + name: string; +} + +export const isHashtag = (object: IObject): object is IApHashtag => + getApType(object) === 'Hashtag' && + typeof object.name === 'string'; + +export interface IApEmoji extends IObject { + type: 'Emoji'; + updated: Date; +} + +export const isEmoji = (object: IObject): object is IApEmoji => + getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null; + +export interface ICreate extends IActivity { + type: 'Create'; +} + +export interface IDelete extends IActivity { + type: 'Delete'; +} + +export interface IUpdate extends IActivity { + type: 'Update'; +} + +export interface IRead extends IActivity { + type: 'Read'; +} + +export interface IUndo extends IActivity { + type: 'Undo'; +} + +export interface IFollow extends IActivity { + type: 'Follow'; +} + +export interface IAccept extends IActivity { + type: 'Accept'; +} + +export interface IReject extends IActivity { + type: 'Reject'; +} + +export interface IAdd extends IActivity { + type: 'Add'; +} + +export interface IRemove extends IActivity { + type: 'Remove'; +} + +export interface ILike extends IActivity { + type: 'Like' | 'EmojiReaction' | 'EmojiReact'; + _misskey_reaction?: string; +} + +export interface IAnnounce extends IActivity { + type: 'Announce'; +} + +export interface IBlock extends IActivity { + type: 'Block'; +} + +export interface IFlag extends IActivity { + type: 'Flag'; +} + +export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; +export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; +export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; +export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read'; +export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo'; +export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow'; +export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept'; +export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; +export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; +export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; +export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact'; +export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; +export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; +export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts index 40c60910ea..6683d76587 100644 --- a/packages/backend/src/core/chart/charts/active-users.ts +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -5,7 +5,7 @@ import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/active-users.js'; +import { name, schema } from '@/core/entities/active-users.js'; import type { KVs } from '../core.js'; const week = 1000 * 60 * 60 * 24 * 7; diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts index 4b91fbbf18..1de21a6a16 100644 --- a/packages/backend/src/core/chart/charts/ap-request.ts +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -4,7 +4,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/ap-request.js'; +import { name, schema } from '@/core/entities/ap-request.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts index 494dfbbe57..638e31ac8d 100644 --- a/packages/backend/src/core/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -5,7 +5,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/drive.js'; +import { name, schema } from '@/core/entities/drive.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index 21e4cedea3..75a565cebc 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -6,7 +6,7 @@ import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/federation.js'; +import { name, schema } from '@/core/entities/federation.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/hashtag.ts b/packages/backend/src/core/chart/charts/hashtag.ts index 8b8c795cfd..ff83b8aa5d 100644 --- a/packages/backend/src/core/chart/charts/hashtag.ts +++ b/packages/backend/src/core/chart/charts/hashtag.ts @@ -6,7 +6,7 @@ import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/hashtag.js'; +import { name, schema } from '@/core/entities/hashtag.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts index 2e0f4c7126..41a35a2123 100644 --- a/packages/backend/src/core/chart/charts/instance.ts +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -8,7 +8,7 @@ import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/instance.js'; +import { name, schema } from '@/core/entities/instance.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts index 2153cfe4b4..083b0d5519 100644 --- a/packages/backend/src/core/chart/charts/notes.ts +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -6,7 +6,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/notes.js'; +import { name, schema } from '@/core/entities/notes.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts index a44460bb4e..9b2e2d3b5a 100644 --- a/packages/backend/src/core/chart/charts/per-user-drive.ts +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -7,7 +7,7 @@ import { DI } from '@/di-symbols.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-drive.js'; +import { name, schema } from '@/core/entities/per-user-drive.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 5ea08a0872..6bd6f1a7dc 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -7,7 +7,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FollowingsRepository } from '@/models/index.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-following.js'; +import { name, schema } from '@/core/entities/per-user-following.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index 5c14309d89..53bacd434a 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -7,7 +7,7 @@ import { DI } from '@/di-symbols.js'; import type { NotesRepository } from '@/models/index.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-notes.js'; +import { name, schema } from '@/core/entities/per-user-notes.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts index 4160219720..78a7be0383 100644 --- a/packages/backend/src/core/chart/charts/per-user-reactions.ts +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -7,7 +7,7 @@ import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/per-user-reactions.js'; +import { name, schema } from '@/core/entities/per-user-reactions.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts index bc215f3942..95585ec93e 100644 --- a/packages/backend/src/core/chart/charts/test-grouped.ts +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -4,7 +4,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import Chart from '../core.js'; -import { name, schema } from './entities/test-grouped.js'; +import { name, schema } from '@/core/entities/test-grouped.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts index a074a7dded..c404a211a5 100644 --- a/packages/backend/src/core/chart/charts/test-intersection.ts +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -4,7 +4,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import Chart from '../core.js'; -import { name, schema } from './entities/test-intersection.js'; +import { name, schema } from '@/core/entities/test-intersection.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts index 4d3e2f2403..5430db852b 100644 --- a/packages/backend/src/core/chart/charts/test-unique.ts +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -4,7 +4,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import Chart from '../core.js'; -import { name, schema } from './entities/test-unique.js'; +import { name, schema } from '@/core/entities/test-unique.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts index 72caf79e0f..7510b533c2 100644 --- a/packages/backend/src/core/chart/charts/test.ts +++ b/packages/backend/src/core/chart/charts/test.ts @@ -4,7 +4,7 @@ import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import Chart from '../core.js'; -import { name, schema } from './entities/test.js'; +import { name, schema } from '@/core/entities/test.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts index f0359968eb..0731617354 100644 --- a/packages/backend/src/core/chart/charts/users.ts +++ b/packages/backend/src/core/chart/charts/users.ts @@ -7,7 +7,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/index.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; -import { name, schema } from './entities/users.js'; +import { name, schema } from '@/core/entities/users.js'; import type { KVs } from '../core.js'; /** diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index c54285d9df..ebf6116f27 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -6,7 +6,7 @@ import type { Packed } from '@/misc/schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { MetaService } from '../MetaService.js'; +import { MetaService } from '.@/core/MetaService.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() diff --git a/packages/backend/src/core/queue/QueueModule.ts b/packages/backend/src/core/queue/QueueModule.ts deleted file mode 100644 index 3a271ea37f..0000000000 --- a/packages/backend/src/core/queue/QueueModule.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Module } from '@nestjs/common'; -import Bull from 'bull'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../../queue/types.js'; - -function q(config: Config, name: string, limitPerSec = -1) { - return new Bull(name, { - redis: { - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - db: config.redis.db ?? 0, - }, - prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', - limiter: limitPerSec > 0 ? { - max: limitPerSec, - duration: 1000, - } : undefined, - settings: { - backoffStrategies: { - apBackoff, - }, - }, - }); -} - -// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 -function apBackoff(attemptsMade: number, err: Error) { - const baseDelay = 60 * 1000; // 1min - const maxBackoff = 8 * 60 * 60 * 1000; // 8hours - let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; - backoff = Math.min(backoff, maxBackoff); - backoff += Math.round(backoff * Math.random() * 0.2); - return backoff; -} - -export type SystemQueue = Bull.Queue>; -export type EndedPollNotificationQueue = Bull.Queue; -export type DeliverQueue = Bull.Queue; -export type InboxQueue = Bull.Queue; -export type DbQueue = Bull.Queue; -export type ObjectStorageQueue = Bull.Queue; -export type WebhookDeliverQueue = Bull.Queue; - -const $system: Provider = { - provide: 'queue:system', - useFactory: (config: Config) => q(config, 'system'), - inject: [DI.config], -}; - -const $endedPollNotification: Provider = { - provide: 'queue:endedPollNotification', - useFactory: (config: Config) => q(config, 'endedPollNotification'), - inject: [DI.config], -}; - -const $deliver: Provider = { - provide: 'queue:deliver', - useFactory: (config: Config) => q(config, 'deliver', config.deliverJobPerSec ?? 128), - inject: [DI.config], -}; - -const $inbox: Provider = { - provide: 'queue:inbox', - useFactory: (config: Config) => q(config, 'inbox', config.inboxJobPerSec ?? 16), - inject: [DI.config], -}; - -const $db: Provider = { - provide: 'queue:db', - useFactory: (config: Config) => q(config, 'db'), - inject: [DI.config], -}; - -const $objectStorage: Provider = { - provide: 'queue:objectStorage', - useFactory: (config: Config) => q(config, 'objectStorage'), - inject: [DI.config], -}; - -const $webhookDeliver: Provider = { - provide: 'queue:webhookDeliver', - useFactory: (config: Config) => q(config, 'webhookDeliver', 64), - inject: [DI.config], -}; - -@Module({ - imports: [ - ], - providers: [ - $system, - $endedPollNotification, - $deliver, - $inbox, - $db, - $objectStorage, - $webhookDeliver, - ], - exports: [ - $system, - $endedPollNotification, - $deliver, - $inbox, - $db, - $objectStorage, - $webhookDeliver, - ], -}) -export class QueueModule {} diff --git a/packages/backend/src/core/remote/RemoteLoggerService.ts b/packages/backend/src/core/remote/RemoteLoggerService.ts deleted file mode 100644 index 68246466c8..0000000000 --- a/packages/backend/src/core/remote/RemoteLoggerService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { LoggerService } from '@/core/LoggerService.js'; - -@Injectable() -export class RemoteLoggerService { - public logger: Logger; - - constructor( - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('remote', 'cyan'); - } -} diff --git a/packages/backend/src/core/remote/ResolveUserService.ts b/packages/backend/src/core/remote/ResolveUserService.ts deleted file mode 100644 index 2fd9e7c378..0000000000 --- a/packages/backend/src/core/remote/ResolveUserService.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import chalk from 'chalk'; -import { IsNull } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; -import type { IRemoteUser, User } from '@/models/entities/User.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { UtilityService } from '../UtilityService.js'; -import { WebfingerService } from './WebfingerService.js'; -import { RemoteLoggerService } from './RemoteLoggerService.js'; -import { ApPersonService } from './activitypub/models/ApPersonService.js'; - -@Injectable() -export class ResolveUserService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private utilityService: UtilityService, - private webfingerService: WebfingerService, - private remoteLoggerService: RemoteLoggerService, - private apPersonService: ApPersonService, - ) { - this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user'); - } - - public async resolveUser(username: string, host: string | null): Promise { - const usernameLower = username.toLowerCase(); - - if (host == null) { - this.logger.info(`return local user: ${usernameLower}`); - return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - host = this.utilityService.toPuny(host); - - if (this.config.host === host) { - this.logger.info(`return local user: ${usernameLower}`); - return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - const user = await this.usersRepository.findOneBy({ usernameLower, host }) as IRemoteUser | null; - - const acctLower = `${usernameLower}@${host}`; - - if (user == null) { - const self = await this.resolveSelf(acctLower); - - this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await this.apPersonService.createPerson(self.href); - } - - // ユーザー情報が古い場合は、WebFilgerからやりなおして返す - if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する - await this.usersRepository.update(user.id, { - lastFetchedAt: new Date(), - }); - - this.logger.info(`try resync: ${acctLower}`); - const self = await this.resolveSelf(acctLower); - - if (user.uri !== self.href) { - // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. - this.logger.info(`uri missmatch: ${acctLower}`); - this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); - - // validate uri - const uri = new URL(self.href); - if (uri.hostname !== host) { - throw new Error('Invalid uri'); - } - - await this.usersRepository.update({ - usernameLower, - host: host, - }, { - uri: self.href, - }); - } else { - this.logger.info(`uri is fine: ${acctLower}`); - } - - await this.apPersonService.updatePerson(self.href); - - this.logger.info(`return resynced remote user: ${acctLower}`); - return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { - if (u == null) { - throw new Error('user not found'); - } else { - return u; - } - }); - } - - this.logger.info(`return existing remote user: ${acctLower}`); - return user; - } - - private async resolveSelf(acctLower: string) { - this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); - const finger = await this.webfingerService.webfinger(acctLower).catch(err => { - this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); - throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`); - }); - const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); - if (!self) { - this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); - throw new Error('self link not found'); - } - return self; - } -} diff --git a/packages/backend/src/core/remote/WebfingerService.ts b/packages/backend/src/core/remote/WebfingerService.ts deleted file mode 100644 index d2a88be583..0000000000 --- a/packages/backend/src/core/remote/WebfingerService.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { query as urlQuery } from '@/misc/prelude/url.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; - -type ILink = { - href: string; - rel?: string; -}; - -type IWebFinger = { - links: ILink[]; - subject: string; -}; - -@Injectable() -export class WebfingerService { - constructor( - @Inject(DI.config) - private config: Config, - - private httpRequestService: HttpRequestService, - ) { - } - - public async webfinger(query: string): Promise { - const url = this.genUrl(query); - - return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json') as IWebFinger; - } - - private genUrl(query: string): string { - if (query.match(/^https?:\/\//)) { - const u = new URL(query); - return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); - } - - const m = query.match(/^([^@]+)@(.*)/); - if (m) { - const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); - } - - throw new Error(`Invalid query (${query})`); - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApAudienceService.ts b/packages/backend/src/core/remote/activitypub/ApAudienceService.ts deleted file mode 100644 index 744017aa3a..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApAudienceService.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import promiseLimit from 'promise-limit'; -import { DI } from '@/di-symbols.js'; -import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; -import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import type { ApObject } from './type.js'; -import type { Resolver } from './ApResolverService.js'; - -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -type AudienceInfo = { - visibility: Visibility, - mentionedUsers: CacheableUser[], - visibleUsers: CacheableUser[], -}; - -@Injectable() -export class ApAudienceService { - constructor( - private apPersonService: ApPersonService, - ) { - } - - public async parseAudience(actor: CacheableRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { - const toGroups = this.groupingAudience(getApIds(to), actor); - const ccGroups = this.groupingAudience(getApIds(cc), actor); - - const others = unique(concat([toGroups.other, ccGroups.other])); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), - )).filter((x): x is CacheableUser => x != null); - - if (toGroups.public.length > 0) { - return { - visibility: 'public', - mentionedUsers, - visibleUsers: [], - }; - } - - if (ccGroups.public.length > 0) { - return { - visibility: 'home', - mentionedUsers, - visibleUsers: [], - }; - } - - if (toGroups.followers.length > 0) { - return { - visibility: 'followers', - mentionedUsers, - visibleUsers: [], - }; - } - - return { - visibility: 'specified', - mentionedUsers, - visibleUsers: mentionedUsers, - }; - } - - private groupingAudience(ids: string[], actor: CacheableRemoteUser) { - const groups = { - public: [] as string[], - followers: [] as string[], - other: [] as string[], - }; - - for (const id of ids) { - if (this.isPublic(id)) { - groups.public.push(id); - } else if (this.isFollowers(id, actor)) { - groups.followers.push(id); - } else { - groups.other.push(id); - } - } - - groups.other = unique(groups.other); - - return groups; - } - - private isPublic(id: string) { - return [ - 'https://www.w3.org/ns/activitystreams#Public', - 'as#Public', - 'Public', - ].includes(id); - } - - private isFollowers(id: string, actor: CacheableRemoteUser) { - return ( - id === (actor.followersUri ?? `${actor.uri}/followers`) - ); - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts b/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts deleted file mode 100644 index 77d200c3c8..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApDbResolverService.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import escapeRegexp from 'escape-regexp'; -import { DI } from '@/di-symbols.js'; -import type { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { CacheableRemoteUser, CacheableUser } from '@/models/entities/User.js'; -import { Cache } from '@/misc/cache.js'; -import type { UserPublickey } from '@/models/entities/UserPublickey.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; -import type { Note } from '@/models/entities/Note.js'; -import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; -import { getApId } from './type.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import type { IObject } from './type.js'; - -export type UriParseResult = { - /** wether the URI was generated by us */ - local: true; - /** id in DB */ - id: string; - /** hint of type, e.g. "notes", "users" */ - type: string; - /** any remaining text after type and id, not including the slash after id. undefined if empty */ - rest?: string; -} | { - /** wether the URI was generated by us */ - local: false; - /** uri in DB */ - uri: string; -}; - -@Injectable() -export class ApDbResolverService { - private publicKeyCache: Cache; - private publicKeyByUserIdCache: Cache; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.messagingMessagesRepository) - private messagingMessagesRepository: MessagingMessagesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.userPublickeysRepository) - private userPublickeysRepository: UserPublickeysRepository, - - private userCacheService: UserCacheService, - private apPersonService: ApPersonService, - ) { - this.publicKeyCache = new Cache(Infinity); - this.publicKeyByUserIdCache = new Cache(Infinity); - } - - public parseUri(value: string | IObject): UriParseResult { - const uri = getApId(value); - - // the host part of a URL is case insensitive, so use the 'i' flag. - const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i'); - const matchLocal = uri.match(localRegex); - - if (matchLocal) { - return { - local: true, - type: matchLocal[1], - id: matchLocal[2], - rest: matchLocal[3], - }; - } else { - return { - local: false, - uri, - }; - } - } - - /** - * AP Note => Misskey Note in DB - */ - public async getNoteFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'notes') return null; - - return await this.notesRepository.findOneBy({ - id: parsed.id, - }); - } else { - return await this.notesRepository.findOneBy({ - uri: parsed.uri, - }); - } - } - - public async getMessageFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'notes') return null; - - return await this.messagingMessagesRepository.findOneBy({ - id: parsed.id, - }); - } else { - return await this.messagingMessagesRepository.findOneBy({ - uri: parsed.uri, - }); - } - } - - /** - * AP Person => Misskey User in DB - */ - public async getUserFromApId(value: string | IObject): Promise { - const parsed = this.parseUri(value); - - if (parsed.local) { - if (parsed.type !== 'users') return null; - - return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ - id: parsed.id, - }).then(x => x ?? undefined)) ?? null; - } else { - return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ - uri: parsed.uri, - })); - } - } - - /** - * AP KeyId => Misskey User and Key - */ - public async getAuthUserFromKeyId(keyId: string): Promise<{ - user: CacheableRemoteUser; - key: UserPublickey; - } | null> { - const key = await this.publicKeyCache.fetch(keyId, async () => { - const key = await this.userPublickeysRepository.findOneBy({ - keyId, - }); - - if (key == null) return null; - - return key; - }, key => key != null); - - if (key == null) return null; - - return { - user: await this.userCacheService.userByIdCache.fetch(key.userId, () => this.usersRepository.findOneByOrFail({ id: key.userId })) as CacheableRemoteUser, - key, - }; - } - - /** - * AP Actor id => Misskey User and Key - */ - public async getAuthUserFromApId(uri: string): Promise<{ - user: CacheableRemoteUser; - key: UserPublickey | null; - } | null> { - const user = await this.apPersonService.resolvePerson(uri) as CacheableRemoteUser; - - if (user == null) return null; - - const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null); - - return { - user, - key, - }; - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts deleted file mode 100644 index 6fc75a0397..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApDeliverManagerService.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; -import { QueueService } from '@/core/QueueService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -interface IRecipe { - type: string; -} - -interface IFollowersRecipe extends IRecipe { - type: 'Followers'; -} - -interface IDirectRecipe extends IRecipe { - type: 'Direct'; - to: IRemoteUser; -} - -const isFollowers = (recipe: any): recipe is IFollowersRecipe => - recipe.type === 'Followers'; - -const isDirect = (recipe: any): recipe is IDirectRecipe => - recipe.type === 'Direct'; - -@Injectable() -export class ApDeliverManagerService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, - private queueService: QueueService, - ) { - } - - /** - * Deliver activity to followers - * @param activity Activity - * @param from Followee - */ - public async deliverToFollowers(actor: { id: ILocalUser['id']; host: null; }, activity: any) { - const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - actor, - activity, - ); - manager.addFollowersRecipe(); - await manager.execute(); - } - - /** - * Deliver activity to user - * @param activity Activity - * @param to Target user - */ - public async deliverToUser(actor: { id: ILocalUser['id']; host: null; }, activity: any, to: IRemoteUser) { - const manager = new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - actor, - activity, - ); - manager.addDirectRecipe(to); - await manager.execute(); - } - - public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) { - return new DeliverManager( - this.userEntityService, - this.followingsRepository, - this.queueService, - - actor, - activity, - ); - } -} - -class DeliverManager { - private actor: { id: User['id']; host: null; }; - private activity: any; - private recipes: IRecipe[] = []; - - /** - * Constructor - * @param actor Actor - * @param activity Activity to deliver - */ - constructor( - private userEntityService: UserEntityService, - private followingsRepository: FollowingsRepository, - private queueService: QueueService, - - actor: { id: User['id']; host: null; }, - activity: any, - ) { - this.actor = actor; - this.activity = activity; - } - - /** - * Add recipe for followers deliver - */ - public addFollowersRecipe() { - const deliver = { - type: 'Followers', - } as IFollowersRecipe; - - this.addRecipe(deliver); - } - - /** - * Add recipe for direct deliver - * @param to To - */ - public addDirectRecipe(to: IRemoteUser) { - const recipe = { - type: 'Direct', - to, - } as IDirectRecipe; - - this.addRecipe(recipe); - } - - /** - * Add recipe - * @param recipe Recipe - */ - public addRecipe(recipe: IRecipe) { - this.recipes.push(recipe); - } - - /** - * Execute delivers - */ - public async execute() { - if (!this.userEntityService.isLocalUser(this.actor)) return; - - const inboxes = new Set(); - - /* - build inbox list - - Process follower recipes first to avoid duplication when processing - direct recipes later. - */ - if (this.recipes.some(r => isFollowers(r))) { - // followers deliver - // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう - // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? - const followers = await this.followingsRepository.find({ - where: { - followeeId: this.actor.id, - followerHost: Not(IsNull()), - }, - select: { - followerSharedInbox: true, - followerInbox: true, - }, - }) as { - followerSharedInbox: string | null; - followerInbox: string; - }[]; - - for (const following of followers) { - const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.add(inbox); - } - } - - this.recipes.filter((recipe): recipe is IDirectRecipe => - // followers recipes have already been processed - isDirect(recipe) - // check that shared inbox has not been added yet - && !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox)) - // check that they actually have an inbox - && recipe.to.inbox != null, - ) - .forEach(recipe => inboxes.add(recipe.to.inbox!)); - - // deliver - for (const inbox of inboxes) { - this.queueService.deliver(this.actor, this.activity, inbox); - } - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApInboxService.ts b/packages/backend/src/core/remote/activitypub/ApInboxService.ts deleted file mode 100644 index 3da384ec2d..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApInboxService.ts +++ /dev/null @@ -1,740 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { CacheableRemoteUser } from '@/models/entities/User.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; -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 { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; -import { IdService } from '@/core/IdService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { MessagingService } from '@/core/MessagingService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; -import { ApNoteService } from './models/ApNoteService.js'; -import { ApLoggerService } from './ApLoggerService.js'; -import { ApDbResolverService } from './ApDbResolverService.js'; -import { ApResolverService } from './ApResolverService.js'; -import { ApAudienceService } from './ApAudienceService.js'; -import { ApPersonService } from './models/ApPersonService.js'; -import { ApQuestionService } from './models/ApQuestionService.js'; -import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IRead, IReject, IRemove, IUndo, IUpdate } from './type.js'; - -@Injectable() -export class ApInboxService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.messagingMessagesRepository) - private messagingMessagesRepository: MessagingMessagesRepository, - - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, - private utilityService: UtilityService, - private idService: IdService, - private metaService: MetaService, - private userFollowingService: UserFollowingService, - private apAudienceService: ApAudienceService, - private reactionService: ReactionService, - private relayService: RelayService, - private notePiningService: NotePiningService, - private userBlockingService: UserBlockingService, - private noteCreateService: NoteCreateService, - private noteDeleteService: NoteDeleteService, - private appLockService: AppLockService, - private apResolverService: ApResolverService, - private apDbResolverService: ApDbResolverService, - private apLoggerService: ApLoggerService, - private apNoteService: ApNoteService, - private apPersonService: ApPersonService, - private apQuestionService: ApQuestionService, - private queueService: QueueService, - private messagingService: MessagingService, - ) { - this.logger = this.apLoggerService.logger; - } - - public async performActivity(actor: CacheableRemoteUser, activity: IObject) { - 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); - } catch (err) { - if (err instanceof Error || typeof err === 'string') { - this.logger.error(err); - } - } - } - } else { - await this.performOneActivity(actor, activity); - } - - // ついでにリモートユーザーの情報が古かったら更新しておく - if (actor.uri) { - if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - setImmediate(() => { - this.apPersonService.updatePerson(actor.uri!); - }); - } - } - } - - public async performOneActivity(actor: CacheableRemoteUser, activity: IObject): Promise { - if (actor.isSuspended) return; - - if (isCreate(activity)) { - await this.create(actor, activity); - } else if (isDelete(activity)) { - await this.delete(actor, activity); - } else if (isUpdate(activity)) { - await this.update(actor, activity); - } else if (isRead(activity)) { - await this.read(actor, activity); - } else if (isFollow(activity)) { - await this.follow(actor, activity); - } else if (isAccept(activity)) { - await this.accept(actor, activity); - } else if (isReject(activity)) { - await this.reject(actor, activity); - } else if (isAdd(activity)) { - await this.add(actor, activity).catch(err => this.logger.error(err)); - } else if (isRemove(activity)) { - await this.remove(actor, activity).catch(err => this.logger.error(err)); - } else if (isAnnounce(activity)) { - await this.announce(actor, activity); - } else if (isLike(activity)) { - await this.like(actor, activity); - } else if (isUndo(activity)) { - await this.undo(actor, activity); - } else if (isBlock(activity)) { - await this.block(actor, activity); - } else if (isFlag(activity)) { - await this.flag(actor, activity); - } else { - this.logger.warn(`unrecognized activity type: ${(activity as any).type}`); - } - } - - private async follow(actor: CacheableRemoteUser, activity: IFollow): Promise { - const followee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (followee == null) { - return 'skip: followee not found'; - } - - if (followee.host != null) { - return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; - } - - await this.userFollowingService.follow(actor, followee, activity.id); - return 'ok'; - } - - private async like(actor: CacheableRemoteUser, activity: ILike): Promise { - const targetUri = getApId(activity.object); - - const note = await this.apNoteService.fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); - - return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { - if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { - return 'skip: already reacted'; - } else { - throw err; - } - }).then(() => 'ok'); - } - - private async read(actor: CacheableRemoteUser, activity: IRead): Promise { - const id = await getApId(activity.object); - - if (!this.utilityService.isSelfHost(this.utilityService.extractDbHost(id))) { - return `skip: Read to foreign host (${id})`; - } - - const messageId = id.split('/').pop(); - - const message = await this.messagingMessagesRepository.findOneBy({ id: messageId }); - if (message == null) { - return 'skip: message not found'; - } - - if (actor.id !== message.recipientId) { - return 'skip: actor is not a message recipient'; - } - - await this.messagingService.readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); - return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; - } - - private async accept(actor: CacheableRemoteUser, activity: IAccept): Promise { - const uri = activity.id ?? activity; - - this.logger.info(`Accept: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(err => { - this.logger.error(`Resolution failed: ${err}`); - throw err; - }); - - if (isFollow(object)) return await this.acceptFollow(actor, object); - - return `skip: Unknown Accept type: ${getApType(object)}`; - } - - private async acceptFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - - if (follower == null) { - return 'skip: follower not found'; - } - - if (follower.host != null) { - return 'skip: follower is not a local user'; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await this.relayService.relayAccepted(match[1]); - } - - await this.userFollowingService.acceptFollowRequest(actor, follower); - return 'ok'; - } - - private async add(actor: CacheableRemoteUser, activity: IAdd): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await this.notePiningService.addPinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); - } - - private async announce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { - const uri = getApId(activity); - - this.logger.info(`Announce: ${uri}`); - - const targetUri = getApId(activity.object); - - this.announceNote(actor, activity, targetUri); - } - - private async announceNote(actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise { - const uri = getApId(activity); - - if (actor.isSuspended) { - return; - } - - // アナウンス先をブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) return; - - const unlock = await this.appLockService.getApLock(uri); - - try { - // 既に同じURIを持つものが登録されていないかチェック - const exist = await this.apNoteService.fetchNote(uri); - if (exist) { - return; - } - - // Announce対象をresolve - let renote; - try { - renote = await this.apNoteService.resolveNote(targetUri); - if (renote == null) throw new Error('announce target is null'); - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (err.isClientError) { - this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); - return; - } - - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`); - } - throw err; - } - - if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - this.logger.warn('skip: invalid actor for this activity'); - return; - } - - this.logger.info(`Creating the (Re)Note: ${uri}`); - - const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); - - await this.noteCreateService.create(actor, { - createdAt: activity.published ? new Date(activity.published) : null, - renote, - visibility: activityAudience.visibility, - visibleUsers: activityAudience.visibleUsers, - uri, - }); - } finally { - unlock(); - } - } - - private async block(actor: CacheableRemoteUser, activity: IBlock): Promise { - // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず - - const blockee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (blockee == null) { - return 'skip: blockee not found'; - } - - if (blockee.host != null) { - return 'skip: ブロックしようとしているユーザーはローカルユーザーではありません'; - } - - await this.userBlockingService.block(await this.usersRepository.findOneByOrFail({ id: actor.id }), await this.usersRepository.findOneByOrFail({ id: blockee.id })); - return 'ok'; - } - - private async create(actor: CacheableRemoteUser, activity: ICreate): Promise { - const uri = getApId(activity); - - this.logger.info(`Create: ${uri}`); - - // copy audiences between activity <=> object. - if (typeof activity.object === 'object') { - const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); - const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); - - activity.to = to; - activity.cc = cc; - activity.object.to = to; - activity.object.cc = cc; - } - - // If there is no attributedTo, use Activity actor. - if (typeof activity.object === 'object' && !activity.object.attributedTo) { - activity.object.attributedTo = activity.actor; - } - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isPost(object)) { - this.createNote(resolver, actor, object, false, activity); - } else { - this.logger.warn(`Unknown type: ${getApType(object)}`); - } - } - - private async createNote(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise { - const uri = getApId(note); - - if (typeof note === 'object') { - if (actor.uri !== note.attributedTo) { - return 'skip: actor.uri !== note.attributedTo'; - } - - if (typeof note.id === 'string') { - if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { - return 'skip: host in actor.uri !== note.id'; - } - } - } - - const unlock = await this.appLockService.getApLock(uri); - - try { - const exist = await this.apNoteService.fetchNote(note); - if (exist) return 'skip: note exists'; - - await this.apNoteService.createNote(note, resolver, silent); - return 'ok'; - } catch (err) { - if (err instanceof StatusError && err.isClientError) { - return `skip ${err.statusCode}`; - } else { - throw err; - } - } finally { - unlock(); - } - } - - private async delete(actor: CacheableRemoteUser, activity: IDelete): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - // 削除対象objectのtype - let formerType: string | undefined; - - if (typeof activity.object === 'string') { - // typeが不明だけど、どうせ消えてるのでremote resolveしない - formerType = undefined; - } else { - const object = activity.object as IObject; - if (isTombstone(object)) { - formerType = toSingle(object.formerType); - } else { - formerType = toSingle(object.type); - } - } - - const uri = getApId(activity.object); - - // type不明でもactorとobjectが同じならばそれはPersonに違いない - if (!formerType && actor.uri === uri) { - formerType = 'Person'; - } - - // それでもなかったらおそらくNote - if (!formerType) { - formerType = 'Note'; - } - - if (validPost.includes(formerType)) { - return await this.deleteNote(actor, uri); - } else if (validActor.includes(formerType)) { - return await this.deleteActor(actor, uri); - } else { - return `Unknown type ${formerType}`; - } - } - - private async deleteActor(actor: CacheableRemoteUser, uri: string): Promise { - this.logger.info(`Deleting the Actor: ${uri}`); - - if (actor.uri !== uri) { - return `skip: delete actor ${actor.uri} !== ${uri}`; - } - - const user = await this.usersRepository.findOneByOrFail({ id: actor.id }); - if (user.isDeleted) { - this.logger.info('skip: already deleted'); - } - - const job = await this.queueService.createDeleteAccountJob(actor); - - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - - return `ok: queued ${job.name} ${job.id}`; - } - - private async deleteNote(actor: CacheableRemoteUser, uri: string): Promise { - this.logger.info(`Deleting the Note: ${uri}`); - - const unlock = await this.appLockService.getApLock(uri); - - try { - const note = await this.apDbResolverService.getNoteFromApId(uri); - - if (note == null) { - const message = await this.apDbResolverService.getMessageFromApId(uri); - if (message == null) return 'message not found'; - - if (message.userId !== actor.id) { - return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; - } - - await this.messagingService.deleteMessage(message); - - return 'ok: message deleted'; - } - - if (note.userId !== actor.id) { - return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; - } - - await this.noteDeleteService.delete(actor, note); - return 'ok: note deleted'; - } finally { - unlock(); - } - } - - private async flag(actor: CacheableRemoteUser, activity: IFlag): Promise { - // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので - // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する - const uris = getApIds(activity.object); - - const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!); - const users = await this.usersRepository.findBy({ - id: In(userIds), - }); - if (users.length < 1) return 'skip'; - - await this.abuseUserReportsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - targetUserId: users[0].id, - targetUserHost: users[0].host, - reporterId: actor.id, - reporterHost: actor.host, - comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, - }); - - return 'ok'; - } - - private async reject(actor: CacheableRemoteUser, activity: IReject): Promise { - const uri = activity.id ?? activity; - - this.logger.info(`Reject: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await this.rejectFollow(actor, object); - - return `skip: Unknown Reject type: ${getApType(object)}`; - } - - private async rejectFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { - // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - - const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - - if (follower == null) { - return 'skip: follower not found'; - } - - if (!this.userEntityService.isLocalUser(follower)) { - return 'skip: follower is not a local user'; - } - - // relay - const match = activity.id?.match(/follow-relay\/(\w+)/); - if (match) { - return await this.relayService.relayRejected(match[1]); - } - - await this.userFollowingService.remoteReject(actor, follower); - return 'ok'; - } - - private async remove(actor: CacheableRemoteUser, activity: IRemove): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - if (activity.target == null) { - throw new Error('target is null'); - } - - if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); - await this.notePiningService.removePinned(actor, note.id); - return; - } - - throw new Error(`unknown target: ${activity.target}`); - } - - private async undo(actor: CacheableRemoteUser, activity: IUndo): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { - throw new Error('invalid actor'); - } - - const uri = activity.id ?? activity; - - this.logger.info(`Undo: ${uri}`); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isFollow(object)) return await this.undoFollow(actor, object); - if (isBlock(object)) return await this.undoBlock(actor, object); - if (isLike(object)) return await this.undoLike(actor, object); - if (isAnnounce(object)) return await this.undoAnnounce(actor, object); - if (isAccept(object)) return await this.undoAccept(actor, object); - - return `skip: unknown object type ${getApType(object)}`; - } - - private async undoAccept(actor: CacheableRemoteUser, activity: IAccept): Promise { - const follower = await this.apDbResolverService.getUserFromApId(activity.object); - if (follower == null) { - return 'skip: follower not found'; - } - - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: actor.id, - }); - - if (following) { - await this.userFollowingService.unfollow(follower, actor); - return 'ok: unfollowed'; - } - - return 'skip: フォローされていない'; - } - - private async undoAnnounce(actor: CacheableRemoteUser, activity: IAnnounce): Promise { - const uri = getApId(activity); - - const note = await this.notesRepository.findOneBy({ - uri, - userId: actor.id, - }); - - if (!note) return 'skip: no such Announce'; - - await this.noteDeleteService.delete(actor, note); - return 'ok: deleted'; - } - - private async undoBlock(actor: CacheableRemoteUser, activity: IBlock): Promise { - const blockee = await this.apDbResolverService.getUserFromApId(activity.object); - - if (blockee == null) { - return 'skip: blockee not found'; - } - - if (blockee.host != null) { - return 'skip: ブロック解除しようとしているユーザーはローカルユーザーではありません'; - } - - await this.userBlockingService.unblock(await this.usersRepository.findOneByOrFail({ id: actor.id }), blockee); - return 'ok'; - } - - private async undoFollow(actor: CacheableRemoteUser, activity: IFollow): Promise { - const followee = await this.apDbResolverService.getUserFromApId(activity.object); - if (followee == null) { - return 'skip: followee not found'; - } - - if (followee.host != null) { - return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません'; - } - - const req = await this.followRequestsRepository.findOneBy({ - followerId: actor.id, - followeeId: followee.id, - }); - - const following = await this.followingsRepository.findOneBy({ - followerId: actor.id, - followeeId: followee.id, - }); - - if (req) { - await this.userFollowingService.cancelFollowRequest(followee, actor); - return 'ok: follow request canceled'; - } - - if (following) { - await this.userFollowingService.unfollow(actor, followee); - return 'ok: unfollowed'; - } - - return 'skip: リクエストもフォローもされていない'; - } - - private async undoLike(actor: CacheableRemoteUser, activity: ILike): Promise { - const targetUri = getApId(activity.object); - - const note = await this.apNoteService.fetchNote(targetUri); - if (!note) return `skip: target note not found ${targetUri}`; - - await this.reactionService.delete(actor, note).catch(e => { - if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; - throw e; - }); - - return 'ok'; - } - - private async update(actor: CacheableRemoteUser, activity: IUpdate): Promise { - if ('actor' in activity && actor.uri !== activity.actor) { - return 'skip: invalid actor'; - } - - this.logger.debug('Update'); - - const resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(activity.object).catch(e => { - this.logger.error(`Resolution failed: ${e}`); - throw e; - }); - - if (isActor(object)) { - await this.apPersonService.updatePerson(actor.uri!, resolver, object); - return 'ok: Person updated'; - } else if (getApType(object) === 'Question') { - await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); - return 'ok: Question updated'; - } else { - return `skip: Unknown type: ${getApType(object)}`; - } - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApLoggerService.ts b/packages/backend/src/core/remote/activitypub/ApLoggerService.ts deleted file mode 100644 index 82fd7c5f18..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApLoggerService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type Logger from '@/logger.js'; -import { RemoteLoggerService } from '@/core/remote/RemoteLoggerService.js'; - -@Injectable() -export class ApLoggerService { - public logger: Logger; - - constructor( - private remoteLoggerService: RemoteLoggerService, - ) { - this.logger = this.remoteLoggerService.logger.createSubLogger('ap', 'magenta'); - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApMfmService.ts b/packages/backend/src/core/remote/activitypub/ApMfmService.ts deleted file mode 100644 index 8804fde64a..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApMfmService.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import * as mfm from 'mfm-js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { MfmService } from '@/core/MfmService.js'; -import type { Note } from '@/models/entities/Note.js'; -import { extractApHashtagObjects } from './models/tag.js'; -import type { IObject } from './type.js'; - -@Injectable() -export class ApMfmService { - constructor( - @Inject(DI.config) - private config: Config, - - private mfmService: MfmService, - ) { - } - - public htmlToMfm(html: string, tag?: IObject | IObject[]) { - const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); - - return this.mfmService.fromHtml(html, hashtagNames); - } - - public getNoteHtml(note: Note) { - if (!note.text) return ''; - return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApRendererService.ts b/packages/backend/src/core/remote/activitypub/ApRendererService.ts deleted file mode 100644 index 38a92567c3..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApRendererService.ts +++ /dev/null @@ -1,703 +0,0 @@ -import { createPublicKey } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import * as mfm from 'mfm-js'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; -import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; -import type { Blocking } from '@/models/entities/Blocking.js'; -import type { Relay } from '@/models/entities/Relay.js'; -import type { DriveFile } from '@/models/entities/DriveFile.js'; -import type { NoteReaction } from '@/models/entities/NoteReaction.js'; -import type { Emoji } from '@/models/entities/Emoji.js'; -import type { Poll } from '@/models/entities/Poll.js'; -import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; -import type { PollVote } from '@/models/entities/PollVote.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; -import { MfmService } from '@/core/MfmService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import type { UserKeypair } from '@/models/entities/UserKeypair.js'; -import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; -import { LdSignatureService } from './LdSignatureService.js'; -import { ApMfmService } from './ApMfmService.js'; -import type { IActivity, IObject } from './type.js'; -import type { IIdentifier } from './models/identifier.js'; - -@Injectable() -export class ApRendererService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private ldSignatureService: LdSignatureService, - private userKeypairStoreService: UserKeypairStoreService, - private apMfmService: ApMfmService, - private mfmService: MfmService, - ) { - } - - public renderAccept(object: any, user: { id: User['id']; host: null }) { - return { - type: 'Accept', - actor: `${this.config.url}/users/${user.id}`, - object, - }; - } - - public renderAdd(user: ILocalUser, target: any, object: any) { - return { - type: 'Add', - actor: `${this.config.url}/users/${user.id}`, - target, - object, - }; - } - - public renderAnnounce(object: any, note: Note) { - const attributedTo = `${this.config.url}/users/${note.userId}`; - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`]; - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public']; - } else { - return null; - } - - return { - id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, - type: 'Announce', - published: note.createdAt.toISOString(), - to, - cc, - object, - }; - } - - /** - * Renders a block into its ActivityPub representation. - * - * @param block The block to be rendered. The blockee relation must be loaded. - */ - public renderBlock(block: Blocking) { - if (block.blockee?.uri == null) { - throw new Error('renderBlock: missing blockee uri'); - } - - return { - type: 'Block', - id: `${this.config.url}/blocks/${block.id}`, - actor: `${this.config.url}/users/${block.blockerId}`, - object: block.blockee.uri, - }; - } - - public renderCreate(object: any, note: Note) { - const activity = { - id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, - type: 'Create', - published: note.createdAt.toISOString(), - object, - } as any; - - if (object.to) activity.to = object.to; - if (object.cc) activity.cc = object.cc; - - return activity; - } - - public renderDelete(object: any, user: { id: User['id']; host: null }) { - return { - type: 'Delete', - actor: `${this.config.url}/users/${user.id}`, - object, - published: new Date().toISOString(), - }; - } - - public renderDocument(file: DriveFile) { - return { - type: 'Document', - mediaType: file.type, - url: this.driveFileEntityService.getPublicUrl(file), - name: file.comment, - }; - } - - public renderEmoji(emoji: Emoji) { - return { - id: `${this.config.url}/emojis/${emoji.name}`, - type: 'Emoji', - name: `:${emoji.name}:`, - updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, - icon: { - type: 'Image', - mediaType: emoji.type ?? 'image/png', - url: emoji.publicUrl ?? emoji.originalUrl, // ?? emoji.originalUrl してるのは後方互換性のため - }, - }; - } - - // to anonymise reporters, the reporting actor must be a system user - // object has to be a uri or array of uris - public renderFlag(user: ILocalUser, object: [string], content: string) { - return { - type: 'Flag', - actor: `${this.config.url}/users/${user.id}`, - content, - object, - }; - } - - public renderFollowRelay(relay: Relay, relayActor: ILocalUser) { - const follow = { - id: `${this.config.url}/activities/follow-relay/${relay.id}`, - type: 'Follow', - actor: `${this.config.url}/users/${relayActor.id}`, - object: 'https://www.w3.org/ns/activitystreams#Public', - }; - - return follow; - } - - /** - * Convert (local|remote)(Follower|Followee)ID to URL - * @param id Follower|Followee ID - */ - public async renderFollowUser(id: User['id']) { - const user = await this.usersRepository.findOneByOrFail({ id: id }); - return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; - } - - public renderFollow( - follower: { id: User['id']; host: User['host']; uri: User['host'] }, - followee: { id: User['id']; host: User['host']; uri: User['host'] }, - requestId?: string, - ) { - const follow = { - id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, - type: 'Follow', - actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri, - object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri, - } as any; - - return follow; - } - - public renderHashtag(tag: string) { - return { - type: 'Hashtag', - href: `${this.config.url}/tags/${encodeURIComponent(tag)}`, - name: `#${tag}`, - }; - } - - public renderImage(file: DriveFile) { - return { - type: 'Image', - url: this.driveFileEntityService.getPublicUrl(file), - sensitive: file.isSensitive, - name: file.comment, - }; - } - - public renderKey(user: ILocalUser, key: UserKeypair, postfix?: string) { - return { - id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, - type: 'Key', - owner: `${this.config.url}/users/${user.id}`, - publicKeyPem: createPublicKey(key.publicKey).export({ - type: 'spki', - format: 'pem', - }), - }; - } - - public async renderLike(noteReaction: NoteReaction, note: { uri: string | null }) { - const reaction = noteReaction.reaction; - - const object = { - type: 'Like', - id: `${this.config.url}/likes/${noteReaction.id}`, - actor: `${this.config.url}/users/${noteReaction.userId}`, - object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`, - content: reaction, - _misskey_reaction: reaction, - } as any; - - if (reaction.startsWith(':')) { - const name = reaction.replace(/:/g, ''); - const emoji = await this.emojisRepository.findOneBy({ - name, - host: IsNull(), - }); - - if (emoji) object.tag = [this.renderEmoji(emoji)]; - } - - return object; - } - - public renderMention(mention: User) { - return { - type: 'Mention', - href: this.userEntityService.isRemoteUser(mention) ? mention.uri : `${this.config.url}/users/${(mention as ILocalUser).id}`, - name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, - }; - } - - public async renderNote(note: Note, dive = true, isTalk = false): Promise { - const getPromisedFiles = async (ids: string[]) => { - if (!ids || ids.length === 0) return []; - const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; - }; - - let inReplyTo; - let inReplyToNote: Note | null; - - if (note.replyId) { - inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - - if (inReplyToNote != null) { - const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - - if (inReplyToUser != null) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await this.renderNote(inReplyToNote, false); - } else { - inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; - } - } - } - } - } else { - inReplyTo = null; - } - - let quote; - - if (note.renoteId) { - const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - - if (renote) { - quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; - } - } - - const attributedTo = `${this.config.url}/users/${note.userId}`; - - const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`].concat(mentions); - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = mentions; - } else { - to = mentions; - } - - const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ - id: In(note.mentions), - }) : []; - - const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u)); - - const files = await getPromisedFiles(note.fileIds); - - const text = note.text ?? ''; - let poll: Poll | null = null; - - if (note.hasPoll) { - poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - } - - let apText = text; - - if (quote) { - apText += `\n\nRE: ${quote}`; - } - - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: apText, - })); - - const emojis = await this.getEmojis(note.emojis); - const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); - - const tag = [ - ...hashtagTags, - ...mentionTags, - ...apemojis, - ]; - - const asPoll = poll ? { - type: 'Question', - content: this.apMfmService.getNoteHtml(Object.assign({}, note, { - text: text, - })), - [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - type: 'Note', - name: text, - replies: { - type: 'Collection', - totalItems: poll!.votes[i], - }, - })), - } : {}; - - const asTalk = isTalk ? { - _misskey_talk: true, - } : {}; - - return { - id: `${this.config.url}/notes/${note.id}`, - type: 'Note', - attributedTo, - summary: summary ?? undefined, - content: content ?? undefined, - _misskey_content: text, - source: { - content: text, - mediaType: 'text/x.misskeymarkdown', - }, - _misskey_quote: quote, - quoteUrl: quote, - published: note.createdAt.toISOString(), - to, - cc, - inReplyTo, - attachment: files.map(x => this.renderDocument(x)), - sensitive: note.cw != null || files.some(file => file.isSensitive), - tag, - ...asPoll, - ...asTalk, - }; - } - - public async renderPerson(user: ILocalUser) { - const id = `${this.config.url}/users/${user.id}`; - const isSystem = !!user.username.match(/\./); - - const [avatar, banner, profile] = await Promise.all([ - user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined), - user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined), - this.userProfilesRepository.findOneByOrFail({ userId: user.id }), - ]); - - const attachment: { - type: 'PropertyValue', - name: string, - value: string, - identifier?: IIdentifier, - }[] = []; - - if (profile.fields) { - for (const field of profile.fields) { - attachment.push({ - type: 'PropertyValue', - name: field.name, - value: (field.value != null && field.value.match(/^https?:/)) - ? `${new URL(field.value).href}` - : field.value, - }); - } - } - - const emojis = await this.getEmojis(user.emojis); - const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); - - const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag)); - - const tag = [ - ...apemojis, - ...hashtagTags, - ]; - - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - - const person = { - type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', - id, - inbox: `${id}/inbox`, - outbox: `${id}/outbox`, - followers: `${id}/followers`, - following: `${id}/following`, - featured: `${id}/collections/featured`, - sharedInbox: `${this.config.url}/inbox`, - endpoints: { sharedInbox: `${this.config.url}/inbox` }, - url: `${this.config.url}/@${user.username}`, - preferredUsername: user.username, - name: user.name, - summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, - icon: avatar ? this.renderImage(avatar) : null, - image: banner ? this.renderImage(banner) : null, - tag, - manuallyApprovesFollowers: user.isLocked, - discoverable: !!user.isExplorable, - publicKey: this.renderKey(user, keypair, '#main-key'), - isCat: user.isCat, - attachment: attachment.length ? attachment : undefined, - } as any; - - if (profile.birthday) { - person['vcard:bday'] = profile.birthday; - } - - if (profile.location) { - person['vcard:Address'] = profile.location; - } - - return person; - } - - public async renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { - const question = { - type: 'Question', - id: `${this.config.url}/questions/${note.id}`, - actor: `${this.config.url}/users/${user.id}`, - content: note.text ?? '', - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - name: text, - _misskey_votes: poll.votes[i], - replies: { - type: 'Collection', - totalItems: poll.votes[i], - }, - })), - }; - - return question; - } - - public renderRead(user: { id: User['id'] }, message: MessagingMessage) { - return { - type: 'Read', - actor: `${this.config.url}/users/${user.id}`, - object: message.uri, - }; - } - - public renderReject(object: any, user: { id: User['id'] }) { - return { - type: 'Reject', - actor: `${this.config.url}/users/${user.id}`, - object, - }; - } - - public renderRemove(user: { id: User['id'] }, target: any, object: any) { - return { - type: 'Remove', - actor: `${this.config.url}/users/${user.id}`, - target, - object, - }; - } - - public renderTombstone(id: string) { - return { - id, - type: 'Tombstone', - }; - } - - public renderUndo(object: any, user: { id: User['id'] }) { - if (object == null) return null; - const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; - - return { - type: 'Undo', - ...(id ? { id } : {}), - actor: `${this.config.url}/users/${user.id}`, - object, - published: new Date().toISOString(), - }; - } - - public renderUpdate(object: any, user: { id: User['id'] }) { - const activity = { - id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${this.config.url}/users/${user.id}`, - type: 'Update', - to: ['https://www.w3.org/ns/activitystreams#Public'], - object, - published: new Date().toISOString(), - } as any; - - return activity; - } - - public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser) { - return { - id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${this.config.url}/users/${user.id}`, - type: 'Create', - to: [pollOwner.uri], - published: new Date().toISOString(), - object: { - id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, - type: 'Note', - attributedTo: `${this.config.url}/users/${user.id}`, - to: [pollOwner.uri], - inReplyTo: note.uri, - name: poll.choices[vote.choice], - }, - }; - } - - public renderActivity(x: any): IActivity | null { - if (x == null) return null; - - if (typeof x === 'object' && x.id == null) { - x.id = `${this.config.url}/${uuid()}`; - } - - return Object.assign({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - // as non-standards - manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', - sensitive: 'as:sensitive', - Hashtag: 'as:Hashtag', - quoteUrl: 'as:quoteUrl', - // Mastodon - toot: 'http://joinmastodon.org/ns#', - Emoji: 'toot:Emoji', - featured: 'toot:featured', - discoverable: 'toot:discoverable', - // schema - schema: 'http://schema.org#', - PropertyValue: 'schema:PropertyValue', - value: 'schema:value', - // Misskey - misskey: 'https://misskey-hub.net/ns#', - '_misskey_content': 'misskey:_misskey_content', - '_misskey_quote': 'misskey:_misskey_quote', - '_misskey_reaction': 'misskey:_misskey_reaction', - '_misskey_votes': 'misskey:_misskey_votes', - '_misskey_talk': 'misskey:_misskey_talk', - 'isCat': 'misskey:isCat', - // vcard - vcard: 'http://www.w3.org/2006/vcard/ns#', - }, - ], - }, x); - } - - public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - - const ldSignature = this.ldSignatureService.use(); - ldSignature.debug = false; - activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); - - return activity; - } - - /** - * Render OrderedCollectionPage - * @param id URL of self - * @param totalItems Number of total items - * @param orderedItems Items - * @param partOf URL of base - * @param prev URL of prev page (optional) - * @param next URL of next page (optional) - */ - public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { - const page = { - id, - partOf, - type: 'OrderedCollectionPage', - totalItems, - orderedItems, - } as any; - - if (prev) page.prev = prev; - if (next) page.next = next; - - return page; - } - - /** - * Render OrderedCollection - * @param id URL of self - * @param totalItems Total number of items - * @param first URL of first page (optional) - * @param last URL of last page (optional) - * @param orderedItems attached objects (optional) - */ - public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) { - const page: any = { - id, - type: 'OrderedCollection', - totalItems, - }; - - if (first) page.first = first; - if (last) page.last = last; - if (orderedItems) page.orderedItems = orderedItems; - - return page; - } - - private async getEmojis(names: string[]): Promise { - if (names == null || names.length === 0) return []; - - const emojis = await Promise.all( - names.map(name => this.emojisRepository.findOneBy({ - name, - host: IsNull(), - })), - ); - - return emojis.filter(emoji => emoji != null) as Emoji[]; - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApRequestService.ts b/packages/backend/src/core/remote/activitypub/ApRequestService.ts deleted file mode 100644 index baad46d668..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApRequestService.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as crypto from 'node:crypto'; -import { URL } from 'node:url'; -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import type { User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; - -type Request = { - url: string; - method: string; - headers: Record; -}; - -type Signed = { - request: Request; - signingString: string; - signature: string; - signatureHeader: string; -}; - -type PrivateKey = { - privateKeyPem: string; - keyId: string; -}; - -@Injectable() -export class ApRequestService { - constructor( - @Inject(DI.config) - private config: Config, - - private userKeypairStoreService: UserKeypairStoreService, - private httpRequestService: HttpRequestService, - ) { - } - - private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; - - const request: Request = { - url: u.href, - method: 'POST', - headers: this.objectAssignWithLcKey({ - 'Date': new Date().toUTCString(), - 'Host': u.hostname, - 'Content-Type': 'application/activity+json', - 'Digest': digestHeader, - }, args.additionalHeaders), - }; - - const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - - const request: Request = { - url: u.href, - method: 'GET', - headers: this.objectAssignWithLcKey({ - 'Accept': 'application/activity+json, application/ld+json', - 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).hostname, - }, args.additionalHeaders), - }; - - const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { - const signingString = this.genSigningString(request, includeHeaders); - const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); - const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; - - request.headers = this.objectAssignWithLcKey(request.headers, { - Signature: signatureHeader, - }); - - return { - request, - signingString, - signature, - signatureHeader, - }; - } - - private genSigningString(request: Request, includeHeaders: string[]): string { - request.headers = this.lcObjectKey(request.headers); - - const results: string[] = []; - - for (const key of includeHeaders.map(x => x.toLowerCase())) { - if (key === '(request-target)') { - results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); - } else { - results.push(`${key}: ${request.headers[key]}`); - } - } - - return results.join('\n'); - } - - private lcObjectKey(src: Record): Record { - const dst: Record = {}; - for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; - return dst; - } - - private objectAssignWithLcKey(a: Record, b: Record): Record { - return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b)); - } - - public async signedPost(user: { id: User['id'] }, url: string, object: any) { - const body = JSON.stringify(object); - - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - - const req = this.createSignedPost({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, - url, - body, - additionalHeaders: { - 'User-Agent': this.config.userAgent, - }, - }); - - await this.httpRequestService.getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - body, - }); - } - - /** - * Get AP object with http-signature - * @param user http-signature user - * @param url URL to fetch - */ - public async signedGet(url: string, user: { id: User['id'] }) { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); - - const req = this.createSignedGet({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, - url, - additionalHeaders: { - 'User-Agent': this.config.userAgent, - }, - }); - - const res = await this.httpRequestService.getResponse({ - url, - method: req.request.method, - headers: req.request.headers, - }); - - return await res.json(); - } -} diff --git a/packages/backend/src/core/remote/activitypub/ApResolverService.ts b/packages/backend/src/core/remote/activitypub/ApResolverService.ts deleted file mode 100644 index bcdb9383d1..0000000000 --- a/packages/backend/src/core/remote/activitypub/ApResolverService.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { ILocalUser } from '@/models/entities/User.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { DI } from '@/di-symbols.js'; -import { UtilityService } from '@/core/UtilityService.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'; - -@Injectable() -export class ApResolverService { - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - ) { - } - - public createResolver(): Resolver { - return new Resolver( - this.config, - this.usersRepository, - this.notesRepository, - this.pollsRepository, - this.noteReactionsRepository, - this.utilityService, - this.instanceActorService, - this.metaService, - this.apRequestService, - this.httpRequestService, - this.apRendererService, - this.apDbResolverService, - ); - } -} - -export class Resolver { - private history: Set; - private user?: ILocalUser; - - constructor( - private config: Config, - private usersRepository: UsersRepository, - private notesRepository: NotesRepository, - private pollsRepository: PollsRepository, - private noteReactionsRepository: NoteReactionsRepository, - private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private recursionLimit = 100 - ) { - this.history = new Set(); - } - - public getHistory(): string[] { - return Array.from(this.history); - } - - public async resolveCollection(value: string | IObject): Promise { - const collection = typeof value === 'string' - ? await this.resolve(value) - : value; - - if (isCollectionOrOrderedCollection(collection)) { - return collection; - } else { - throw new Error(`unrecognized collection type: ${collection.type}`); - } - } - - public async resolve(value: string | IObject): Promise { - if (value == null) { - throw new Error('resolvee is null (or undefined)'); - } - - if (typeof value !== 'string') { - return value; - } - - if (value.includes('#')) { - // URLs with fragment parts cannot be resolved correctly because - // the fragment part does not get transmitted over HTTP(S). - // Avoid strange behaviour by not trying to resolve these at all. - throw new Error(`cannot resolve URL with fragment: ${value}`); - } - - if (this.history.has(value)) { - throw new Error('cannot resolve already resolved one'); - } - - if (this.history.size > this.recursionLimit) { - throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); - } - - this.history.add(value); - - const host = this.utilityService.extractDbHost(value); - if (this.utilityService.isSelfHost(host)) { - return await this.resolveLocal(value); - } - - const meta = await this.metaService.fetch(); - if (meta.blockedHosts.includes(host)) { - throw new Error('Instance is blocked'); - } - - if (this.config.signToActivityPubGet && !this.user) { - this.user = await this.instanceActorService.getInstanceActor(); - } - - const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) - : await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject; - - if (object == null || ( - Array.isArray(object['@context']) ? - !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : - object['@context'] !== 'https://www.w3.org/ns/activitystreams' - )) { - throw new Error('invalid response'); - } - - return object; - } - - private resolveLocal(url: string): Promise { - const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new Error('resolveLocal: not local'); - - switch (parsed.type) { - case 'notes': - return this.notesRepository.findOneByOrFail({ id: parsed.id }) - .then(note => { - if (parsed.rest === 'activity') { - // this refers to the create activity and not the note itself - return this.apRendererService.renderActivity(this.apRendererService.renderCreate(this.apRendererService.renderNote(note), note)); - } else { - return this.apRendererService.renderNote(note); - } - }); - case 'users': - return this.usersRepository.findOneByOrFail({ id: parsed.id }) - .then(user => this.apRendererService.renderPerson(user as ILocalUser)); - case 'questions': - // Polls are indexed by the note they are attached to. - return Promise.all([ - this.notesRepository.findOneByOrFail({ id: parsed.id }), - this.pollsRepository.findOneByOrFail({ noteId: parsed.id }), - ]) - .then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)); - case 'likes': - return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(reaction => - this.apRendererService.renderActivity(this.apRendererService.renderLike(reaction, { uri: null }))!); - case 'follows': - // rest should be - if (parsed.rest == null || !/^\w+$/.test(parsed.rest)) throw new Error('resolveLocal: invalid follow URI'); - - return Promise.all( - [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), - ) - .then(([follower, followee]) => this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee, url))); - default: - throw new Error(`resolveLocal: type ${parsed.type} unhandled`); - } - } -} diff --git a/packages/backend/src/core/remote/activitypub/LdSignatureService.ts b/packages/backend/src/core/remote/activitypub/LdSignatureService.ts deleted file mode 100644 index ea39f15b2b..0000000000 --- a/packages/backend/src/core/remote/activitypub/LdSignatureService.ts +++ /dev/null @@ -1,147 +0,0 @@ -import * as crypto from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import fetch from 'node-fetch'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { CONTEXTS } from './misc/contexts.js'; - -// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 - -@Injectable() -export class LdSignatureService { - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - public use(): LdSignature { - return new LdSignature(this.httpRequestService); - } -} - -class LdSignature { - public debug = false; - public preLoad = true; - public loderTimeout = 10 * 1000; - - constructor( - private httpRequestService: HttpRequestService, - ) { - } - - public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise { - const options = { - type: 'RsaSignature2017', - creator, - domain, - nonce: crypto.randomBytes(16).toString('hex'), - created: (created ?? new Date()).toISOString(), - } as { - type: string; - creator: string; - domain?: string; - nonce: string; - created: string; - }; - - if (!domain) { - delete options.domain; - } - - const toBeSigned = await this.createVerifyData(data, options); - - const signer = crypto.createSign('sha256'); - signer.update(toBeSigned); - signer.end(); - - const signature = signer.sign(privateKey); - - return { - ...data, - signature: { - ...options, - signatureValue: signature.toString('base64'), - }, - }; - } - - public async verifyRsaSignature2017(data: any, publicKey: string): Promise { - const toBeSigned = await this.createVerifyData(data, data.signature); - const verifier = crypto.createVerify('sha256'); - verifier.update(toBeSigned); - return verifier.verify(publicKey, data.signature.signatureValue, 'base64'); - } - - public async createVerifyData(data: any, options: any) { - const transformedOptions = { - ...options, - '@context': 'https://w3id.org/identity/v1', - }; - delete transformedOptions['type']; - delete transformedOptions['id']; - delete transformedOptions['signatureValue']; - const canonizedOptions = await this.normalize(transformedOptions); - const optionsHash = this.sha256(canonizedOptions.toString()); - const transformedData = { ...data }; - delete transformedData['signature']; - const cannonidedData = await this.normalize(transformedData); - if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); - const documentHash = this.sha256(cannonidedData.toString()); - const verifyData = `${optionsHash}${documentHash}`; - return verifyData; - } - - public async normalize(data: any) { - const customLoader = this.getLoader(); - return 42; - } - - private getLoader() { - return async (url: string): Promise => { - if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`; - - if (this.preLoad) { - if (url in CONTEXTS) { - if (this.debug) console.debug(`HIT: ${url}`); - return { - contextUrl: null, - document: CONTEXTS[url], - documentUrl: url, - }; - } - } - - if (this.debug) console.debug(`MISS: ${url}`); - const document = await this.fetchDocument(url); - return { - contextUrl: null, - document: document, - documentUrl: url, - }; - }; - } - - private async fetchDocument(url: string) { - const json = await fetch(url, { - headers: { - Accept: 'application/ld+json, application/json', - }, - // TODO - //timeout: this.loderTimeout, - agent: u => u.protocol === 'http:' ? this.httpRequestService.httpAgent : this.httpRequestService.httpsAgent, - }).then(res => { - if (!res.ok) { - throw `${res.status} ${res.statusText}`; - } else { - return res.json(); - } - }); - - return json; - } - - public sha256(data: string): string { - const hash = crypto.createHash('sha256'); - hash.update(data); - return hash.digest('hex'); - } -} diff --git a/packages/backend/src/core/remote/activitypub/misc/contexts.ts b/packages/backend/src/core/remote/activitypub/misc/contexts.ts deleted file mode 100644 index aee0d3629c..0000000000 --- a/packages/backend/src/core/remote/activitypub/misc/contexts.ts +++ /dev/null @@ -1,526 +0,0 @@ -/* eslint:disable:quotemark indent */ -const id_v1 = { - '@context': { - 'id': '@id', - 'type': '@type', - - 'cred': 'https://w3id.org/credentials#', - 'dc': 'http://purl.org/dc/terms/', - 'identity': 'https://w3id.org/identity#', - 'perm': 'https://w3id.org/permissions#', - 'ps': 'https://w3id.org/payswarm#', - 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', - 'rdfs': 'http://www.w3.org/2000/01/rdf-schema#', - 'sec': 'https://w3id.org/security#', - 'schema': 'http://schema.org/', - 'xsd': 'http://www.w3.org/2001/XMLSchema#', - - 'Group': 'https://www.w3.org/ns/activitystreams#Group', - - 'claim': { '@id': 'cred:claim', '@type': '@id' }, - 'credential': { '@id': 'cred:credential', '@type': '@id' }, - 'issued': { '@id': 'cred:issued', '@type': 'xsd:dateTime' }, - 'issuer': { '@id': 'cred:issuer', '@type': '@id' }, - 'recipient': { '@id': 'cred:recipient', '@type': '@id' }, - 'Credential': 'cred:Credential', - 'CryptographicKeyCredential': 'cred:CryptographicKeyCredential', - - 'about': { '@id': 'schema:about', '@type': '@id' }, - 'address': { '@id': 'schema:address', '@type': '@id' }, - 'addressCountry': 'schema:addressCountry', - 'addressLocality': 'schema:addressLocality', - 'addressRegion': 'schema:addressRegion', - 'comment': 'rdfs:comment', - 'created': { '@id': 'dc:created', '@type': 'xsd:dateTime' }, - 'creator': { '@id': 'dc:creator', '@type': '@id' }, - 'description': 'schema:description', - 'email': 'schema:email', - 'familyName': 'schema:familyName', - 'givenName': 'schema:givenName', - 'image': { '@id': 'schema:image', '@type': '@id' }, - 'label': 'rdfs:label', - 'name': 'schema:name', - 'postalCode': 'schema:postalCode', - 'streetAddress': 'schema:streetAddress', - 'title': 'dc:title', - 'url': { '@id': 'schema:url', '@type': '@id' }, - 'Person': 'schema:Person', - 'PostalAddress': 'schema:PostalAddress', - 'Organization': 'schema:Organization', - - 'identityService': { '@id': 'identity:identityService', '@type': '@id' }, - 'idp': { '@id': 'identity:idp', '@type': '@id' }, - 'Identity': 'identity:Identity', - - 'paymentProcessor': 'ps:processor', - 'preferences': { '@id': 'ps:preferences', '@type': '@vocab' }, - - 'cipherAlgorithm': 'sec:cipherAlgorithm', - 'cipherData': 'sec:cipherData', - 'cipherKey': 'sec:cipherKey', - 'digestAlgorithm': 'sec:digestAlgorithm', - 'digestValue': 'sec:digestValue', - 'domain': 'sec:domain', - 'expires': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, - 'initializationVector': 'sec:initializationVector', - 'member': { '@id': 'schema:member', '@type': '@id' }, - 'memberOf': { '@id': 'schema:memberOf', '@type': '@id' }, - 'nonce': 'sec:nonce', - 'normalizationAlgorithm': 'sec:normalizationAlgorithm', - 'owner': { '@id': 'sec:owner', '@type': '@id' }, - 'password': 'sec:password', - 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, - 'privateKeyPem': 'sec:privateKeyPem', - 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, - 'publicKeyPem': 'sec:publicKeyPem', - 'publicKeyService': { '@id': 'sec:publicKeyService', '@type': '@id' }, - 'revoked': { '@id': 'sec:revoked', '@type': 'xsd:dateTime' }, - 'signature': 'sec:signature', - 'signatureAlgorithm': 'sec:signatureAlgorithm', - 'signatureValue': 'sec:signatureValue', - 'CryptographicKey': 'sec:Key', - 'EncryptedMessage': 'sec:EncryptedMessage', - 'GraphSignature2012': 'sec:GraphSignature2012', - 'LinkedDataSignature2015': 'sec:LinkedDataSignature2015', - - 'accessControl': { '@id': 'perm:accessControl', '@type': '@id' }, - 'writePermission': { '@id': 'perm:writePermission', '@type': '@id' }, - }, -}; - -const security_v1 = { - '@context': { - 'id': '@id', - 'type': '@type', - - 'dc': 'http://purl.org/dc/terms/', - 'sec': 'https://w3id.org/security#', - 'xsd': 'http://www.w3.org/2001/XMLSchema#', - - 'EcdsaKoblitzSignature2016': 'sec:EcdsaKoblitzSignature2016', - 'Ed25519Signature2018': 'sec:Ed25519Signature2018', - 'EncryptedMessage': 'sec:EncryptedMessage', - 'GraphSignature2012': 'sec:GraphSignature2012', - 'LinkedDataSignature2015': 'sec:LinkedDataSignature2015', - 'LinkedDataSignature2016': 'sec:LinkedDataSignature2016', - 'CryptographicKey': 'sec:Key', - - 'authenticationTag': 'sec:authenticationTag', - 'canonicalizationAlgorithm': 'sec:canonicalizationAlgorithm', - 'cipherAlgorithm': 'sec:cipherAlgorithm', - 'cipherData': 'sec:cipherData', - 'cipherKey': 'sec:cipherKey', - 'created': { '@id': 'dc:created', '@type': 'xsd:dateTime' }, - 'creator': { '@id': 'dc:creator', '@type': '@id' }, - 'digestAlgorithm': 'sec:digestAlgorithm', - 'digestValue': 'sec:digestValue', - 'domain': 'sec:domain', - 'encryptionKey': 'sec:encryptionKey', - 'expiration': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, - 'expires': { '@id': 'sec:expiration', '@type': 'xsd:dateTime' }, - 'initializationVector': 'sec:initializationVector', - 'iterationCount': 'sec:iterationCount', - 'nonce': 'sec:nonce', - 'normalizationAlgorithm': 'sec:normalizationAlgorithm', - 'owner': { '@id': 'sec:owner', '@type': '@id' }, - 'password': 'sec:password', - 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, - 'privateKeyPem': 'sec:privateKeyPem', - 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, - 'publicKeyBase58': 'sec:publicKeyBase58', - 'publicKeyPem': 'sec:publicKeyPem', - 'publicKeyWif': 'sec:publicKeyWif', - 'publicKeyService': { '@id': 'sec:publicKeyService', '@type': '@id' }, - 'revoked': { '@id': 'sec:revoked', '@type': 'xsd:dateTime' }, - 'salt': 'sec:salt', - 'signature': 'sec:signature', - 'signatureAlgorithm': 'sec:signingAlgorithm', - 'signatureValue': 'sec:signatureValue', - }, -}; - -const activitystreams = { - '@context': { - '@vocab': '_:', - 'xsd': 'http://www.w3.org/2001/XMLSchema#', - 'as': 'https://www.w3.org/ns/activitystreams#', - 'ldp': 'http://www.w3.org/ns/ldp#', - 'vcard': 'http://www.w3.org/2006/vcard/ns#', - 'id': '@id', - 'type': '@type', - 'Accept': 'as:Accept', - 'Activity': 'as:Activity', - 'IntransitiveActivity': 'as:IntransitiveActivity', - 'Add': 'as:Add', - 'Announce': 'as:Announce', - 'Application': 'as:Application', - 'Arrive': 'as:Arrive', - 'Article': 'as:Article', - 'Audio': 'as:Audio', - 'Block': 'as:Block', - 'Collection': 'as:Collection', - 'CollectionPage': 'as:CollectionPage', - 'Relationship': 'as:Relationship', - 'Create': 'as:Create', - 'Delete': 'as:Delete', - 'Dislike': 'as:Dislike', - 'Document': 'as:Document', - 'Event': 'as:Event', - 'Follow': 'as:Follow', - 'Flag': 'as:Flag', - 'Group': 'as:Group', - 'Ignore': 'as:Ignore', - 'Image': 'as:Image', - 'Invite': 'as:Invite', - 'Join': 'as:Join', - 'Leave': 'as:Leave', - 'Like': 'as:Like', - 'Link': 'as:Link', - 'Mention': 'as:Mention', - 'Note': 'as:Note', - 'Object': 'as:Object', - 'Offer': 'as:Offer', - 'OrderedCollection': 'as:OrderedCollection', - 'OrderedCollectionPage': 'as:OrderedCollectionPage', - 'Organization': 'as:Organization', - 'Page': 'as:Page', - 'Person': 'as:Person', - 'Place': 'as:Place', - 'Profile': 'as:Profile', - 'Question': 'as:Question', - 'Reject': 'as:Reject', - 'Remove': 'as:Remove', - 'Service': 'as:Service', - 'TentativeAccept': 'as:TentativeAccept', - 'TentativeReject': 'as:TentativeReject', - 'Tombstone': 'as:Tombstone', - 'Undo': 'as:Undo', - 'Update': 'as:Update', - 'Video': 'as:Video', - 'View': 'as:View', - 'Listen': 'as:Listen', - 'Read': 'as:Read', - 'Move': 'as:Move', - 'Travel': 'as:Travel', - 'IsFollowing': 'as:IsFollowing', - 'IsFollowedBy': 'as:IsFollowedBy', - 'IsContact': 'as:IsContact', - 'IsMember': 'as:IsMember', - 'subject': { - '@id': 'as:subject', - '@type': '@id', - }, - 'relationship': { - '@id': 'as:relationship', - '@type': '@id', - }, - 'actor': { - '@id': 'as:actor', - '@type': '@id', - }, - 'attributedTo': { - '@id': 'as:attributedTo', - '@type': '@id', - }, - 'attachment': { - '@id': 'as:attachment', - '@type': '@id', - }, - 'bcc': { - '@id': 'as:bcc', - '@type': '@id', - }, - 'bto': { - '@id': 'as:bto', - '@type': '@id', - }, - 'cc': { - '@id': 'as:cc', - '@type': '@id', - }, - 'context': { - '@id': 'as:context', - '@type': '@id', - }, - 'current': { - '@id': 'as:current', - '@type': '@id', - }, - 'first': { - '@id': 'as:first', - '@type': '@id', - }, - 'generator': { - '@id': 'as:generator', - '@type': '@id', - }, - 'icon': { - '@id': 'as:icon', - '@type': '@id', - }, - 'image': { - '@id': 'as:image', - '@type': '@id', - }, - 'inReplyTo': { - '@id': 'as:inReplyTo', - '@type': '@id', - }, - 'items': { - '@id': 'as:items', - '@type': '@id', - }, - 'instrument': { - '@id': 'as:instrument', - '@type': '@id', - }, - 'orderedItems': { - '@id': 'as:items', - '@type': '@id', - '@container': '@list', - }, - 'last': { - '@id': 'as:last', - '@type': '@id', - }, - 'location': { - '@id': 'as:location', - '@type': '@id', - }, - 'next': { - '@id': 'as:next', - '@type': '@id', - }, - 'object': { - '@id': 'as:object', - '@type': '@id', - }, - 'oneOf': { - '@id': 'as:oneOf', - '@type': '@id', - }, - 'anyOf': { - '@id': 'as:anyOf', - '@type': '@id', - }, - 'closed': { - '@id': 'as:closed', - '@type': 'xsd:dateTime', - }, - 'origin': { - '@id': 'as:origin', - '@type': '@id', - }, - 'accuracy': { - '@id': 'as:accuracy', - '@type': 'xsd:float', - }, - 'prev': { - '@id': 'as:prev', - '@type': '@id', - }, - 'preview': { - '@id': 'as:preview', - '@type': '@id', - }, - 'replies': { - '@id': 'as:replies', - '@type': '@id', - }, - 'result': { - '@id': 'as:result', - '@type': '@id', - }, - 'audience': { - '@id': 'as:audience', - '@type': '@id', - }, - 'partOf': { - '@id': 'as:partOf', - '@type': '@id', - }, - 'tag': { - '@id': 'as:tag', - '@type': '@id', - }, - 'target': { - '@id': 'as:target', - '@type': '@id', - }, - 'to': { - '@id': 'as:to', - '@type': '@id', - }, - 'url': { - '@id': 'as:url', - '@type': '@id', - }, - 'altitude': { - '@id': 'as:altitude', - '@type': 'xsd:float', - }, - 'content': 'as:content', - 'contentMap': { - '@id': 'as:content', - '@container': '@language', - }, - 'name': 'as:name', - 'nameMap': { - '@id': 'as:name', - '@container': '@language', - }, - 'duration': { - '@id': 'as:duration', - '@type': 'xsd:duration', - }, - 'endTime': { - '@id': 'as:endTime', - '@type': 'xsd:dateTime', - }, - 'height': { - '@id': 'as:height', - '@type': 'xsd:nonNegativeInteger', - }, - 'href': { - '@id': 'as:href', - '@type': '@id', - }, - 'hreflang': 'as:hreflang', - 'latitude': { - '@id': 'as:latitude', - '@type': 'xsd:float', - }, - 'longitude': { - '@id': 'as:longitude', - '@type': 'xsd:float', - }, - 'mediaType': 'as:mediaType', - 'published': { - '@id': 'as:published', - '@type': 'xsd:dateTime', - }, - 'radius': { - '@id': 'as:radius', - '@type': 'xsd:float', - }, - 'rel': 'as:rel', - 'startIndex': { - '@id': 'as:startIndex', - '@type': 'xsd:nonNegativeInteger', - }, - 'startTime': { - '@id': 'as:startTime', - '@type': 'xsd:dateTime', - }, - 'summary': 'as:summary', - 'summaryMap': { - '@id': 'as:summary', - '@container': '@language', - }, - 'totalItems': { - '@id': 'as:totalItems', - '@type': 'xsd:nonNegativeInteger', - }, - 'units': 'as:units', - 'updated': { - '@id': 'as:updated', - '@type': 'xsd:dateTime', - }, - 'width': { - '@id': 'as:width', - '@type': 'xsd:nonNegativeInteger', - }, - 'describes': { - '@id': 'as:describes', - '@type': '@id', - }, - 'formerType': { - '@id': 'as:formerType', - '@type': '@id', - }, - 'deleted': { - '@id': 'as:deleted', - '@type': 'xsd:dateTime', - }, - 'inbox': { - '@id': 'ldp:inbox', - '@type': '@id', - }, - 'outbox': { - '@id': 'as:outbox', - '@type': '@id', - }, - 'following': { - '@id': 'as:following', - '@type': '@id', - }, - 'followers': { - '@id': 'as:followers', - '@type': '@id', - }, - 'streams': { - '@id': 'as:streams', - '@type': '@id', - }, - 'preferredUsername': 'as:preferredUsername', - 'endpoints': { - '@id': 'as:endpoints', - '@type': '@id', - }, - 'uploadMedia': { - '@id': 'as:uploadMedia', - '@type': '@id', - }, - 'proxyUrl': { - '@id': 'as:proxyUrl', - '@type': '@id', - }, - 'liked': { - '@id': 'as:liked', - '@type': '@id', - }, - 'oauthAuthorizationEndpoint': { - '@id': 'as:oauthAuthorizationEndpoint', - '@type': '@id', - }, - 'oauthTokenEndpoint': { - '@id': 'as:oauthTokenEndpoint', - '@type': '@id', - }, - 'provideClientKey': { - '@id': 'as:provideClientKey', - '@type': '@id', - }, - 'signClientKey': { - '@id': 'as:signClientKey', - '@type': '@id', - }, - 'sharedInbox': { - '@id': 'as:sharedInbox', - '@type': '@id', - }, - 'Public': { - '@id': 'as:Public', - '@type': '@id', - }, - 'source': 'as:source', - 'likes': { - '@id': 'as:likes', - '@type': '@id', - }, - 'shares': { - '@id': 'as:shares', - '@type': '@id', - }, - 'alsoKnownAs': { - '@id': 'as:alsoKnownAs', - '@type': '@id', - }, - }, -}; - -export const CONTEXTS: Record = { - 'https://w3id.org/identity/v1': id_v1, - 'https://w3id.org/security/v1': security_v1, - 'https://www.w3.org/ns/activitystreams': activitystreams, -}; diff --git a/packages/backend/src/core/remote/activitypub/models/ApImageService.ts b/packages/backend/src/core/remote/activitypub/models/ApImageService.ts deleted file mode 100644 index 9bf87f19d4..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/ApImageService.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { CacheableRemoteUser } from '@/models/entities/User.js'; -import type { DriveFile } from '@/models/entities/DriveFile.js'; -import { MetaService } from '@/core/MetaService.js'; -import { truncate } from '@/misc/truncate.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; -import { DriveService } from '@/core/DriveService.js'; -import type Logger from '@/logger.js'; -import { ApResolverService } from '../ApResolverService.js'; -import { ApLoggerService } from '../ApLoggerService.js'; - -@Injectable() -export class ApImageService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private metaService: MetaService, - private apResolverService: ApResolverService, - private driveService: DriveService, - private apLoggerService: ApLoggerService, - ) { - this.logger = this.apLoggerService.logger; - } - - /** - * Imageを作成します。 - */ - public async createImage(actor: CacheableRemoteUser, value: any): Promise { - // 投稿者が凍結されていたらスキップ - if (actor.isSuspended) { - throw new Error('actor has been suspended'); - } - - const image = await this.apResolverService.createResolver().resolve(value) as any; - - if (image.url == null) { - throw new Error('invalid image: url not privided'); - } - - this.logger.info(`Creating the Image: ${image.url}`); - - const instance = await this.metaService.fetch(); - - let file = await this.driveService.uploadFromUrl({ - url: image.url, - user: actor, - uri: image.url, - sensitive: image.sensitive, - isLink: !instance.cacheRemoteFiles, - comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), - }); - - if (file.isLink) { - // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 - // URLを更新する - if (file.url !== image.url) { - await this.driveFilesRepository.update({ id: file.id }, { - url: image.url, - uri: image.url, - }); - - file = await this.driveFilesRepository.findOneByOrFail({ id: file.id }); - } - } - - return file; - } - - /** - * Imageを解決します。 - * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - public async resolveImage(actor: CacheableRemoteUser, value: any): Promise { - // TODO - - // リモートサーバーからフェッチしてきて登録 - return await this.createImage(actor, value); - } -} diff --git a/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts b/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts deleted file mode 100644 index 1275e24c62..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import { toArray, unique } from '@/misc/prelude/array.js'; -import type { CacheableUser } from '@/models/entities/User.js'; -import { isMention } from '../type.js'; -import { ApResolverService, Resolver } from '../ApResolverService.js'; -import { ApPersonService } from './ApPersonService.js'; -import type { IObject, IApMention } from '../type.js'; - -@Injectable() -export class ApMentionService { - constructor( - @Inject(DI.config) - private config: Config, - - private apResolverService: ApResolverService, - private apPersonService: ApPersonService, - ) { - } - - public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) { - const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); - - const limit = promiseLimit(2); - const mentionedUsers = (await Promise.all( - hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), - )).filter((x): x is CacheableUser => x != null); - - return mentionedUsers; - } - - public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { - if (tags == null) return []; - return toArray(tags).filter(isMention); - } -} diff --git a/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts b/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts deleted file mode 100644 index 7cf6725a38..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/ApNoteService.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import { DI } from '@/di-symbols.js'; -import type { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js'; -import type { UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { CacheableRemoteUser } from '@/models/entities/User.js'; -import type { Note } from '@/models/entities/Note.js'; -import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import type { Emoji } from '@/models/entities/Emoji.js'; -import { MetaService } from '@/core/MetaService.js'; -import { AppLockService } from '@/core/AppLockService.js'; -import type { DriveFile } from '@/models/entities/DriveFile.js'; -import { NoteCreateService } from '@/core/NoteCreateService.js'; -import type Logger from '@/logger.js'; -import { IdService } from '@/core/IdService.js'; -import { PollService } from '@/core/PollService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { MessagingService } from '@/core/MessagingService.js'; -import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { ApLoggerService } from '../ApLoggerService.js'; -import { ApMfmService } from '../ApMfmService.js'; -import { ApDbResolverService } from '../ApDbResolverService.js'; -import { ApResolverService } from '../ApResolverService.js'; -import { ApAudienceService } from '../ApAudienceService.js'; -import { ApPersonService } from './ApPersonService.js'; -import { extractApHashtags } from './tag.js'; -import { ApMentionService } from './ApMentionService.js'; -import { ApQuestionService } from './ApQuestionService.js'; -import { ApImageService } from './ApImageService.js'; -import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IPost } from '../type.js'; - -@Injectable() -export class ApNoteService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - @Inject(DI.messagingMessagesRepository) - private messagingMessagesRepository: MessagingMessagesRepository, - - private idService: IdService, - private apMfmService: ApMfmService, - private apResolverService: ApResolverService, - - // 循環参照のため / for circular dependency - @Inject(forwardRef(() => ApPersonService)) - private apPersonService: ApPersonService, - - private utilityService: UtilityService, - private apAudienceService: ApAudienceService, - private apMentionService: ApMentionService, - private apImageService: ApImageService, - private apQuestionService: ApQuestionService, - private metaService: MetaService, - private messagingService: MessagingService, - private appLockService: AppLockService, - private pollService: PollService, - private noteCreateService: NoteCreateService, - private apDbResolverService: ApDbResolverService, - private apLoggerService: ApLoggerService, - ) { - this.logger = this.apLoggerService.logger; - } - - public validateNote(object: any, uri: string) { - const expectHost = this.utilityService.extractDbHost(uri); - - if (object == null) { - return new Error('invalid Note: object is null'); - } - - if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); - } - - if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { - return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); - } - - if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); - } - - return null; - } - - /** - * Noteをフェッチします。 - * - * Misskeyに対象のNoteが登録されていればそれを返します。 - */ - public async fetchNote(object: string | IObject): Promise { - return await this.apDbResolverService.getNoteFromApId(object); - } - - /** - * Noteを作成します。 - */ - public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object: any = 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: value, - object: object, - }); - throw new Error('invalid note'); - } - - const note: IPost = object; - - this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - - this.logger.info(`Creating the Note: ${note.id}`); - - // 投稿者をフェッチ - const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as CacheableRemoteUser; - - // 投稿者が凍結されていたらスキップ - 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'; - } - } - - let isMessaging = note._misskey_talk && visibility === 'specified'; - - const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); - const apHashtags = await extractApHashtags(note.tag); - - // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - // Noteがsensitiveなら添付もsensitiveにする - const limit = promiseLimit(2); - - note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; - const files = note.attachment - .map(attach => attach.sensitive = note.sensitive) - ? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise))) - .filter(image => image != null) - : []; - - // リプライ - const reply: Note | null = note.inReplyTo - ? await this.resolveNote(note.inReplyTo, resolver).then(x => { - if (x == null) { - this.logger.warn('Specified inReplyTo, but nout found'); - throw new Error('inReplyTo not found'); - } else { - return x; - } - }).catch(async err => { - // トークだったらinReplyToのエラーは無視 - const uri = getApId(note.inReplyTo); - if (uri.startsWith(this.config.url + '/')) { - const id = uri.split('/').pop(); - const talk = await this.messagingMessagesRepository.findOneBy({ id }); - if (talk) { - isMessaging = true; - return null; - } - } - - this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); - throw err; - }) - : null; - - // 引用 - let quote: Note | undefined | null; - - if (note._misskey_quote || note.quoteUrl) { - const tryResolveNote = async (uri: string): Promise<{ - status: 'ok'; - res: Note | null; - } | { - status: 'permerror' | 'temperror'; - }> => { - if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; - try { - const res = await this.resolveNote(uri); - if (res) { - return { - status: 'ok', - res, - }; - } else { - return { - status: 'permerror', - }; - } - } 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(uri => tryResolveNote(uri))); - - quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); - if (!quote) { - if (results.some(x => x.status === 'temperror')) { - throw '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 [] as Emoji[]; - }); - - const apEmojis = emojis.map(emoji => emoji.name); - - const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - - if (isMessaging) { - for (const recipient of visibleUsers) { - await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id); - return null; - } - } - - return await this.noteCreateService.create(actor, { - createdAt: note.published ? new Date(note.published) : null, - files, - reply, - renote: quote, - name: note.name, - cw, - text, - localOnly: false, - visibility, - visibleUsers, - apMentions, - apHashtags, - apEmojis, - poll, - uri: note.id, - url: getOneApHrefNullable(note.url), - }, silent); - } - - /** - * Noteを解決します。 - * - * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - public async resolveNote(value: string | IObject, resolver?: Resolver): Promise { - const uri = typeof value === 'string' ? value : value.id; - if (uri == null) throw new Error('missing uri'); - - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (meta.blockedHosts.includes(this.utilityService.extractDbHost(uri))) throw { statusCode: 451 }; - - const unlock = await this.appLockService.getApLock(uri); - - try { - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchNote(uri); - - if (exist) { - return exist; - } - //#endregion - - if (uri.startsWith(this.config.url)) { - throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); - } - - // リモートサーバーからフェッチしてきて登録 - // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが - // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 - return await this.createNote(uri, resolver, true); - } finally { - unlock(); - } - } - - public async extractEmojis(tags: IObject | IObject[], host: string): Promise { - host = this.utilityService.toPuny(host); - - if (!tags) return []; - - const eomjiTags = toArray(tags).filter(isEmoji); - - return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); - tag.icon = toSingle(tag.icon); - - const exists = await this.emojisRepository.findOneBy({ - host, - name, - }); - - if (exists) { - if ((tag.updated != null && exists.updatedAt == null) - || (tag.id != null && exists.uri == null) - || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) - || (tag.icon!.url !== exists.originalUrl) - ) { - await this.emojisRepository.update({ - host, - name, - }, { - uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, - updatedAt: new Date(), - }); - - return await this.emojisRepository.findOneBy({ - host, - name, - }) as Emoji; - } - - return exists; - } - - this.logger.info(`register emoji host=${host}, name=${name}`); - - return await this.emojisRepository.insert({ - id: this.idService.genId(), - host, - name, - uri: tag.id, - originalUrl: tag.icon!.url, - publicUrl: tag.icon!.url, - updatedAt: new Date(), - aliases: [], - } as Partial).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - })); - } -} diff --git a/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts b/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts deleted file mode 100644 index f9d6f42ef6..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/ApPersonService.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; -import { DataSource } from 'typeorm'; -import { ModuleRef } from '@nestjs/core'; -import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { CacheableUser, IRemoteUser } from '@/models/entities/User.js'; -import { User } from '@/models/entities/User.js'; -import { truncate } from '@/misc/truncate.js'; -import type { UserCacheService } from '@/core/UserCacheService.js'; -import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type Logger from '@/logger.js'; -import type { Note } from '@/models/entities/Note.js'; -import type { IdService } from '@/core/IdService.js'; -import type { MfmService } from '@/core/MfmService.js'; -import type { Emoji } from '@/models/entities/Emoji.js'; -import { toArray } from '@/misc/prelude/array.js'; -import type { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { UserProfile } from '@/models/entities/UserProfile.js'; -import { UserPublickey } from '@/models/entities/UserPublickey.js'; -import type UsersChart from '@/core/chart/charts/users.js'; -import type InstanceChart from '@/core/chart/charts/instance.js'; -import type { HashtagService } from '@/core/HashtagService.js'; -import { UserNotePining } from '@/models/entities/UserNotePining.js'; -import { StatusError } from '@/misc/status-error.js'; -import type { UtilityService } from '@/core/UtilityService.js'; -import type { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; -import { extractApHashtags } from './tag.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { ApNoteService } from './ApNoteService.js'; -import type { ApMfmService } from '../ApMfmService.js'; -import type { ApResolverService, Resolver } from '../ApResolverService.js'; -import type { ApLoggerService } from '../ApLoggerService.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import type { ApImageService } from './ApImageService.js'; -import type { IActor, IObject, IApPropertyValue } from '../type.js'; - -const nameLength = 128; -const summaryLength = 2048; - -const services: { - [x: string]: (id: string, username: string) => any -} = { - 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), - 'misskey:authentication:github': (id, login) => ({ id, login }), - 'misskey:authentication:discord': (id, name) => $discord(id, name), -}; - -const $discord = (id: string, name: string) => { - if (typeof name !== 'string') { - name = 'unknown#0000'; - } - const [username, discriminator] = name.split('#'); - return { id, username, discriminator }; -}; - -function addService(target: { [x: string]: any }, source: IApPropertyValue) { - const service = services[source.name]; - - if (typeof source.value !== 'string') { - source.value = 'unknown'; - } - - const [id, username] = source.value.split('@'); - - if (service) { - target[source.name.split(':')[2]] = service(id, username); - } -} - -@Injectable() -export class ApPersonService implements OnModuleInit { - private utilityService: UtilityService; - private userEntityService: UserEntityService; - private idService: IdService; - private globalEventService: GlobalEventService; - private federatedInstanceService: FederatedInstanceService; - private fetchInstanceMetadataService: FetchInstanceMetadataService; - private userCacheService: UserCacheService; - private apResolverService: ApResolverService; - private apNoteService: ApNoteService; - private apImageService: ApImageService; - private apMfmService: ApMfmService; - private mfmService: MfmService; - private hashtagService: HashtagService; - private usersChart: UsersChart; - private instanceChart: InstanceChart; - private apLoggerService: ApLoggerService; - private logger: Logger; - - constructor( - private moduleRef: ModuleRef, - - @Inject(DI.config) - private config: Config, - - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.userPublickeysRepository) - private userPublickeysRepository: UserPublickeysRepository, - - @Inject(DI.instancesRepository) - private instancesRepository: InstancesRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - //private utilityService: UtilityService, - //private userEntityService: UserEntityService, - //private idService: IdService, - //private globalEventService: GlobalEventService, - //private federatedInstanceService: FederatedInstanceService, - //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private userCacheService: UserCacheService, - //private apResolverService: ApResolverService, - //private apNoteService: ApNoteService, - //private apImageService: ApImageService, - //private apMfmService: ApMfmService, - //private mfmService: MfmService, - //private hashtagService: HashtagService, - //private usersChart: UsersChart, - //private instanceChart: InstanceChart, - //private apLoggerService: ApLoggerService, - ) { - } - - onModuleInit() { - this.utilityService = this.moduleRef.get('UtilityService'); - this.userEntityService = this.moduleRef.get('UserEntityService'); - this.idService = this.moduleRef.get('IdService'); - this.globalEventService = this.moduleRef.get('GlobalEventService'); - this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); - this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.userCacheService = this.moduleRef.get('UserCacheService'); - this.apResolverService = this.moduleRef.get('ApResolverService'); - this.apNoteService = this.moduleRef.get('ApNoteService'); - this.apImageService = this.moduleRef.get('ApImageService'); - this.apMfmService = this.moduleRef.get('ApMfmService'); - this.mfmService = this.moduleRef.get('MfmService'); - this.hashtagService = this.moduleRef.get('HashtagService'); - this.usersChart = this.moduleRef.get('UsersChart'); - this.instanceChart = this.moduleRef.get('InstanceChart'); - this.apLoggerService = this.moduleRef.get('ApLoggerService'); - this.logger = this.apLoggerService.logger; - } - - /** - * Validate and convert to actor object - * @param x Fetched object - * @param uri Fetch target URI - */ - private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.utilityService.toPuny(new URL(uri).hostname); - - if (x == null) { - throw new Error('invalid Actor: object is null'); - } - - if (!isActor(x)) { - throw new Error(`invalid Actor type '${x.type}'`); - } - - if (!(typeof x.id === 'string' && x.id.length > 0)) { - throw new Error('invalid Actor: wrong id'); - } - - if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { - throw new Error('invalid Actor: wrong inbox'); - } - - if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { - throw new Error('invalid Actor: wrong username'); - } - - // These fields are only informational, and some AP software allows these - // fields to be very long. If they are too long, we cut them off. This way - // we can at least see these users and their activities. - if (x.name) { - if (!(typeof x.name === 'string' && x.name.length > 0)) { - throw new Error('invalid Actor: wrong name'); - } - x.name = truncate(x.name, nameLength); - } - if (x.summary) { - if (!(typeof x.summary === 'string' && x.summary.length > 0)) { - throw new Error('invalid Actor: wrong summary'); - } - x.summary = truncate(x.summary, summaryLength); - } - - const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); - if (idHost !== expectHost) { - throw new Error('invalid Actor: id has different host'); - } - - if (x.publicKey) { - if (typeof x.publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); - } - - const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); - } - } - - return x; - } - - /** - * Personをフェッチします。 - * - * Misskeyに対象のPersonが登録されていればそれを返します。 - */ - public async fetchPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - const cached = this.userCacheService.uriPersonCache.get(uri); - if (cached) return cached; - - // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(this.config.url + '/')) { - const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }); - if (u) this.userCacheService.uriPersonCache.set(uri, u); - return u; - } - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }); - - if (exist) { - this.userCacheService.uriPersonCache.set(uri, exist); - return exist; - } - //#endregion - - return null; - } - - /** - * Personを作成します。 - */ - public async createPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - if (uri.startsWith(this.config.url)) { - throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); - } - - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = await resolver.resolve(uri) as any; - - const person = this.validateActor(object, uri); - - this.logger.info(`Creating the Person: ${person.id}`); - - const host = this.utilityService.toPuny(new URL(object.id).hostname); - - const { fields } = this.analyzeAttachments(person.attachment ?? []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const isBot = getApType(object) === 'Service'; - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - // Create user - let user: IRemoteUser; - try { - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - user = await transactionalEntityManager.save(new User({ - id: this.idService.genId(), - avatarId: null, - bannerId: null, - createdAt: new Date(), - lastFetchedAt: new Date(), - name: truncate(person.name, nameLength), - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - username: person.preferredUsername, - usernameLower: person.preferredUsername!.toLowerCase(), - host, - inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured ? getApId(person.featured) : undefined, - uri: person.id, - tags, - isBot, - isCat: (person as any).isCat === true, - showTimelineReplies: false, - })) as IRemoteUser; - - await transactionalEntityManager.save(new UserProfile({ - userId: user.id, - description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - url: getOneApHrefNullable(person.url), - fields, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] ?? null, - userHost: host, - })); - - if (person.publicKey) { - await transactionalEntityManager.save(new UserPublickey({ - userId: user.id, - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - })); - } - }); - } catch (e) { - // duplicate key error - if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 - const u = await this.usersRepository.findOneBy({ - uri: person.id, - }); - - if (u) { - user = u as IRemoteUser; - } else { - throw new Error('already registered'); - } - } else { - this.logger.error(e instanceof Error ? e : new Error(e as string)); - throw e; - } - } - - // Register host - this.federatedInstanceService.registerOrFetchInstanceDoc(host).then(i => { - this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.instanceChart.newUser(i.host); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - }); - - this.usersChart.update(user!, true); - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(user!, tags); - - //#region アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(user!, img).catch(() => null), - )); - - const avatarId = avatar ? avatar.id : null; - const bannerId = banner ? banner.id : null; - - await this.usersRepository.update(user!.id, { - avatarId, - bannerId, - }); - - user!.avatarId = avatarId; - user!.bannerId = bannerId; - //#endregion - - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { - this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); - //#endregion - - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); - - return user!; - } - - /** - * Personの情報を更新します。 - * Misskeyに対象のPersonが登録されていなければ無視します。 - * @param uri URI of Person - * @param resolver Resolver - * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) - */ - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) { - return; - } - - //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as IRemoteUser; - - if (exist == null) { - return; - } - //#endregion - - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const object = hint ?? await resolver.resolve(uri); - - const person = this.validateActor(object, uri); - - this.logger.info(`Updating the Person: ${person.id}`); - - // アバターとヘッダー画像をフェッチ - const [avatar, banner] = await Promise.all([ - person.icon, - person.image, - ].map(img => - img == null - ? Promise.resolve(null) - : this.apImageService.resolveImage(exist, img).catch(() => null), - )); - - // カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => { - this.logger.info(`extractEmojis: ${e}`); - return [] as Emoji[]; - }); - - const emojiNames = emojis.map(emoji => emoji.name); - - const { fields } = this.analyzeAttachments(person.attachment ?? []); - - const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); - - const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); - - const updates = { - lastFetchedAt: new Date(), - inbox: person.inbox, - sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), - followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, - emojis: emojiNames, - name: truncate(person.name, nameLength), - tags, - isBot: getApType(object) === 'Service', - isCat: (person as any).isCat === true, - isLocked: !!person.manuallyApprovesFollowers, - isExplorable: !!person.discoverable, - } as Partial; - - if (avatar) { - updates.avatarId = avatar.id; - } - - if (banner) { - updates.bannerId = banner.id; - } - - // Update user - await this.usersRepository.update(exist.id, updates); - - if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - }); - } - - await this.userProfilesRepository.update({ userId: exist.id }, { - url: getOneApHrefNullable(person.url), - fields, - description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, - birthday: bday ? bday[0] : null, - location: person['vcard:Address'] ?? null, - }); - - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(exist, tags); - - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update({ - followerId: exist.id, - }, { - followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined), - }); - - await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); - } - - /** - * Personを解決します。 - * - * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 - */ - public async resolvePerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); - - //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchPerson(uri); - - if (exist) { - return exist; - } - //#endregion - - // リモートサーバーからフェッチしてきて登録 - if (resolver == null) resolver = this.apResolverService.createResolver(); - return await this.createPerson(uri, resolver); - } - - public analyzeAttachments(attachments: IObject | IObject[] | undefined) { - const fields: { - name: string, - value: string - }[] = []; - const services: { [x: string]: any } = {}; - - if (Array.isArray(attachments)) { - for (const attachment of attachments.filter(isPropertyValue)) { - if (isPropertyValue(attachment.identifier)) { - addService(services, attachment.identifier); - } else { - fields.push({ - name: attachment.name, - value: this.mfmService.fromHtml(attachment.value), - }); - } - } - } - - return { fields, services }; - } - - public async updateFeatured(userId: User['id'], resolver?: Resolver) { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); - if (!this.userEntityService.isRemoteUser(user)) return; - if (!user.featured) return; - - this.logger.info(`Updating the featured: ${user.uri}`); - - if (resolver == null) resolver = this.apResolverService.createResolver(); - - // Resolve to (Ordered)Collection Object - const collection = await resolver.resolveCollection(user.featured); - if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection'); - - // Resolve to Object(may be Note) arrays - const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; - const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); - - // Resolve and regist Notes - const limit = promiseLimit(2); - const featuredNotes = await Promise.all(items - .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも - .slice(0, 5) - .map(item => limit(() => this.apNoteService.resolveNote(item, resolver)))); - - await this.db.transaction(async transactionalEntityManager => { - await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); - - // とりあえずidを別の時間で生成して順番を維持 - let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { - td -= 1000; - transactionalEntityManager.insert(UserNotePining, { - id: this.idService.genId(new Date(Date.now() + td)), - createdAt: new Date(), - userId: user.id, - noteId: note!.id, - }); - } - }); - } -} diff --git a/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts deleted file mode 100644 index 5793b98353..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { NotesRepository, PollsRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; -import type { IPoll } from '@/models/entities/Poll.js'; -import type Logger from '@/logger.js'; -import { isQuestion } from '../type.js'; -import { ApLoggerService } from '../ApLoggerService.js'; -import { ApResolverService } from '../ApResolverService.js'; -import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IQuestion } from '../type.js'; - -@Injectable() -export class ApQuestionService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - private apResolverService: ApResolverService, - private apLoggerService: ApLoggerService, - ) { - this.logger = this.apLoggerService.logger; - } - - public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { - if (resolver == null) resolver = this.apResolverService.createResolver(); - - const question = await resolver.resolve(source); - - if (!isQuestion(question)) { - throw new Error('invalid type'); - } - - const multiple = !question.oneOf; - const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; - - if (multiple && !question.anyOf) { - throw new Error('invalid question'); - } - - const choices = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.name!); - - const votes = question[multiple ? 'anyOf' : 'oneOf']! - .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); - - return { - choices, - votes, - multiple, - expiresAt, - }; - } - - /** - * Update votes of Question - * @param uri URI of AP Question object - * @returns true if updated - */ - public async updateQuestion(value: any, resolver?: Resolver) { - const uri = typeof value === 'string' ? value : value.id; - - // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); - - //#region このサーバーに既に登録されているか - const note = await this.notesRepository.findOneBy({ uri }); - if (note == null) throw new Error('Question is not registed'); - - const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - if (poll == null) throw new Error('Question is not registed'); - //#endregion - - // resolve new Question object - if (resolver == null) resolver = this.apResolverService.createResolver(); - const question = await resolver.resolve(value) as IQuestion; - this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); - - if (question.type !== 'Question') throw new Error('object is not a Question'); - - const apChoices = question.oneOf ?? question.anyOf; - - let changed = false; - - for (const choice of poll.choices) { - const oldCount = poll.votes[poll.choices.indexOf(choice)]; - const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; - - if (oldCount !== newCount) { - changed = true; - poll.votes[poll.choices.indexOf(choice)] = newCount; - } - } - - await this.pollsRepository.update({ noteId: note.id }, { - votes: poll.votes, - }); - - return changed; - } -} diff --git a/packages/backend/src/core/remote/activitypub/models/icon.ts b/packages/backend/src/core/remote/activitypub/models/icon.ts deleted file mode 100644 index 50794a937d..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/icon.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type IIcon = { - type: string; - mediaType?: string; - url?: string; -}; diff --git a/packages/backend/src/core/remote/activitypub/models/identifier.ts b/packages/backend/src/core/remote/activitypub/models/identifier.ts deleted file mode 100644 index f6c3bb8c88..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/identifier.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type IIdentifier = { - type: string; - name: string; - value: string; -}; diff --git a/packages/backend/src/core/remote/activitypub/models/tag.ts b/packages/backend/src/core/remote/activitypub/models/tag.ts deleted file mode 100644 index 803846a0b0..0000000000 --- a/packages/backend/src/core/remote/activitypub/models/tag.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { toArray } from '@/misc/prelude/array.js'; -import { isHashtag } from '../type.js'; -import type { IObject, IApHashtag } from '../type.js'; - -export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { - if (tags == null) return []; - - const hashtags = extractApHashtagObjects(tags); - - return hashtags.map(tag => { - const m = tag.name.match(/^#(.+)/); - return m ? m[1] : null; - }).filter((x): x is string => x != null); -} - -export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { - if (tags == null) return []; - return toArray(tags).filter(isHashtag); -} diff --git a/packages/backend/src/core/remote/activitypub/type.ts b/packages/backend/src/core/remote/activitypub/type.ts deleted file mode 100644 index dcc5110aa5..0000000000 --- a/packages/backend/src/core/remote/activitypub/type.ts +++ /dev/null @@ -1,296 +0,0 @@ -export type obj = { [x: string]: any }; -export type ApObject = IObject | string | (IObject | string)[]; - -export interface IObject { - '@context': string | string[] | obj | obj[]; - type: string | string[]; - id?: string; - summary?: string; - published?: string; - cc?: ApObject; - to?: ApObject; - attributedTo: ApObject; - attachment?: any[]; - inReplyTo?: any; - replies?: ICollection; - content?: string; - name?: string; - startTime?: Date; - endTime?: Date; - icon?: any; - image?: any; - url?: ApObject; - href?: string; - tag?: IObject | IObject[]; - sensitive?: boolean; -} - -/** - * Get array of ActivityStreams Objects id - */ -export function getApIds(value: ApObject | undefined): string[] { - if (value == null) return []; - const array = Array.isArray(value) ? value : [value]; - return array.map(x => getApId(x)); -} - -/** - * Get first ActivityStreams Object id - */ -export function getOneApId(value: ApObject): string { - const firstOne = Array.isArray(value) ? value[0] : value; - return getApId(firstOne); -} - -/** - * Get ActivityStreams Object id - */ -export function getApId(value: string | IObject): string { - if (typeof value === 'string') return value; - if (typeof value.id === 'string') return value.id; - throw new Error('cannot detemine id'); -} - -/** - * Get ActivityStreams Object type - */ -export function getApType(value: IObject): string { - if (typeof value.type === 'string') return value.type; - if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; - throw new Error('cannot detect type'); -} - -export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { - const firstOne = Array.isArray(value) ? value[0] : value; - return getApHrefNullable(firstOne); -} - -export function getApHrefNullable(value: string | IObject | undefined): string | undefined { - if (typeof value === 'string') return value; - if (typeof value?.href === 'string') return value.href; - return undefined; -} - -export interface IActivity extends IObject { - //type: 'Activity'; - actor: IObject | string; - object: IObject | string; - target?: IObject | string; - /** LD-Signature */ - signature?: { - type: string; - created: Date; - creator: string; - domain?: string; - nonce?: string; - signatureValue: string; - }; -} - -export interface ICollection extends IObject { - type: 'Collection'; - totalItems: number; - items: ApObject; -} - -export interface IOrderedCollection extends IObject { - type: 'OrderedCollection'; - totalItems: number; - orderedItems: ApObject; -} - -export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; - -export const isPost = (object: IObject): object is IPost => - validPost.includes(getApType(object)); - -export interface IPost extends IObject { - type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; - source?: { - content: string; - mediaType: string; - }; - _misskey_quote?: string; - _misskey_content?: string; - quoteUrl?: string; - _misskey_talk?: boolean; -} - -export interface IQuestion extends IObject { - type: 'Note' | 'Question'; - source?: { - content: string; - mediaType: string; - }; - _misskey_quote?: string; - quoteUrl?: string; - oneOf?: IQuestionChoice[]; - anyOf?: IQuestionChoice[]; - endTime?: Date; - closed?: Date; -} - -export const isQuestion = (object: IObject): object is IQuestion => - getApType(object) === 'Note' || getApType(object) === 'Question'; - -interface IQuestionChoice { - name?: string; - replies?: ICollection; - _misskey_votes?: number; -} -export interface ITombstone extends IObject { - type: 'Tombstone'; - formerType?: string; - deleted?: Date; -} - -export const isTombstone = (object: IObject): object is ITombstone => - getApType(object) === 'Tombstone'; - -export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; - -export const isActor = (object: IObject): object is IActor => - validActor.includes(getApType(object)); - -export interface IActor extends IObject { - type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; - name?: string; - preferredUsername?: string; - manuallyApprovesFollowers?: boolean; - discoverable?: boolean; - inbox: string; - sharedInbox?: string; // 後方互換性のため - publicKey?: { - id: string; - publicKeyPem: string; - }; - followers?: string | ICollection | IOrderedCollection; - following?: string | ICollection | IOrderedCollection; - featured?: string | IOrderedCollection; - outbox: string | IOrderedCollection; - endpoints?: { - sharedInbox?: string; - }; - 'vcard:bday'?: string; - 'vcard:Address'?: string; -} - -export const isCollection = (object: IObject): object is ICollection => - getApType(object) === 'Collection'; - -export const isOrderedCollection = (object: IObject): object is IOrderedCollection => - getApType(object) === 'OrderedCollection'; - -export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => - isCollection(object) || isOrderedCollection(object); - -export interface IApPropertyValue extends IObject { - type: 'PropertyValue'; - identifier: IApPropertyValue; - name: string; - value: string; -} - -export const isPropertyValue = (object: IObject): object is IApPropertyValue => - object && - getApType(object) === 'PropertyValue' && - typeof object.name === 'string' && - typeof (object as any).value === 'string'; - -export interface IApMention extends IObject { - type: 'Mention'; - href: string; -} - -export const isMention = (object: IObject): object is IApMention => - getApType(object) === 'Mention' && - typeof object.href === 'string'; - -export interface IApHashtag extends IObject { - type: 'Hashtag'; - name: string; -} - -export const isHashtag = (object: IObject): object is IApHashtag => - getApType(object) === 'Hashtag' && - typeof object.name === 'string'; - -export interface IApEmoji extends IObject { - type: 'Emoji'; - updated: Date; -} - -export const isEmoji = (object: IObject): object is IApEmoji => - getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null; - -export interface ICreate extends IActivity { - type: 'Create'; -} - -export interface IDelete extends IActivity { - type: 'Delete'; -} - -export interface IUpdate extends IActivity { - type: 'Update'; -} - -export interface IRead extends IActivity { - type: 'Read'; -} - -export interface IUndo extends IActivity { - type: 'Undo'; -} - -export interface IFollow extends IActivity { - type: 'Follow'; -} - -export interface IAccept extends IActivity { - type: 'Accept'; -} - -export interface IReject extends IActivity { - type: 'Reject'; -} - -export interface IAdd extends IActivity { - type: 'Add'; -} - -export interface IRemove extends IActivity { - type: 'Remove'; -} - -export interface ILike extends IActivity { - type: 'Like' | 'EmojiReaction' | 'EmojiReact'; - _misskey_reaction?: string; -} - -export interface IAnnounce extends IActivity { - type: 'Announce'; -} - -export interface IBlock extends IActivity { - type: 'Block'; -} - -export interface IFlag extends IActivity { - type: 'Flag'; -} - -export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; -export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; -export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; -export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read'; -export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo'; -export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow'; -export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept'; -export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; -export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; -export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; -export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact'; -export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; -export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; -export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; -- cgit v1.2.3-freya