diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-09-18 03:27:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-09-18 03:27:08 +0900 |
| commit | b75184ec8e3436200bacdcd832e3324702553d20 (patch) | |
| tree | 8b7e316f29e95df921db57289c8b8da476d18f07 /packages/backend/src/core | |
| parent | Update ROADMAP.md (diff) | |
| download | sharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.gz sharkey-b75184ec8e3436200bacdcd832e3324702553d20.tar.bz2 sharkey-b75184ec8e3436200bacdcd832e3324702553d20.zip | |
なんかもうめっちゃ変えた
Diffstat (limited to 'packages/backend/src/core')
142 files changed, 18450 insertions, 0 deletions
diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts new file mode 100644 index 0000000000..204e1d0170 --- /dev/null +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { User } from '@/models/entities/User.js'; +import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class AccountUpdateService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private relayService: RelayService, + ) { + } + + public async publishToFollowers(userId: User['id']) { + const user = await this.usersRepository.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + if (this.userEntityService.isLocalUser(user)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } + } +} diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts new file mode 100644 index 0000000000..1cfc3382a9 --- /dev/null +++ b/packages/backend/src/core/AiService.ts @@ -0,0 +1,60 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { Inject, Injectable } from '@nestjs/common'; +import * as nsfw from 'nsfwjs'; +import si from 'systeminformation'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; +let isSupportedCpu: undefined | boolean = undefined; + +@Injectable() +export class AiService { + #model: nsfw.NSFWJS; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> { + try { + if (isSupportedCpu === undefined) { + const cpuFlags = await this.#getCpuFlags(); + isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); + } + + if (!isSupportedCpu) { + console.error('This CPU cannot use TensorFlow.'); + return null; + } + + const tf = await import('@tensorflow/tfjs-node'); + + if (this.#model == null) this.#model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); + + const buffer = await fs.promises.readFile(path); + const image = await tf.node.decodeImage(buffer, 3) as any; + try { + const predictions = await this.#model.classify(image); + return predictions; + } finally { + image.dispose(); + } + } catch (err) { + console.error(err); + return null; + } + } + + async #getCpuFlags(): Promise<string[]> { + const str = await si.cpuFlags(); + return str.split(/\s+/); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts new file mode 100644 index 0000000000..8993880a06 --- /dev/null +++ b/packages/backend/src/core/AntennaService.ts @@ -0,0 +1,228 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { Antenna } from '@/models/entities/Antenna.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import * as Acct from '@/misc/acct.js'; +import { Cache } from '@/misc/cache.js'; +import type { Packed } from '@/misc/schema.js'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import { UtilityService } from './UtilityService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class AntennaService implements OnApplicationShutdown { + #antennasFetched: boolean; + #antennas: Antenna[]; + #blockingCache: Cache<User['id'][]>; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private utilityService: UtilityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + ) { + this.#antennasFetched = false; + this.#antennas = []; + this.#blockingCache = new Cache<User['id'][]>(1000 * 60 * 5); + + this.redisSubscriber.on('message', this.onRedisMessage); + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onRedisMessage); + } + + private async onRedisMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'antennaCreated': + this.#antennas.push(body); + break; + case 'antennaUpdated': + this.#antennas[this.#antennas.findIndex(a => a.id === body.id)] = body; + break; + case 'antennaDeleted': + this.#antennas = this.#antennas.filter(a => a.id !== body.id); + break; + default: + break; + } + } + } + + public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> { + // 通知しない設定になっているか、自分自身の投稿なら既読にする + const read = !antenna.notify || (antenna.userId === noteUser.id); + + this.antennaNotesRepository.insert({ + id: this.idService.genId(), + antennaId: antenna.id, + noteId: note.id, + read: read, + }); + + this.globalEventServie.publishAntennaStream(antenna.id, 'note', note); + + if (!read) { + const mutings = await this.mutingsRepository.find({ + where: { + muterId: antenna.userId, + }, + select: ['muteeId'], + }); + + // Copy + const _note: Note = { + ...note, + }; + + if (note.replyId != null) { + _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); + } + if (note.renoteId != null) { + _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + } + + if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { + return; + } + + // 2秒経っても既読にならなかったら通知 + setTimeout(async () => { + const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); + if (unread) { + this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna); + } + }, 2000); + } + } + + // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている + + /** + * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい + */ + public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> { + if (note.visibility === 'specified') return false; + + // アンテナ作成者がノート作成者にブロックされていたらスキップ + const blockings = await this.#blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId))); + if (blockings.some(blocking => blocking === antenna.userId)) return false; + + if (note.visibility === 'followers') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } + + if (!antenna.withReplies && note.replyId != null) return false; + + if (antenna.src === 'home') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } else if (antenna.src === 'list') { + const listUsers = (await this.userListJoiningsRepository.findBy({ + userListId: antenna.userListId!, + })).map(x => x.userId); + + if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === 'group') { + const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); + + const groupUsers = (await this.userGroupJoiningsRepository.findBy({ + userGroupId: joining.userGroupId, + })).map(x => x.userId); + + if (!groupUsers.includes(note.userId)) return false; + } else if (antenna.src === 'users') { + const accts = antenna.users.map(x => { + const { username, host } = Acct.parse(x); + return this.utilityService.getFullApAccount(username, host).toLowerCase(); + }); + if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + } + + const keywords = antenna.keywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (keywords.length > 0) { + if (note.text == null) return false; + + const matched = keywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()), + )); + + if (!matched) return false; + } + + const excludeKeywords = antenna.excludeKeywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (excludeKeywords.length > 0) { + if (note.text == null) return false; + + const matched = excludeKeywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()), + )); + + if (matched) return false; + } + + if (antenna.withFile) { + if (note.fileIds && note.fileIds.length === 0) return false; + } + + // TODO: eval expression + + return true; + } + + public async getAntennas() { + if (!this.#antennasFetched) { + this.#antennas = await this.antennasRepository.find(); + this.#antennasFetched = true; + } + + return this.#antennas; + } +} diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts new file mode 100644 index 0000000000..f3c345493b --- /dev/null +++ b/packages/backend/src/core/AppLockService.ts @@ -0,0 +1,40 @@ +import { promisify } from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import redisLock from 'redis-lock'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; + +/** + * Retry delay (ms) for lock acquisition + */ +const retryDelay = 100; + +@Injectable() +export class AppLockService { + #lock: (key: string, timeout?: number) => Promise<() => void>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + ) { + this.#lock = promisify(redisLock(this.redisClient, retryDelay)); + } + + /** + * Get AP Object lock + * @param uri AP object ID + * @param timeout Lock timeout (ms), The timeout releases previous lock. + * @returns Unlock function + */ + public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> { + return this.#lock(`ap-object:${uri}`, timeout); + } + + public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> { + return this.#lock(`instance:${host}`, timeout); + } + + public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { + return this.#lock(`chart-insert:${lockKey}`, timeout); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts new file mode 100644 index 0000000000..891e5315c2 --- /dev/null +++ b/packages/backend/src/core/CaptchaService.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { HttpRequestService } from './HttpRequestService.js'; + +type CaptchaResponse = { + success: boolean; + 'error-codes'?: string[]; +}; + +@Injectable() +export class CaptchaService { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + async #getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> { + const params = new URLSearchParams({ + secret, + response, + }); + + const res = await fetch(url, { + method: 'POST', + body: params, + headers: { + 'User-Agent': this.config.userAgent, + }, + // TODO + //timeout: 10 * 1000, + agent: (url, bypassProxy) => this.httpRequestService.getAgentByUrl(url, bypassProxy), + }).catch(err => { + throw `${err.message ?? err}`; + }); + + if (!res.ok) { + throw `${res.status}`; + } + + return await res.json() as CaptchaResponse; + } + + public async verifyRecaptcha(secret: string, response: string): Promise<void> { + const result = await this.#getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { + throw `recaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; + throw `recaptcha-failed: ${errorCodes}`; + } + } + + public async verifyHcaptcha(secret: string, response: string): Promise<void> { + const result = await this.#getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { + throw `hcaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; + throw `hcaptcha-failed: ${errorCodes}`; + } + } +} + diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts new file mode 100644 index 0000000000..f6dd0debb9 --- /dev/null +++ b/packages/backend/src/core/CoreModule.ts @@ -0,0 +1,696 @@ +import { Module } from '@nestjs/common'; +import { DI } from '../di-symbols.js'; +import { AccountUpdateService } from './AccountUpdateService.js'; +import { AiService } from './AiService.js'; +import { AntennaService } from './AntennaService.js'; +import { AppLockService } from './AppLockService.js'; +import { CaptchaService } from './CaptchaService.js'; +import { CreateNotificationService } from './CreateNotificationService.js'; +import { CreateSystemUserService } from './CreateSystemUserService.js'; +import { CustomEmojiService } from './CustomEmojiService.js'; +import { DeleteAccountService } from './DeleteAccountService.js'; +import { DownloadService } from './DownloadService.js'; +import { DriveService } from './DriveService.js'; +import { EmailService } from './EmailService.js'; +import { FederatedInstanceService } from './FederatedInstanceService.js'; +import { FetchInstanceMetadataService } from './FetchInstanceMetadataService.js'; +import { GlobalEventService } from './GlobalEventService.js'; +import { HashtagService } from './HashtagService.js'; +import { HttpRequestService } from './HttpRequestService.js'; +import { IdService } from './IdService.js'; +import { ImageProcessingService } from './ImageProcessingService.js'; +import { InstanceActorService } from './InstanceActorService.js'; +import { InternalStorageService } from './InternalStorageService.js'; +import { MessagingService } from './MessagingService.js'; +import { MetaService } from './MetaService.js'; +import { MfmService } from './MfmService.js'; +import { ModerationLogService } from './ModerationLogService.js'; +import { NoteCreateService } from './NoteCreateService.js'; +import { NoteDeleteService } from './NoteDeleteService.js'; +import { NotePiningService } from './NotePiningService.js'; +import { NoteReadService } from './NoteReadService.js'; +import { NotificationService } from './NotificationService.js'; +import { PollService } from './PollService.js'; +import { PushNotificationService } from './PushNotificationService.js'; +import { QueryService } from './QueryService.js'; +import { ReactionService } from './ReactionService.js'; +import { RelayService } from './RelayService.js'; +import { S3Service } from './S3Service.js'; +import { SignupService } from './SignupService.js'; +import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; +import { UserBlockingService } from './UserBlockingService.js'; +import { UserCacheService } from './UserCacheService.js'; +import { UserFollowingService } from './UserFollowingService.js'; +import { UserKeypairStoreService } from './UserKeypairStoreService.js'; +import { UserListService } from './UserListService.js'; +import { UserMutingService } from './UserMutingService.js'; +import { UserSuspendService } from './UserSuspendService.js'; +import { VideoProcessingService } from './VideoProcessingService.js'; +import { WebhookService } from './WebhookService.js'; +import { ProxyAccountService } from './ProxyAccountService.js'; +import { UtilityService } from './UtilityService.js'; +import { FileInfoService } from './FileInfoService.js'; +import FederationChart from './chart/charts/federation.js'; +import NotesChart from './chart/charts/notes.js'; +import UsersChart from './chart/charts/users.js'; +import ActiveUsersChart from './chart/charts/active-users.js'; +import InstanceChart from './chart/charts/instance.js'; +import PerUserNotesChart from './chart/charts/per-user-notes.js'; +import DriveChart from './chart/charts/drive.js'; +import PerUserReactionsChart from './chart/charts/per-user-reactions.js'; +import HashtagChart from './chart/charts/hashtag.js'; +import PerUserFollowingChart from './chart/charts/per-user-following.js'; +import PerUserDriveChart from './chart/charts/per-user-drive.js'; +import ApRequestChart from './chart/charts/ap-request.js'; +import { ChartManagementService } from './chart/ChartManagementService.js'; +import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AntennaEntityService } from './entities/AntennaEntityService.js'; +import { AppEntityService } from './entities/AppEntityService.js'; +import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; +import { BlockingEntityService } from './entities/BlockingEntityService.js'; +import { ChannelEntityService } from './entities/ChannelEntityService.js'; +import { ClipEntityService } from './entities/ClipEntityService.js'; +import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; +import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; +import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { FollowingEntityService } from './entities/FollowingEntityService.js'; +import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; +import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; +import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js'; +import { HashtagEntityService } from './entities/HashtagEntityService.js'; +import { InstanceEntityService } from './entities/InstanceEntityService.js'; +import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; +import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; +import { MutingEntityService } from './entities/MutingEntityService.js'; +import { NoteEntityService } from './entities/NoteEntityService.js'; +import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; +import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; +import { NotificationEntityService } from './entities/NotificationEntityService.js'; +import { PageEntityService } from './entities/PageEntityService.js'; +import { PageLikeEntityService } from './entities/PageLikeEntityService.js'; +import { SigninEntityService } from './entities/SigninEntityService.js'; +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 { QueueService } from './QueueService.js'; +import type { Provider } from '@nestjs/common'; + +//#region 文字列ベースでのinjection用(循環参照対応のため) +const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useClass: AccountUpdateService }; +const $AiService: Provider = { provide: 'AiService', useClass: AiService }; +const $AntennaService: Provider = { provide: 'AntennaService', useClass: AntennaService }; +const $AppLockService: Provider = { provide: 'AppLockService', useClass: AppLockService }; +const $CaptchaService: Provider = { provide: 'CaptchaService', useClass: CaptchaService }; +const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useClass: CreateNotificationService }; +const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useClass: CreateSystemUserService }; +const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useClass: CustomEmojiService }; +const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useClass: DeleteAccountService }; +const $DownloadService: Provider = { provide: 'DownloadService', useClass: DownloadService }; +const $DriveService: Provider = { provide: 'DriveService', useClass: DriveService }; +const $EmailService: Provider = { provide: 'EmailService', useClass: EmailService }; +const $FederatedInstanceService: Provider = { provide: 'FederatedInstanceService', useClass: FederatedInstanceService }; +const $FetchInstanceMetadataService: Provider = { provide: 'FetchInstanceMetadataService', useClass: FetchInstanceMetadataService }; +const $GlobalEventService: Provider = { provide: 'GlobalEventService', useClass: GlobalEventService }; +const $HashtagService: Provider = { provide: 'HashtagService', useClass: HashtagService }; +const $HttpRequestService: Provider = { provide: 'HttpRequestService', useClass: HttpRequestService }; +const $IdService: Provider = { provide: 'IdService', useClass: IdService }; +const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useClass: ImageProcessingService }; +const $InstanceActorService: Provider = { provide: 'InstanceActorService', useClass: InstanceActorService }; +const $InternalStorageService: Provider = { provide: 'InternalStorageService', useClass: InternalStorageService }; +const $MessagingService: Provider = { provide: 'MessagingService', useClass: MessagingService }; +const $MetaService: Provider = { provide: 'MetaService', useClass: MetaService }; +const $MfmService: Provider = { provide: 'MfmService', useClass: MfmService }; +const $ModerationLogService: Provider = { provide: 'ModerationLogService', useClass: ModerationLogService }; +const $NoteCreateService: Provider = { provide: 'NoteCreateService', useClass: NoteCreateService }; +const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useClass: NoteDeleteService }; +const $NotePiningService: Provider = { provide: 'NotePiningService', useClass: NotePiningService }; +const $NoteReadService: Provider = { provide: 'NoteReadService', useClass: NoteReadService }; +const $NotificationService: Provider = { provide: 'NotificationService', useClass: NotificationService }; +const $PollService: Provider = { provide: 'PollService', useClass: PollService }; +const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useClass: ProxyAccountService }; +const $PushNotificationService: Provider = { provide: 'PushNotificationService', useClass: PushNotificationService }; +const $QueryService: Provider = { provide: 'QueryService', useClass: QueryService }; +const $ReactionService: Provider = { provide: 'ReactionService', useClass: ReactionService }; +const $RelayService: Provider = { provide: 'RelayService', useClass: RelayService }; +const $S3Service: Provider = { provide: 'S3Service', useClass: S3Service }; +const $SignupService: Provider = { provide: 'SignupService', useClass: SignupService }; +const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useClass: TwoFactorAuthenticationService }; +const $UserBlockingService: Provider = { provide: 'UserBlockingService', useClass: UserBlockingService }; +const $UserCacheService: Provider = { provide: 'UserCacheService', useClass: UserCacheService }; +const $UserFollowingService: Provider = { provide: 'UserFollowingService', useClass: UserFollowingService }; +const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useClass: UserKeypairStoreService }; +const $UserListService: Provider = { provide: 'UserListService', useClass: UserListService }; +const $UserMutingService: Provider = { provide: 'UserMutingService', useClass: UserMutingService }; +const $UserSuspendService: Provider = { provide: 'UserSuspendService', useClass: UserSuspendService }; +const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useClass: VideoProcessingService }; +const $WebhookService: Provider = { provide: 'WebhookService', useClass: WebhookService }; +const $UtilityService: Provider = { provide: 'UtilityService', useClass: UtilityService }; +const $FileInfoService: Provider = { provide: 'FileInfoService', useClass: FileInfoService }; +const $FederationChart: Provider = { provide: 'FederationChart', useClass: FederationChart }; +const $NotesChart: Provider = { provide: 'NotesChart', useClass: NotesChart }; +const $UsersChart: Provider = { provide: 'UsersChart', useClass: UsersChart }; +const $ActiveUsersChart: Provider = { provide: 'ActiveUsersChart', useClass: ActiveUsersChart }; +const $InstanceChart: Provider = { provide: 'InstanceChart', useClass: InstanceChart }; +const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useClass: PerUserNotesChart }; +const $DriveChart: Provider = { provide: 'DriveChart', useClass: DriveChart }; +const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useClass: PerUserReactionsChart }; +const $HashtagChart: Provider = { provide: 'HashtagChart', useClass: HashtagChart }; +const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useClass: PerUserFollowingChart }; +const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useClass: PerUserDriveChart }; +const $ApRequestChart: Provider = { provide: 'ApRequestChart', useClass: ApRequestChart }; +const $ChartManagementService: Provider = { provide: 'ChartManagementService', useClass: ChartManagementService }; + +const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useClass: AbuseUserReportEntityService }; +const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useClass: AntennaEntityService }; +const $AppEntityService: Provider = { provide: 'AppEntityService', useClass: AppEntityService }; +const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useClass: AuthSessionEntityService }; +const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useClass: BlockingEntityService }; +const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useClass: ChannelEntityService }; +const $ClipEntityService: Provider = { provide: 'ClipEntityService', useClass: ClipEntityService }; +const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useClass: DriveFileEntityService }; +const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useClass: DriveFolderEntityService }; +const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useClass: EmojiEntityService }; +const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useClass: FollowingEntityService }; +const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useClass: FollowRequestEntityService }; +const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useClass: GalleryLikeEntityService }; +const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useClass: GalleryPostEntityService }; +const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useClass: HashtagEntityService }; +const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useClass: InstanceEntityService }; +const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useClass: MessagingMessageEntityService }; +const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useClass: ModerationLogEntityService }; +const $MutingEntityService: Provider = { provide: 'MutingEntityService', useClass: MutingEntityService }; +const $NoteEntityService: Provider = { provide: 'NoteEntityService', useClass: NoteEntityService }; +const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useClass: NoteFavoriteEntityService }; +const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useClass: NoteReactionEntityService }; +const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useClass: NotificationEntityService }; +const $PageEntityService: Provider = { provide: 'PageEntityService', useClass: PageEntityService }; +const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useClass: PageLikeEntityService }; +const $SigninEntityService: Provider = { provide: 'SigninEntityService', useClass: SigninEntityService }; +const $UserEntityService: Provider = { provide: 'UserEntityService', useClass: UserEntityService }; +const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useClass: UserGroupEntityService }; +const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useClass: UserGroupInvitationEntityService }; +const $UserListEntityService: Provider = { provide: 'UserListEntityService', useClass: UserListEntityService }; + +const $ApAudienceService: Provider = { provide: 'ApAudienceService', useClass: ApAudienceService }; +const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useClass: ApDbResolverService }; +const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useClass: ApDeliverManagerService }; +const $ApInboxService: Provider = { provide: 'ApInboxService', useClass: ApInboxService }; +const $ApLoggerService: Provider = { provide: 'ApLoggerService', useClass: ApLoggerService }; +const $ApMfmService: Provider = { provide: 'ApMfmService', useClass: ApMfmService }; +const $ApRendererService: Provider = { provide: 'ApRendererService', useClass: ApRendererService }; +const $ApRequestService: Provider = { provide: 'ApRequestService', useClass: ApRequestService }; +const $ApResolverService: Provider = { provide: 'ApResolverService', useClass: ApResolverService }; +const $LdSignatureService: Provider = { provide: 'LdSignatureService', useClass: LdSignatureService }; +const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useClass: RemoteLoggerService }; +const $ResolveUserService: Provider = { provide: 'ResolveUserService', useClass: ResolveUserService }; +const $WebfingerService: Provider = { provide: 'WebfingerService', useClass: WebfingerService }; +const $ApImageService: Provider = { provide: 'ApImageService', useClass: ApImageService }; +const $ApMentionService: Provider = { provide: 'ApMentionService', useClass: ApMentionService }; +const $ApNoteService: Provider = { provide: 'ApNoteService', useClass: ApNoteService }; +const $ApPersonService: Provider = { provide: 'ApPersonService', useClass: ApPersonService }; +const $ApQuestionService: Provider = { provide: 'ApQuestionService', useClass: ApQuestionService }; +//#endregion + +@Module({ + imports: [ + QueueModule, + ], + providers: [ + AccountUpdateService, + AiService, + AntennaService, + AppLockService, + CaptchaService, + CreateNotificationService, + CreateSystemUserService, + CustomEmojiService, + DeleteAccountService, + DownloadService, + DriveService, + EmailService, + FederatedInstanceService, + FetchInstanceMetadataService, + GlobalEventService, + HashtagService, + HttpRequestService, + IdService, + ImageProcessingService, + InstanceActorService, + InternalStorageService, + MessagingService, + MetaService, + MfmService, + ModerationLogService, + NoteCreateService, + NoteDeleteService, + NotePiningService, + NoteReadService, + NotificationService, + PollService, + ProxyAccountService, + PushNotificationService, + QueryService, + ReactionService, + RelayService, + S3Service, + SignupService, + TwoFactorAuthenticationService, + UserBlockingService, + UserCacheService, + UserFollowingService, + UserKeypairStoreService, + UserListService, + UserMutingService, + UserSuspendService, + VideoProcessingService, + WebhookService, + UtilityService, + FileInfoService, + FederationChart, + NotesChart, + UsersChart, + ActiveUsersChart, + InstanceChart, + PerUserNotesChart, + DriveChart, + PerUserReactionsChart, + HashtagChart, + PerUserFollowingChart, + PerUserDriveChart, + ApRequestChart, + ChartManagementService, + AbuseUserReportEntityService, + AntennaEntityService, + AppEntityService, + AuthSessionEntityService, + BlockingEntityService, + ChannelEntityService, + ClipEntityService, + DriveFileEntityService, + DriveFolderEntityService, + EmojiEntityService, + FollowingEntityService, + FollowRequestEntityService, + GalleryLikeEntityService, + GalleryPostEntityService, + HashtagEntityService, + InstanceEntityService, + MessagingMessageEntityService, + ModerationLogEntityService, + MutingEntityService, + NoteEntityService, + NoteFavoriteEntityService, + NoteReactionEntityService, + NotificationEntityService, + PageEntityService, + PageLikeEntityService, + SigninEntityService, + UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, + UserListEntityService, + ApAudienceService, + ApDbResolverService, + ApDeliverManagerService, + ApInboxService, + ApLoggerService, + ApMfmService, + ApRendererService, + ApRequestService, + ApResolverService, + LdSignatureService, + RemoteLoggerService, + ResolveUserService, + WebfingerService, + ApImageService, + ApMentionService, + ApNoteService, + ApPersonService, + ApQuestionService, + QueueService, + + //#region 文字列ベースでのinjection用(循環参照対応のため) + $AccountUpdateService, + $AiService, + $AntennaService, + $AppLockService, + $CaptchaService, + $CreateNotificationService, + $CreateSystemUserService, + $CustomEmojiService, + $DeleteAccountService, + $DownloadService, + $DriveService, + $EmailService, + $FederatedInstanceService, + $FetchInstanceMetadataService, + $GlobalEventService, + $HashtagService, + $HttpRequestService, + $IdService, + $ImageProcessingService, + $InstanceActorService, + $InternalStorageService, + $MessagingService, + $MetaService, + $MfmService, + $ModerationLogService, + $NoteCreateService, + $NoteDeleteService, + $NotePiningService, + $NoteReadService, + $NotificationService, + $PollService, + $ProxyAccountService, + $PushNotificationService, + $QueryService, + $ReactionService, + $RelayService, + $S3Service, + $SignupService, + $TwoFactorAuthenticationService, + $UserBlockingService, + $UserCacheService, + $UserFollowingService, + $UserKeypairStoreService, + $UserListService, + $UserMutingService, + $UserSuspendService, + $VideoProcessingService, + $WebhookService, + $UtilityService, + $FileInfoService, + $FederationChart, + $NotesChart, + $UsersChart, + $ActiveUsersChart, + $InstanceChart, + $PerUserNotesChart, + $DriveChart, + $PerUserReactionsChart, + $HashtagChart, + $PerUserFollowingChart, + $PerUserDriveChart, + $ApRequestChart, + $ChartManagementService, + $AbuseUserReportEntityService, + $AntennaEntityService, + $AppEntityService, + $AuthSessionEntityService, + $BlockingEntityService, + $ChannelEntityService, + $ClipEntityService, + $DriveFileEntityService, + $DriveFolderEntityService, + $EmojiEntityService, + $FollowingEntityService, + $FollowRequestEntityService, + $GalleryLikeEntityService, + $GalleryPostEntityService, + $HashtagEntityService, + $InstanceEntityService, + $MessagingMessageEntityService, + $ModerationLogEntityService, + $MutingEntityService, + $NoteEntityService, + $NoteFavoriteEntityService, + $NoteReactionEntityService, + $NotificationEntityService, + $PageEntityService, + $PageLikeEntityService, + $SigninEntityService, + $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, + $UserListEntityService, + $ApAudienceService, + $ApDbResolverService, + $ApDeliverManagerService, + $ApInboxService, + $ApLoggerService, + $ApMfmService, + $ApRendererService, + $ApRequestService, + $ApResolverService, + $LdSignatureService, + $RemoteLoggerService, + $ResolveUserService, + $WebfingerService, + $ApImageService, + $ApMentionService, + $ApNoteService, + $ApPersonService, + $ApQuestionService, + //#endregion + ], + exports: [ + QueueModule, + AccountUpdateService, + AiService, + AntennaService, + AppLockService, + CaptchaService, + CreateNotificationService, + CreateSystemUserService, + CustomEmojiService, + DeleteAccountService, + DownloadService, + DriveService, + EmailService, + FederatedInstanceService, + FetchInstanceMetadataService, + GlobalEventService, + HashtagService, + HttpRequestService, + IdService, + ImageProcessingService, + InstanceActorService, + InternalStorageService, + MessagingService, + MetaService, + MfmService, + ModerationLogService, + NoteCreateService, + NoteDeleteService, + NotePiningService, + NoteReadService, + NotificationService, + PollService, + ProxyAccountService, + PushNotificationService, + QueryService, + ReactionService, + RelayService, + S3Service, + SignupService, + TwoFactorAuthenticationService, + UserBlockingService, + UserCacheService, + UserFollowingService, + UserKeypairStoreService, + UserListService, + UserMutingService, + UserSuspendService, + VideoProcessingService, + WebhookService, + UtilityService, + FileInfoService, + FederationChart, + NotesChart, + UsersChart, + ActiveUsersChart, + InstanceChart, + PerUserNotesChart, + DriveChart, + PerUserReactionsChart, + HashtagChart, + PerUserFollowingChart, + PerUserDriveChart, + ApRequestChart, + ChartManagementService, + AbuseUserReportEntityService, + AntennaEntityService, + AppEntityService, + AuthSessionEntityService, + BlockingEntityService, + ChannelEntityService, + ClipEntityService, + DriveFileEntityService, + DriveFolderEntityService, + EmojiEntityService, + FollowingEntityService, + FollowRequestEntityService, + GalleryLikeEntityService, + GalleryPostEntityService, + HashtagEntityService, + InstanceEntityService, + MessagingMessageEntityService, + ModerationLogEntityService, + MutingEntityService, + NoteEntityService, + NoteFavoriteEntityService, + NoteReactionEntityService, + NotificationEntityService, + PageEntityService, + PageLikeEntityService, + SigninEntityService, + UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, + UserListEntityService, + ApAudienceService, + ApDbResolverService, + ApDeliverManagerService, + ApInboxService, + ApLoggerService, + ApMfmService, + ApRendererService, + ApRequestService, + ApResolverService, + LdSignatureService, + RemoteLoggerService, + ResolveUserService, + WebfingerService, + ApImageService, + ApMentionService, + ApNoteService, + ApPersonService, + ApQuestionService, + QueueService, + + //#region 文字列ベースでのinjection用(循環参照対応のため) + $AccountUpdateService, + $AiService, + $AntennaService, + $AppLockService, + $CaptchaService, + $CreateNotificationService, + $CreateSystemUserService, + $CustomEmojiService, + $DeleteAccountService, + $DownloadService, + $DriveService, + $EmailService, + $FederatedInstanceService, + $FetchInstanceMetadataService, + $GlobalEventService, + $HashtagService, + $HttpRequestService, + $IdService, + $ImageProcessingService, + $InstanceActorService, + $InternalStorageService, + $MessagingService, + $MetaService, + $MfmService, + $ModerationLogService, + $NoteCreateService, + $NoteDeleteService, + $NotePiningService, + $NoteReadService, + $NotificationService, + $PollService, + $ProxyAccountService, + $PushNotificationService, + $QueryService, + $ReactionService, + $RelayService, + $S3Service, + $SignupService, + $TwoFactorAuthenticationService, + $UserBlockingService, + $UserCacheService, + $UserFollowingService, + $UserKeypairStoreService, + $UserListService, + $UserMutingService, + $UserSuspendService, + $VideoProcessingService, + $WebhookService, + $UtilityService, + $FileInfoService, + $FederationChart, + $NotesChart, + $UsersChart, + $ActiveUsersChart, + $InstanceChart, + $PerUserNotesChart, + $DriveChart, + $PerUserReactionsChart, + $HashtagChart, + $PerUserFollowingChart, + $PerUserDriveChart, + $ApRequestChart, + $ChartManagementService, + $AbuseUserReportEntityService, + $AntennaEntityService, + $AppEntityService, + $AuthSessionEntityService, + $BlockingEntityService, + $ChannelEntityService, + $ClipEntityService, + $DriveFileEntityService, + $DriveFolderEntityService, + $EmojiEntityService, + $FollowingEntityService, + $FollowRequestEntityService, + $GalleryLikeEntityService, + $GalleryPostEntityService, + $HashtagEntityService, + $InstanceEntityService, + $MessagingMessageEntityService, + $ModerationLogEntityService, + $MutingEntityService, + $NoteEntityService, + $NoteFavoriteEntityService, + $NoteReactionEntityService, + $NotificationEntityService, + $PageEntityService, + $PageLikeEntityService, + $SigninEntityService, + $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, + $UserListEntityService, + $ApAudienceService, + $ApDbResolverService, + $ApDeliverManagerService, + $ApInboxService, + $ApLoggerService, + $ApMfmService, + $ApRendererService, + $ApRequestService, + $ApResolverService, + $LdSignatureService, + $RemoteLoggerService, + $ResolveUserService, + $WebfingerService, + $ApImageService, + $ApMentionService, + $ApNoteService, + $ApPersonService, + $ApQuestionService, + //#endregion + ], +}) +export class CoreModule {} diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts new file mode 100644 index 0000000000..10aa2c5df5 --- /dev/null +++ b/packages/backend/src/core/CreateNotificationService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +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'; + +@Injectable() +export class CreateNotificationService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial<Notification>, + ): Promise<Notification | null> { + if (data.notifierId && (notifieeId === data.notifierId)) { + return null; + } + + const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); + + const isMuted = profile?.mutingNotificationTypes.includes(type); + + // Create notification + const notification = await this.notificationsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + notifieeId: notifieeId, + type: type, + // 相手がこの通知をミュートしているようなら、既読を予めつけておく + isRead: isMuted, + ...data, + } as Partial<Notification>) + .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); + + const packed = await this.notificationEntityService.pack(notification, {}); + + // Publish notification event + this.globalEventServie.publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); + if (fresh == null) return; // 既に削除されているかもしれない + if (fresh.isRead) return; + + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await this.mutingsRepository.findBy({ + muterId: notifieeId, + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; + } + //#endregion + + this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed); + this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); + + if (type === 'follow') this.#emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'receiveFollowRequest') this.#emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + }, 2000); + + return notification; + } + + // TODO + //const locales = await import('../../../../locales/index.js'); + + // TODO: locale ファイルをクライアント用とサーバー用で分けたい + + async #emailNotificationFollow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + async #emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } +} diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts new file mode 100644 index 0000000000..71f50d7cb3 --- /dev/null +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import { IsNull, DataSource } from 'typeorm'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { User } from '@/models/entities/User.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { IdService } from '@/core/IdService.js'; +import { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { UsedUsername } from '@/models/entities/UsedUsername.js'; +import { DI } from '@/di-symbols.js'; +import generateNativeUserToken from '@/misc/generate-native-user-token.js'; + +@Injectable() +export class CreateSystemUserService { + constructor( + @Inject(DI.db) + private db: DataSource, + + private idService: IdService, + ) { + } + + public async createSystemUser(username: string): Promise<User> { + const password = uuid(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(4096); + + let account!: User; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error('the user is already exists'); + + account = await transactionalEntityManager.insert(User, { + id: this.idService.genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: null, + token: secret, + isAdmin: false, + isLocked: true, + isExplorable: false, + isBot: true, + }).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0])); + + await transactionalEntityManager.insert(UserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id, + }); + + await transactionalEntityManager.insert(UserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(UsedUsername, { + createdAt: new Date(), + username: username.toLowerCase(), + }); + }); + + return account; + } +} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts new file mode 100644 index 0000000000..83e28baea4 --- /dev/null +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -0,0 +1,175 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { IdService } from '@/core/IdService.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { Cache } from '@/misc/cache.js'; +import { query } from '@/misc/prelude/url.js'; +import type { Note } from '@/models/entities/Note.js'; +import { EmojisRepository } from '@/models/index.js'; +import { UtilityService } from './UtilityService.js'; +import { ReactionService } from './ReactionService.js'; + +/** + * 添付用絵文字情報 + */ +type PopulatedEmoji = { + name: string; + url: string; +}; + +@Injectable() +export class CustomEmojiService { + #cache: Cache<Emoji | null>; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private idService: IdService, + private globalEventServie: GlobalEventService, + private utilityService: UtilityService, + private reactionService: ReactionService, + ) { + this.#cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); + } + + public async add(data: { + driveFile: DriveFile; + name: string; + category: string | null; + aliases: string[]; + host: string | null; + }): Promise<Emoji> { + const emoji = await this.emojisRepository.insert({ + id: this.idService.genId(), + updatedAt: new Date(), + name: data.name, + category: data.category, + host: data.host, + aliases: data.aliases, + originalUrl: data.driveFile.url, + publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, + type: data.driveFile.webpublicType ?? data.driveFile.type, + }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + + await this.db.queryResultCache!.remove(['meta_emojis']); + + return emoji; + } + + #normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = this.utilityService.toPunyNullable(host); + + return host; + } + + #parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = this.utilityService.toPunyNullable(this.#normalizeHost(match[2], noteUserHost)); + + return { name, host }; + } + + /** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ + public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> { + const { name, host } = this.#parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => (await this.emojisRepository.findOneBy({ + name, + host: host ?? IsNull(), + })) ?? null; + + const emoji = await this.#cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため + const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`; + + return { + name: emojiName, + url, + }; + } + + /** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ + public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> { + const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); + return emojis.filter((x): x is PopulatedEmoji => x != null); + } + + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.#parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.#parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.#parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.#parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; + } + + /** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ + public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { + const notCachedEmojis = emojis.filter(emoji => this.#cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host ?? IsNull(), + }); + } + const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({ + where: emojisQuery, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }) : []; + for (const emoji of _emojis) { + this.#cache.set(`${emoji.name} ${emoji.host}`, emoji); + } + } +} diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts new file mode 100644 index 0000000000..ba67bc499e --- /dev/null +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository } from '@/models/index.js'; +import { QueueService } from '@/core/QueueService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class DeleteAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userSuspendService: UserSuspendService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + ) { + } + + public async deleteAccount(user: { + id: string; + host: string | null; + }): Promise<void> { + // 物理削除する前にDelete activityを送信する + await this.userSuspendService.doPostSuspend(user).catch(e => {}); + + this.queueService.createDeleteAccountJob(user, { + soft: false, + }); + + await this.usersRepository.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + this.globalEventServie.publishUserEvent(user.id, 'terminate', {}); + } +} diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts new file mode 100644 index 0000000000..84d5ca2e8b --- /dev/null +++ b/packages/backend/src/core/DownloadService.ts @@ -0,0 +1,123 @@ +import * as fs from 'node:fs'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import IPCIDR from 'ip-cidr'; +import PrivateIp from 'private-ip'; +import got, * as Got from 'got'; +import chalk from 'chalk'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import Logger from '@/logger.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { StatusError } from '@/misc/status-error.js'; + +const pipeline = util.promisify(stream.pipeline); + +@Injectable() +export class DownloadService { + #logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + this.#logger = new Logger('download'); + } + + public async downloadUrl(url: string, path: string): Promise<void> { + this.#logger.info(`Downloading ${chalk.cyan(url)} ...`); + + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const maxSize = this.config.maxFileSize ?? 262144000; + + const req = got.stream(url, { + headers: { + 'User-Agent': this.config.userAgent, + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: this.httpRequestService.httpAgent, + https: this.httpRequestService.httpsAgent, + }, + http2: false, // default + retry: { + limit: 0, + }, + }).on('response', (res: Got.Response) => { + if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) { + if (this.#isPrivateIp(res.ip)) { + this.#logger.warn(`Blocked address: ${res.ip}`); + req.destroy(); + } + } + + const contentLength = res.headers['content-length']; + if (contentLength != null) { + const size = Number(contentLength); + if (size > maxSize) { + this.#logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); + req.destroy(); + } + } + }).on('downloadProgress', (progress: Got.Progress) => { + if (progress.transferred > maxSize) { + this.#logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); + req.destroy(); + } + }); + + try { + await pipeline(req, fs.createWriteStream(path)); + } catch (e) { + if (e instanceof Got.HTTPError) { + throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); + } else { + throw e; + } + } + + this.#logger.succ(`Download finished: ${chalk.cyan(url)}`); + } + + public async downloadTextFile(url: string): Promise<string> { + // Create temp file + const [path, cleanup] = await createTemp(); + + this.#logger.info(`text file: Temp file is ${path}`); + + try { + // write content at URL to temp file + await this.downloadUrl(url, path); + + const text = await util.promisify(fs.readFile)(path, 'utf8'); + + return text; + } finally { + cleanup(); + } + } + + #isPrivateIp(ip: string): boolean { + for (const net of this.config.allowedPrivateNetworks ?? []) { + const cidr = new IPCIDR(net); + if (cidr.contains(ip)) { + return false; + } + } + + return PrivateIp(ip); + } +} diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts new file mode 100644 index 0000000000..f54412ff41 --- /dev/null +++ b/packages/backend/src/core/DriveService.ts @@ -0,0 +1,740 @@ +import * as fs from 'node:fs'; +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; +import sharp from 'sharp'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import Logger from '@/Logger.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DriveFile } from '@/models/entities/DriveFile.js'; +import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import type { IImage } from '@/core/ImageProcessingService.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { createTemp } from '@/misc/create-temp.js'; +import DriveChart from '@/core/chart/charts/drive.js'; +import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js'; +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 type S3 from 'aws-sdk/clients/s3.js'; + +type AddFileArgs = { + /** User who wish to add file */ + user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; + /** File path */ + path: string; + /** Name */ + name?: string | null; + /** Comment */ + comment?: string | null; + /** Folder ID */ + folderId?: any; + /** If set to true, forcibly upload the file even if there is a file with the same hash. */ + force?: boolean; + /** Do not save file to local */ + isLink?: boolean; + /** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */ + url?: string | null; + /** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */ + uri?: string | null; + /** Mark file as sensitive */ + sensitive?: boolean | null; + + requestIp?: string | null; + requestHeaders?: Record<string, string> | null; +}; + +type UploadFromUrlArgs = { + url: string; + user: { id: User['id']; host: User['host'] } | null; + folderId?: DriveFolder['id'] | null; + uri?: string | null; + sensitive?: boolean; + force?: boolean; + isLink?: boolean; + comment?: string | null; + requestIp?: string | null; + requestHeaders?: Record<string, string> | null; +}; + +@Injectable() +export class DriveService { + #registerLogger: Logger; + #downloaderLogger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + private fileInfoService: FileInfoService, + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + private metaService: MetaService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + private s3Service: S3Service, + private imageProcessingService: ImageProcessingService, + private videoProcessingService: VideoProcessingService, + private globalEventService: GlobalEventService, + private queueService: QueueService, + private driveChart: DriveChart, + private perUserDriveChart: PerUserDriveChart, + private instanceChart: InstanceChart, + ) { + const logger = new Logger('drive', 'blue'); + this.#registerLogger = logger.createSubLogger('register', 'yellow'); + this.#downloaderLogger = logger.createSubLogger('downloader'); + } + + /*** + * Save file + * @param path Path for original + * @param name Name for original + * @param type Content-Type for original + * @param hash Hash for original + * @param size Size for original + */ + async #save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> { + // thunbnail, webpublic を必要なら生成 + const alts = await this.generateAlts(path, type, !file.uri); + + const meta = await this.metaService.fetch(); + + if (meta.useObjectStorage) { + //#region ObjectStorage params + let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); + + if (ext === '') { + if (type === 'image/jpeg') ext = '.jpg'; + if (type === 'image/png') ext = '.png'; + if (type === 'image/webp') ext = '.webp'; + if (type === 'image/apng') ext = '.apng'; + if (type === 'image/vnd.mozilla.apng') ext = '.apng'; + } + + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + if (!FILE_TYPE_BROWSERSAFE.includes(type)) { + ext = ''; + } + + const baseUrl = meta.objectStorageBaseUrl + || `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + + // for original + const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; + const url = `${ baseUrl }/${ key }`; + + // for alts + let webpublicKey: string | null = null; + let webpublicUrl: string | null = null; + let thumbnailKey: string | null = null; + let thumbnailUrl: string | null = null; + //#endregion + + //#region Uploads + this.#registerLogger.info(`uploading original: ${key}`); + const uploads = [ + this.#upload(key, fs.createReadStream(path), type, name), + ]; + + if (alts.webpublic) { + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; + webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + + this.#registerLogger.info(`uploading webpublic: ${webpublicKey}`); + uploads.push(this.#upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + } + + if (alts.thumbnail) { + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; + thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + + this.#registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); + uploads.push(this.#upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + } + + await Promise.all(uploads); + //#endregion + + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = key; + file.thumbnailAccessKey = thumbnailKey; + file.webpublicAccessKey = webpublicKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + file.storedInternal = false; + + return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } else { // use internal storage + const accessKey = uuid(); + const thumbnailAccessKey = 'thumbnail-' + uuid(); + const webpublicAccessKey = 'webpublic-' + uuid(); + + const url = this.internalStorageService.saveFromPath(accessKey, path); + + let thumbnailUrl: string | null = null; + let webpublicUrl: string | null = null; + + if (alts.thumbnail) { + thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); + this.#registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (alts.webpublic) { + webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + this.#registerLogger.info(`web stored: ${webpublicAccessKey}`); + } + + file.storedInternal = true; + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = accessKey; + file.thumbnailAccessKey = thumbnailAccessKey; + file.webpublicAccessKey = webpublicAccessKey; + file.webpublicType = alts.webpublic?.type ?? null; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + + return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + } + + /** + * Generate webpublic, thumbnail, etc + * @param path Path for original + * @param type Content-Type for original + * @param generateWeb Generate webpublic or not + */ + public async generateAlts(path: string, type: string, generateWeb: boolean) { + if (type.startsWith('video/')) { + try { + const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path); + return { + webpublic: null, + thumbnail, + }; + } catch (err) { + this.#registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + } + + if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) { + this.#registerLogger.debug('web image and thumbnail not created (not an required file)'); + return { + webpublic: null, + thumbnail: null, + }; + } + + let img: sharp.Sharp | null = null; + let satisfyWebpublic: boolean; + + try { + img = sharp(path); + const metadata = await img.metadata(); + const isAnimated = metadata.pages && metadata.pages > 1; + + // skip animated + if (isAnimated) { + return { + webpublic: null, + thumbnail: null, + }; + } + + satisfyWebpublic = !!( + type !== 'image/svg+xml' && type !== 'image/webp' && + !(metadata.exif || metadata.iptc || metadata.xmp || metadata.tifftagPhotoshop) && + metadata.width && metadata.width <= 2048 && + metadata.height && metadata.height <= 2048 + ); + } catch (err) { + this.#registerLogger.warn(`sharp failed: ${err}`); + return { + webpublic: null, + thumbnail: null, + }; + } + + // #region webpublic + let webpublic: IImage | null = null; + + if (generateWeb && !satisfyWebpublic) { + this.#registerLogger.info('creating web image'); + + try { + if (['image/jpeg', 'image/webp'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); + } else if (['image/png'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + } else if (['image/svg+xml'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); + } else { + this.#registerLogger.debug('web image not created (not an required image)'); + } + } catch (err) { + this.#registerLogger.warn('web image not created (an error occured)', err as Error); + } + } else { + if (satisfyWebpublic) this.#registerLogger.info('web image not created (original satisfies webpublic)'); + else this.#registerLogger.info('web image not created (from remote)'); + } + // #endregion webpublic + + // #region thumbnail + let thumbnail: IImage | null = null; + + try { + if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) { + thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); + } else { + this.#registerLogger.debug('thumbnail not created (not an required file)'); + } + } catch (err) { + this.#registerLogger.warn('thumbnail not created (an error occured)', err as Error); + } + // #endregion thumbnail + + return { + webpublic, + thumbnail, + }; + } + + /** + * Upload to ObjectStorage + */ + async #upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + if (type === 'image/apng') type = 'image/png'; + if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; + + const meta = await this.metaService.fetch(); + + const params = { + Bucket: meta.objectStorageBucket, + Key: key, + Body: stream, + ContentType: type, + CacheControl: 'max-age=31536000, immutable', + } as S3.PutObjectRequest; + + if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + + const s3 = this.s3Service.getS3(meta); + + const upload = s3.upload(params, { + partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, + }); + + const result = await upload.promise(); + if (result) this.#registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); + } + + async #deleteOldFile(user: IRemoteUser) { + const q = this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .andWhere('file.isLink = FALSE'); + + if (user.avatarId) { + q.andWhere('file.id != :avatarId', { avatarId: user.avatarId }); + } + + if (user.bannerId) { + q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); + } + + q.orderBy('file.id', 'ASC'); + + const oldFile = await q.getOne(); + + if (oldFile) { + this.deleteFile(oldFile, true); + } + } + + /** + * Add file to drive + * + */ + public async addFile({ + user, + path, + name = null, + comment = null, + folderId = null, + force = false, + isLink = false, + url = null, + uri = null, + sensitive = null, + requestIp = null, + requestHeaders = null, + }: AddFileArgs): Promise<DriveFile> { + let skipNsfwCheck = false; + const instance = await this.metaService.fetch(); + if (user == null) skipNsfwCheck = true; + if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; + if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; + + const info = await this.fileInfoService.getFileInfo(path, { + skipSensitiveDetection: skipNsfwCheck, + sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる + instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + 0.5, + sensitiveThresholdForPorn: 0.75, + enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + }); + this.#registerLogger.info(`${JSON.stringify(info)}`); + + // 現状 false positive が多すぎて実用に耐えない + //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { + // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); + //} + + // detect name + const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + + if (user && !force) { + // Check if there is a file with the same hash + const much = await this.driveFilesRepository.findOneBy({ + md5: info.md5, + userId: user.id, + }); + + if (much) { + this.#registerLogger.info(`file with same hash is found: ${much.id}`); + return much; + } + } + + //#region Check drive usage + if (user && !isLink) { + const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const u = await this.usersRepository.findOneBy({ id: user.id }); + + const instance = await this.metaService.fetch(); + let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + + if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { + driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; + this.#registerLogger.debug('drive capacity override applied'); + this.#registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + } + + this.#registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); + + // If usage limit exceeded + if (usage + info.size > driveCapacity) { + if (this.userEntityService.isLocalUser(user)) { + throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + } else { + // (アバターまたはバナーを含まず)最も古いファイルを削除する + this.#deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as IRemoteUser); + } + } + } + //#endregion + + const fetchFolder = async () => { + if (!folderId) { + return null; + } + + const driveFolder = await this.driveFoldersRepository.findOneBy({ + id: folderId, + userId: user ? user.id : IsNull(), + }); + + if (driveFolder == null) throw new Error('folder-not-found'); + + return driveFolder; + }; + + const properties: { + width?: number; + height?: number; + orientation?: number; + } = {}; + + if (info.width) { + properties['width'] = info.width; + properties['height'] = info.height; + } + if (info.orientation != null) { + properties['orientation'] = info.orientation; + } + + const profile = user ? await this.userProfilesRepository.findOneBy({ userId: user.id }) : null; + + const folder = await fetchFolder(); + + let file = new DriveFile(); + file.id = this.idService.genId(); + file.createdAt = new Date(); + file.userId = user ? user.id : null; + file.userHost = user ? user.host : null; + file.folderId = folder !== null ? folder.id : null; + file.comment = comment; + file.properties = properties; + file.blurhash = info.blurhash ?? null; + file.isLink = isLink; + file.requestIp = requestIp; + file.requestHeaders = requestHeaders; + file.maybeSensitive = info.sensitive; + file.maybePorn = info.porn; + file.isSensitive = user + ? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : + (sensitive !== null && sensitive !== undefined) + ? sensitive + : false + : false; + + if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; + if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + + if (url !== null) { + file.src = url; + + if (isLink) { + file.url = url; + // ローカルプロキシ用 + file.accessKey = uuid(); + file.thumbnailAccessKey = 'thumbnail-' + uuid(); + file.webpublicAccessKey = 'webpublic-' + uuid(); + } + } + + if (uri !== null) { + file.uri = uri; + } + + if (isLink) { + try { + file.size = 0; + file.md5 = info.md5; + file.name = detectedName; + file.type = info.type.mime; + file.storedInternal = false; + + file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } catch (err) { + // duplicate key error (when already registered) + if (isDuplicateKeyValueError(err)) { + this.#registerLogger.info(`already registered ${file.uri}`); + + file = await this.driveFilesRepository.findOneBy({ + uri: file.uri!, + userId: user ? user.id : IsNull(), + }) as DriveFile; + } else { + this.#registerLogger.error(err as Error); + throw err; + } + } + } else { + file = await (this.#save(file, path, detectedName, info.type.mime, info.md5, info.size)); + } + + this.#registerLogger.succ(`drive file has been created ${file.id}`); + + if (user) { + this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { + // Publish driveFileCreated event + this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); + this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); + }); + } + + // 統計を更新 + this.driveChart.update(file, true); + this.perUserDriveChart.update(file, true); + if (file.userHost !== null) { + this.instanceChart.updateDrive(file, true); + } + + return file; + } + + public async deleteFile(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + this.internalStorageService.del(file.accessKey!); + + if (file.thumbnailUrl) { + this.internalStorageService.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.internalStorageService.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + this.queueService.createDeleteObjectStorageFileJob(file.accessKey!); + + if (file.thumbnailUrl) { + this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!); + } + } + + this.#deletePostProcess(file, isExpired); + } + + public async deleteFileSync(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + this.internalStorageService.del(file.accessKey!); + + if (file.thumbnailUrl) { + this.internalStorageService.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + this.internalStorageService.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + const promises = []; + + promises.push(this.deleteObjectStorageFile(file.accessKey!)); + + if (file.thumbnailUrl) { + promises.push(this.deleteObjectStorageFile(file.thumbnailAccessKey!)); + } + + if (file.webpublicUrl) { + promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!)); + } + + await Promise.all(promises); + } + + this.#deletePostProcess(file, isExpired); + } + + async #deletePostProcess(file: DriveFile, isExpired = false) { + // リモートファイル期限切れ削除後は直リンクにする + if (isExpired && file.userHost !== null && file.uri != null) { + this.driveFilesRepository.update(file.id, { + isLink: true, + url: file.uri, + thumbnailUrl: null, + webpublicUrl: null, + storedInternal: false, + // ローカルプロキシ用 + accessKey: uuid(), + thumbnailAccessKey: 'thumbnail-' + uuid(), + webpublicAccessKey: 'webpublic-' + uuid(), + }); + } else { + this.driveFilesRepository.delete(file.id); + } + + // 統計を更新 + this.driveChart.update(file, false); + this.perUserDriveChart.update(file, false); + if (file.userHost !== null) { + this.instanceChart.updateDrive(file, false); + } + } + + public async deleteObjectStorageFile(key: string) { + const meta = await this.metaService.fetch(); + + const s3 = this.s3Service.getS3(meta); + + await s3.deleteObject({ + Bucket: meta.objectStorageBucket!, + Key: key, + }).promise(); + } + + public async uploadFromUrl({ + url, + user, + folderId = null, + uri = null, + sensitive = false, + force = false, + isLink = false, + comment = null, + requestIp = null, + requestHeaders = null, + }: UploadFromUrlArgs): Promise<DriveFile> { + let name = new URL(url).pathname.split('/').pop() ?? null; + if (name == null || !this.driveFileEntityService.validateFileName(name)) { + name = null; + } + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + // write content at URL to temp file + await this.downloadService.downloadUrl(url, path); + + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); + this.#downloaderLogger.succ(`Got: ${driveFile.id}`); + return driveFile!; + } catch (err) { + this.#downloaderLogger.error(`Failed to create drive file: ${err}`, { + url: url, + e: err, + }); + throw err; + } finally { + cleanup(); + } + } +} diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts new file mode 100644 index 0000000000..7d6960b73b --- /dev/null +++ b/packages/backend/src/core/EmailService.ts @@ -0,0 +1,175 @@ +import * as nodemailer from 'nodemailer'; +import { Inject, Injectable } from '@nestjs/common'; +import { validate as validateEmail } from 'deep-email-validator'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import Logger from '@/logger.js'; +import { UserProfilesRepository } from '@/models/index.js'; + +@Injectable() +export class EmailService { + #logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private metaService: MetaService, + ) { + this.#logger = new Logger('email'); + } + + public async sendEmail(to: string, subject: string, html: string, text: string) { + const meta = await this.metaService.fetch(true); + + const iconUrl = `${this.config.url}/static-assets/mi-white.png`; + const emailSettingUrl = `${this.config.url}/settings/email`; + + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + + const transporter = nodemailer.createTransport({ + host: meta.smtpHost, + port: meta.smtpPort, + secure: meta.smtpSecure, + ignoreTLS: !enableAuth, + proxy: this.config.proxySmtp, + auth: enableAuth ? { + user: meta.smtpUser, + pass: meta.smtpPass, + } : undefined, + } as any); + + try { + // TODO: htmlサニタイズ + const info = await transporter.sendMail({ + from: meta.email!, + to: to, + subject: subject, + text: text, + html: `<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <title>${ subject }</title> + <style> + html { + background: #eee; + } + + body { + padding: 16px; + margin: 0; + font-family: sans-serif; + font-size: 14px; + } + + a { + text-decoration: none; + color: #86b300; + } + a:hover { + text-decoration: underline; + } + + main { + max-width: 500px; + margin: 0 auto; + background: #fff; + color: #555; + } + main > header { + padding: 32px; + background: #86b300; + } + main > header > img { + max-width: 128px; + max-height: 28px; + vertical-align: bottom; + } + main > article { + padding: 32px; + } + main > article > h1 { + margin: 0 0 1em 0; + } + main > footer { + padding: 32px; + border-top: solid 1px #eee; + } + + nav { + box-sizing: border-box; + max-width: 500px; + margin: 16px auto 0 auto; + padding: 0 32px; + } + nav > a { + color: #888; + } + </style> + </head> + <body> + <main> + <header> + <img src="${ meta.logoImageUrl ?? meta.iconUrl ?? iconUrl }"/> + </header> + <article> + <h1>${ subject }</h1> + <div>${ html }</div> + </article> + <footer> + <a href="${ emailSettingUrl }">${ 'Email setting' }</a> + </footer> + </main> + <nav> + <a href="${ this.config.url }">${ this.config.host }</a> + </nav> + </body> +</html>`, + }); + + this.#logger.info(`Message sent: ${info.messageId}`); + } catch (err) { + this.#logger.error(err as Error); + throw err; + } + } + + public async validateEmailForAccount(emailAddress: string): Promise<{ + available: boolean; + reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; + }> { + const meta = await this.metaService.fetch(); + + const exist = await this.userProfilesRepository.countBy({ + emailVerified: true, + email: emailAddress, + }); + + const validated = meta.enableActiveEmailValidation ? await validateEmail({ + email: emailAddress, + validateRegex: true, + validateMx: true, + validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので + validateDisposable: true, // 捨てアドかどうかチェック + validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので + }) : { valid: true }; + + const available = exist === 0 && validated.valid; + + return { + available, + reason: available ? null : + exist !== 0 ? 'used' : + validated.reason === 'regex' ? 'format' : + validated.reason === 'disposable' ? 'disposable' : + validated.reason === 'mx' ? 'mx' : + validated.reason === 'smtp' ? 'smtp' : + null, + }; + } +} diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts new file mode 100644 index 0000000000..24bedd8192 --- /dev/null +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InstancesRepository } from '@/models/index.js'; +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'; + +@Injectable() +export class FederatedInstanceService { + #cache: Cache<Instance>; + + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private utilityService: UtilityService, + private idService: IdService, + ) { + this.#cache = new Cache<Instance>(1000 * 60 * 60); + } + + public async registerOrFetchInstanceDoc(host: string): Promise<Instance> { + host = this.utilityService.toPuny(host); + + const cached = this.#cache.get(host); + if (cached) return cached; + + const index = await this.instancesRepository.findOneBy({ host }); + + if (index == null) { + const i = await this.instancesRepository.insert({ + id: this.idService.genId(), + host, + caughtAt: new Date(), + lastCommunicatedAt: new Date(), + }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); + + this.#cache.set(host, i); + return i; + } else { + this.#cache.set(host, index); + return index; + } + } +} diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts new file mode 100644 index 0000000000..6353784c13 --- /dev/null +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -0,0 +1,283 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { JSDOM } from 'jsdom'; +import fetch from 'node-fetch'; +import tinycolor from 'tinycolor2'; +import type { Instance } from '@/models/entities/Instance.js'; +import { InstancesRepository } from '@/models/index.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import Logger from '@/logger.js'; +import { DI } from '@/di-symbols.js'; +import { HttpRequestService } from './HttpRequestService.js'; +import type { DOMWindow } from 'jsdom'; + +const logger = new Logger('metadata', 'cyan'); + +type NodeInfo = { + openRegistrations?: any; + software?: { + name?: any; + version?: any; + }; + metadata?: { + name?: any; + nodeName?: any; + nodeDescription?: any; + description?: any; + maintainer?: { + name?: any; + email?: any; + }; + }; +}; + +@Injectable() +export class FetchInstanceMetadataService { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private appLockService: AppLockService, + private httpRequestService: HttpRequestService, + ) { + } + + public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> { + const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host); + + if (!force) { + const _instance = await this.instancesRepository.findOneBy({ host: instance.host }); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + unlock(); + return; + } + } + + logger.info(`Fetching metadata of ${instance.host} ...`); + + try { + const [info, dom, manifest] = await Promise.all([ + this.#fetchNodeinfo(instance).catch(() => null), + this.#fetchDom(instance).catch(() => null), + this.#fetchManifest(instance).catch(() => null), + ]); + + const [favicon, icon, themeColor, name, description] = await Promise.all([ + this.#fetchFaviconUrl(instance, dom).catch(() => null), + this.#fetchIconUrl(instance, dom, manifest).catch(() => null), + this.#getThemeColor(info, dom, manifest).catch(() => null), + this.#getSiteName(info, dom, manifest).catch(() => null), + this.#getDescription(info, dom, manifest).catch(() => null), + ]); + + logger.succ(`Successfuly fetched metadata of ${instance.host}`); + + const updates = { + infoUpdatedAt: new Date(), + } as Record<string, any>; + + if (info) { + updates.softwareName = info.software?.name.toLowerCase(); + updates.softwareVersion = info.software?.version; + updates.openRegistrations = info.openRegistrations; + updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; + updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; + } + + if (name) updates.name = name; + if (description) updates.description = description; + if (icon || favicon) updates.iconUrl = icon ?? favicon; + if (favicon) updates.faviconUrl = favicon; + if (themeColor) updates.themeColor = themeColor; + + await this.instancesRepository.update(instance.id, updates); + + logger.succ(`Successfuly updated metadata of ${instance.host}`); + } catch (e) { + logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + } finally { + unlock(); + } + } + + async #fetchNodeinfo(instance: Instance): Promise<NodeInfo> { + logger.info(`Fetching nodeinfo of ${instance.host} ...`); + + try { + const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') + .catch(err => { + if (err.statusCode === 404) { + throw 'No nodeinfo provided'; + } else { + throw err.statusCode ?? err.message; + } + }) as Record<string, unknown>; + + if (wellknown.links == null || !Array.isArray(wellknown.links)) { + throw 'No wellknown links'; + } + + const links = wellknown.links as any[]; + + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); + const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); + const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); + const link = lnik2_1 ?? lnik2_0 ?? lnik1_0; + + if (link == null) { + throw 'No nodeinfo link provided'; + } + + const info = await this.httpRequestService.getJson(link.href) + .catch(err => { + throw err.statusCode ?? err.message; + }); + + logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + + return info as NodeInfo; + } catch (err) { + logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`); + + throw err; + } + } + + async #fetchDom(instance: Instance): Promise<DOMWindow['document']> { + logger.info(`Fetching HTML of ${instance.host} ...`); + + const url = 'https://' + instance.host; + + const html = await this.httpRequestService.getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + return doc; + } + + async #fetchManifest(instance: Instance): Promise<Record<string, unknown> | null> { + const url = 'https://' + instance.host; + + const manifestUrl = url + '/manifest.json'; + + const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>; + + return manifest; + } + + async #fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> { + const url = 'https://' + instance.host; + + if (doc) { + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + + if (href) { + return (new URL(href, url)).href; + } + } + + const faviconUrl = url + '/favicon.ico'; + + const favicon = await fetch(faviconUrl, { + // TODO + //timeout: 10000, + agent: url => this.httpRequestService.getAgentByUrl(url), + }); + + if (favicon.ok) { + return faviconUrl; + } + + return null; + } + + async #fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { + const url = 'https://' + instance.host; + return (new URL(manifest.icons[0].src, url)).href; + } + + if (doc) { + const url = 'https://' + instance.host; + + // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 + const links = Array.from(doc.getElementsByTagName('link')).reverse(); + // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 + const href = + [ + links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, + links.find(link => link.relList.contains('apple-touch-icon'))?.href, + links.find(link => link.relList.contains('icon'))?.href, + ] + .find(href => href); + + if (href) { + return (new URL(href, url)).href; + } + } + + return null; + } + + async #getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; + + if (themeColor) { + const color = new tinycolor(themeColor); + if (color.isValid()) return color.toHexString(); + } + + return null; + } + + async #getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (info && info.metadata) { + if (info.metadata.nodeName || info.metadata.name) { + return info.metadata.nodeName ?? info.metadata.name; + } + } + + if (doc) { + const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + + if (og) { + return og; + } + } + + if (manifest) { + return manifest.name ?? manifest.short_name; + } + + return null; + } + + async #getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (info && info.metadata) { + if (info.metadata.nodeDescription || info.metadata.description) { + return info.metadata.nodeDescription ?? info.metadata.description; + } + } + + if (doc) { + const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + if (meta) { + return meta; + } + + const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + if (og) { + return og; + } + } + + if (manifest) { + return manifest.name ?? manifest.short_name; + } + + return null; + } +} diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts new file mode 100644 index 0000000000..45c94dafdc --- /dev/null +++ b/packages/backend/src/core/FileInfoService.ts @@ -0,0 +1,382 @@ +import * as fs from 'node:fs'; +import * as crypto from 'node:crypto'; +import { join } from 'node:path'; +import * as stream from 'node:stream'; +import * as util from 'node:util'; +import { Inject, Injectable } from '@nestjs/common'; +import { FSWatcher } from 'chokidar'; +import { fileTypeFromFile } from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; +import isSvg from 'is-svg'; +import probeImageSize from 'probe-image-size'; +import { type predictionType } from 'nsfwjs'; +import sharp from 'sharp'; +import { encode } from 'blurhash'; +import { createTempDir } from '@/misc/create-temp.js'; +import { AiService } from '@/core/AiService.js'; + +const pipeline = util.promisify(stream.pipeline); + +export type FileInfo = { + size: number; + md5: string; + type: { + mime: string; + ext: string | null; + }; + width?: number; + height?: number; + orientation?: number; + blurhash?: string; + sensitive: boolean; + porn: boolean; + warnings: string[]; +}; + +const TYPE_OCTET_STREAM = { + mime: 'application/octet-stream', + ext: null, +}; + +const TYPE_SVG = { + mime: 'image/svg+xml', + ext: 'svg', +}; +@Injectable() +export class FileInfoService { + constructor( + private aiService: AiService, + ) { + } + + /** + * Get file information + */ + public async getFileInfo(path: string, opts: { + skipSensitiveDetection: boolean; + sensitiveThreshold?: number; + sensitiveThresholdForPorn?: number; + enableSensitiveMediaDetectionForVideos?: boolean; + }): Promise<FileInfo> { + const warnings = [] as string[]; + + const size = await this.getFileSize(path); + const md5 = await this.#calcHash(path); + + let type = await this.detectType(path); + + // image dimensions + let width: number | undefined; + let height: number | undefined; + let orientation: number | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { + const imageSize = await this.#detectImageSize(path).catch(e => { + warnings.push(`detectImageSize failed: ${e}`); + return undefined; + }); + + // うまく判定できない画像は octet-stream にする + if (!imageSize) { + warnings.push('cannot detect image dimensions'); + type = TYPE_OCTET_STREAM; + } else if (imageSize.wUnits === 'px') { + width = imageSize.width; + height = imageSize.height; + orientation = imageSize.orientation; + + // 制限を超えている画像は octet-stream にする + if (imageSize.width > 16383 || imageSize.height > 16383) { + warnings.push('image dimensions exceeds limits'); + type = TYPE_OCTET_STREAM; + } + } else { + warnings.push(`unsupported unit type: ${imageSize.wUnits}`); + } + } + + let blurhash: string | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { + blurhash = await this.#getBlurhash(path).catch(e => { + warnings.push(`getBlurhash failed: ${e}`); + return undefined; + }); + } + + let sensitive = false; + let porn = false; + + if (!opts.skipSensitiveDetection) { + await this.#detectSensitivity( + path, + type.mime, + opts.sensitiveThreshold ?? 0.5, + opts.sensitiveThresholdForPorn ?? 0.75, + opts.enableSensitiveMediaDetectionForVideos ?? false, + ).then(value => { + [sensitive, porn] = value; + }, error => { + warnings.push(`detectSensitivity failed: ${error}`); + }); + } + + return { + size, + md5, + type, + width, + height, + orientation, + blurhash, + sensitive, + porn, + warnings, + }; + } + + async #detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { + let sensitive = false; + let porn = false; + + function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + let sensitive = false; + let porn = false; + + if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; + + return [sensitive, porn]; + } + + if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) { + const result = await this.aiService.detectSensitive(source); + if (result) { + [sensitive, porn] = judgePrediction(result); + } + } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { + const [outDir, disposeOutDir] = await createTempDir(); + try { + const command = FFmpeg() + .input(source) + .inputOptions([ + '-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない) + '-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない) + ]) + .noAudio() + .videoFilters([ + { + filter: 'select', // フレームのフィルタリング + options: { + e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい) + }, + }, + { + filter: 'blackframe', // 暗いフレームの検出 + options: { + amount: '0', // 暗さに関わらず全てのフレームで測定値を取る + }, + }, + { + filter: 'metadata', + options: { + mode: 'select', // フレーム選択モード + key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する) + value: '50', + function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので) + }, + }, + { + filter: 'scale', + options: { + w: 299, + h: 299, + }, + }, + ]) + .format('image2') + .output(join(outDir, '%d.png')) + .outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない + const results: ReturnType<typeof judgePrediction>[] = []; + let frameIndex = 0; + let targetIndex = 0; + let nextIndex = 1; + for await (const path of this.#asyncIterateFrames(outDir, command)) { + try { + const index = frameIndex++; + if (index !== targetIndex) { + continue; + } + targetIndex = nextIndex; + nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける + const result = await this.aiService.detectSensitive(path); + if (result) { + results.push(judgePrediction(result)); + } + } finally { + fs.promises.unlink(path); + } + } + sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold); + porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn); + } finally { + disposeOutDir(); + } + } + + return [sensitive, porn]; + } + + async *#asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> { + const watcher = new FSWatcher({ + cwd, + disableGlobbing: true, + }); + let finished = false; + command.once('end', () => { + finished = true; + watcher.close(); + }); + command.run(); + for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + const current = `${i}.png`; + const next = `${i + 1}.png`; + const framePath = join(cwd, current); + if (await this.#exists(join(cwd, next))) { + yield framePath; + } else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + watcher.add(next); + await new Promise<void>((resolve, reject) => { + watcher.on('add', function onAdd(path) { + if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている + watcher.unwatch(current); + watcher.off('add', onAdd); + resolve(); + } + }); + command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている + command.once('error', reject); + }); + yield framePath; + } else if (await this.#exists(framePath)) { + yield framePath; + } else { + return; + } + } + } + + #exists(path: string): Promise<boolean> { + return fs.promises.access(path).then(() => true, () => false); + } + + /** + * Detect MIME Type and extension + */ + public async detectType(path: string): Promise<{ + mime: string; + ext: string | null; +}> { + // Check 0 byte + const fileSize = await this.getFileSize(path); + if (fileSize === 0) { + return TYPE_OCTET_STREAM; + } + + const type = await fileTypeFromFile(path); + + if (type) { + // XMLはSVGかもしれない + if (type.mime === 'application/xml' && await this.checkSvg(path)) { + return TYPE_SVG; + } + + return { + mime: type.mime, + ext: type.ext, + }; + } + + // 種類が不明でもSVGかもしれない + if (await this.checkSvg(path)) { + return TYPE_SVG; + } + + // それでも種類が不明なら application/octet-stream にする + return TYPE_OCTET_STREAM; + } + + /** + * Check the file is SVG or not + */ + public async checkSvg(path: string) { + try { + const size = await this.getFileSize(path); + if (size > 1 * 1024 * 1024) return false; + return isSvg(fs.readFileSync(path)); + } catch { + return false; + } + } + + /** + * Get file size + */ + public async getFileSize(path: string): Promise<number> { + const getStat = util.promisify(fs.stat); + return (await getStat(path)).size; + } + + /** + * Calculate MD5 hash + */ + async #calcHash(path: string): Promise<string> { + const hash = crypto.createHash('md5').setEncoding('hex'); + await pipeline(fs.createReadStream(path), hash); + return hash.read(); + } + + /** + * Detect dimensions of image + */ + async #detectImageSize(path: string): Promise<{ + width: number; + height: number; + wUnits: string; + hUnits: string; + orientation?: number; +}> { + const readable = fs.createReadStream(path); + const imageSize = await probeImageSize(readable); + readable.destroy(); + return imageSize; + } + + /** + * Calculate average color of image + */ + #getBlurhash(path: string): Promise<string> { + return new Promise((resolve, reject) => { + sharp(path) + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer((err, buffer, { width, height }) => { + if (err) return reject(err); + + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); + }); + }); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts new file mode 100644 index 0000000000..c36de63fde --- /dev/null +++ b/packages/backend/src/core/GlobalEventService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { Antenna } from '@/models/entities/Antenna.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import type { + StreamChannels, + AdminStreamTypes, + AntennaStreamTypes, + BroadcastTypes, + ChannelStreamTypes, + DriveStreamTypes, + GroupMessagingStreamTypes, + InternalStreamTypes, + MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, + NoteStreamTypes, + UserListStreamTypes, + UserStreamTypes, +} from '@/server/api/stream/types.js'; +import type { Packed } from '@/misc/schema.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +@Injectable() +export class GlobalEventService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + ) { + } + + private publish(channel: StreamChannels, type: string | null, value?: any): void { + const message = type == null ? value : value == null ? + { type: type, body: null } : + { type: type, body: value }; + + this.redisClient.publish(this.config.host, JSON.stringify({ + channel: channel, + message: message, + })); + } + + public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void { + this.publish('internal', type, typeof value === 'undefined' ? null : value); + } + + public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { + this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void { + this.publish('broadcast', type, typeof value === 'undefined' ? null : value); + } + + public publishMainStream<K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void { + this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishDriveStream<K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void { + this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void { + this.publish(`noteStream:${noteId}`, type, { + id: noteId, + body: value, + }); + } + + public publishChannelStream<K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void { + this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishUserListStream<K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void { + this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void { + this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream<K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishGroupMessagingStream<K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingIndexStream<K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNotesStream(note: Packed<'Note'>): void { + this.publish('notesStream', null, note); + } + + public publishAdminStream<K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void { + this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } +} diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts new file mode 100644 index 0000000000..f6c06d48f4 --- /dev/null +++ b/packages/backend/src/core/HashtagService.ts @@ -0,0 +1,147 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { IdService } from '@/core/IdService.js'; +import type { Hashtag } from '@/models/entities/Hashtag.js'; +import HashtagChart from '@/core/chart/charts/hashtag.js'; +import { HashtagsRepository, UsersRepository } from '@/models/index.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class HashtagService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private hashtagChart: HashtagChart, + ) { + } + + public async updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) { + for (const tag of tags) { + await this.updateHashtag(user, tag); + } + } + + public async updateUsertags(user: User, tags: string[]) { + for (const tag of tags) { + await this.updateHashtag(user, tag, true, true); + } + + for (const tag of (user.tags ?? []).filter(x => !tags.includes(x))) { + await this.updateHashtag(user, tag, true, false); + } + } + + public async updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) { + tag = normalizeForSearch(tag); + + const index = await this.hashtagsRepository.findOneBy({ name: tag }); + + if (index == null && !inc) return; + + if (index != null) { + const q = this.hashtagsRepository.createQueryBuilder('tag').update() + .where('name = :name', { name: tag }); + + const set = {} as any; + + if (isUserAttached) { + if (inc) { + // 自分が初めてこのタグを使ったなら + if (!index.attachedUserIds.some(id => id === user.id)) { + set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => '"attachedUsersCount" + 1'; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { + set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1'; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { + set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1'; + } + } else { + set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => '"attachedUsersCount" - 1'; + if (this.userEntityService.isLocalUser(user)) { + set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1'; + } else { + set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1'; + } + } + } else { + // 自分が初めてこのタグを使ったなら + if (!index.mentionedUserIds.some(id => id === user.id)) { + set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; + set.mentionedUsersCount = () => '"mentionedUsersCount" + 1'; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { + set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; + set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1'; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { + set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; + set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1'; + } + } + + if (Object.keys(set).length > 0) { + q.set(set); + q.execute(); + } + } else { + if (isUserAttached) { + this.hashtagsRepository.insert({ + id: this.idService.genId(), + name: tag, + mentionedUserIds: [], + mentionedUsersCount: 0, + mentionedLocalUserIds: [], + mentionedLocalUsersCount: 0, + mentionedRemoteUserIds: [], + mentionedRemoteUsersCount: 0, + attachedUserIds: [user.id], + attachedUsersCount: 1, + attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + } as Hashtag); + } else { + this.hashtagsRepository.insert({ + id: this.idService.genId(), + name: tag, + mentionedUserIds: [user.id], + mentionedUsersCount: 1, + mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [], + mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0, + mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [], + mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0, + attachedUserIds: [], + attachedUsersCount: 0, + attachedLocalUserIds: [], + attachedLocalUsersCount: 0, + attachedRemoteUserIds: [], + attachedRemoteUsersCount: 0, + } as Hashtag); + } + } + + if (!isUserAttached) { + this.hashtagChart.update(tag, user); + } + } +} diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts new file mode 100644 index 0000000000..21cde12536 --- /dev/null +++ b/packages/backend/src/core/HttpRequestService.ts @@ -0,0 +1,154 @@ +import * as http from 'node:http'; +import * as https from 'node:https'; +import CacheableLookup from 'cacheable-lookup'; +import fetch from 'node-fetch'; +import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { StatusError } from '@/misc/status-error.js'; +import type { Response } from 'node-fetch'; +import type { URL } from 'node:url'; + +@Injectable() +export class HttpRequestService { + /** + * Get http non-proxy agent + */ + #http: http.Agent; + + /** + * Get https non-proxy agent + */ + #https: https.Agent; + + /** + * Get http proxy or non-proxy agent + */ + public httpAgent: http.Agent; + + /** + * Get https proxy or non-proxy agent + */ + public httpsAgent: https.Agent; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + const cache = new CacheableLookup({ + maxTtl: 3600, // 1hours + errorTtl: 30, // 30secs + lookup: false, // nativeのdns.lookupにfallbackしない + }); + + this.#http = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, + } as http.AgentOptions); + + this.#https = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, + } as https.AgentOptions); + + const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); + + this.httpAgent = config.proxy + ? new HttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : this.#http; + + this.httpsAgent = config.proxy + ? new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy, + }) + : this.#https; + } + + /** + * Get agent by URL + * @param url URL + * @param bypassProxy Allways bypass proxy + */ + public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent { + if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) { + return url.protocol === 'http:' ? this.#http : this.#https; + } else { + return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; + } + } + + public async getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>): Promise<unknown> { + const res = await this.getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': this.config.userAgent, + Accept: accept, + }, headers ?? {}), + timeout, + }); + + return await res.json(); + } + + public async getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>): Promise<string> { + const res = await this.getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': this.config.userAgent, + Accept: accept, + }, headers ?? {}), + timeout, + }); + + return await res.text(); + } + + public async getResponse(args: { + url: string, + method: string, + body?: string, + headers: Record<string, string>, + timeout?: number, + size?: number, + }): Promise<Response> { + const timeout = args.timeout ?? 10 * 1000; + + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, timeout * 6); + + const res = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.body, + timeout, + size: args.size ?? 10 * 1024 * 1024, + agent: (url) => this.getAgentByUrl(url), + signal: controller.signal, + }); + + if (!res.ok) { + throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + } + + return res; + } +} diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts new file mode 100644 index 0000000000..b3b0d63627 --- /dev/null +++ b/packages/backend/src/core/IdService.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ulid } from 'ulid'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { genAid } from '@/misc/id/aid.js'; +import { genMeid } from '@/misc/id/meid.js'; +import { genMeidg } from '@/misc/id/meidg.js'; +import { genObjectId } from '@/misc/id/object-id.js'; + +@Injectable() +export class IdService { + #metohd: string; + + constructor( + @Inject(DI.config) + private config: Config, + ) { + this.#metohd = config.id.toLowerCase(); + } + + public genId(date?: Date): string { + if (!date || (date > new Date())) date = new Date(); + + switch (this.#metohd) { + case 'aid': return genAid(date); + case 'meid': return genMeid(date); + case 'meidg': return genMeidg(date); + case 'ulid': return ulid(date.getTime()); + case 'objectid': return genObjectId(date); + default: throw new Error('unrecognized id generation method'); + } + } +} diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts new file mode 100644 index 0000000000..d215be2131 --- /dev/null +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import sharp from 'sharp'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +export type IImage = { + data: Buffer; + ext: string | null; + type: string; +}; + +@Injectable() +export class ImageProcessingService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + /** + * Convert to JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> { + return this.convertSharpToJpeg(await sharp(path), width, height); + } + + public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true, + }) + .toBuffer(); + + return { + data, + ext: 'jpg', + type: 'image/jpeg', + }; + } + + /** + * Convert to WebP + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToWebp(path: string, width: number, height: number, quality = 85): Promise<IImage> { + return this.convertSharpToWebp(await sharp(path), width, height, quality); + } + + public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality = 85): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .webp({ + quality, + }) + .toBuffer(); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * Convert to PNG + * with resize, remove metadata, resolve orientation, stop animation + */ + public async convertToPng(path: string, width: number, height: number): Promise<IImage> { + return this.convertSharpToPng(await sharp(path), width, height); + } + + public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .png() + .toBuffer(); + + return { + data, + ext: 'png', + type: 'image/png', + }; + } +} diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts new file mode 100644 index 0000000000..3a93a49c7b --- /dev/null +++ b/packages/backend/src/core/InstanceActorService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { UsersRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import { DI } from '@/di-symbols.js'; +import { CreateSystemUserService } from './CreateSystemUserService.js'; + +const ACTOR_USERNAME = 'instance.actor' as const; + +@Injectable() +export class InstanceActorService { + #cache: Cache<ILocalUser>; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private createSystemUserService: CreateSystemUserService, + ) { + this.#cache = new Cache<ILocalUser>(Infinity); + } + + public async getInstanceActor(): Promise<ILocalUser> { + const cached = this.#cache.get(null); + if (cached) return cached; + + const user = await this.usersRepository.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }) as ILocalUser | undefined; + + if (user) { + this.#cache.set(null, user); + return user; + } else { + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as ILocalUser; + this.#cache.set(null, created); + return created; + } + } +} diff --git a/packages/backend/src/core/InternalStorageService.ts b/packages/backend/src/core/InternalStorageService.ts new file mode 100644 index 0000000000..9bc3597baf --- /dev/null +++ b/packages/backend/src/core/InternalStorageService.ts @@ -0,0 +1,45 @@ +import * as fs from 'node:fs'; +import * as Path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const path = Path.resolve(_dirname, '../../../../files'); + +@Injectable() +export class InternalStorageService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public resolvePath(key: string) { + return Path.resolve(path, key); + } + + public read(key: string) { + return fs.createReadStream(this.resolvePath(key)); + } + + public saveFromPath(key: string, srcPath: string) { + fs.mkdirSync(path, { recursive: true }); + fs.copyFileSync(srcPath, this.resolvePath(key)); + return `${this.config.url}/files/${key}`; + } + + public saveFromBuffer(key: string, data: Buffer) { + fs.mkdirSync(path, { recursive: true }); + fs.writeFileSync(this.resolvePath(key), data); + return `${this.config.url}/files/${key}`; + } + + public del(key: string) { + fs.unlink(this.resolvePath(key), () => {}); + } +} diff --git a/packages/backend/src/core/MessagingService.ts b/packages/backend/src/core/MessagingService.ts new file mode 100644 index 0000000000..669089e1e5 --- /dev/null +++ b/packages/backend/src/core/MessagingService.ts @@ -0,0 +1,300 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User, CacheableUser, IRemoteUser } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { QueueService } from '@/core/QueueService.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { 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'; + +@Injectable() +export class MessagingService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private messagingMessageEntityService: MessagingMessageEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) { + const message = { + id: this.idService.genId(), + createdAt: new Date(), + fileId: file ? file.id : null, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, + text: text ? text.trim() : null, + userId: user.id, + isRead: false, + reads: [] as any[], + uri, + } as MessagingMessage; + + await this.messagingMessagesRepository.insert(message); + + const messageObj = await this.messagingMessageEntityService.pack(message); + + if (recipientUser) { + if (this.userEntityService.isLocalUser(user)) { + // 自分のストリーム + this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj); + this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj); + } + + if (this.userEntityService.isLocalUser(recipientUser)) { + // 相手のストリーム + this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } + } else if (recipientGroup) { + // グループのストリーム + this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj); + this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } + + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id }); + if (freshMessage == null) return; // メッセージが削除されている場合もある + + if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) { + if (freshMessage.isRead) return; // 既読 + + //#region ただしミュートされているなら発行しない + const mute = await this.mutingsRepository.findBy({ + muterId: recipientUser.id, + }); + if (mute.map(m => m.muteeId).includes(user.id)) return; + //#endregion + + this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); + } + } + }, 2000); + + if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) { + const note = { + id: message.id, + createdAt: message.createdAt, + fileIds: message.fileId ? [message.fileId] : [], + text: message.text, + userId: message.userId, + visibility: 'specified', + mentions: [recipientUser].map(u => u.id), + mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({ + uri: u.uri, + username: u.username, + host: u.host, + }))), + } as Note; + + const activity = this.apRendererService.renderActivity(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note)); + + this.queueService.deliver(user, activity, recipientUser.inbox); + } + return messageObj; + } + + public async deleteMessage(message: MessagingMessage) { + await this.messagingMessagesRepository.delete(message.id); + this.#postDeleteMessage(message); + } + + async #postDeleteMessage(message: MessagingMessage) { + if (message.recipientId) { + const user = await this.usersRepository.findOneByOrFail({ id: message.userId }); + const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId }); + + if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) { + const activity = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user)); + this.queueService.deliver(user, activity, recipient.inbox); + } + } else if (message.groupId) { + this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } + } + + /** + * Mark messages as read + */ + public async readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await this.messagingMessagesRepository.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, { + isRead: true, + }); + + // Publish event + this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds); + this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのユーザーとのメッセージで未読がなければイベント発行 + const count = await this.messagingMessagesRepository.count({ + where: { + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + take: 1, + }); + + if (!count) { + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); + } + } + } + + /** + * Mark messages as read + */ + public async readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await this.messagingMessagesRepository.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any, + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + this.globalEventService.publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId, + }); + this.globalEventService.publishMessagingIndexStream(userId, 'read', reads); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのグループにおいて未読がなければイベント発行 + const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: groupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null); + + if (!unreadExist) { + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId }); + } + } + } + + public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => this.apRendererService.renderRead(user, x)); + + if (contents.length > 1) { + const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents); + this.queueService.deliver(user, this.apRendererService.renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + this.queueService.deliver(user, this.apRendererService.renderActivity(content), recipient.inbox); + } + } + } +} diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts new file mode 100644 index 0000000000..b5bd423765 --- /dev/null +++ b/packages/backend/src/core/MetaService.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { Meta } from '@/models/entities/Meta.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class MetaService implements OnApplicationShutdown { + #cache: Meta | undefined; + #intervalId: NodeJS.Timer; + + constructor( + @Inject(DI.db) + private db: DataSource, + ) { + if (process.env.NODE_ENV !== 'test') { + this.#intervalId = setInterval(() => { + this.fetch(true).then(meta => { + this.#cache = meta; + }); + }, 1000 * 10); + } + } + + async fetch(noCache = false): Promise<Meta> { + if (!noCache && this.#cache) return this.#cache; + + return await this.db.transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const metas = await transactionalEntityManager.find(Meta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + this.#cache = meta; + return meta; + } else { + // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + const saved = await transactionalEntityManager + .upsert( + Meta, + { + id: 'x', + }, + ['id'], + ) + .then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0])); + + this.#cache = saved; + return saved; + } + }); + } + + public onApplicationShutdown(signal?: string | undefined) { + clearInterval(this.#intervalId); + } +} diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts new file mode 100644 index 0000000000..236be4bbf8 --- /dev/null +++ b/packages/backend/src/core/MfmService.ts @@ -0,0 +1,384 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import * as parse5 from 'parse5'; +import { JSDOM } from 'jsdom'; +import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { intersperse } from '@/misc/prelude/array.js'; +import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; +import type * as mfm from 'mfm-js'; + +const treeAdapter = TreeAdapter.defaultTreeAdapter; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; + +@Injectable() +export class MfmService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public fromHtml(html: string, hashtagNames?: string[]): string { + // some AP servers like Pixelfed use br tags as well as newlines + html = html.replace(/<br\s?\/?>\r?\n/gi, '\n'); + + const dom = parse5.parseFragment(html); + + let text = ''; + + for (const n of dom.childNodes) { + analyze(n); + } + + return text.trim(); + + function getText(node: TreeAdapter.Node): string { + if (treeAdapter.isTextNode(node)) return node.value; + if (!treeAdapter.isElementNode(node)) return ''; + if (node.nodeName === 'br') return '\n'; + + if (node.childNodes) { + return node.childNodes.map(n => getText(n)).join(''); + } + + return ''; + } + + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { + if (childNodes) { + for (const n of childNodes) { + analyze(n); + } + } + } + + function analyze(node: TreeAdapter.Node) { + if (treeAdapter.isTextNode(node)) { + text += node.value; + return; + } + + // Skip comment or document type node + if (!treeAdapter.isElementNode(node)) return; + + switch (node.nodeName) { + case 'br': { + text += '\n'; + break; + } + + case 'a': + { + const txt = getText(node); + const rel = node.attrs.find(x => x.name === 'rel'); + const href = node.attrs.find(x => x.name === 'href'); + + // ハッシュタグ + if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + text += txt; + // メンション + } else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) { + const part = txt.split('@'); + + if (part.length === 2 && href) { + //#region ホスト名部分が省略されているので復元する + const acct = `${txt}@${(new URL(href.value)).hostname}`; + text += acct; + //#endregion + } else if (part.length === 3) { + text += txt; + } + // その他 + } else { + const generateLink = () => { + if (!href && !txt) { + return ''; + } + if (!href) { + return txt; + } + if (!txt || txt === href.value) { // #6383: Missing text node + if (href.value.match(urlRegexFull)) { + return href.value; + } else { + return `<${href.value}>`; + } + } + if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { + return `[${txt}](<${href.value}>)`; // #6846 + } else { + return `[${txt}](${href.value})`; + } + }; + + text += generateLink(); + } + break; + } + + case 'h1': + { + text += '【'; + appendChildren(node.childNodes); + text += '】\n'; + break; + } + + case 'b': + case 'strong': + { + text += '**'; + appendChildren(node.childNodes); + text += '**'; + break; + } + + case 'small': + { + text += '<small>'; + appendChildren(node.childNodes); + text += '</small>'; + break; + } + + case 's': + case 'del': + { + text += '~~'; + appendChildren(node.childNodes); + text += '~~'; + break; + } + + case 'i': + case 'em': + { + text += '<i>'; + appendChildren(node.childNodes); + text += '</i>'; + break; + } + + // block code (<pre><code>) + case 'pre': { + if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') { + text += '\n```\n'; + text += getText(node.childNodes[0]); + text += '\n```\n'; + } else { + appendChildren(node.childNodes); + } + break; + } + + // inline code (<code>) + case 'code': { + text += '`'; + appendChildren(node.childNodes); + text += '`'; + break; + } + + case 'blockquote': { + const t = getText(node); + if (t) { + text += '\n> '; + text += t.split('\n').join('\n> '); + } + break; + } + + case 'p': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + { + text += '\n\n'; + appendChildren(node.childNodes); + break; + } + + // other block elements + case 'div': + case 'header': + case 'footer': + case 'article': + case 'li': + case 'dt': + case 'dd': + { + text += '\n'; + appendChildren(node.childNodes); + break; + } + + default: // includes inline elements + { + appendChildren(node.childNodes); + break; + } + } + } + } + + public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) { + if (nodes == null) { + return null; + } + + const { window } = new JSDOM(''); + + const doc = window.document; + + function appendChildren(children: mfm.MfmNode[], targetElement: any): void { + if (children) { + for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child); + } + } + + const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = { + bold: (node) => { + const el = doc.createElement('b'); + appendChildren(node.children, el); + return el; + }, + + small: (node) => { + const el = doc.createElement('small'); + appendChildren(node.children, el); + return el; + }, + + strike: (node) => { + const el = doc.createElement('del'); + appendChildren(node.children, el); + return el; + }, + + italic: (node) => { + const el = doc.createElement('i'); + appendChildren(node.children, el); + return el; + }, + + fn: (node) => { + const el = doc.createElement('i'); + appendChildren(node.children, el); + return el; + }, + + blockCode: (node) => { + const pre = doc.createElement('pre'); + const inner = doc.createElement('code'); + inner.textContent = node.props.code; + pre.appendChild(inner); + return pre; + }, + + center: (node) => { + const el = doc.createElement('div'); + appendChildren(node.children, el); + return el; + }, + + emojiCode: (node) => { + return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + }, + + unicodeEmoji: (node) => { + return doc.createTextNode(node.props.emoji); + }, + + hashtag: (node) => { + const a = doc.createElement('a'); + a.href = `${this.config.url}/tags/${node.props.hashtag}`; + a.textContent = `#${node.props.hashtag}`; + a.setAttribute('rel', 'tag'); + return a; + }, + + inlineCode: (node) => { + const el = doc.createElement('code'); + el.textContent = node.props.code; + return el; + }, + + mathInline: (node) => { + const el = doc.createElement('code'); + el.textContent = node.props.formula; + return el; + }, + + mathBlock: (node) => { + const el = doc.createElement('code'); + el.textContent = node.props.formula; + return el; + }, + + link: (node) => { + const a = doc.createElement('a'); + a.href = node.props.url; + appendChildren(node.children, a); + return a; + }, + + mention: (node) => { + const a = doc.createElement('a'); + const { username, host, acct } = node.props; + const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); + a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`; + a.className = 'u-url mention'; + a.textContent = acct; + return a; + }, + + quote: (node) => { + const el = doc.createElement('blockquote'); + appendChildren(node.children, el); + return el; + }, + + text: (node) => { + const el = doc.createElement('span'); + const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x)); + + for (const x of intersperse<FIXME | 'br'>('br', nodes)) { + el.appendChild(x === 'br' ? doc.createElement('br') : x); + } + + return el; + }, + + url: (node) => { + const a = doc.createElement('a'); + a.href = node.props.url; + a.textContent = node.props.url; + return a; + }, + + search: (node) => { + const a = doc.createElement('a'); + a.href = `https://www.google.com/search?q=${node.props.query}`; + a.textContent = node.props.content; + return a; + }, + + plain: (node) => { + const el = doc.createElement('span'); + appendChildren(node.children, el); + return el; + }, + }; + + appendChildren(nodes, doc.body); + + return `<p>${doc.body.innerHTML}</p>`; + } +} diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts new file mode 100644 index 0000000000..191148ac25 --- /dev/null +++ b/packages/backend/src/core/ModerationLogService.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogsRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class ModerationLogService { + constructor( + @Inject(DI.moderationLogsRepository) + private moderationLogsRepository: ModerationLogsRepository, + + private idService: IdService, + ) { + } + + public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) { + await this.moderationLogsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info ?? {}, + }); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts new file mode 100644 index 0000000000..83c080893d --- /dev/null +++ b/packages/backend/src/core/NoteCreateService.ts @@ -0,0 +1,742 @@ +import * as mfm from 'mfm-js'; +import { Not, In, DataSource } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; +import { extractHashtags } from '@/misc/extract-hashtags.js'; +import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import { Note } from '@/models/entities/Note.js'; +import { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { App } from '@/models/entities/App.js'; +import { concat } from '@/misc/prelude/array.js'; +import { IdService } from '@/core/IdService.js'; +import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js'; +import type { IPoll } from '@/models/entities/Poll.js'; +import { Poll } from '@/models/entities/Poll.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import { checkWordMute } from '@/misc/check-word-mute.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserProfile } from '@/models/entities/UserProfile.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +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'; + +const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; + +class NotificationManager { + private notifier: { id: User['id']; }; + private note: Note; + private queue: { + target: ILocalUser['id']; + reason: NotificationType; + }[]; + + constructor( + private mutingsRepository: MutingsRepository, + private createNotificationService: CreateNotificationService, + notifier: { id: User['id']; }, + note: Note, + ) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + public push(notifiee: ILocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason !== 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee, + }); + } + } + + public async deliver() { + for (const x of this.queue) { + // ミュート情報を取得 + const mentioneeMutes = await this.mutingsRepository.findBy({ + muterId: x.target, + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + this.createNotificationService.createNotification(x.target, x.reason, { + notifierId: this.notifier.id, + noteId: this.note.id, + }); + } + } + } +} + +type MinimumUser = { + id: User['id']; + host: User['host']; + username: User['username']; + uri: User['uri']; +}; + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: Note | null; + renote?: Note | null; + files?: DriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: Channel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: App | null; +}; + +@Injectable() +export class NoteCreateService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private queueService: QueueService, + private noteReadService: NoteReadService, + private createNotificationService: CreateNotificationService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private hashtagService: HashtagService, + private antennaService: AntennaService, + private webhookService: WebhookService, + private resolveUserService: ResolveUserService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + ) {} + + public async create(user: { + id: User['id']; + username: User['username']; + host: User['host']; + isSilenced: User['isSilenced']; + createdAt: User['createdAt']; + }, data: Option, silent = false): Promise<Note> { + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId }); + } + + if (data.createdAt == null) data.createdAt = new Date(); + if (data.visibility == null) data.visibility = 'public'; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + + // サイレンス + if (user.isSilenced && data.visibility === 'public' && data.channel == null) { + data.visibility = 'home'; + } + + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { + throw new Error('Renote target is not public or home'); + } + + // Renote対象がpublicではないならhomeにする + if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // Renote対象がfollowersならfollowersにする + if (data.renote && data.renote.visibility === 'followers') { + data.visibility = 'followers'; + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + if (data.text) { + data.text = data.text.trim(); + } else { + data.text = null; + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags ?? extractHashtags(combinedTokens); + + emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = data.apMentions ?? await this.#extractMentionedUsers(user, combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32); + + if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { + mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId })); + } + } + + const note = await this.#insertNote(user, data, tags, emojis, mentionedUsers); + + setImmediate(() => this.#postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); + + return note; + } + + async #insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + const insert = new Note({ + id: this.idService.genId(data.createdAt!), + createdAt: data.createdAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw == null ? null : data.cw, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + userId: user.id, + localOnly: data.localOnly!, + visibility: data.visibility as any, + visibleUserIds: data.visibility === 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + }); + + if (data.uri != null) insert.uri = data.uri; + if (data.url != null) insert.url = data.url; + + // Append mentions data + if (mentionedUsers.length > 0) { + insert.mentions = mentionedUsers.map(u => u.id); + const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) }); + insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId === u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url == null ? undefined : url, + username: u.username, + host: u.host, + } as IMentionedRemoteUsers[0]; + })); + } + + // 投稿を作成 + try { + if (insert.hasPoll) { + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + await transactionalEntityManager.insert(Note, insert); + + const poll = new Poll({ + noteId: insert.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(Poll, poll); + }); + } else { + await this.notesRepository.insert(insert); + } + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + const err = new Error('Duplicated note'); + err.name = 'duplicated'; + throw err; + } + + console.error(e); + + throw e; + } + } + + async #postNoteCreated(note: Note, user: { + id: User['id']; + username: User['username']; + host: User['host']; + isSilenced: User['isSilenced']; + createdAt: User['createdAt']; + }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + // 統計を更新 + this.notesChart.update(note, true); + this.perUserNotesChart.update(user, note, true); + + // Register host + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); + this.instanceChart.updateNote(i.host, note, true); + }); + } + + // ハッシュタグ更新 + if (data.visibility === 'public' || data.visibility === 'home') { + this.hashtagService.updateHashtags(user, tags); + } + + // Increment notes count (user) + this.#incNotesCountOfUser(user); + + // Word mute + mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + where: { + enableWordMute: true, + }, + select: ['userId', 'mutedWords'], + })).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + this.mutedNotesRepository.insert({ + id: this.idService.genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + + // Antenna + for (const antenna of (await this.antennaService.getAntennas())) { + this.antennaService.checkHitAntenna(antenna, note, user).then(hit => { + if (hit) { + this.antennaService.addNoteToAntenna(antenna, note, user); + } + }); + } + + // Channel + if (note.channelId) { + this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { + for (const following of followings) { + this.noteReadService.insertNoteUnread(following.followerId, note, { + isSpecified: false, + isMentioned: false, + }); + } + }); + } + + if (data.reply) { + this.#saveReply(data.reply, note); + } + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) { + this.#incRenoteCount(data.renote); + } + + if (data.poll && data.poll.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + this.queueService.endedPollNotificationQueue.add({ + noteId: note.id, + }, { + delay, + removeOnComplete: true, + }); + } + + if (!silent) { + if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!this.userEntityService.isLocalUser(u)) continue; + + this.noteReadService.insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await this.noteEntityService.pack(note); + + this.globalEventServie.publishNotesStream(noteObj); + + this.webhookService.getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'note', { + note: noteObj, + }); + } + }); + + const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); + const nmRelatedPromises = []; + + await this.#createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // 通知 + if (data.reply.userHost === null) { + const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ + userId: data.reply.userId, + threadId: data.reply.threadId ?? data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, 'reply'); + this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'reply', { + note: noteObj, + }); + } + } + } + } + + // If it is renote + if (data.renote) { + const type = data.text ? 'quote' : 'renote'; + + // Notify + if (data.renote.userHost === null) { + nm.push(data.renote.userId, type); + } + + // Publish event + if ((user.id !== data.renote.userId) && data.renote.userHost === null) { + this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'renote', { + note: noteObj, + }); + } + } + } + + Promise.all(nmRelatedPromises).then(() => { + nm.deliver(); + }); + + //#region AP deliver + if (this.userEntityService.isLocalUser(user)) { + (async () => { + const noteActivity = await this.#renderNoteOrRenoteActivity(data, note); + const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.reply.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && data.renote.userHost !== null) { + const u = await this.usersRepository.findOneBy({ id: data.renote.userId }); + if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (['public'].includes(note.visibility)) { + this.relayService.deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1); + this.channelsRepository.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + this.notesRepository.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Register to search database + this.#index(note); + } + + #incRenoteCount(renote: Note) { + this.notesRepository.createQueryBuilder().update() + .set({ + renoteCount: () => '"renoteCount" + 1', + score: () => '"score" + 1', + }) + .where('id = :id', { id: renote.id }) + .execute(); + } + + async #createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) { + const threadMuted = await this.noteThreadMutingsRepository.findOneBy({ + userId: u.id, + threadId: note.threadId ?? note.id, + }); + + if (threadMuted) { + continue; + } + + const detailPackedNote = await this.noteEntityService.pack(note, u, { + detail: true, + }); + + this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'mention', { + note: detailPackedNote, + }); + } + + // Create notification + nm.push(u.id, 'mention'); + } + } + + #saveReply(reply: Note, note: Note) { + this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); + } + + async #renderNoteOrRenoteActivity(data: Option, note: Note) { + if (data.localOnly) return null; + + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0) + ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) + : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + + return this.apRendererService.renderActivity(content); + } + + #index(note: Note) { + if (note.text == null || this.config.elasticsearch == null) return; + /* + es!.index({ + index: this.config.elasticsearch.index ?? 'misskey_note', + id: note.id.toString(), + body: { + text: normalizeForSearch(note.text), + userId: note.userId, + userHost: note.userHost, + }, + });*/ + } + + #incNotesCountOfUser(user: { id: User['id']; }) { + this.usersRepository.createQueryBuilder().update() + .set({ + updatedAt: new Date(), + notesCount: () => '"notesCount" + 1', + }) + .where('id = :id', { id: user.id }) + .execute(); + } + + async #extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + let mentionedUsers = (await Promise.all(mentions.map(m => + this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null), + ))).filter(x => x != null) as User[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id), + ); + + return mentionedUsers; + } +} diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts new file mode 100644 index 0000000000..9153418beb --- /dev/null +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -0,0 +1,168 @@ +import { Brackets, In } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; +import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js'; +import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js'; +import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js'; +import { RelayService } from '@/core/RelayService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import NotesChart from '@/core/chart/charts/notes.js'; +import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class NoteDeleteService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private globalEventServie: GlobalEventService, + private relayService: RelayService, + private federatedInstanceService: FederatedInstanceService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private notesChart: NotesChart, + private perUserNotesChart: PerUserNotesChart, + private instanceChart: InstanceChart, + ) {} + + /** + * 投稿を削除します。 + * @param user 投稿者 + * @param note 投稿 + */ + async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) { + const deletedAt = new Date(); + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) { + this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1); + this.notesRepository.decrement({ id: note.renoteId }, 'score', 1); + } + + if (note.replyId) { + await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); + } + + if (!quiet) { + this.globalEventServie.publishNoteStream(note.id, 'deleted', { + deletedAt: deletedAt, + }); + + //#region ローカルの投稿なら削除アクティビティを配送 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + let renote: Note | null = null; + + // if deletd note is renote + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + renote = await this.notesRepository.findOneBy({ + id: note.renoteId, + }); + } + + const content = this.apRendererService.renderActivity(renote + ? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user) + : this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user)); + + this.#deliverToConcerned(user, note, content); + } + + // also deliever delete activity to cascaded notes + const cascadingNotes = (await this.#findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes + for (const cascadingNote of cascadingNotes) { + if (!cascadingNote.user) continue; + if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue; + const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); + this.#deliverToConcerned(cascadingNote.user, cascadingNote, content); + } + //#endregion + + // 統計を更新 + this.notesChart.update(note, false); + this.perUserNotesChart.update(user, note, false); + + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + this.instanceChart.updateNote(i.host, note, false); + }); + } + } + + await this.notesRepository.delete({ + id: note.id, + userId: user.id, + }); + } + + async #findCascadingNotes(note: Note) { + const cascadingNotes: Note[] = []; + + const recursive = async (noteId: string) => { + const query = this.notesRepository.createQueryBuilder('note') + .where('note.replyId = :noteId', { noteId }) + .orWhere(new Brackets(q => { + q.where('note.renoteId = :noteId', { noteId }) + .andWhere('note.text IS NOT NULL'); + })) + .leftJoinAndSelect('note.user', 'user'); + const replies = await query.getMany(); + for (const reply of replies) { + cascadingNotes.push(reply); + await recursive(reply.id); + } + }; + await recursive(note.id); + + return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users + } + + async #getMentionedRemoteUsers(note: Note) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) }, + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId, + }); + } + + if (where.length === 0) return []; + + return await this.usersRepository.find({ + where, + }) as IRemoteUser[]; + } + + async #deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) { + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + const remoteUsers = await this.#getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + this.apDeliverManagerService.deliverToUser(user, content, remoteUser); + } + } +} diff --git a/packages/backend/src/core/NotePiningService.ts b/packages/backend/src/core/NotePiningService.ts new file mode 100644 index 0000000000..576e90bd43 --- /dev/null +++ b/packages/backend/src/core/NotePiningService.ts @@ -0,0 +1,117 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserNotePining } from '@/models/entities/UserNotePining.js'; +import { RelayService } from '@/core/RelayService.js'; +import { Config } from '@/config.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +@Injectable() +export class NotePiningService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private relayService: RelayService, + private apDeliverManagerService: ApDeliverManagerService, + private apRendererService: ApRendererService, + ) { + } + + /** + * 指定した投稿をピン留めします + * @param user + * @param noteId + */ + public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch pinee + const note = await this.notesRepository.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); + } + + const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); + + if (pinings.length >= 5) { + throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); + } + + if (pinings.some(pining => pining.noteId === note.id)) { + throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); + } + + await this.userNotePiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id, + } as UserNotePining); + + // Deliver to remote followers + if (this.userEntityService.isLocalUser(user)) { + this.deliverPinnedChange(user.id, note.id, true); + } + } + + /** + * 指定した投稿のピン留めを解除します + * @param user + * @param noteId + */ + public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch unpinee + const note = await this.notesRepository.findOneBy({ + id: noteId, + userId: user.id, + }); + + if (note == null) { + throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); + } + + this.userNotePiningsRepository.delete({ + userId: user.id, + noteId: note.id, + }); + + // Deliver to remote followers + if (this.userEntityService.isLocalUser(user)) { + this.deliverPinnedChange(user.id, noteId, false); + } + } + + public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) { + const user = await this.usersRepository.findOneBy({ id: userId }); + if (user == null) throw new Error('user not found'); + + if (!this.userEntityService.isLocalUser(user)) return; + + const target = `${this.config.url}/users/${user.id}/collections/featured`; + const item = `${this.config.url}/notes/${noteId}`; + const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item)); + + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts new file mode 100644 index 0000000000..b1572c631a --- /dev/null +++ b/packages/backend/src/core/NoteReadService.ts @@ -0,0 +1,214 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, IsNull, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { NotificationService } from './NotificationService.js'; +import { AntennaService } from './AntennaService.js'; + +@Injectable() +export class NoteReadService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private notificationService: NotificationService, + private antennaService: AntennaService, + ) { + } + + public async insertNoteUnread(userId: User['id'], note: Note, params: { + // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse + isSpecified: boolean; + isMentioned: boolean; + }): Promise<void> { + //#region ミュートしているなら無視 + // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする + const mute = await this.mutingsRepository.findBy({ + muterId: userId, + }); + if (mute.map(m => m.muteeId).includes(note.userId)) return; + //#endregion + + // スレッドミュート + const threadMute = await this.noteThreadMutingsRepository.findOneBy({ + userId: userId, + threadId: note.threadId ?? note.id, + }); + if (threadMute) return; + + const unread = { + id: this.idService.genId(), + noteId: note.id, + userId: userId, + isSpecified: params.isSpecified, + isMentioned: params.isMentioned, + noteChannelId: note.channelId, + noteUserId: note.userId, + }; + + await this.noteUnreadsRepository.insert(unread); + + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); + + if (exist == null) return; + + if (params.isMentioned) { + this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id); + } + if (params.isSpecified) { + this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id); + } + if (note.channelId) { + this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id); + } + }, 2000); + } + + public async read( + userId: User['id'], + notes: (Note | Packed<'Note'>)[], + info?: { + following: Set<User['id']>; + followingChannels: Set<Channel['id']>; + }, + ): Promise<void> { + const following = info?.following ? info.following : new Set<string>((await this.followingsRepository.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({ + where: { + followerId: userId, + }, + select: ['followeeId'], + })).map(x => x.followeeId)); + + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); + const readMentions: (Note | Packed<'Note'>)[] = []; + const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; + const readChannelNotes: (Note | Packed<'Note'>)[] = []; + const readAntennaNotes: (Note | Packed<'Note'>)[] = []; + + for (const note of notes) { + if (note.mentions && note.mentions.includes(userId)) { + readMentions.push(note); + } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { + readSpecifiedNotes.push(note); + } + + if (note.channelId && followingChannels.has(note.channelId)) { + readChannelNotes.push(note); + } + + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 + for (const antenna of myAntennas) { + if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) { + readAntennaNotes.push(note); + } + } + } + } + + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + // Remove the record + await this.noteUnreadsRepository.delete({ + userId: userId, + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + }); + + // TODO: ↓まとめてクエリしたい + + this.noteUnreadsRepository.countBy({ + userId: userId, + isMentioned: true, + }).then(mentionsCount => { + if (mentionsCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions'); + } + }); + + this.noteUnreadsRepository.countBy({ + userId: userId, + isSpecified: true, + }).then(specifiedCount => { + if (specifiedCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); + } + }); + + this.noteUnreadsRepository.countBy({ + userId: userId, + noteChannelId: Not(IsNull()), + }).then(channelNoteCount => { + if (channelNoteCount === 0) { + // 全て既読になったイベントを発行 + this.globalEventServie.publishMainStream(userId, 'readAllChannels'); + } + }); + + this.notificationService.readNotificationByQuery(userId, { + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), + }); + } + + if (readAntennaNotes.length > 0) { + await this.antennaNotesRepository.update({ + antennaId: In(myAntennas.map(a => a.id)), + noteId: In(readAntennaNotes.map(n => n.id)), + }, { + read: true, + }); + + // TODO: まとめてクエリしたい + for (const antenna of myAntennas) { + const count = await this.antennaNotesRepository.countBy({ + antennaId: antenna.id, + read: false, + }); + + if (count === 0) { + this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna); + } + } + + this.userEntityService.getHasUnreadAntenna(userId).then(unread => { + if (!unread) { + this.globalEventServie.publishMainStream(userId, 'readAllAntennas'); + } + }); + } + } +} diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts new file mode 100644 index 0000000000..44957d62d3 --- /dev/null +++ b/packages/backend/src/core/NotificationService.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { 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 { GlobalEventService } from './GlobalEventService.js'; +import { PushNotificationService } from './PushNotificationService.js'; + +@Injectable() +export class NotificationService { + constructor( + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private pushNotificationService: PushNotificationService, + ) { + } + + public async readNotification( + userId: User['id'], + notificationIds: Notification['id'][], + ) { + if (notificationIds.length === 0) return; + + // Update documents + const result = await this.notificationsRepository.update({ + notifieeId: userId, + id: In(notificationIds), + isRead: false, + }, { + isRead: true, + }); + + if (result.affected === 0) return; + + if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.#postReadAllNotifications(userId); + else return this.#postReadNotifications(userId, notificationIds); + } + + public async readNotificationByQuery( + userId: User['id'], + query: Record<string, any>, + ) { + const notificationIds = await this.notificationsRepository.findBy({ + ...query, + notifieeId: userId, + isRead: false, + }).then(notifications => notifications.map(notification => notification.id)); + + return this.readNotification(userId, notificationIds); + } + + #postReadAllNotifications(userId: User['id']) { + this.globalEventService.publishMainStream(userId, 'readAllNotifications'); + return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); + } + + #postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { + this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds); + return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); + } +} diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts new file mode 100644 index 0000000000..8bc94c8a82 --- /dev/null +++ b/packages/backend/src/core/PollService.ts @@ -0,0 +1,115 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, UsersRepository, BlockingsRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import { RelayService } from '@/core/RelayService.js'; +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'; + +@Injectable() +export class PollService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private relayService: RelayService, + private globalEventServie: GlobalEventService, + private createNotificationService: CreateNotificationService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + ) { + } + + public async vote(user: CacheableUser, note: Note, choice: number) { + const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); + + if (poll == null) throw new Error('poll not found'); + + // Check whether is valid choice + if (poll.choices[choice] == null) throw new Error('invalid choice param'); + + // Check blocking + if (note.userId !== user.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new Error('blocked'); + } + } + + // if already voted + const exist = await this.pollVotesRepository.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (poll.multiple) { + if (exist.some(x => x.choice === choice)) { + throw new Error('already voted'); + } + } else if (exist.length !== 0) { + throw new Error('already voted'); + } + + // Create vote + await this.pollVotesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + choice: choice, + }); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + this.globalEventServie.publishNoteStream(note.id, 'pollVoted', { + choice: choice, + userId: user.id, + }); + + // Notify + this.createNotificationService.createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } + + public async deliverQuestionUpdate(noteId: Note['id']) { + const note = await this.notesRepository.findOneBy({ id: noteId }); + if (note == null) throw new Error('note not found'); + + const user = await this.usersRepository.findOneBy({ id: note.userId }); + if (user == null) throw new Error('note not found'); + + if (this.userEntityService.isLocalUser(user)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); + this.apDeliverManagerService.deliverToFollowers(user, content); + this.relayService.deliverToRelays(user, content); + } + } +} diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts new file mode 100644 index 0000000000..40ccc8226a --- /dev/null +++ b/packages/backend/src/core/ProxyAccountService.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { 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'; + +@Injectable() +export class ProxyAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private metaService: MetaService, + ) { + } + + public async fetch(): Promise<ILocalUser | null> { + const meta = await this.metaService.fetch(); + if (meta.proxyAccountId == null) return null; + return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser; + } +} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts new file mode 100644 index 0000000000..31d29bed97 --- /dev/null +++ b/packages/backend/src/core/PushNotificationService.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import push from 'web-push'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; +import { SwSubscriptionsRepository } from '@/models/index.js'; +import { MetaService } from './MetaService.js'; + +// Defined also packages/sw/types.ts#L14-L21 +type pushNotificationsTypes = { + 'notification': Packed<'Notification'>; + 'unreadMessagingMessage': Packed<'MessagingMessage'>; + 'readNotifications': { notificationIds: string[] }; + 'readAllNotifications': undefined; + 'readAllMessagingMessages': undefined; + 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; +}; + +// プッシュメッセージサーバーには文字数制限があるため、内容を削減します +function truncateNotification(notification: Packed<'Notification'>): any { + if (notification.note) { + return { + ...notification, + note: { + ...notification.note, + // textをgetNoteSummaryしたものに置き換える + text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note), + + cw: undefined, + reply: undefined, + renote: undefined, + user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる + }, + }; + } + + return notification; +} + +@Injectable() +export class PushNotificationService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.swSubscriptionsRepository) + private swSubscriptionsRepository: SwSubscriptionsRepository, + + private metaService: MetaService, + ) { + } + + public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) { + const meta = await this.metaService.fetch(); + + if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails(this.config.url, + meta.swPublicKey, + meta.swPrivateKey); + + // Fetch + const subscriptions = await this.swSubscriptionsRepository.findBy({ + userId: userId, + }); + + for (const subscription of subscriptions) { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + auth: subscription.auth, + p256dh: subscription.publickey, + }, + }; + + push.sendNotification(pushSubscription, JSON.stringify({ + type, + body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body, + userId, + dateTime: (new Date()).getTime(), + }), { + proxy: this.config.proxy, + }).catch((err: any) => { + //swLogger.info(err.statusCode); + //swLogger.info(err.headers); + //swLogger.info(err.body); + + if (err.statusCode === 410) { + this.swSubscriptionsRepository.delete({ + userId: userId, + endpoint: subscription.endpoint, + auth: subscription.auth, + publickey: subscription.publickey, + }); + } + }); + } + } +} diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts new file mode 100644 index 0000000000..1613f70c80 --- /dev/null +++ b/packages/backend/src/core/QueryService.ts @@ -0,0 +1,262 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { SelectQueryBuilder } from 'typeorm'; + +@Injectable() +export class QueryService { + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.mutedNotesRepository) + private mutedNotesRepository: MutedNotesRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + ) { + } + + public makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, 'ASC'); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.orderBy(`${q.alias}.createdAt`, 'ASC'); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else { + q.orderBy(`${q.alias}.id`, 'DESC'); + } + return q; + } + + // ここでいうBlockedは被Blockedの意 + public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q + .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserId IS NULL') + .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserId IS NULL') + .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + })); + + q.setParameters(blockingQuery.getParameters()); + } + + public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockeeId') + .where('blocking.blockerId = :blockerId', { blockerId: me.id }); + + const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); + q.setParameters(blockedQuery.getParameters()); + } + + public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } + } + + public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void { + const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); + } + + public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void { + const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where('note.threadId IS NULL') + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); + } + + public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User): void { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + } + + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q + .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserId IS NULL') + .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserId IS NULL') + .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + // mute instances + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + })); + + q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); + } + + public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + + q.setParameters(mutingQuery.getParameters()); + } + + public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } else if (!me.showTimelineReplies) { + q.andWhere(new Brackets(qb => { qb + .where('note.replyId IS NULL') // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where('note.replyId IS NOT NULL') + .andWhere('note.replyUserId = note.userId'); + })); + })); + } + } + + public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void { + // This code must always be synchronized with the checks in Notes.isVisibleForMe. + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })); + } else { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId'); + + q.andWhere(new Brackets(qb => { qb + // 公開投稿である + .where(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + // または 自分自身 + .orWhere('note.userId = :meId') + // または 自分宛て + .orWhere(':meId = ANY(note.visibleUserIds)') + .orWhere(':meId = ANY(note.mentions)') + .orWhere(new Brackets(qb => { qb + // または フォロワー宛ての投稿であり、 + .where('note.visibility = \'followers\'') + .andWhere(new Brackets(qb => { qb + // 自分がフォロワーである + .where(`note.userId IN (${ followingQuery.getQuery() })`) + // または 自分の投稿へのリプライ + .orWhere('note.replyUserId = :meId'); + })); + })); + })); + + q.setParameters({ meId: me.id }); + } + } +} + diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts new file mode 100644 index 0000000000..7e771c100f --- /dev/null +++ b/packages/backend/src/core/QueueService.ts @@ -0,0 +1,242 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; +import type { IActivity } from '@/core/remote/activitypub/type.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; +import { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js'; +import type { ThinUser } from '../queue/types.js'; +import type httpSignature from '@peertube/http-signature'; + +@Injectable() +export class QueueService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject('queue:system') public systemQueue: SystemQueue, + @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:deliver') public deliverQueue: DeliverQueue, + @Inject('queue:inbox') public inboxQueue: InboxQueue, + @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, + @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + ) {} + + public deliver(user: ThinUser, content: IActivity, to: string | null) { + if (content == null) return null; + if (to == null) return null; + + const data = { + user: { + id: user.id, + }, + content, + to, + }; + + return this.deliverQueue.add(data, { + attempts: this.config.deliverJobMaxAttempts ?? 12, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + const data = { + activity: activity, + signature, + }; + + return this.inboxQueue.add(data, { + attempts: this.config.inboxJobMaxAttempts ?? 8, + timeout: 5 * 60 * 1000, // 5min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteDriveFilesJob(user: ThinUser) { + return this.dbQueue.add('deleteDriveFiles', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportCustomEmojisJob(user: ThinUser) { + return this.dbQueue.add('exportCustomEmojis', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportNotesJob(user: ThinUser) { + return this.dbQueue.add('exportNotes', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { + return this.dbQueue.add('exportFollowing', { + user: user, + excludeMuting, + excludeInactive, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportMuteJob(user: ThinUser) { + return this.dbQueue.add('exportMuting', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportBlockingJob(user: ThinUser) { + return this.dbQueue.add('exportBlocking', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createExportUserListsJob(user: ThinUser) { + return this.dbQueue.add('exportUserLists', { + user: user, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importFollowing', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importMuting', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importBlocking', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importUserLists', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { + return this.dbQueue.add('importCustomEmojis', { + user: user, + fileId: fileId, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { + return this.dbQueue.add('deleteAccount', { + user: user, + soft: opts.soft, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createDeleteObjectStorageFileJob(key: string) { + return this.objectStorageQueue.add('deleteFile', { + key: key, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public createCleanRemoteFilesJob() { + return this.objectStorageQueue.add('cleanRemoteFiles', {}, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) { + const data = { + type, + content, + webhookId: webhook.id, + userId: webhook.userId, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: uuid(), + }; + + return this.webhookDeliverQueue.add(data, { + attempts: 4, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + public destroy() { + this.deliverQueue.once('cleaned', (jobs, status) => { + //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + this.deliverQueue.clean(0, 'delayed'); + + this.inboxQueue.once('cleaned', (jobs, status) => { + //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + this.inboxQueue.clean(0, 'delayed'); + } +} diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts new file mode 100644 index 0000000000..3006456577 --- /dev/null +++ b/packages/backend/src/core/ReactionService.ts @@ -0,0 +1,340 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { IdService } from '@/core/IdService.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +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 { UtilityService } from './UtilityService.js'; + +const legacies: Record<string, string> = { + 'like': '👍', + 'love': '❤', // ここに記述する場合は異体字セレクタを入れない + 'laugh': '😆', + 'hmm': '🤔', + 'surprise': '😮', + 'congrats': '🎉', + 'angry': '💢', + 'confused': '😥', + 'rip': '😇', + 'pudding': '🍮', + 'star': '⭐', +}; + +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +@Injectable() +export class ReactionService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private utilityService: UtilityService, + private metaService: MetaService, + private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, + private idService: IdService, + private globalEventServie: GlobalEventService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private createNotificationService: CreateNotificationService, + private perUserReactionsChart: PerUserReactionsChart, + ) { + } + + public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) { + // Check blocking + if (note.userId !== user.id) { + const block = await this.blockingsRepository.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); + } + } + + // check visibility + if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { + throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); + } + + // TODO: cache + reaction = await this.toDbReaction(reaction, user.host); + + const record: NoteReaction = { + id: this.idService.genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + reaction, + }; + + // Create reaction + try { + await this.noteReactionsRepository.insert(record); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + const exists = await this.noteReactionsRepository.findOneByOrFail({ + noteId: note.id, + userId: user.id, + }); + + if (exists.reaction !== reaction) { + // 別のリアクションがすでにされていたら置き換える + await this.delete(user, note); + await this.noteReactionsRepository.insert(record); + } else { + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + } else { + throw e; + } + } + + // Increment reactions count + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + score: () => '"score" + 1', + }) + .where('id = :id', { id: note.id }) + .execute(); + + this.perUserReactionsChart.update(user, note); + + // カスタム絵文字リアクションだったら絵文字情報も送る + const decodedReaction = this.decodeReaction(reaction); + + const emoji = await this.emojisRepository.findOne({ + where: { + name: decodedReaction.name, + host: decodedReaction.host ?? IsNull(), + }, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }); + + this.globalEventServie.publishNoteStream(note.id, 'reacted', { + reaction: decodedReaction.reaction, + emoji: emoji != null ? { + name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため + } : null, + userId: user.id, + }); + + // リアクションされたユーザーがローカルユーザーなら通知を作成 + if (note.userHost === null) { + this.createNotificationService.createNotification(note.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction, + }); + } + + //#region 配信 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note)); + const dm = this.apDeliverManagerService.createDeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await this.usersRepository.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } else if (note.visibility === 'specified') { + const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id }))); + for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + } + + dm.execute(); + } + //#endregion + } + + public async delete(user: { id: User['id']; host: User['host']; }, note: Note) { + // if already unreacted + const exist = await this.noteReactionsRepository.findOneBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist == null) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Delete reaction + const result = await this.noteReactionsRepository.delete(exist.id); + + if (result.affected !== 1) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Decrement reactions count + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await this.notesRepository.createQueryBuilder().update() + .set({ + reactions: () => sql, + }) + .where('id = :id', { id: note.id }) + .execute(); + + this.notesRepository.decrement({ id: note.id }, 'score', 1); + + this.globalEventServie.publishNoteStream(note.id, 'unreacted', { + reaction: this.decodeReaction(exist.reaction).reaction, + userId: user.id, + }); + + //#region 配信 + if (this.userEntityService.isLocalUser(user) && !note.localOnly) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); + const dm = this.apDeliverManagerService.createDeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await this.usersRepository.findOneBy({ id: note.userId }); + dm.addDirectRecipe(reactee as IRemoteUser); + } + dm.addFollowersRecipe(); + dm.execute(); + } + //#endregion + } + + public async getFallbackReaction(): Promise<string> { + const meta = await this.metaService.fetch(); + return meta.useStarForReactionFallback ? '⭐' : '👍'; + } + + public convertLegacyReactions(reactions: Record<string, number>) { + const _reactions = {} as Record<string, number>; + + for (const reaction of Object.keys(reactions)) { + if (reactions[reaction] <= 0) continue; + + if (Object.keys(legacies).includes(reaction)) { + if (_reactions[legacies[reaction]]) { + _reactions[legacies[reaction]] += reactions[reaction]; + } else { + _reactions[legacies[reaction]] = reactions[reaction]; + } + } else { + if (_reactions[reaction]) { + _reactions[reaction] += reactions[reaction]; + } else { + _reactions[reaction] = reactions[reaction]; + } + } + } + + const _reactions2 = {} as Record<string, number>; + + for (const reaction of Object.keys(_reactions)) { + _reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction]; + } + + return _reactions2; + } + + public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { + if (reaction == null) return await this.getFallbackReaction(); + + reacterHost = this.utilityService.toPunyNullable(reacterHost); + + // 文字列タイプのリアクションを絵文字に変換 + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + + // Unicode絵文字 + const match = emojiRegex.exec(reaction); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } + + const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); + if (custom) { + const name = custom[1]; + const emoji = await this.emojisRepository.findOneBy({ + host: reacterHost ?? IsNull(), + name, + }); + + if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + } + + return await this.getFallbackReaction(); + } + + public decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + + if (custom) { + const name = custom[1]; + const host = custom[2] ?? null; + + return { + reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする + name, + host, + }; + } + + return { + reaction: str, + name: undefined, + host: undefined, + }; + } + + public convertLegacyReaction(reaction: string): string { + reaction = this.decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; + } +} diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts new file mode 100644 index 0000000000..c1d85e8b48 --- /dev/null +++ b/packages/backend/src/core/RelayService.ts @@ -0,0 +1,119 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import type { ILocalUser, User } from '@/models/entities/User.js'; +import { RelaysRepository, UsersRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +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 { DI } from '@/di-symbols.js'; + +const ACTOR_USERNAME = 'relay.actor' as const; + +@Injectable() +export class RelayService { + #relaysCache: Cache<Relay[]>; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.relaysRepository) + private relaysRepository: RelaysRepository, + + private idService: IdService, + private queueService: QueueService, + private createSystemUserService: CreateSystemUserService, + private apRendererService: ApRendererService, + ) { + this.#relaysCache = new Cache<Relay[]>(1000 * 60 * 10); + } + + async #getRelayActor(): Promise<ILocalUser> { + const user = await this.usersRepository.findOneBy({ + host: IsNull(), + username: ACTOR_USERNAME, + }); + + if (user) return user as ILocalUser; + + const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME); + return created as ILocalUser; + } + + public async addRelay(inbox: string): Promise<Relay> { + const relay = await this.relaysRepository.insert({ + id: this.idService.genId(), + inbox, + status: 'requesting', + }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); + + const relayActor = await this.#getRelayActor(); + const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); + const activity = this.apRendererService.renderActivity(follow); + this.queueService.deliver(relayActor, activity, relay.inbox); + + return relay; + } + + public async removeRelay(inbox: string): Promise<void> { + const relay = await this.relaysRepository.findOneBy({ + inbox, + }); + + if (relay == null) { + throw new Error('relay not found'); + } + + const relayActor = await this.#getRelayActor(); + const follow = this.apRendererService.renderFollowRelay(relay, relayActor); + const undo = this.apRendererService.renderUndo(follow, relayActor); + const activity = this.apRendererService.renderActivity(undo); + this.queueService.deliver(relayActor, activity, relay.inbox); + + await this.relaysRepository.delete(relay.id); + } + + public async listRelay(): Promise<Relay[]> { + const relays = await this.relaysRepository.find(); + return relays; + } + + public async relayAccepted(id: string): Promise<string> { + const result = await this.relaysRepository.update(id, { + status: 'accepted', + }); + + return JSON.stringify(result); + } + + public async relayRejected(id: string): Promise<string> { + const result = await this.relaysRepository.update(id, { + status: 'rejected', + }); + + return JSON.stringify(result); + } + + public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> { + if (activity == null) return; + + const relays = await this.#relaysCache.fetch(null, () => this.relaysRepository.findBy({ + status: 'accepted', + })); + if (relays.length === 0) return; + + // TODO + //const copy = structuredClone(activity); + const copy = JSON.parse(JSON.stringify(activity)); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + const signed = await this.apRendererService.attachLdSignature(copy, user); + + for (const relay of relays) { + this.queueService.deliver(user, signed, relay.inbox); + } + } +} diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts new file mode 100644 index 0000000000..9549e19990 --- /dev/null +++ b/packages/backend/src/core/S3Service.ts @@ -0,0 +1,38 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import S3 from 'aws-sdk/clients/s3.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Meta } from '@/models/entities/Meta.js'; +import { HttpRequestService } from './HttpRequestService.js'; + +@Injectable() +export class S3Service { + constructor( + @Inject(DI.config) + private config: Config, + + private httpRequestService: HttpRequestService, + ) { + } + + public getS3(meta: Meta) { + const u = meta.objectStorageEndpoint != null + ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; + + return new S3({ + endpoint: meta.objectStorageEndpoint ?? undefined, + accessKeyId: meta.objectStorageAccessKey!, + secretAccessKey: meta.objectStorageSecretKey!, + region: meta.objectStorageRegion ?? undefined, + sslEnabled: meta.objectStorageUseSSL, + s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted + ? false + : meta.objectStorageS3ForcePathStyle, + httpOptions: { + agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), + }, + }); + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts new file mode 100644 index 0000000000..a876668b94 --- /dev/null +++ b/packages/backend/src/core/SignupService.ts @@ -0,0 +1,141 @@ +import { generateKeyPair } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import bcrypt from 'bcryptjs'; +import { DataSource, IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { UsedUsernamesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import { User } from '@/models/entities/User.js'; +import { UserProfile } from '@/models/entities/UserProfile.js'; +import { IdService } from '@/core/IdService.js'; +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 { UtilityService } from './UtilityService.js'; + +@Injectable() +export class SignupService { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + + private utilityService: UtilityService, + private userEntityService: UserEntityService, + private idService: IdService, + private usersChart: UsersChart, + ) { + } + + public async signup(opts: { + username: User['username']; + password?: string | null; + passwordHash?: UserProfile['password'] | null; + host?: string | null; + }) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + // Validate username + if (!this.userEntityService.validateLocalUsername(username)) { + throw new Error('INVALID_USERNAME'); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!this.userEntityService.validatePassword(password)) { + throw new Error('INVALID_PASSWORD'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + hash = await bcrypt.hash(password, salt); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise<string[]>((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined, + }, + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]), + )); + + let account!: User; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(User, { + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: this.idService.genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: this.utilityService.toPunyNullable(host), + token: secret, + isAdmin: (await this.usersRepository.countBy({ + host: IsNull(), + })) === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id, + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + this.usersChart.update(account, true); + + return { account, secret }; + } +} + diff --git a/packages/backend/src/core/TwoFactorAuthenticationService.ts b/packages/backend/src/core/TwoFactorAuthenticationService.ts new file mode 100644 index 0000000000..be31534c02 --- /dev/null +++ b/packages/backend/src/core/TwoFactorAuthenticationService.ts @@ -0,0 +1,439 @@ +import * as crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import * as jsrsasign from 'jsrsasign'; +import { DI } from '@/di-symbols.js'; +import { UsersRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + '3059301306072a8648ce3d020106082a8648ce3d030107034200', + 'hex', +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + const fields = {} as Record<string, string>; + for (const field of subjectFields) { + const eqIndex = field.indexOf('='); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = 'PUBLIC KEY'; + } + const cert = pemBuffer.toString('base64'); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return ( + `-----BEGIN ${type}-----\n` + + keyParts.join('\n') + + `\n-----END ${type}-----\n` + ); +} + +@Injectable() +export class TwoFactorAuthenticationService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + } + + public hash(data: Buffer) { + return crypto + .createHash('sha256') + .update(data) + .digest(); + } + + public verifySignin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge, + }: { + publicKey: Buffer, + authenticatorData: Buffer, + clientDataJSON: Buffer, + clientData: any, + signature: Buffer, + challenge: string + }) { + if (clientData.type !== 'webauthn.get') { + throw new Error('type is not webauthn.get'); + } + + if (this.hash(clientData.challenge).toString('hex') !== challenge) { + throw new Error('challenge mismatch'); + } + if (clientData.origin !== this.config.scheme + '://' + this.config.host) { + throw new Error('origin mismatch'); + } + + const verificationData = Buffer.concat( + [authenticatorData, this.hash(clientDataJSON)], + 32 + authenticatorData.length, + ); + + return crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(publicKey), signature); + } + + public getProcedures() { + return { + none: { + verify({ publicKey }: { publicKey: Map<number, Buffer> }) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + publicKey: publicKeyU2F, + valid: true, + }; + }, + }, + 'android-key': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + if (attStmt.alg !== -7) { + throw new Error('alg mismatch'); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error('public key mismatch'); + } + + const isValid = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData, + }; + }, + }, + // what a stupid attestation + 'android-safetynet': { + verify: ({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) => { + const verificationData = this.hash( + Buffer.concat([authenticatorData, clientDataHash]), + ); + + const jwsParts = attStmt.response.toString('utf-8').split('.'); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString('utf-8'), + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { + throw new Error('invalid nonce'); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') { + throw new Error('invalid common name'); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error('Invalid certificate chain!'); + } + + const signatureBase = Buffer.from( + jwsParts[0] + '.' + jwsParts[1], + 'utf-8', + ); + + const valid = crypto + .createVerify('sha256') + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + return { + valid, + publicKey: publicKeyData, + }; + }, + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + valid: validSignature, + publicKey: publicKeyData, + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error('ECDAA-Verify is not supported'); + } else { + if (attStmt.alg !== -7) throw new Error('alg mismatch'); + + throw new Error('self attestation is not supported'); + } + }, + }, + + 'fido-u2f': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>, + rpIdHash: Buffer, + credentialId: Buffer + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length !== 1) { + throw new Error('x5c length does not match expectation'); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F, + ]); + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F, + }; + }, + }, + }; + } +} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts new file mode 100644 index 0000000000..a9396fcbba --- /dev/null +++ b/packages/backend/src/core/UserBlockingService.ts @@ -0,0 +1,199 @@ + +import { Inject, Injectable } from '@nestjs/common'; +import { IdService } from '@/core/IdService.js'; +import type { CacheableUser, User } from '@/models/entities/User.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { WebhookService } from './WebhookService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +@Injectable() +export class UserBlockingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + private webhookService: WebhookService, + private apRendererService: ApRendererService, + private perUserFollowingChart: PerUserFollowingChart, + ) { + } + + public async block(blocker: User, blockee: User) { + await Promise.all([ + this.#cancelRequest(blocker, blockee), + this.#cancelRequest(blockee, blocker), + this.#unFollow(blocker, blockee), + this.#unFollow(blockee, blocker), + this.#removeFromList(blockee, blocker), + ]); + + const blocking = { + id: this.idService.genId(), + createdAt: new Date(), + blocker, + blockerId: blocker.id, + blockee, + blockeeId: blockee.id, + } as Blocking; + + await this.blockingsRepository.insert(blocking); + + if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking)); + this.queueService.deliver(blocker, content, blockee.inbox); + } + } + + async #cancelRequest(follower: User, followee: User) { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + return; + } + + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(followee, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローリクエストをしていたらUndoFollow送信 + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + + // リモートからフォローリクエストを受けていたらReject送信 + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + async #unFollow(follower: User, followee: User) { + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + return; + } + + await Promise.all([ + this.followingsRepository.delete(following.id), + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + this.perUserFollowingChart.update(follower, followee, false), + ]); + + // Publish unfollow event + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + // リモートにフォローをしていたらUndoFollow送信 + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + } + + async #removeFromList(listOwner: User, user: User) { + const userLists = await this.userListsRepository.findBy({ + userId: listOwner.id, + }); + + for (const userList of userLists) { + await this.userListJoiningsRepository.delete({ + userListId: userList.id, + userId: user.id, + }); + } + } + + public async unblock(blocker: CacheableUser, blockee: CacheableUser) { + const blocking = await this.blockingsRepository.findOneBy({ + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (blocking == null) { + logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); + return; + } + + // Since we already have the blocker and blockee, we do not need to fetch + // them in the query above and can just manually insert them here. + blocking.blocker = blocker; + blocking.blockee = blockee; + + await this.blockingsRepository.delete(blocking.id); + + // deliver if remote bloking + if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); + this.queueService.deliver(blocker, content, blockee.inbox); + } + } +} diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts new file mode 100644 index 0000000000..8212abf7bb --- /dev/null +++ b/packages/backend/src/core/UserCacheService.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { 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 type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class UserCacheService implements OnApplicationShutdown { + public userByIdCache: Cache<CacheableUser>; + public localUserByNativeTokenCache: Cache<CacheableLocalUser | null>; + public localUserByIdCache: Cache<CacheableLocalUser>; + public uriPersonCache: Cache<CacheableUser | null>; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private userEntityService: UserEntityService, + ) { + this.onMessage = this.onMessage.bind(this); + + this.userByIdCache = new Cache<CacheableUser>(Infinity); + this.localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity); + this.localUserByIdCache = new Cache<CacheableLocalUser>(Infinity); + this.uriPersonCache = new Cache<CacheableUser | null>(Infinity); + + this.redisSubscriber.on('message', this.onMessage); + } + + private async onMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'userChangeSuspendedState': + case 'userChangeSilencedState': + case 'userChangeModeratorState': + case 'remoteUserUpdated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }); + this.userByIdCache.set(user.id, user); + for (const [k, v] of this.uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + this.uriPersonCache.set(k, user); + } + } + if (this.userEntityService.isLocalUser(user)) { + this.localUserByNativeTokenCache.set(user.token, user); + this.localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser; + this.localUserByNativeTokenCache.delete(body.oldToken); + this.localUserByNativeTokenCache.set(body.newToken, user); + break; + } + default: + break; + } + } + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts new file mode 100644 index 0000000000..6aae5a6b99 --- /dev/null +++ b/packages/backend/src/core/UserFollowingService.ts @@ -0,0 +1,574 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { QueueService } from '@/core/QueueService.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; +import type { Packed } from '@/misc/schema.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { WebhookService } from '@/core/WebhookService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import Logger from '../logger.js'; +import { UserEntityService } from './entities/UserEntityService.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; + +const logger = new Logger('following/create'); + +type Local = ILocalUser | { + id: ILocalUser['id']; + host: ILocalUser['host']; + uri: ILocalUser['uri'] +}; +type Remote = IRemoteUser | { + id: IRemoteUser['id']; + host: IRemoteUser['host']; + uri: IRemoteUser['uri']; + inbox: IRemoteUser['inbox']; +}; +type Both = Local | Remote; + +@Injectable() +export class UserFollowingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + private createNotificationService: CreateNotificationService, + private federatedInstanceService: FederatedInstanceService, + private webhookService: WebhookService, + private apRendererService: ApRendererService, + private perUserFollowingChart: PerUserFollowingChart, + private instanceChart: InstanceChart, + ) { + } + + public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> { + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: _follower.id }), + this.usersRepository.findOneByOrFail({ id: _followee.id }), + ]); + + // check blocking + const [blocking, blocked] = await Promise.all([ + this.blockingsRepository.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + this.blockingsRepository.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { + // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); + this.queueService.deliver(followee, content, follower.inbox); + return; + } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { + // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 + await this.blockingsRepository.delete(blocking.id); + } else { + // それ以外は単純に例外 + if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); + if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); + } + + const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id }); + + // フォロー対象が鍵アカウントである or + // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or + // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである + // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく + if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { + let autoAccept = false; + + // 鍵アカウントであっても、既にフォローされていた場合はスルー + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + if (following) { + autoAccept = true; + } + + // フォローしているユーザーは自動承認オプション + if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { + const followed = await this.followingsRepository.findOneBy({ + followerId: followee.id, + followeeId: follower.id, + }); + + if (followed) autoAccept = true; + } + + if (!autoAccept) { + await this.createFollowRequest(follower, followee, requestId); + return; + } + } + + await this.#insertFollowingDoc(followee, follower); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + async #insertFollowingDoc( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] + }, + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] + }, + ): Promise<void> { + if (follower.id === followee.id) return; + + let alreadyFollowed = false as boolean; + + await this.followingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + + // 非正規化 + followerHost: follower.host, + followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, + followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null, + followeeHost: followee.host, + followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null, + followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null, + }).catch(err => { + if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); + alreadyFollowed = true; + } else { + throw err; + } + }); + + const req = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (req) { + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + // 通知を作成 + this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { + notifierId: followee.id, + }); + } + + if (alreadyFollowed) return; + + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + this.instanceChart.updateFollowing(i.host, true); + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + this.instanceChart.updateFollowers(i.host, true); + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); + + // Publish follow event + if (this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); + this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'follow', { + user: packed, + }); + } + }); + } + + // Publish followed event + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(follower.id, followee).then(async packed => { + this.globalEventServie.publishMainStream(followee.id, 'followed', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'followed', { + user: packed, + }); + } + }); + + // 通知を作成 + this.createNotificationService.createNotification(followee.id, 'follow', { + notifierId: follower.id, + }); + } + } + + public async unfollow( + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + silent = false, + ): Promise<void> { + const following = await this.followingsRepository.findOneBy({ + followerId: follower.id, + followeeId: followee.id, + }); + + if (following == null) { + logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + return; + } + + await this.followingsRepository.delete(following.id); + + this.#decrementFollowing(follower, followee); + + // Publish unfollow event + if (!silent && this.userEntityService.isLocalUser(follower)) { + this.userEntityService.pack(followee.id, follower, { + detail: true, + }).then(async packed => { + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packed, + }); + } + }); + } + + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + this.queueService.deliver(follower, content, followee.inbox); + } + + if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { + // local user has null host + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + } + + async #decrementFollowing( + follower: {id: User['id']; host: User['host']; }, + followee: { id: User['id']; host: User['host']; }, + ): Promise<void> { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + this.instanceChart.updateFollowing(i.host, false); + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + this.instanceChart.updateFollowers(i.host, false); + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } + + public async createFollowRequest( + follower: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + requestId?: string, + ): Promise<void> { + if (follower.id === followee.id) return; + + // check blocking + const [blocking, blocked] = await Promise.all([ + this.blockingsRepository.findOneBy({ + blockerId: follower.id, + blockeeId: followee.id, + }), + this.blockingsRepository.findOneBy({ + blockerId: followee.id, + blockeeId: follower.id, + }), + ]); + + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + + const followRequest = await this.followRequestsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + requestId, + + // 非正規化 + followerHost: follower.host, + followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, + followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined, + followeeHost: followee.host, + followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, + followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, + }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); + + // Publish receiveRequest event + if (this.userEntityService.isLocalUser(followee)) { + this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed)); + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + + // 通知を作成 + this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { + notifierId: follower.id, + followRequestId: followRequest.id, + }); + } + + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)); + this.queueService.deliver(follower, content, followee.inbox); + } + } + + public async cancelFollowRequest( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] + }, + follower: { + id: User['id']; host: User['host']; uri: User['host'] + }, + ): Promise<void> { + if (this.userEntityService.isRemoteUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + + if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので + this.queueService.deliver(follower, content, followee.inbox); + } + } + + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + } + + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + public async acceptFollowRequest( + followee: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + follower: CacheableUser, + ): Promise<void> { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (request == null) { + throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); + } + + await this.#insertFollowingDoc(followee, follower); + + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + + this.userEntityService.pack(followee.id, followee, { + detail: true, + }).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); + } + + public async acceptAllFollowRequests( + user: { + id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; + }, + ): Promise<void> { + const requests = await this.followRequestsRepository.findBy({ + followeeId: user.id, + }); + + for (const request of requests) { + const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); + this.acceptFollowRequest(user, follower); + } + } + + /** + * API following/request/reject + */ + public async rejectFollowRequest(user: Local, follower: Both): Promise<void> { + if (this.userEntityService.isRemoteUser(follower)) { + this.#deliverReject(user, follower); + } + + await this.#removeFollowRequest(user, follower); + + if (this.userEntityService.isLocalUser(follower)) { + this.#publishUnfollow(user, follower); + } + } + + /** + * API following/reject + */ + public async rejectFollow(user: Local, follower: Both): Promise<void> { + if (this.userEntityService.isRemoteUser(follower)) { + this.#deliverReject(user, follower); + } + + await this.#removeFollow(user, follower); + + if (this.userEntityService.isLocalUser(follower)) { + this.#publishUnfollow(user, follower); + } + } + + /** + * AP Reject/Follow + */ + public async remoteReject(actor: Remote, follower: Local): Promise<void> { + await this.#removeFollowRequest(actor, follower); + await this.#removeFollow(actor, follower); + this.#publishUnfollow(actor, follower); + } + + /** + * Remove follow request record + */ + async #removeFollowRequest(followee: Both, follower: Both): Promise<void> { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!request) return; + + await this.followRequestsRepository.delete(request.id); + } + + /** + * Remove follow record + */ + async #removeFollow(followee: Both, follower: Both): Promise<void> { + const following = await this.followingsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + if (!following) return; + + await this.followingsRepository.delete(following.id); + this.#decrementFollowing(follower, followee); + } + + /** + * Deliver Reject to remote + */ + async #deliverReject(followee: Local, follower: Remote): Promise<void> { + const request = await this.followRequestsRepository.findOneBy({ + followeeId: followee.id, + followerId: follower.id, + }); + + const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); + this.queueService.deliver(followee, content, follower.inbox); + } + + /** + * Publish unfollow to local + */ + async #publishUnfollow(followee: Both, follower: Local): Promise<void> { + const packedFollowee = await this.userEntityService.pack(followee.id, follower, { + detail: true, + }); + + this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee); + this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee); + + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + this.queueService.webhookDeliver(webhook, 'unfollow', { + user: packedFollowee, + }); + } + } +} diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts new file mode 100644 index 0000000000..f093d2ea91 --- /dev/null +++ b/packages/backend/src/core/UserKeypairStoreService.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { User } from '@/models/entities/User.js'; +import { UserKeypairsRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class UserKeypairStoreService { + #cache: Cache<UserKeypair>; + + constructor( + @Inject(DI.userKeypairsRepository) + private userKeypairsRepository: UserKeypairsRepository, + ) { + this.#cache = new Cache<UserKeypair>(Infinity); + } + + public async getUserKeypair(userId: User['id']): Promise<UserKeypair> { + return await this.#cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); + } +} diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts new file mode 100644 index 0000000000..03113f042a --- /dev/null +++ b/packages/backend/src/core/UserListService.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import type { UserListJoining } from '@/models/entities/UserListJoining.js'; +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'; + +@Injectable() +export class UserListService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private userFollowingService: UserFollowingService, + private globalEventServie: GlobalEventService, + private proxyAccountService: ProxyAccountService, + ) { + } + + public async push(target: User, list: UserList) { + await this.userListJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: target.id, + userListId: list.id, + } as UserListJoining); + + this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (this.userEntityService.isRemoteUser(target)) { + const proxy = await this.proxyAccountService.fetch(); + if (proxy) { + this.userFollowingService.follow(proxy, target); + } + } + } +} diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts new file mode 100644 index 0000000000..9146360df1 --- /dev/null +++ b/packages/backend/src/core/UserMutingService.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UsersRepository, MutingsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; + +@Injectable() +export class UserMutingService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private idService: IdService, + private queueService: QueueService, + private globalEventServie: GlobalEventService, + ) { + } + + public async mute(user: User, target: User): Promise<void> { + await this.mutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); + } +} diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts new file mode 100644 index 0000000000..068341cb2c --- /dev/null +++ b/packages/backend/src/core/UserSuspendService.ts @@ -0,0 +1,88 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Not, IsNull } from 'typeorm'; +import { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { ApRendererService } from './remote/activitypub/ApRendererService.js'; +import { UserEntityService } from './entities/UserEntityService.js'; + +@Injectable() +export class UserSuspendService { + 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, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + ) { + } + + public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise<void> { + this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user, content, inbox); + } + } + } + + public async doPostUnsuspend(user: User): Promise<void> { + this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); + + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 + const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + + const queue: string[] = []; + + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + this.queueService.deliver(user as any, content, inbox); + } + } + } +} diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts new file mode 100644 index 0000000000..ba03dfc069 --- /dev/null +++ b/packages/backend/src/core/UtilityService.ts @@ -0,0 +1,37 @@ +import { URL } from 'node:url'; +import { toASCII } from 'punycode'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; + +@Injectable() +export class UtilityService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + public getFullApAccount(username: string, host: string | null): string { + return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`; + } + + public isSelfHost(host: string | null): boolean { + if (host == null) return true; + return this.toPuny(this.config.host) === this.toPuny(host); + } + + public extractDbHost(uri: string): string { + const url = new URL(uri); + return this.toPuny(url.hostname); + } + + public toPuny(host: string): string { + return toASCII(host.toLowerCase()); + } + + public toPunyNullable(host: string | null | undefined): string | null { + if (host == null) return null; + return toASCII(host.toLowerCase()); + } +} diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts new file mode 100644 index 0000000000..70b9664c76 --- /dev/null +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import FFmpeg from 'fluent-ffmpeg'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import type { IImage } from '@/core/ImageProcessingService.js'; +import { createTempDir } from '@/misc/create-temp.js'; + +@Injectable() +export class VideoProcessingService { + constructor( + @Inject(DI.config) + private config: Config, + + private imageProcessingService: ImageProcessingService, + ) { + } + + public async generateVideoThumbnail(source: string): Promise<IImage> { + const [dir, cleanup] = await createTempDir(); + + try { + await new Promise((res, rej) => { + FFmpeg({ + source, + }) + .on('end', res) + .on('error', rej) + .screenshot({ + folder: dir, + filename: 'out.png', // must have .png extension + count: 1, + timestamps: ['5%'], + }); + }); + + // JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる) + return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280); + } finally { + cleanup(); + } + } +} + diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts new file mode 100644 index 0000000000..3ed615ca0b --- /dev/null +++ b/packages/backend/src/core/WebhookService.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { WebhooksRepository } from '@/models/index.js'; +import type { Webhook } from '@/models/entities/Webhook.js'; +import { DI } from '@/di-symbols.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class WebhookService implements OnApplicationShutdown { + #webhooksFetched = false; + #webhooks: Webhook[] = []; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + this.onMessage = this.onMessage.bind(this); + this.redisSubscriber.on('message', this.onMessage); + } + + public async getActiveWebhooks() { + if (!this.#webhooksFetched) { + this.#webhooks = await this.webhooksRepository.findBy({ + active: true, + }); + this.#webhooksFetched = true; + } + + return this.#webhooks; + } + + private async onMessage(_, data) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'webhookCreated': + if (body.active) { + this.#webhooks.push(body); + } + break; + case 'webhookUpdated': + if (body.active) { + const i = this.#webhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.#webhooks[i] = body; + } else { + this.#webhooks.push(body); + } + } else { + this.#webhooks = this.#webhooks.filter(a => a.id !== body.id); + } + break; + case 'webhookDeleted': + this.#webhooks = this.#webhooks.filter(a => a.id !== body.id); + break; + default: + break; + } + } + } + + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts new file mode 100644 index 0000000000..2020ca0ca6 --- /dev/null +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -0,0 +1,59 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { beforeShutdown } from '@/misc/before-shutdown.js'; + +import FederationChart from './charts/federation.js'; +import NotesChart from './charts/notes.js'; +import UsersChart from './charts/users.js'; +import ActiveUsersChart from './charts/active-users.js'; +import InstanceChart from './charts/instance.js'; +import PerUserNotesChart from './charts/per-user-notes.js'; +import DriveChart from './charts/drive.js'; +import PerUserReactionsChart from './charts/per-user-reactions.js'; +import HashtagChart from './charts/hashtag.js'; +import PerUserFollowingChart from './charts/per-user-following.js'; +import PerUserDriveChart from './charts/per-user-drive.js'; +import ApRequestChart from './charts/ap-request.js'; + +@Injectable() +export class ChartManagementService { + constructor( + private federationChart: FederationChart, + private notesChart: NotesChart, + private usersChart: UsersChart, + private activeUsersChart: ActiveUsersChart, + private instanceChart: InstanceChart, + private perUserNotesChart: PerUserNotesChart, + private driveChart: DriveChart, + private perUserReactionsChart: PerUserReactionsChart, + private hashtagChart: HashtagChart, + private perUserFollowingChart: PerUserFollowingChart, + private perUserDriveChart: PerUserDriveChart, + private apRequestChart: ApRequestChart, + ) {} + + public async run() { + const charts = [ + this.federationChart, + this.notesChart, + this.usersChart, + this.activeUsersChart, + this.instanceChart, + this.perUserNotesChart, + this.driveChart, + this.perUserReactionsChart, + this.hashtagChart, + this.perUserFollowingChart, + this.perUserDriveChart, + this.apRequestChart, + ]; + + // 20分おきにメモリ情報をDBに書き込み + setInterval(() => { + for (const chart of charts) { + chart.save(); + } + }, 1000 * 60 * 20); + + beforeShutdown(() => Promise.all(charts.map(chart => chart.save()))); + } +} diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts new file mode 100644 index 0000000000..a5d9f166ed --- /dev/null +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -0,0 +1,54 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import type { User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/active-users.js'; +import type { KVs } from '../core.js'; + +const week = 1000 * 60 * 60 * 24 * 7; +const month = 1000 * 60 * 60 * 24 * 30; +const year = 1000 * 60 * 60 * 24 * 365; + +/** + * アクティブユーザーに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class ActiveUsersChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async read(user: { id: User['id'], host: null, createdAt: User['createdAt'] }): Promise<void> { + await this.commit({ + 'read': [user.id], + 'registeredWithinWeek': (Date.now() - user.createdAt.getTime() < week) ? [user.id] : [], + 'registeredWithinMonth': (Date.now() - user.createdAt.getTime() < month) ? [user.id] : [], + 'registeredWithinYear': (Date.now() - user.createdAt.getTime() < year) ? [user.id] : [], + 'registeredOutsideWeek': (Date.now() - user.createdAt.getTime() > week) ? [user.id] : [], + 'registeredOutsideMonth': (Date.now() - user.createdAt.getTime() > month) ? [user.id] : [], + 'registeredOutsideYear': (Date.now() - user.createdAt.getTime() > year) ? [user.id] : [], + }); + } + + public async write(user: { id: User['id'], host: null, createdAt: User['createdAt'] }): Promise<void> { + await this.commit({ + 'write': [user.id], + }); + } +} diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts new file mode 100644 index 0000000000..c857cea98c --- /dev/null +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -0,0 +1,49 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/ap-request.js'; +import type { KVs } from '../core.js'; + +/** + * Chart about ActivityPub requests + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class ApRequestChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async deliverSucc(): Promise<void> { + await this.commit({ + 'deliverSucceeded': 1, + }); + } + + public async deliverFail(): Promise<void> { + await this.commit({ + 'deliverFailed': 1, + }); + } + + public async inbox(): Promise<void> { + await this.commit({ + 'inboxReceived': 1, + }); + } +} diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts new file mode 100644 index 0000000000..dd6d002030 --- /dev/null +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -0,0 +1,47 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/drive.js'; +import type { KVs } from '../core.js'; + +/** + * ドライブに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class DriveChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(file: DriveFile, isAdditional: boolean): Promise<void> { + const fileSizeKb = file.size / 1000; + await this.commit(file.userHost === null ? { + 'local.incCount': isAdditional ? 1 : 0, + 'local.incSize': isAdditional ? fileSizeKb : 0, + 'local.decCount': isAdditional ? 0 : 1, + 'local.decSize': isAdditional ? 0 : fileSizeKb, + } : { + 'remote.incCount': isAdditional ? 1 : 0, + 'remote.incSize': isAdditional ? fileSizeKb : 0, + 'remote.decCount': isAdditional ? 0 : 1, + 'remote.decSize': isAdditional ? 0 : fileSizeKb, + }); + } +} diff --git a/packages/backend/src/core/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts new file mode 100644 index 0000000000..5767b76f8e --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/active-users.ts @@ -0,0 +1,17 @@ +import Chart from '../../core.js'; + +export const name = 'activeUsers'; + +export const schema = { + 'readWrite': { intersection: ['read', 'write'], range: 'small' }, + 'read': { uniqueIncrement: true, range: 'small' }, + 'write': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, + 'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, + 'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/ap-request.ts b/packages/backend/src/core/chart/charts/entities/ap-request.ts new file mode 100644 index 0000000000..3a9f3dacfd --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/ap-request.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'apRequest'; + +export const schema = { + 'deliverFailed': { }, + 'deliverSucceeded': { }, + 'inboxReceived': { }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/drive.ts b/packages/backend/src/core/chart/charts/entities/drive.ts new file mode 100644 index 0000000000..4bf5bb729e --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/drive.ts @@ -0,0 +1,16 @@ +import Chart from '../../core.js'; + +export const name = 'drive'; + +export const schema = { + 'local.incCount': {}, + 'local.incSize': {}, // in kilobyte + 'local.decCount': {}, + 'local.decSize': {}, // in kilobyte + 'remote.incCount': {}, + 'remote.incSize': {}, // in kilobyte + 'remote.decCount': {}, + 'remote.decSize': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/federation.ts b/packages/backend/src/core/chart/charts/entities/federation.ts new file mode 100644 index 0000000000..a8466b0b4c --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/federation.ts @@ -0,0 +1,16 @@ +import Chart from '../../core.js'; + +export const name = 'federation'; + +export const schema = { + 'deliveredInstances': { uniqueIncrement: true, range: 'small' }, + 'inboxInstances': { uniqueIncrement: true, range: 'small' }, + 'stalled': { uniqueIncrement: true, range: 'small' }, + 'sub': { accumulate: true, range: 'small' }, + 'pub': { accumulate: true, range: 'small' }, + 'pubsub': { accumulate: true, range: 'small' }, + 'subActive': { accumulate: true, range: 'small' }, + 'pubActive': { accumulate: true, range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/hashtag.ts b/packages/backend/src/core/chart/charts/entities/hashtag.ts new file mode 100644 index 0000000000..4d04039047 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/hashtag.ts @@ -0,0 +1,10 @@ +import Chart from '../../core.js'; + +export const name = 'hashtag'; + +export const schema = { + 'local.users': { uniqueIncrement: true }, + 'remote.users': { uniqueIncrement: true }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/instance.ts b/packages/backend/src/core/chart/charts/entities/instance.ts new file mode 100644 index 0000000000..06962120e2 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/instance.ts @@ -0,0 +1,32 @@ +import Chart from '../../core.js'; + +export const name = 'instance'; + +export const schema = { + 'requests.failed': { range: 'small' }, + 'requests.succeeded': { range: 'small' }, + 'requests.received': { range: 'small' }, + 'notes.total': { accumulate: true }, + 'notes.inc': {}, + 'notes.dec': {}, + 'notes.diffs.normal': {}, + 'notes.diffs.reply': {}, + 'notes.diffs.renote': {}, + 'notes.diffs.withFile': {}, + 'users.total': { accumulate: true }, + 'users.inc': { range: 'small' }, + 'users.dec': { range: 'small' }, + 'following.total': { accumulate: true }, + 'following.inc': { range: 'small' }, + 'following.dec': { range: 'small' }, + 'followers.total': { accumulate: true }, + 'followers.inc': { range: 'small' }, + 'followers.dec': { range: 'small' }, + 'drive.totalFiles': { accumulate: true }, + 'drive.incFiles': {}, + 'drive.decFiles': {}, + 'drive.incUsage': {}, // in kilobyte + 'drive.decUsage': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/notes.ts b/packages/backend/src/core/chart/charts/entities/notes.ts new file mode 100644 index 0000000000..9387dbfb2c --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/notes.ts @@ -0,0 +1,22 @@ +import Chart from '../../core.js'; + +export const name = 'notes'; + +export const schema = { + 'local.total': { accumulate: true }, + 'local.inc': {}, + 'local.dec': {}, + 'local.diffs.normal': {}, + 'local.diffs.reply': {}, + 'local.diffs.renote': {}, + 'local.diffs.withFile': {}, + 'remote.total': { accumulate: true }, + 'remote.inc': {}, + 'remote.dec': {}, + 'remote.diffs.normal': {}, + 'remote.diffs.reply': {}, + 'remote.diffs.renote': {}, + 'remote.diffs.withFile': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-drive.ts b/packages/backend/src/core/chart/charts/entities/per-user-drive.ts new file mode 100644 index 0000000000..6111640ea0 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/per-user-drive.ts @@ -0,0 +1,14 @@ +import Chart from '../../core.js'; + +export const name = 'perUserDrive'; + +export const schema = { + 'totalCount': { accumulate: true }, + 'totalSize': { accumulate: true }, // in kilobyte + 'incCount': { range: 'small' }, + 'incSize': {}, // in kilobyte + 'decCount': { range: 'small' }, + 'decSize': {}, // in kilobyte +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-following.ts b/packages/backend/src/core/chart/charts/entities/per-user-following.ts new file mode 100644 index 0000000000..4118daa474 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/per-user-following.ts @@ -0,0 +1,20 @@ +import Chart from '../../core.js'; + +export const name = 'perUserFollowing'; + +export const schema = { + 'local.followings.total': { accumulate: true }, + 'local.followings.inc': { range: 'small' }, + 'local.followings.dec': { range: 'small' }, + 'local.followers.total': { accumulate: true }, + 'local.followers.inc': { range: 'small' }, + 'local.followers.dec': { range: 'small' }, + 'remote.followings.total': { accumulate: true }, + 'remote.followings.inc': { range: 'small' }, + 'remote.followings.dec': { range: 'small' }, + 'remote.followers.total': { accumulate: true }, + 'remote.followers.inc': { range: 'small' }, + 'remote.followers.dec': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-notes.ts b/packages/backend/src/core/chart/charts/entities/per-user-notes.ts new file mode 100644 index 0000000000..c1fa174452 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/per-user-notes.ts @@ -0,0 +1,15 @@ +import Chart from '../../core.js'; + +export const name = 'perUserNotes'; + +export const schema = { + 'total': { accumulate: true }, + 'inc': { range: 'small' }, + 'dec': { range: 'small' }, + 'diffs.normal': { range: 'small' }, + 'diffs.reply': { range: 'small' }, + 'diffs.renote': { range: 'small' }, + 'diffs.withFile': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts b/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts new file mode 100644 index 0000000000..5e1a6c7b30 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/per-user-reactions.ts @@ -0,0 +1,10 @@ +import Chart from '../../core.js'; + +export const name = 'perUserReaction'; + +export const schema = { + 'local.count': { range: 'small' }, + 'remote.count': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/test-grouped.ts b/packages/backend/src/core/chart/charts/entities/test-grouped.ts new file mode 100644 index 0000000000..66b6e8e864 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/test-grouped.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'testGrouped'; + +export const schema = { + 'foo.total': { accumulate: true }, + 'foo.inc': {}, + 'foo.dec': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema, true); diff --git a/packages/backend/src/core/chart/charts/entities/test-intersection.ts b/packages/backend/src/core/chart/charts/entities/test-intersection.ts new file mode 100644 index 0000000000..a3bdcb367f --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/test-intersection.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'testIntersection'; + +export const schema = { + 'a': { uniqueIncrement: true }, + 'b': { uniqueIncrement: true }, + 'aAndB': { intersection: ['a', 'b'] }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/test-unique.ts b/packages/backend/src/core/chart/charts/entities/test-unique.ts new file mode 100644 index 0000000000..b2cfb71b05 --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/test-unique.ts @@ -0,0 +1,9 @@ +import Chart from '../../core.js'; + +export const name = 'testUnique'; + +export const schema = { + 'foo': { uniqueIncrement: true }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/test.ts b/packages/backend/src/core/chart/charts/entities/test.ts new file mode 100644 index 0000000000..7cba21e16a --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/test.ts @@ -0,0 +1,11 @@ +import Chart from '../../core.js'; + +export const name = 'test'; + +export const schema = { + 'foo.total': { accumulate: true }, + 'foo.inc': {}, + 'foo.dec': {}, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/entities/users.ts b/packages/backend/src/core/chart/charts/entities/users.ts new file mode 100644 index 0000000000..c0b83094ae --- /dev/null +++ b/packages/backend/src/core/chart/charts/entities/users.ts @@ -0,0 +1,14 @@ +import Chart from '../../core.js'; + +export const name = 'users'; + +export const schema = { + 'local.total': { accumulate: true }, + 'local.inc': { range: 'small' }, + 'local.dec': { range: 'small' }, + 'remote.total': { accumulate: true }, + 'remote.inc': { range: 'small' }, + 'remote.dec': { range: 'small' }, +} as const; + +export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts new file mode 100644 index 0000000000..372e0f1fae --- /dev/null +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -0,0 +1,121 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { FollowingsRepository, InstancesRepository } from '@/models/index.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/federation.js'; +import type { KVs } from '../core.js'; + +/** + * フェデレーションに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class FederationChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private metaService: MetaService, + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return { + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + const meta = await this.metaService.fetch(); + + const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') + .select('instance.host') + .where('instance.isSuspended = true'); + + const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') + .select('f.followerHost') + .where('f.followerHost IS NOT NULL'); + + const subInstancesQuery = this.followingsRepository.createQueryBuilder('f') + .select('f.followeeHost') + .where('f.followeeHost IS NOT NULL'); + + const pubInstancesQuery = this.followingsRepository.createQueryBuilder('f') + .select('f.followerHost') + .where('f.followerHost IS NOT NULL'); + + const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([ + this.followingsRepository.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followeeHost)') + .where('following.followeeHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .getRawOne() + .then(x => parseInt(x.count, 10)), + this.followingsRepository.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followerHost)') + .where('following.followerHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .getRawOne() + .then(x => parseInt(x.count, 10)), + this.followingsRepository.createQueryBuilder('following') + .select('COUNT(DISTINCT following.followeeHost)') + .where('following.followeeHost IS NOT NULL') + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) + .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) + .setParameters(pubsubSubQuery.getParameters()) + .getRawOne() + .then(x => parseInt(x.count, 10)), + this.instancesRepository.createQueryBuilder('instance') + .select('COUNT(instance.id)') + .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere('instance.isSuspended = false') + .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .getRawOne() + .then(x => parseInt(x.count, 10)), + this.instancesRepository.createQueryBuilder('instance') + .select('COUNT(instance.id)') + .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) + .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts }) + .andWhere('instance.isSuspended = false') + .andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) + .getRawOne() + .then(x => parseInt(x.count, 10)), + ]); + + return { + 'sub': sub, + 'pub': pub, + 'pubsub': pubsub, + 'subActive': subActive, + 'pubActive': pubActive, + }; + } + + public async deliverd(host: string, succeeded: boolean): Promise<void> { + await this.commit(succeeded ? { + 'deliveredInstances': [host], + } : { + 'stalled': [host], + }); + } + + public async inbox(host: string): Promise<void> { + await this.commit({ + 'inboxInstances': [host], + }); + } +} diff --git a/packages/backend/src/core/chart/charts/hashtag.ts b/packages/backend/src/core/chart/charts/hashtag.ts new file mode 100644 index 0000000000..66ac0b882b --- /dev/null +++ b/packages/backend/src/core/chart/charts/hashtag.ts @@ -0,0 +1,41 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/hashtag.js'; +import type { KVs } from '../core.js'; + +/** + * ハッシュタグに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class HashtagChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise<void> { + await this.commit({ + 'local.users': this.userEntityService.isLocalUser(user) ? [user.id] : [], + 'remote.users': this.userEntityService.isLocalUser(user) ? [] : [user.id], + }, hashtag); + } +} diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts new file mode 100644 index 0000000000..c43ebeddc1 --- /dev/null +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -0,0 +1,127 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/instance.js'; +import type { KVs } from '../core.js'; + +/** + * インスタンスごとのチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class InstanceChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private utilityService: UtilityService, + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + const [ + notesCount, + usersCount, + followingCount, + followersCount, + driveFiles, + ] = await Promise.all([ + this.notesRepository.countBy({ userHost: group }), + this.usersRepository.countBy({ host: group }), + this.followingsRepository.countBy({ followerHost: group }), + this.followingsRepository.countBy({ followeeHost: group }), + this.driveFilesRepository.countBy({ userHost: group }), + ]); + + return { + 'notes.total': notesCount, + 'users.total': usersCount, + 'following.total': followingCount, + 'followers.total': followersCount, + 'drive.totalFiles': driveFiles, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async requestReceived(host: string): Promise<void> { + await this.commit({ + 'requests.received': 1, + }, this.utilityService.toPuny(host)); + } + + public async requestSent(host: string, isSucceeded: boolean): Promise<void> { + await this.commit({ + 'requests.succeeded': isSucceeded ? 1 : 0, + 'requests.failed': isSucceeded ? 0 : 1, + }, this.utilityService.toPuny(host)); + } + + public async newUser(host: string): Promise<void> { + await this.commit({ + 'users.total': 1, + 'users.inc': 1, + }, this.utilityService.toPuny(host)); + } + + public async updateNote(host: string, note: Note, isAdditional: boolean): Promise<void> { + await this.commit({ + 'notes.total': isAdditional ? 1 : -1, + 'notes.inc': isAdditional ? 1 : 0, + 'notes.dec': isAdditional ? 0 : 1, + 'notes.diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, + 'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }, this.utilityService.toPuny(host)); + } + + public async updateFollowing(host: string, isAdditional: boolean): Promise<void> { + await this.commit({ + 'following.total': isAdditional ? 1 : -1, + 'following.inc': isAdditional ? 1 : 0, + 'following.dec': isAdditional ? 0 : 1, + }, this.utilityService.toPuny(host)); + } + + public async updateFollowers(host: string, isAdditional: boolean): Promise<void> { + await this.commit({ + 'followers.total': isAdditional ? 1 : -1, + 'followers.inc': isAdditional ? 1 : 0, + 'followers.dec': isAdditional ? 0 : 1, + }, this.utilityService.toPuny(host)); + } + + public async updateDrive(file: DriveFile, isAdditional: boolean): Promise<void> { + const fileSizeKb = file.size / 1000; + await this.commit({ + 'drive.totalFiles': isAdditional ? 1 : -1, + 'drive.incFiles': isAdditional ? 1 : 0, + 'drive.incUsage': isAdditional ? fileSizeKb : 0, + 'drive.decFiles': isAdditional ? 1 : 0, + 'drive.decUsage': isAdditional ? fileSizeKb : 0, + }, file.userHost); + } +} diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts new file mode 100644 index 0000000000..1597b5727e --- /dev/null +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -0,0 +1,58 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import { NotesRepository } from '@/models/index.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/notes.js'; +import type { KVs } from '../core.js'; + +/** + * ノートに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class NotesChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + const [localCount, remoteCount] = await Promise.all([ + this.notesRepository.countBy({ userHost: IsNull() }), + this.notesRepository.countBy({ userHost: Not(IsNull()) }), + ]); + + return { + 'local.total': localCount, + 'remote.total': remoteCount, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(note: Note, isAdditional: boolean): Promise<void> { + const prefix = note.userHost === null ? 'local' : 'remote'; + + await this.commit({ + [`${prefix}.total`]: isAdditional ? 1 : -1, + [`${prefix}.inc`]: isAdditional ? 1 : 0, + [`${prefix}.dec`]: isAdditional ? 0 : 1, + [`${prefix}.diffs.normal`]: note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.renote`]: note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.reply`]: note.replyId != null ? (isAdditional ? 1 : -1) : 0, + [`${prefix}.diffs.withFile`]: note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts new file mode 100644 index 0000000000..181b9a38bb --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -0,0 +1,58 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/per-user-drive.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのドライブに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserDriveChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private appLockService: AppLockService, + private driveFileEntityService: DriveFileEntityService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + const [count, size] = await Promise.all([ + this.driveFilesRepository.countBy({ userId: group }), + this.driveFileEntityService.calcDriveUsageOf(group), + ]); + + return { + 'totalCount': count, + 'totalSize': size, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(file: DriveFile, isAdditional: boolean): Promise<void> { + const fileSizeKb = file.size / 1000; + await this.commit({ + 'totalCount': isAdditional ? 1 : -1, + 'totalSize': isAdditional ? fileSizeKb : -fileSizeKb, + 'incCount': isAdditional ? 1 : 0, + 'incSize': isAdditional ? fileSizeKb : 0, + 'decCount': isAdditional ? 0 : 1, + 'decSize': isAdditional ? 0 : fileSizeKb, + }, file.userId); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts new file mode 100644 index 0000000000..5195723a25 --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -0,0 +1,71 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { FollowingsRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/per-user-following.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのフォローに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserFollowingChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount, + ] = await Promise.all([ + this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }), + this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }), + this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }), + this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }), + ]); + + return { + 'local.followings.total': localFollowingsCount, + 'local.followers.total': localFollowersCount, + 'remote.followings.total': remoteFollowingsCount, + 'remote.followers.total': remoteFollowersCount, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise<void> { + const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote'; + const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote'; + + this.commit({ + [`${prefixFollower}.followings.total`]: isFollow ? 1 : -1, + [`${prefixFollower}.followings.inc`]: isFollow ? 1 : 0, + [`${prefixFollower}.followings.dec`]: isFollow ? 0 : 1, + }, follower.id); + this.commit({ + [`${prefixFollowee}.followers.total`]: isFollow ? 1 : -1, + [`${prefixFollowee}.followers.inc`]: isFollow ? 1 : 0, + [`${prefixFollowee}.followers.dec`]: isFollow ? 0 : 1, + }, followee.id); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts new file mode 100644 index 0000000000..6dbe309b7c --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -0,0 +1,55 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/per-user-notes.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのノートに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserNotesChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + const [count] = await Promise.all([ + this.notesRepository.countBy({ userId: group }), + ]); + + return { + total: count, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise<void> { + await this.commit({ + 'total': isAdditional ? 1 : -1, + 'inc': isAdditional ? 1 : 0, + 'dec': isAdditional ? 0 : 1, + 'diffs.normal': note.replyId == null && note.renoteId == null ? (isAdditional ? 1 : -1) : 0, + 'diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0, + 'diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0, + 'diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0, + }, user.id); + } +} diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts new file mode 100644 index 0000000000..73a58656f8 --- /dev/null +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -0,0 +1,42 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/per-user-reactions.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザーごとのリアクションに関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class PerUserReactionsChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise<void> { + const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; + this.commit({ + [`${prefix}.count`]: 1, + }, note.userId); + } +} diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts new file mode 100644 index 0000000000..e6cbe89790 --- /dev/null +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -0,0 +1,46 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/test-grouped.js'; +import type { KVs } from '../core.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class TestGroupedChart extends Chart<typeof schema> { + private total = {} as Record<string, number>; + + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true); + } + + protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> { + return { + 'foo.total': this.total[group], + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async increment(group: string): Promise<void> { + if (this.total[group] == null) this.total[group] = 0; + + this.total[group]++; + + await this.commit({ + 'foo.total': 1, + 'foo.inc': 1, + }, group); + } +} diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts new file mode 100644 index 0000000000..f2f17c8de6 --- /dev/null +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -0,0 +1,43 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/test-intersection.js'; +import type { KVs } from '../core.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class TestIntersectionChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async addA(key: string): Promise<void> { + await this.commit({ + a: [key], + }); + } + + public async addB(key: string): Promise<void> { + await this.commit({ + b: [key], + }); + } +} diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts new file mode 100644 index 0000000000..ce01594520 --- /dev/null +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -0,0 +1,37 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/test-unique.js'; +import type { KVs } from '../core.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class TestUniqueChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async uniqueIncrement(key: string): Promise<void> { + await this.commit({ + foo: [key], + }); + } +} diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts new file mode 100644 index 0000000000..bd59b7aa63 --- /dev/null +++ b/packages/backend/src/core/chart/charts/test.ts @@ -0,0 +1,53 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/test.js'; +import type { KVs } from '../core.js'; + +/** + * For testing + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class TestChart extends Chart<typeof schema> { + public total = 0; // publicにするのはテストのため + + constructor( + @Inject(DI.db) + private db: DataSource, + + private appLockService: AppLockService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + return { + 'foo.total': this.total, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async increment(): Promise<void> { + this.total++; + + await this.commit({ + 'foo.total': 1, + 'foo.inc': 1, + }); + } + + public async decrement(): Promise<void> { + this.total--; + + await this.commit({ + 'foo.total': -1, + 'foo.dec': 1, + }); + } +} diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts new file mode 100644 index 0000000000..4fdddcc0a3 --- /dev/null +++ b/packages/backend/src/core/chart/charts/users.ts @@ -0,0 +1,56 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Not, IsNull, DataSource } from 'typeorm'; +import type { User } from '@/models/entities/User.js'; +import { AppLockService } from '@/core/AppLockService.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { UsersRepository } from '@/models/index.js'; +import Chart from '../core.js'; +import { name, schema } from './entities/users.js'; +import type { KVs } from '../core.js'; + +/** + * ユーザー数に関するチャート + */ +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class UsersChart extends Chart<typeof schema> { + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private appLockService: AppLockService, + private userEntityService: UserEntityService, + ) { + super(db, (k) => appLockService.getChartInsertLock(k), name, schema); + } + + protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { + const [localCount, remoteCount] = await Promise.all([ + this.usersRepository.countBy({ host: IsNull() }), + this.usersRepository.countBy({ host: Not(IsNull()) }), + ]); + + return { + 'local.total': localCount, + 'remote.total': remoteCount, + }; + } + + protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { + return {}; + } + + public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean): Promise<void> { + const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote'; + + await this.commit({ + [`${prefix}.total`]: isAdditional ? 1 : -1, + [`${prefix}.inc`]: isAdditional ? 1 : 0, + [`${prefix}.dec`]: isAdditional ? 0 : 1, + }); + } +} diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts new file mode 100644 index 0000000000..1933e80c7b --- /dev/null +++ b/packages/backend/src/core/chart/core.ts @@ -0,0 +1,679 @@ +/** + * チャートエンジン + * + * Tests located in test/chart + */ + +import * as nestedProperty from 'nested-property'; +import { EntitySchema, LessThan, Between } from 'typeorm'; +import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js'; +import Logger from '@/logger.js'; +import type { Repository, DataSource } from 'typeorm'; + +const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test'); + +const columnPrefix = '___' as const; +const uniqueTempColumnPrefix = 'unique_temp___' as const; +const columnDot = '_' as const; + +type Schema = Record<string, { + uniqueIncrement?: boolean; + + intersection?: string[] | ReadonlyArray<string>; + + range?: 'big' | 'small' | 'medium'; + + // previousな値を引き継ぐかどうか + accumulate?: boolean; +}>; + +type KeyToColumnName<T extends string> = T extends `${infer R1}.${infer R2}` ? `${R1}${typeof columnDot}${KeyToColumnName<R2>}` : T; + +type Columns<S extends Schema> = { + [K in keyof S as `${typeof columnPrefix}${KeyToColumnName<string & K>}`]: number; +}; + +type TempColumnsForUnique<S extends Schema> = { + [K in keyof S as `${typeof uniqueTempColumnPrefix}${KeyToColumnName<string & K>}`]: S[K]['uniqueIncrement'] extends true ? string[] : never; +}; + +type RawRecord<S extends Schema> = { + id: number; + + /** + * 集計のグループ + */ + group?: string | null; + + /** + * 集計日時のUnixタイムスタンプ(秒) + */ + date: number; +} & TempColumnsForUnique<S> & Columns<S>; + +const camelToSnake = (str: string): string => { + return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase()); +}; + +const removeDuplicates = (array: any[]) => Array.from(new Set(array)); + +type Commit<S extends Schema> = { + [K in keyof S]?: S[K]['uniqueIncrement'] extends true ? string[] : number; +}; + +export type KVs<S extends Schema> = { + [K in keyof S]: number; +}; + +type ChartResult<T extends Schema> = { + [P in keyof T]: number[]; +}; + +type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never; + +type UnflattenSingleton<K extends string, V> = K extends `${infer A}.${infer B}` + ? { [_ in A]: UnflattenSingleton<B, V>; } + : { [_ in K]: V; }; + +type Unflatten<T extends Record<string, any>> = UnionToIntersection< + { + [K in Extract<keyof T, string>]: UnflattenSingleton<K, T[K]>; + }[Extract<keyof T, string>] +>; + +type ToJsonSchema<S> = { + type: 'object'; + properties: { + [K in keyof S]: S[K] extends number[] ? { type: 'array'; items: { type: 'number'; }; } : ToJsonSchema<S[K]>; + }, + required: (keyof S)[]; +}; + +export function getJsonSchema<S extends Schema>(schema: S): ToJsonSchema<Unflatten<ChartResult<S>>> { + const jsonSchema = { + type: 'object', + properties: {} as Record<string, unknown>, + required: [], + }; + + for (const k in schema) { + jsonSchema.properties[k] = { + type: 'array', + items: { type: 'number' }, + }; + } + + return jsonSchema as ToJsonSchema<Unflatten<ChartResult<S>>>; +} + +/** + * 様々なチャートの管理を司るクラス + */ +// eslint-disable-next-line import/no-default-export +export default abstract class Chart<T extends Schema> { + public schema: T; + + private name: string; + private buffer: { + diff: Commit<T>; + group: string | null; + }[] = []; + // ↓にしたいけどfindOneとかで型エラーになる + //private repositoryForHour: Repository<RawRecord<T>>; + //private repositoryForDay: Repository<RawRecord<T>>; + private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }>; + private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }>; + + /** + * 1日に一回程度実行されれば良いような計算処理を入れる(主にCASCADE削除などアプリケーション側で感知できない変動によるズレの修正用) + */ + protected abstract tickMajor(group: string | null): Promise<Partial<KVs<T>>>; + + /** + * 少なくとも最小スパン内に1回は実行されて欲しい計算処理を入れる + */ + protected abstract tickMinor(group: string | null): Promise<Partial<KVs<T>>>; + + private static convertSchemaToColumnDefinitions(schema: Schema): Record<string, { type: string; array?: boolean; default?: any; }> { + const columns = {} as Record<string, { type: string; array?: boolean; default?: any; }>; + for (const [k, v] of Object.entries(schema)) { + const name = k.replaceAll('.', columnDot); + const type = v.range === 'big' ? 'bigint' : v.range === 'small' ? 'smallint' : 'integer'; + if (v.uniqueIncrement) { + columns[uniqueTempColumnPrefix + name] = { + type: 'varchar', + array: true, + default: '{}', + }; + columns[columnPrefix + name] = { + type, + default: 0, + }; + } else { + columns[columnPrefix + name] = { + type, + default: 0, + }; + } + } + return columns; + } + + private static dateToTimestamp(x: Date): number { + return Math.floor(x.getTime() / 1000); + } + + private static parseDate(date: Date): [number, number, number, number, number, number, number] { + const y = date.getUTCFullYear(); + const m = date.getUTCMonth(); + const d = date.getUTCDate(); + const h = date.getUTCHours(); + const _m = date.getUTCMinutes(); + const _s = date.getUTCSeconds(); + const _ms = date.getUTCMilliseconds(); + + return [y, m, d, h, _m, _s, _ms]; + } + + private static getCurrentDate() { + return Chart.parseDate(new Date()); + } + + public static schemaToEntity(name: string, schema: Schema, grouped = false): { + hour: EntitySchema, + day: EntitySchema, + } { + const createEntity = (span: 'hour' | 'day'): EntitySchema => new EntitySchema({ + name: + span === 'hour' ? `__chart__${camelToSnake(name)}` : + span === 'day' ? `__chart_day__${camelToSnake(name)}` : + new Error('not happen') as never, + columns: { + id: { + type: 'integer', + primary: true, + generated: true, + }, + date: { + type: 'integer', + }, + ...(grouped ? { + group: { + type: 'varchar', + length: 128, + }, + } : {}), + ...Chart.convertSchemaToColumnDefinitions(schema), + }, + indices: [{ + columns: grouped ? ['date', 'group'] : ['date'], + unique: true, + }], + uniques: [{ + columns: grouped ? ['date', 'group'] : ['date'], + }], + relations: { + /* TODO + group: { + target: () => Foo, + type: 'many-to-one', + onDelete: 'CASCADE', + }, + */ + }, + }); + + return { + hour: createEntity('hour'), + day: createEntity('day'), + }; + } + + private lock: (key: string) => Promise<() => void>; + + constructor(db: DataSource, lock: (key: string) => Promise<() => void>, name: string, schema: T, grouped = false) { + this.name = name; + this.schema = schema; + this.lock = lock; + + const { hour, day } = Chart.schemaToEntity(name, schema, grouped); + this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour); + this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day); + } + + private convertRawRecord(x: RawRecord<T>): KVs<T> { + const kvs = {} as Record<string, number>; + for (const k of Object.keys(x).filter((k) => k.startsWith(columnPrefix)) as (keyof Columns<T>)[]) { + kvs[(k as string).substr(columnPrefix.length).split(columnDot).join('.')] = x[k] as unknown as number; + } + return kvs as KVs<T>; + } + + private getNewLog(latest: KVs<T> | null): KVs<T> { + const log = {} as Record<keyof T, number>; + for (const [k, v] of Object.entries(this.schema) as ([keyof typeof this['schema'], this['schema'][string]])[]) { + if (v.accumulate && latest) { + log[k] = latest[k]; + } else { + log[k] = 0; + } + } + return log as KVs<T>; + } + + private getLatestLog(group: string | null, span: 'hour' | 'day'): Promise<RawRecord<T> | null> { + const repository = + span === 'hour' ? this.repositoryForHour : + span === 'day' ? this.repositoryForDay : + new Error('not happen') as never; + + return repository.findOne({ + where: group ? { + group: group, + } : {}, + order: { + date: -1, + }, + }).then(x => x ?? null) as Promise<RawRecord<T> | null>; + } + + /** + * 現在(=今のHour or Day)のログをデータベースから探して、あればそれを返し、なければ作成して返します。 + */ + private async claimCurrentLog(group: string | null, span: 'hour' | 'day'): Promise<RawRecord<T>> { + const [y, m, d, h] = Chart.getCurrentDate(); + + const current = dateUTC( + span === 'hour' ? [y, m, d, h] : + span === 'day' ? [y, m, d] : + new Error('not happen') as never); + + const repository = + span === 'hour' ? this.repositoryForHour : + span === 'day' ? this.repositoryForDay : + new Error('not happen') as never; + + // 現在(=今のHour or Day)のログ + const currentLog = await repository.findOneBy({ + date: Chart.dateToTimestamp(current), + ...(group ? { group: group } : {}), + }) as RawRecord<T> | undefined; + + // ログがあればそれを返して終了 + if (currentLog != null) { + return currentLog; + } + + let log: RawRecord<T>; + let data: KVs<T>; + + // 集計期間が変わってから、初めてのチャート更新なら + // 最も最近のログを持ってくる + // * 例えば集計期間が「日」である場合で考えると、 + // * 昨日何もチャートを更新するような出来事がなかった場合は、 + // * ログがそもそも作られずドキュメントが存在しないということがあり得るため、 + // * 「昨日の」と決め打ちせずに「もっとも最近の」とします + const latest = await this.getLatestLog(group, span); + + if (latest != null) { + // 空ログデータを作成 + data = this.getNewLog(this.convertRawRecord(latest)); + } else { + // ログが存在しなかったら + // (Misskeyインスタンスを建てて初めてのチャート更新時など) + + // 初期ログデータを作成 + data = this.getNewLog(null); + + logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`); + } + + const date = Chart.dateToTimestamp(current); + const lockKey = group ? `${this.name}:${date}:${span}:${group}` : `${this.name}:${date}:${span}`; + + const unlock = await this.lock(lockKey); + try { + // ロック内でもう1回チェックする + const currentLog = await repository.findOneBy({ + date: date, + ...(group ? { group: group } : {}), + }) as RawRecord<T> | undefined; + + // ログがあればそれを返して終了 + if (currentLog != null) return currentLog; + + const columns = {} as Record<string, number | unknown[]>; + for (const [k, v] of Object.entries(data)) { + const name = k.replaceAll('.', columnDot); + columns[columnPrefix + name] = v; + } + + // 新規ログ挿入 + log = await repository.insert({ + date: date, + ...(group ? { group: group } : {}), + ...columns, + }).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord<T>; + + logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); + + return log; + } finally { + unlock(); + } + } + + protected commit(diff: Commit<T>, group: string | null = null): void { + for (const [k, v] of Object.entries(diff)) { + if (v == null || v === 0 || (Array.isArray(v) && v.length === 0)) delete diff[k]; + } + this.buffer.push({ + diff, group, + }); + } + + public async save(): Promise<void> { + if (this.buffer.length === 0) { + logger.info(`${this.name}: Write skipped`); + return; + } + + // TODO: 前の時間のログがbufferにあった場合のハンドリング + // 例えば、save が20分ごとに行われるとして、前回行われたのは 01:50 だったとする。 + // 次に save が行われるのは 02:10 ということになるが、もし 01:55 に新規ログが buffer に追加されたとすると、 + // そのログは本来は 01:00~ のログとしてDBに保存されて欲しいのに、02:00~ のログ扱いになってしまう。 + // これを回避するための実装は複雑になりそうなため、一旦保留。 + + const update = async (logHour: RawRecord<T>, logDay: RawRecord<T>): Promise<void> => { + const finalDiffs = {} as Record<string, number | string[]>; + + for (const diff of this.buffer.filter(q => q.group == null || (q.group === logHour.group)).map(q => q.diff)) { + for (const [k, v] of Object.entries(diff)) { + if (finalDiffs[k] == null) { + finalDiffs[k] = v; + } else { + if (typeof finalDiffs[k] === 'number') { + (finalDiffs[k] as number) += v as number; + } else { + (finalDiffs[k] as string[]) = (finalDiffs[k] as string[]).concat(v); + } + } + } + } + + const queryForHour: Record<keyof RawRecord<T>, number | (() => string)> = {} as any; + const queryForDay: Record<keyof RawRecord<T>, number | (() => string)> = {} as any; + for (const [k, v] of Object.entries(finalDiffs)) { + if (typeof v === 'number') { + const name = columnPrefix + k.replaceAll('.', columnDot) as string & keyof Columns<T>; + if (v > 0) queryForHour[name] = () => `"${name}" + ${v}`; + if (v < 0) queryForHour[name] = () => `"${name}" - ${Math.abs(v)}`; + if (v > 0) queryForDay[name] = () => `"${name}" + ${v}`; + if (v < 0) queryForDay[name] = () => `"${name}" - ${Math.abs(v)}`; + } else if (Array.isArray(v) && v.length > 0) { // ユニークインクリメント + const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as string & keyof TempColumnsForUnique<T>; + // TODO: item をSQLエスケープ + const itemsForHour = v.filter(item => !(logHour[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); + const itemsForDay = v.filter(item => !(logDay[tempColumnName] as unknown as string[]).includes(item)).map(item => `"${item}"`); + if (itemsForHour.length > 0) queryForHour[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForHour.join(',')}}'::varchar[])`; + if (itemsForDay.length > 0) queryForDay[tempColumnName] = () => `array_cat("${tempColumnName}", '{${itemsForDay.join(',')}}'::varchar[])`; + } + } + + // bake unique count + for (const [k, v] of Object.entries(finalDiffs)) { + if (this.schema[k].uniqueIncrement) { + const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns<T>; + const tempColumnName = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>; + queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; + queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; + } + } + + // compute intersection + // TODO: intersectionに指定されたカラムがintersectionだった場合の対応 + for (const [k, v] of Object.entries(this.schema)) { + const intersection = v.intersection; + if (intersection) { + const name = columnPrefix + k.replaceAll('.', columnDot) as keyof Columns<T>; + const firstKey = intersection[0]; + const firstTempColumnName = uniqueTempColumnPrefix + firstKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>; + const firstValues = finalDiffs[firstKey] as string[] | undefined; + const currentValuesForHour = new Set([...(firstValues ?? []), ...(logHour[firstTempColumnName] as unknown as string[])]); + const currentValuesForDay = new Set([...(firstValues ?? []), ...(logDay[firstTempColumnName] as unknown as string[])]); + for (let i = 1; i < intersection.length; i++) { + const targetKey = intersection[i]; + const targetTempColumnName = uniqueTempColumnPrefix + targetKey.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>; + const targetValues = finalDiffs[targetKey] as string[] | undefined; + const targetValuesForHour = new Set([...(targetValues ?? []), ...(logHour[targetTempColumnName] as unknown as string[])]); + const targetValuesForDay = new Set([...(targetValues ?? []), ...(logDay[targetTempColumnName] as unknown as string[])]); + currentValuesForHour.forEach(v => { + if (!targetValuesForHour.has(v)) currentValuesForHour.delete(v); + }); + currentValuesForDay.forEach(v => { + if (!targetValuesForDay.has(v)) currentValuesForDay.delete(v); + }); + } + queryForHour[name] = currentValuesForHour.size; + queryForDay[name] = currentValuesForDay.size; + } + } + + // ログ更新 + await Promise.all([ + this.repositoryForHour.createQueryBuilder() + .update() + .set(queryForHour as any) + .where('id = :id', { id: logHour.id }) + .execute(), + this.repositoryForDay.createQueryBuilder() + .update() + .set(queryForDay as any) + .where('id = :id', { id: logDay.id }) + .execute(), + ]); + + logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`); + + // TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする + this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group)); + }; + + const groups = removeDuplicates(this.buffer.map(log => log.group)); + + await Promise.all( + groups.map(group => + Promise.all([ + this.claimCurrentLog(group, 'hour'), + this.claimCurrentLog(group, 'day'), + ]).then(([logHour, logDay]) => + update(logHour, logDay)))); + } + + public async tick(major: boolean, group: string | null = null): Promise<void> { + const data = major ? await this.tickMajor(group) : await this.tickMinor(group); + + const columns = {} as Record<keyof Columns<T>, number>; + for (const [k, v] of Object.entries(data) as ([keyof typeof data, number])[]) { + const name = columnPrefix + (k as string).replaceAll('.', columnDot) as keyof Columns<T>; + columns[name] = v; + } + + if (Object.keys(columns).length === 0) { + return; + } + + const update = async (logHour: RawRecord<T>, logDay: RawRecord<T>): Promise<void> => { + await Promise.all([ + this.repositoryForHour.createQueryBuilder() + .update() + .set(columns) + .where('id = :id', { id: logHour.id }) + .execute(), + this.repositoryForDay.createQueryBuilder() + .update() + .set(columns) + .where('id = :id', { id: logDay.id }) + .execute(), + ]); + }; + + return Promise.all([ + this.claimCurrentLog(group, 'hour'), + this.claimCurrentLog(group, 'day'), + ]).then(([logHour, logDay]) => + update(logHour, logDay)); + } + + public resync(group: string | null = null): Promise<void> { + return this.tick(true, group); + } + + public async clean(): Promise<void> { + const current = dateUTC(Chart.getCurrentDate()); + + // 一日以上前かつ三日以内 + const gt = Chart.dateToTimestamp(current) - (60 * 60 * 24 * 3); + const lt = Chart.dateToTimestamp(current) - (60 * 60 * 24); + + const columns = {} as Record<keyof TempColumnsForUnique<T>, []>; + for (const [k, v] of Object.entries(this.schema)) { + if (v.uniqueIncrement) { + const name = uniqueTempColumnPrefix + k.replaceAll('.', columnDot) as keyof TempColumnsForUnique<T>; + columns[name] = []; + } + } + + if (Object.keys(columns).length === 0) { + return; + } + + await Promise.all([ + this.repositoryForHour.createQueryBuilder() + .update() + .set(columns) + .where('date > :gt', { gt }) + .andWhere('date < :lt', { lt }) + .execute(), + this.repositoryForDay.createQueryBuilder() + .update() + .set(columns) + .where('date > :gt', { gt }) + .andWhere('date < :lt', { lt }) + .execute(), + ]); + } + + public async getChartRaw(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise<ChartResult<T>> { + const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate(); + const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never; + + const lt = dateUTC([y, m, d, h, _m, _s, _ms]); + + const gt = + span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') : + span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') : + new Error('not happen') as never; + + const repository = + span === 'hour' ? this.repositoryForHour : + span === 'day' ? this.repositoryForDay : + new Error('not happen') as never; + + // ログ取得 + let logs = await repository.find({ + where: { + date: Between(Chart.dateToTimestamp(gt), Chart.dateToTimestamp(lt)), + ...(group ? { group: group } : {}), + }, + order: { + date: -1, + }, + }) as RawRecord<T>[]; + + // 要求された範囲にログがひとつもなかったら + if (logs.length === 0) { + // もっとも新しいログを持ってくる + // (すくなくともひとつログが無いと隙間埋めできないため) + const recentLog = await repository.findOne({ + where: group ? { + group: group, + } : {}, + order: { + date: -1, + }, + }) as RawRecord<T> | undefined; + + if (recentLog) { + logs = [recentLog]; + } + + // 要求された範囲の最も古い箇所に位置するログが存在しなかったら + } else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) { + // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する + // (隙間埋めできないため) + const outdatedLog = await repository.findOne({ + where: { + date: LessThan(Chart.dateToTimestamp(gt)), + ...(group ? { group: group } : {}), + }, + order: { + date: -1, + }, + }) as RawRecord<T> | undefined; + + if (outdatedLog) { + logs.push(outdatedLog); + } + } + + const chart: KVs<T>[] = []; + + for (let i = (amount - 1); i >= 0; i--) { + const current = + span === 'hour' ? subtractTime(dateUTC([y, m, d, h]), i, 'hour') : + span === 'day' ? subtractTime(dateUTC([y, m, d]), i, 'day') : + new Error('not happen') as never; + + const log = logs.find(l => isTimeSame(new Date(l.date * 1000), current)); + + if (log) { + chart.unshift(this.convertRawRecord(log)); + } else { + // 隙間埋め + const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current)); + const data = latest ? this.convertRawRecord(latest) : null; + chart.unshift(this.getNewLog(data)); + } + } + + const res = {} as ChartResult<T>; + + /** + * [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }] + * を + * { foo: [1, 2, 3], bar: [5, 6, 7] } + * にする + */ + for (const record of chart) { + for (const [k, v] of Object.entries(record) as ([keyof typeof record, number])[]) { + if (res[k]) { + res[k].push(v); + } else { + res[k] = [v]; + } + } + } + + return res; + } + + public async getChart(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise<Unflatten<ChartResult<T>>> { + const result = await this.getChartRaw(span, amount, cursor, group); + const object = {}; + for (const [k, v] of Object.entries(result)) { + nestedProperty.set(object, k, v); + } + return object as Unflatten<ChartResult<T>>; + } +} diff --git a/packages/backend/src/core/chart/entities.ts b/packages/backend/src/core/chart/entities.ts new file mode 100644 index 0000000000..a9eeabd639 --- /dev/null +++ b/packages/backend/src/core/chart/entities.ts @@ -0,0 +1,39 @@ +import { entity as FederationChart } from './charts/entities/federation.js'; +import { entity as NotesChart } from './charts/entities/notes.js'; +import { entity as UsersChart } from './charts/entities/users.js'; +import { entity as ActiveUsersChart } from './charts/entities/active-users.js'; +import { entity as InstanceChart } from './charts/entities/instance.js'; +import { entity as PerUserNotesChart } from './charts/entities/per-user-notes.js'; +import { entity as DriveChart } from './charts/entities/drive.js'; +import { entity as PerUserReactionsChart } from './charts/entities/per-user-reactions.js'; +import { entity as HashtagChart } from './charts/entities/hashtag.js'; +import { entity as PerUserFollowingChart } from './charts/entities/per-user-following.js'; +import { entity as PerUserDriveChart } from './charts/entities/per-user-drive.js'; +import { entity as ApRequestChart } from './charts/entities/ap-request.js'; + +import { entity as TestChart } from './charts/entities/test.js'; +import { entity as TestGroupedChart } from './charts/entities/test-grouped.js'; +import { entity as TestUniqueChart } from './charts/entities/test-unique.js'; +import { entity as TestIntersectionChart } from './charts/entities/test-intersection.js'; + +export const entities = [ + FederationChart.hour, FederationChart.day, + NotesChart.hour, NotesChart.day, + UsersChart.hour, UsersChart.day, + ActiveUsersChart.hour, ActiveUsersChart.day, + InstanceChart.hour, InstanceChart.day, + PerUserNotesChart.hour, PerUserNotesChart.day, + DriveChart.hour, DriveChart.day, + PerUserReactionsChart.hour, PerUserReactionsChart.day, + HashtagChart.hour, HashtagChart.day, + PerUserFollowingChart.hour, PerUserFollowingChart.day, + PerUserDriveChart.hour, PerUserDriveChart.day, + ApRequestChart.hour, ApRequestChart.day, + + ...(process.env.NODE_ENV === 'test' ? [ + TestChart.hour, TestChart.day, + TestGroupedChart.hour, TestGroupedChart.day, + TestUniqueChart.hour, TestUniqueChart.day, + TestIntersectionChart.hour, TestIntersectionChart.day, + ] : []), +]; diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts new file mode 100644 index 0000000000..6cc511fb48 --- /dev/null +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AbuseUserReportsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class AbuseUserReportEntityService { + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: AbuseUserReport['id'] | AbuseUserReport, + ) { + const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: report.id, + createdAt: report.createdAt.toISOString(), + comment: report.comment, + resolved: report.resolved, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, + reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + detail: true, + }), + targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + detail: true, + }), + assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + detail: true, + }) : null, + forwarded: report.forwarded, + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts new file mode 100644 index 0000000000..9193cb81d7 --- /dev/null +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Antenna } from '@/models/entities/Antenna.js'; + +@Injectable() +export class AntennaEntityService { + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + ) { + } + + public async pack( + src: Antenna['id'] | Antenna, + ): Promise<Packed<'Antenna'>> { + const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); + + const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; + const userGroupJoining = antenna.userGroupJoiningId ? await this.userGroupJoiningsRepository.findOneBy({ id: antenna.userGroupJoiningId }) : null; + + return { + id: antenna.id, + createdAt: antenna.createdAt.toISOString(), + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, + users: antenna.users, + caseSensitive: antenna.caseSensitive, + notify: antenna.notify, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + hasUnreadNote, + }; + } +} diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts new file mode 100644 index 0000000000..6491b0b2d9 --- /dev/null +++ b/packages/backend/src/core/entities/AppEntityService.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AccessTokensRepository, AppsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { App } from '@/models/entities/App.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class AppEntityService { + constructor( + @Inject(DI.appsRepository) + private appsRepository: AppsRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + ) { + } + + public async pack( + src: App['id'] | App, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecret?: boolean, + includeProfileImageIds?: boolean + }, + ): Promise<Packed<'App'>> { + const opts = Object.assign({ + detail: false, + includeSecret: false, + includeProfileImageIds: false, + }, options); + + const app = typeof src === 'object' ? src : await this.appsRepository.findOneByOrFail({ id: src }); + + return { + id: app.id, + name: app.name, + callbackUrl: app.callbackUrl, + permission: app.permission, + ...(opts.includeSecret ? { secret: app.secret } : {}), + ...(me ? { + isAuthorized: await this.accessTokensRepository.countBy({ + appId: app.id, + userId: me.id, + }).then(count => count > 0), + } : {}), + }; + } +} diff --git a/packages/backend/src/core/entities/AuthSessionEntityService.ts b/packages/backend/src/core/entities/AuthSessionEntityService.ts new file mode 100644 index 0000000000..a4dab3d9cf --- /dev/null +++ b/packages/backend/src/core/entities/AuthSessionEntityService.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { AuthSessionsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { AuthSession } from '@/models/entities/AuthSession.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; +import { AppEntityService } from './AppEntityService.js'; + +@Injectable() +export class AuthSessionEntityService { + constructor( + @Inject(DI.authSessionsRepository) + private authSessionsRepository: AuthSessionsRepository, + + private appEntityService: AppEntityService, + ) { + } + + public async pack( + src: AuthSession['id'] | AuthSession, + me?: { id: User['id'] } | null | undefined, + ) { + const session = typeof src === 'object' ? src : await this.authSessionsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: session.id, + app: this.appEntityService.pack(session.appId, me), + token: session.token, + }); + } +} diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts new file mode 100644 index 0000000000..74ce6830b6 --- /dev/null +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { BlockingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Blocking } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class BlockingEntityService { + constructor( + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Blocking['id'] | Blocking, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Blocking'>> { + const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: this.userEntityService.pack(blocking.blockeeId, me, { + detail: true, + }), + }); + } + + public packMany( + blockings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(blockings.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts new file mode 100644 index 0000000000..fec76e4e63 --- /dev/null +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Channel } from '@/models/entities/Channel.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class ChannelEntityService { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: Channel['id'] | Channel, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Channel'>> { + const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); + const meId = me ? me.id : null; + + const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; + + const hasUnreadNote = meId ? (await this.noteUnreadsRepository.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined; + + const following = meId ? await this.channelFollowingsRepository.findOneBy({ + followerId: meId, + followeeId: channel.id, + }) : null; + + return { + id: channel.id, + createdAt: channel.createdAt.toISOString(), + lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, + name: channel.name, + description: channel.description, + userId: channel.userId, + bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner, false) : null, + usersCount: channel.usersCount, + notesCount: channel.notesCount, + + ...(me ? { + isFollowing: following != null, + hasUnreadNote, + } : {}), + }; + } +} + diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts new file mode 100644 index 0000000000..27637e42e8 --- /dev/null +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ClipsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Clip } from '@/models/entities/Clip.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class ClipEntityService { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Clip['id'] | Clip, + ): Promise<Packed<'Clip'>> { + const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: clip.id, + createdAt: clip.createdAt.toISOString(), + userId: clip.userId, + user: this.userEntityService.pack(clip.user ?? clip.userId), + name: clip.name, + description: clip.description, + isPublic: clip.isPublic, + }); + } + + public packMany( + clips: Clip[], + ) { + return Promise.all(clips.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts new file mode 100644 index 0000000000..521bf51da0 --- /dev/null +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -0,0 +1,214 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, DriveFilesRepository } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { User } from '@/models/entities/User.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import { UtilityService } from '../UtilityService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFolderEntityService } from './DriveFolderEntityService.js'; + +type PackOptions = { + detail?: boolean, + self?: boolean, + withUser?: boolean, +}; + +@Injectable() +export class DriveFileEntityService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + // 循環参照のため / for circular dependency + @Inject(forwardRef(() => UserEntityService)) + private userEntityService: UserEntityService, + + private utilityService: UtilityService, + private driveFolderEntityService: DriveFolderEntityService, + ) { + } + + public validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); + } + + public getPublicProperties(file: DriveFile): DriveFile['properties'] { + if (file.properties.orientation != null) { + // TODO + //const properties = structuredClone(file.properties); + const properties = JSON.parse(JSON.stringify(file.properties)); + if (file.properties.orientation >= 5) { + [properties.width, properties.height] = [properties.height, properties.width]; + } + properties.orientation = undefined; + return properties; + } + + return file.properties; + } + + public getPublicUrl(file: DriveFile, thumbnail = false): string | null { + // リモートかつメディアプロキシ + if (file.uri != null && file.userHost != null && this.config.mediaProxy != null) { + return appendQuery(this.config.mediaProxy, query({ + url: file.uri, + thumbnail: thumbnail ? '1' : undefined, + })); + } + + // リモートかつ期限切れはローカルプロキシを試みる + if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { + const key = thumbnail ? file.thumbnailAccessKey : file.webpublicAccessKey; + + if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 + return `${this.config.url}/files/${key}`; + } + } + + const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type); + + return thumbnail ? (file.thumbnailUrl ?? (isImage ? (file.webpublicUrl ?? file.url) : null)) : (file.webpublicUrl ?? file.url); + } + + public async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise<number> { + const id = typeof user === 'object' ? user.id : user; + + const { sum } = await this.driveFilesRepository + .createQueryBuilder('file') + .where('file.userId = :id', { id: id }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) ?? 0; + } + + public async calcDriveUsageOfHost(host: string): Promise<number> { + const { sum } = await this.driveFilesRepository + .createQueryBuilder('file') + .where('file.userHost = :host', { host: this.utilityService.toPuny(host) }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) ?? 0; + } + + public async calcDriveUsageOfLocal(): Promise<number> { + const { sum } = await this.driveFilesRepository + .createQueryBuilder('file') + .where('file.userHost IS NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) ?? 0; + } + + public async calcDriveUsageOfRemote(): Promise<number> { + const { sum } = await this.driveFilesRepository + .createQueryBuilder('file') + .where('file.userHost IS NOT NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) ?? 0; + } + + public async pack( + src: DriveFile['id'] | DriveFile, + options?: PackOptions, + ): Promise<Packed<'DriveFile'>> { + const opts = Object.assign({ + detail: false, + self: false, + }, options); + + const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneByOrFail({ id: src }); + + return await awaitAll<Packed<'DriveFile'>>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + detail: true, + }) : null, + userId: opts.withUser ? file.userId : null, + user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, + }); + } + + public async packNullable( + src: DriveFile['id'] | DriveFile, + options?: PackOptions, + ): Promise<Packed<'DriveFile'> | null> { + const opts = Object.assign({ + detail: false, + self: false, + }, options); + + const file = typeof src === 'object' ? src : await this.driveFilesRepository.findOneBy({ id: src }); + if (file == null) return null; + + return await awaitAll<Packed<'DriveFile'>>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + detail: true, + }) : null, + userId: opts.withUser ? file.userId : null, + user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, + }); + } + + public async packMany( + files: (DriveFile['id'] | DriveFile)[], + options?: PackOptions, + ): Promise<Packed<'DriveFile'>[]> { + const items = await Promise.all(files.map(f => this.packNullable(f, options))); + return items.filter((x): x is Packed<'DriveFile'> => x != null); + } +} diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts new file mode 100644 index 0000000000..beebbc3206 --- /dev/null +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { DriveFolder } from '@/models/entities/DriveFolder.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class DriveFolderEntityService { + constructor( + @Inject(DI.driveFoldersRepository) + private driveFoldersRepository: DriveFoldersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + } + + public async pack( + src: DriveFolder['id'] | DriveFolder, + options?: { + detail: boolean + }, + ): Promise<Packed<'DriveFolder'>> { + const opts = Object.assign({ + detail: false, + }, options); + + const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: folder.id, + createdAt: folder.createdAt.toISOString(), + name: folder.name, + parentId: folder.parentId, + + ...(opts.detail ? { + foldersCount: this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: this.driveFilesRepository.countBy({ + folderId: folder.id, + }), + + ...(folder.parentId ? { + parent: this.pack(folder.parentId, { + detail: true, + }), + } : {}), + } : {}), + }); + } +} + diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts new file mode 100644 index 0000000000..10ed0f19ee --- /dev/null +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { EmojisRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Emoji } from '@/models/entities/Emoji.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class EmojiEntityService { + constructor( + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Emoji['id'] | Emoji, + ): Promise<Packed<'Emoji'>> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + // ?? emoji.originalUrl してるのは後方互換性のため + url: emoji.publicUrl ?? emoji.originalUrl, + }; + } + + public packMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts new file mode 100644 index 0000000000..f7e7fd42e4 --- /dev/null +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { FollowRequestsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { FollowRequest } from '@/models/entities/FollowRequest.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class FollowRequestEntityService { + constructor( + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: FollowRequest['id'] | FollowRequest, + me?: { id: User['id'] } | null | undefined, + ) { + const request = typeof src === 'object' ? src : await this.followRequestsRepository.findOneByOrFail({ id: src }); + + return { + id: request.id, + follower: await this.userEntityService.pack(request.followerId, me), + followee: await this.userEntityService.pack(request.followeeId, me), + }; + } +} + diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts new file mode 100644 index 0000000000..93fed85f72 --- /dev/null +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -0,0 +1,98 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { FollowingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Following } from '@/models/entities/Following.js'; +import { UserEntityService } from './UserEntityService.js'; + +type LocalFollowerFollowing = Following & { + followerHost: null; + followerInbox: null; + followerSharedInbox: null; +}; + +type RemoteFollowerFollowing = Following & { + followerHost: string; + followerInbox: string; + followerSharedInbox: string; +}; + +type LocalFolloweeFollowing = Following & { + followeeHost: null; + followeeInbox: null; + followeeSharedInbox: null; +}; + +type RemoteFolloweeFollowing = Following & { + followeeHost: string; + followeeInbox: string; + followeeSharedInbox: string; +}; + +@Injectable() +export class FollowingEntityService { + constructor( + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public isLocalFollower(following: Following): following is LocalFollowerFollowing { + return following.followerHost == null; + } + + public isRemoteFollower(following: Following): following is RemoteFollowerFollowing { + return following.followerHost != null; + } + + public isLocalFollowee(following: Following): following is LocalFolloweeFollowing { + return following.followeeHost == null; + } + + public isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { + return following.followeeHost != null; + } + + public async pack( + src: Following['id'] | Following, + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + }, + ): Promise<Packed<'Following'>> { + const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); + + if (opts == null) opts = {}; + + return await awaitAll({ + id: following.id, + createdAt: following.createdAt.toISOString(), + followeeId: following.followeeId, + followerId: following.followerId, + followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { + detail: true, + }) : undefined, + follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { + detail: true, + }) : undefined, + }); + } + + public packMany( + followings: any[], + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + }, + ) { + return Promise.all(followings.map(x => this.pack(x, me, opts))); + } +} + diff --git a/packages/backend/src/core/entities/GalleryLikeEntityService.ts b/packages/backend/src/core/entities/GalleryLikeEntityService.ts new file mode 100644 index 0000000000..9473ed90b7 --- /dev/null +++ b/packages/backend/src/core/entities/GalleryLikeEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { GalleryPosts, GalleryLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { GalleryLike } from '@/models/entities/GalleryLike.js'; +import { UserEntityService } from './UserEntityService.js'; +import { GalleryPostEntityService } from './GalleryPostEntityService.js'; + +@Injectable() +export class GalleryLikeEntityService { + constructor( + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + + private galleryPostEntityService: GalleryPostEntityService, + ) { + } + + public async pack( + src: GalleryLike['id'] | GalleryLike, + me?: any, + ) { + const like = typeof src === 'object' ? src : await this.galleryLikesRepository.findOneByOrFail({ id: src }); + + return { + id: like.id, + post: await this.galleryPostEntityService.pack(like.post ?? like.postId, me), + }; + } + + public packMany( + likes: any[], + me: any, + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts new file mode 100644 index 0000000000..82b41697c9 --- /dev/null +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class GalleryPostEntityService { + constructor( + @Inject(DI.galleryPostsRepository) + private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.galleryLikesRepository) + private galleryLikesRepository: GalleryLikesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: GalleryPost['id'] | GalleryPost, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'GalleryPost'>> { + const meId = me ? me.id : null; + const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: this.userEntityService.pack(post.user ?? post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: this.driveFileEntityService.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId ? await this.galleryLikesRepository.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + posts: GalleryPost[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(posts.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts new file mode 100644 index 0000000000..6dcbf49030 --- /dev/null +++ b/packages/backend/src/core/entities/HashtagEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { HashtagsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Hashtag } from '@/models/entities/Hashtag.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class HashtagEntityService { + constructor( + @Inject(DI.hashtagsRepository) + private hashtagsRepository: HashtagsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Hashtag, + ): Promise<Packed<'Hashtag'>> { + return { + tag: src.name, + mentionedUsersCount: src.mentionedUsersCount, + mentionedLocalUsersCount: src.mentionedLocalUsersCount, + mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, + attachedUsersCount: src.attachedUsersCount, + attachedLocalUsersCount: src.attachedLocalUsersCount, + attachedRemoteUsersCount: src.attachedRemoteUsersCount, + }; + } + + public packMany( + hashtags: Hashtag[], + ) { + return Promise.all(hashtags.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts new file mode 100644 index 0000000000..c58c2f8f34 --- /dev/null +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { InstancesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +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 { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class InstanceEntityService { + constructor( + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private metaService: MetaService, + ) { + } + + public async pack( + instance: Instance, + ): Promise<Packed<'FederationInstance'>> { + const meta = await this.metaService.fetch(); + return { + id: instance.id, + caughtAt: instance.caughtAt.toISOString(), + host: instance.host, + usersCount: instance.usersCount, + notesCount: instance.notesCount, + followingCount: instance.followingCount, + followersCount: instance.followersCount, + latestRequestSentAt: instance.latestRequestSentAt ? instance.latestRequestSentAt.toISOString() : null, + lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), + isNotResponding: instance.isNotResponding, + isSuspended: instance.isSuspended, + isBlocked: meta.blockedHosts.includes(instance.host), + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + openRegistrations: instance.openRegistrations, + name: instance.name, + description: instance.description, + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, + }; + } + + public packMany( + instances: Instance[], + ) { + return Promise.all(instances.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/MessagingMessageEntityService.ts b/packages/backend/src/core/entities/MessagingMessageEntityService.ts new file mode 100644 index 0000000000..04467b94e4 --- /dev/null +++ b/packages/backend/src/core/entities/MessagingMessageEntityService.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { MessagingMessagesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; + +@Injectable() +export class MessagingMessageEntityService { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private userEntityService: UserEntityService, + private userGroupEntityService: UserGroupEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: MessagingMessage['id'] | MessagingMessage, + me?: { id: User['id'] } | null | undefined, + options?: { + populateRecipient?: boolean, + populateGroup?: boolean, + }, + ): Promise<Packed<'MessagingMessage'>> { + const opts = options ?? { + populateRecipient: true, + populateGroup: true, + }; + + const message = typeof src === 'object' ? src : await this.messagingMessagesRepository.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await this.userEntityService.pack(message.user ?? message.userId, me), + recipientId: message.recipientId, + recipient: message.recipientId && opts.populateRecipient ? await this.userEntityService.pack(message.recipient ?? message.recipientId, me) : undefined, + groupId: message.groupId, + group: message.groupId && opts.populateGroup ? await this.userGroupEntityService.pack(message.group ?? message.groupId) : undefined, + fileId: message.fileId, + file: message.fileId ? await this.driveFileEntityService.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + } +} + diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts new file mode 100644 index 0000000000..45d15088fc --- /dev/null +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { ModerationLog } from '@/models/entities/ModerationLog.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class ModerationLogEntityService { + constructor( + @Inject(DI.moderationLogsRepository) + private moderationLogsRepository: ModerationLogsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt.toISOString(), + type: log.type, + info: log.info, + userId: log.userId, + user: this.userEntityService.pack(log.user ?? log.userId, null, { + detail: true, + }), + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts new file mode 100644 index 0000000000..c5245bf203 --- /dev/null +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { MutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Muting } from '@/models/entities/Muting.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class MutingEntityService { + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Muting['id'] | Muting, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Muting'>> { + const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts new file mode 100644 index 0000000000..eab233bbef --- /dev/null +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -0,0 +1,396 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { DataSource, In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import type { Notes, Polls, PollVotes, DriveFiles, Channels, Followings, Users, NoteReactions } from '@/models/index.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import { nyaize } from '@/misc/nyaize.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { User } from '@/models/entities/User.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { ReactionService } from '../ReactionService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class NoteEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; + private customEmojiService: CustomEmojiService; + private reactionService: ReactionService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.pollVotesRepository) + private pollVotesRepository: PollVotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + //private userEntityService: UserEntityService, + //private driveFileEntityService: DriveFileEntityService, + //private customEmojiService: CustomEmojiService, + //private reactionService: ReactionService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + this.reactionService = this.moduleRef.get('ReactionService'); + } + + async #hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { + // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) + let hide = false; + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (packedNote.visibility === 'specified') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (packedNote.visibility === 'followers') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else if (packedNote.reply && (meId === packedNote.reply.userId)) { + // 自分の投稿に対するリプライ + hide = false; + } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { + // 自分へのメンション + hide = false; + } else { + // フォロワーかどうか + const following = await this.followingsRepository.findOneBy({ + followeeId: packedNote.userId, + followerId: meId, + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + + if (hide) { + packedNote.visibleUserIds = undefined; + packedNote.fileIds = []; + packedNote.files = []; + packedNote.text = null; + packedNote.poll = undefined; + packedNote.cw = null; + packedNote.isHidden = true; + } + } + + async #populatePoll(note: Note, meId: User['id'] | null) { + const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id }); + const choices = poll.choices.map(c => ({ + text: c, + votes: poll.votes[poll.choices.indexOf(c)], + isVoted: false, + })); + + if (meId) { + if (poll.multiple) { + const votes = await this.pollVotesRepository.findBy({ + userId: meId, + noteId: note.id, + }); + + const myChoices = votes.map(v => v.choice); + for (const myChoice of myChoices) { + choices[myChoice].isVoted = true; + } + } else { + const vote = await this.pollVotesRepository.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (vote) { + choices[vote.choice].isVoted = true; + } + } + } + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt, + choices, + }; + } + + async #populateMyReaction(note: Note, meId: User['id'], _hint_?: { + myReactions: Map<Note['id'], NoteReaction | null>; + }) { + if (_hint_?.myReactions) { + const reaction = _hint_.myReactions.get(note.id); + if (reaction) { + return this.reactionService.convertLegacyReaction(reaction.reaction); + } else if (reaction === null) { + return undefined; + } + // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない + } + + const reaction = await this.noteReactionsRepository.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (reaction) { + return this.reactionService.convertLegacyReaction(reaction.reaction); + } + + return undefined; + } + + public async isVisibleForMe(note: Note, meId: User['id'] | null): Promise<boolean> { + // This code must always be synchronized with the checks in generateVisibilityQuery. + // visibility が specified かつ自分が指定されていなかったら非表示 + if (note.visibility === 'specified') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else { + // 指定されているかどうか + return note.visibleUserIds.some((id: any) => meId === id); + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (note.visibility === 'followers') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else if (note.reply && (meId === note.reply.userId)) { + // 自分の投稿に対するリプライ + return true; + } else if (note.mentions && note.mentions.some(id => meId === id)) { + // 自分へのメンション + return true; + } else { + // フォロワーかどうか + const [following, user] = await Promise.all([ + this.followingsRepository.count({ + where: { + followeeId: note.userId, + followerId: meId, + }, + take: 1, + }), + this.usersRepository.findOneByOrFail({ id: meId }), + ]); + + /* If we know the following, everyhting is fine. + + But if we do not know the following, it might be that both the + author of the note and the author of the like are remote users, + in which case we can never know the following. Instead we have + to assume that the users are following each other. + */ + return following > 0 || (note.userHost != null && user.host != null); + } + } + + return true; + } + + public async pack( + src: Note['id'] | Note, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + _hint_?: { + myReactions: Map<Note['id'], NoteReaction | null>; + }; + }, + ): Promise<Packed<'Note'>> { + const opts = Object.assign({ + detail: true, + skipHide: false, + }, options); + + const meId = me ? me.id : null; + const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); + const host = note.userHost; + + let text = note.text; + + if (note.name && (note.url ?? note.uri)) { + text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url ?? note.uri}`; + } + + const channel = note.channelId + ? note.channel + ? note.channel + : await this.channelsRepository.findOneBy({ id: note.channelId }) + : null; + + const reactionEmojiNames = Object.keys(note.reactions).filter(x => x.startsWith(':')).map(x => this.reactionService.decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + + const packed: Packed<'Note'> = await awaitAll({ + id: note.id, + createdAt: note.createdAt.toISOString(), + userId: note.userId, + user: this.userEntityService.pack(note.user ?? note.userId, me, { + detail: false, + }), + text: text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly ?? undefined, + visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + reactions: this.reactionService.convertLegacyReactions(note.reactions), + tags: note.tags.length > 0 ? note.tags : undefined, + emojis: this.customEmojiService.populateEmojis(note.emojis.concat(reactionEmojiNames), host), + fileIds: note.fileIds, + files: this.driveFileEntityService.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + channelId: note.channelId ?? undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + } : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + + ...(opts.detail ? { + reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, { + detail: false, + _hint_: options?._hint_, + }) : undefined, + + renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, { + detail: true, + _hint_: options?._hint_, + }) : undefined, + + poll: note.hasPoll ? this.#populatePoll(note, meId) : undefined, + + ...(meId ? { + myReaction: this.#populateMyReaction(note, meId, options?._hint_), + } : {}), + } : {}), + }); + + if (packed.user.isCat && packed.text) { + const tokens = packed.text ? mfm.parse(packed.text) : []; + mfm.inspect(tokens, node => { + if (node.type === 'text') { + // TODO: quoteなtextはskip + node.props.text = nyaize(node.props.text); + } + }); + packed.text = mfm.toString(tokens); + } + + if (!opts.skipHide) { + await this.#hideNote(packed, meId); + } + + return packed; + } + + public async packMany( + notes: Note[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + }, + ) { + if (notes.length === 0) return []; + + const meId = me ? me.id : null; + const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); + if (meId) { + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...notes.map(n => n.id), ...renoteIds]; + const myReactions = await this.noteReactionsRepository.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + } + } + + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + + return await Promise.all(notes.map(n => this.pack(n, me, { + ...options, + _hint_: { + myReactions: myReactionsMap, + }, + }))); + } + + public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> { + // 指定したユーザーの指定したノートのリノートがいくつあるか数える + const query = this.notesRepository.createQueryBuilder('note') + .where('note.userId = :userId', { userId }) + .andWhere('note.renoteId = :renoteId', { renoteId }); + + // 指定した投稿を除く + if (excludeNoteId) { + query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); + } + + return await query.getCount(); + } +} diff --git a/packages/backend/src/core/entities/NoteFavoriteEntityService.ts b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts new file mode 100644 index 0000000000..f0bbf27b6a --- /dev/null +++ b/packages/backend/src/core/entities/NoteFavoriteEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NoteFavoritesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { NoteFavorite } from '@/models/entities/NoteFavorite.js'; +import { UserEntityService } from './UserEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; + +@Injectable() +export class NoteFavoriteEntityService { + constructor( + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + private noteEntityService: NoteEntityService, + ) { + } + + public async pack( + src: NoteFavorite['id'] | NoteFavorite, + me?: { id: User['id'] } | null | undefined, + ) { + const favorite = typeof src === 'object' ? src : await this.noteFavoritesRepository.findOneByOrFail({ id: src }); + + return { + id: favorite.id, + createdAt: favorite.createdAt.toISOString(), + noteId: favorite.noteId, + note: await this.noteEntityService.pack(favorite.note ?? favorite.noteId, me), + }; + } + + public packMany( + favorites: any[], + me: { id: User['id'] }, + ) { + return Promise.all(favorites.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts new file mode 100644 index 0000000000..e64f2af681 --- /dev/null +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NoteReactionsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { ReactionService } from '../ReactionService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import { ModuleRef } from '@nestjs/core'; + +@Injectable() +export class NoteReactionEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private noteEntityService: NoteEntityService; + private reactionService: ReactionService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + //private userEntityService: UserEntityService, + //private noteEntityService: NoteEntityService, + //private reactionService: ReactionService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.reactionService = this.moduleRef.get('ReactionService'); + } + + public async pack( + src: NoteReaction['id'] | NoteReaction, + me?: { id: User['id'] } | null | undefined, + options?: { + withNote: boolean; + }, + ): Promise<Packed<'NoteReaction'>> { + const opts = Object.assign({ + withNote: false, + }, options); + + const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); + + return { + id: reaction.id, + createdAt: reaction.createdAt.toISOString(), + user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + type: this.reactionService.convertLegacyReaction(reaction.reaction), + ...(opts.withNote ? { + note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), + } : {}), + }; + } +} diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts new file mode 100644 index 0000000000..6a0683d543 --- /dev/null +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -0,0 +1,151 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Notification } from '@/models/entities/Notification.js'; +import type { NoteReaction } from '@/models/entities/NoteReaction.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { Packed } from '@/misc/schema.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { UserEntityService } from './UserEntityService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import type { UserGroupInvitationEntityService } from './UserGroupInvitationEntityService.js'; + +@Injectable() +export class NotificationEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + private noteEntityService: NoteEntityService; + private userGroupInvitationEntityService: UserGroupInvitationEntityService; + private customEmojiService: CustomEmojiService; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.accessTokensRepository) + private accessTokensRepository: AccessTokensRepository, + + //private userEntityService: UserEntityService, + //private noteEntityService: NoteEntityService, + //private userGroupInvitationEntityService: UserGroupInvitationEntityService, + //private customEmojiService: CustomEmojiService, + ) { + } + + onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.userGroupInvitationEntityService = this.moduleRef.get('UserGroupInvitationEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + } + + public async pack( + src: Notification['id'] | Notification, + options: { + _hintForEachNotes_?: { + myReactions: Map<Note['id'], NoteReaction | null>; + }; + }, + ): Promise<Packed<'Notification'>> { + const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + + return await awaitAll({ + id: notification.id, + createdAt: notification.createdAt.toISOString(), + type: notification.type, + isRead: notification.isRead, + userId: notification.notifierId, + user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, + ...(notification.type === 'mention' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reply' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'renote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'quote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'reaction' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + reaction: notification.reaction, + } : {}), + ...(notification.type === 'pollVote' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + choice: notification.choice, + } : {}), + ...(notification.type === 'pollEnded' ? { + note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_, + }), + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader ?? token?.name, + icon: notification.customIcon ?? token?.iconUrl, + } : {}), + }); + } + + public async packMany( + notifications: Notification[], + meId: User['id'], + ) { + if (notifications.length === 0) return []; + + const notes = notifications.filter(x => x.note != null).map(x => x.note!); + const noteIds = notes.map(n => n.id); + const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...noteIds, ...renoteIds]; + const myReactions = await this.noteReactionsRepository.findBy({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + } + + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + + return await Promise.all(notifications.map(x => this.pack(x, { + _hintForEachNotes_: { + myReactions: myReactionsMap, + }, + }))); + } +} diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts new file mode 100644 index 0000000000..cbd193fe0a --- /dev/null +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Page } from '@/models/entities/Page.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class PageEntityService { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + public async pack( + src: Page['id'] | Page, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Page'>> { + const meId = me ? me.id : null; + const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); + + const attachedFiles: Promise<DriveFile | null>[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(this.driveFilesRepository.findOneBy({ + id: x.fileId, + userId: page.userId, + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(page.content); + + // 後方互換性のため + let migrated = false; + const migrate = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + if (x.inputType === 'text') { + x.type = 'textInput'; + } + if (x.inputType === 'number') { + x.type = 'numberInput'; + if (x.default) x.default = parseInt(x.default, 10); + } + migrated = true; + } + if (x.children) { + migrate(x.children); + } + } + }; + migrate(page.content); + if (migrated) { + this.pagesRepository.update(page.id, { + content: page.content, + }); + } + + return await awaitAll({ + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: this.userEntityService.pack(page.user ?? page.userId, me), // { detail: true } すると無限ループするので注意 + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, + alignCenter: page.alignCenter, + font: page.font, + script: page.script, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, + attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)), + likedCount: page.likedCount, + isLiked: meId ? await this.pageLikesRepository.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + pages: Page[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(pages.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/PageLikeEntityService.ts b/packages/backend/src/core/entities/PageLikeEntityService.ts new file mode 100644 index 0000000000..dccaf2edec --- /dev/null +++ b/packages/backend/src/core/entities/PageLikeEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { PageLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { PageLike } from '@/models/entities/PageLike.js'; +import { UserEntityService } from './UserEntityService.js'; +import { PageEntityService } from './PageEntityService.js'; + +@Injectable() +export class PageLikeEntityService { + constructor( + @Inject(DI.pageLikesRepository) + private pageLikesRepository: PageLikesRepository, + + private pageEntityService: PageEntityService, + ) { + } + + public async pack( + src: PageLike['id'] | PageLike, + me?: { id: User['id'] } | null | undefined, + ) { + const like = typeof src === 'object' ? src : await this.pageLikesRepository.findOneByOrFail({ id: src }); + + return { + id: like.id, + page: await this.pageEntityService.pack(like.page ?? like.pageId, me), + }; + } + + public packMany( + likes: any[], + me: { id: User['id'] }, + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/SigninEntityService.ts b/packages/backend/src/core/entities/SigninEntityService.ts new file mode 100644 index 0000000000..521c7669e7 --- /dev/null +++ b/packages/backend/src/core/entities/SigninEntityService.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { SigninsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Signin } from '@/models/entities/Signin.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class SigninEntityService { + constructor( + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: Signin, + ) { + return src; + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts new file mode 100644 index 0000000000..48d8af83fb --- /dev/null +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -0,0 +1,515 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { EntityRepository, Repository, In, Not } from 'typeorm'; +import Ajv from 'ajv'; +import { ModuleRef } from '@nestjs/core'; +import { DI } from '@/di-symbols.js'; +import { Config } from '@/config.js'; +import type { Packed } from '@/misc/schema.js'; +import type { Promiseable } from '@/misc/prelude/await-all.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; +import { Cache } from '@/misc/cache.js'; +import type { Instance } from '@/models/entities/Instance.js'; +import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; +import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { AntennaService } from '../AntennaService.js'; +import type { CustomEmojiService } from '../CustomEmojiService.js'; +import type { NoteEntityService } from './NoteEntityService.js'; +import type { DriveFileEntityService } from './DriveFileEntityService.js'; +import type { PageEntityService } from './PageEntityService.js'; + +type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; +type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> = + Detailed extends true ? + ExpectsMe extends true ? Packed<'MeDetailed'> : + ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : + Packed<'UserDetailed'> : + Packed<'UserLite'>; + +const ajv = new Ajv(); + +function isLocalUser(user: User): user is ILocalUser; +function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; }; +function isLocalUser(user: User | { host: User['host'] }): boolean { + return user.host == null; +} + +function isRemoteUser(user: User): user is IRemoteUser; +function isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; }; +function isRemoteUser(user: User | { host: User['host'] }): boolean { + return !isLocalUser(user); +} + +@Injectable() +export class UserEntityService implements OnModuleInit { + private noteEntityService: NoteEntityService; + private driveFileEntityService: DriveFileEntityService; + private pageEntityService: PageEntityService; + private customEmojiService: CustomEmojiService; + private antennaService: AntennaService; + #userInstanceCache: Cache<Instance | null>; + + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.noteUnreadsRepository) + private noteUnreadsRepository: NoteUnreadsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + @Inject(DI.notificationsRepository) + private notificationsRepository: NotificationsRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.antennaNotesRepository) + private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + + //private noteEntityService: NoteEntityService, + //private driveFileEntityService: DriveFileEntityService, + //private pageEntityService: PageEntityService, + //private customEmojiService: CustomEmojiService, + //private antennaService: AntennaService, + ) { + this.#userInstanceCache = new Cache<Instance | null>(1000 * 60 * 60 * 3); + } + + onModuleInit() { + this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); + this.pageEntityService = this.moduleRef.get('PageEntityService'); + this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + this.antennaService = this.moduleRef.get('AntennaService'); + } + + //#region Validators + public validateLocalUsername = ajv.compile(localUsernameSchema); + public validatePassword = ajv.compile(passwordSchema); + public validateName = ajv.compile(nameSchema); + public validateDescription = ajv.compile(descriptionSchema); + public validateLocation = ajv.compile(locationSchema); + public validateBirthday = ajv.compile(birthdaySchema); + //#endregion + + public isLocalUser = isLocalUser; + public isRemoteUser = isRemoteUser; + + public async getRelation(me: User['id'], target: User['id']) { + return awaitAll({ + id: target, + isFollowing: this.followingsRepository.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then(n => n > 0), + isFollowed: this.followingsRepository.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then(n => n > 0), + hasPendingFollowRequestFromYou: this.followRequestsRepository.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then(n => n > 0), + hasPendingFollowRequestToYou: this.followRequestsRepository.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then(n => n > 0), + isBlocking: this.blockingsRepository.count({ + where: { + blockerId: me, + blockeeId: target, + }, + take: 1, + }).then(n => n > 0), + isBlocked: this.blockingsRepository.count({ + where: { + blockerId: target, + blockeeId: me, + }, + take: 1, + }).then(n => n > 0), + isMuted: this.mutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), + }); + } + + public async getHasUnreadMessagingMessage(userId: User['id']): Promise<boolean> { + const mute = await this.mutingsRepository.findBy({ + muterId: userId, + }); + + const joinings = await this.userGroupJoiningsRepository.findBy({ userId: userId }); + + const groupQs = Promise.all(joinings.map(j => this.messagingMessagesRepository.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: j.userGroupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null))); + + const [withUser, withGroups] = await Promise.all([ + this.messagingMessagesRepository.count({ + where: { + recipientId: userId, + isRead: false, + ...(mute.length > 0 ? { userId: Not(In(mute.map(x => x.muteeId))) } : {}), + }, + take: 1, + }).then(count => count > 0), + groupQs, + ]); + + return withUser || withGroups.some(x => x); + } + + public async getHasUnreadAnnouncement(userId: User['id']): Promise<boolean> { + const reads = await this.announcementReadsRepository.findBy({ + userId: userId, + }); + + const count = await this.announcementsRepository.countBy(reads.length > 0 ? { + id: Not(In(reads.map(read => read.announcementId))), + } : {}); + + return count > 0; + } + + public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); + + const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ + antennaId: In(myAntennas.map(x => x.id)), + read: false, + }) : null; + + return unread != null; + } + + public async getHasUnreadChannel(userId: User['id']): Promise<boolean> { + const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); + + const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ + userId: userId, + noteChannelId: In(channels.map(x => x.followeeId)), + }) : null; + + return unread != null; + } + + public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { + const mute = await this.mutingsRepository.findBy({ + muterId: userId, + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await this.notificationsRepository.count({ + where: { + notifieeId: userId, + ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), + isRead: false, + }, + take: 1, + }); + + return count > 0; + } + + public async getHasPendingReceivedFollowRequest(userId: User['id']): Promise<boolean> { + const count = await this.followRequestsRepository.countBy({ + followeeId: userId, + }); + + return count > 0; + } + + public getOnlineStatus(user: User): 'unknown' | 'online' | 'active' | 'offline' { + if (user.hideOnlineStatus) return 'unknown'; + if (user.lastActiveDate == null) return 'unknown'; + const elapsed = Date.now() - user.lastActiveDate.getTime(); + return ( + elapsed < USER_ONLINE_THRESHOLD ? 'online' : + elapsed < USER_ACTIVE_THRESHOLD ? 'active' : + 'offline' + ); + } + + public async getAvatarUrl(user: User): Promise<string> { + if (user.avatar) { + return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); + } else if (user.avatarId) { + const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); + return this.driveFileEntityService.getPublicUrl(avatar, true) ?? this.getIdenticonUrl(user.id); + } else { + return this.getIdenticonUrl(user.id); + } + } + + public getAvatarUrlSync(user: User): string { + if (user.avatar) { + return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); + } else { + return this.getIdenticonUrl(user.id); + } + } + + public getIdenticonUrl(userId: User['id']): string { + return `${this.config.url}/identicon/${userId}`; + } + + public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>( + src: User['id'] | User, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: D, + includeSecrets?: boolean, + }, + ): Promise<IsMeAndIsUserDetailed<ExpectsMe, D>> { + const opts = Object.assign({ + detail: false, + includeSecrets: false, + }, options); + + let user: User; + + if (typeof src === 'object') { + user = src; + if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; + if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; + } else { + user = await this.usersRepository.findOneOrFail({ + where: { id: src }, + relations: { + avatar: true, + banner: true, + }, + }); + } + + const meId = me ? me.id : null; + const isMe = meId === user.id; + + const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; + const pins = opts.detail ? await this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('pin.note', 'note') + .orderBy('pin.id', 'DESC') + .getMany() : []; + const profile = opts.detail ? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null; + + const followingCount = profile == null ? null : + (profile.ffVisibility === 'public') || isMe ? user.followingCount : + (profile.ffVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount : + null; + + const followersCount = profile == null ? null : + (profile.ffVisibility === 'public') || isMe ? user.followersCount : + (profile.ffVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : + null; + + const falsy = opts.detail ? false : undefined; + + const packed = { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: this.getAvatarUrlSync(user), + avatarBlurhash: user.avatar?.blurhash ?? null, + avatarColor: null, // 後方互換性のため + isAdmin: user.isAdmin ?? falsy, + isModerator: user.isModerator ?? falsy, + isBot: user.isBot ?? falsy, + isCat: user.isCat ?? falsy, + instance: user.host ? this.#userInstanceCache.fetch(user.host, + () => this.instancesRepository.findOneBy({ host: user.host! }), + v => v != null, + ).then(instance => instance ? { + name: instance.name, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + } : undefined) : undefined, + emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), + onlineStatus: this.getOnlineStatus(user), + driveCapacityOverrideMb: user.driveCapacityOverrideMb, + + ...(opts.detail ? { + url: profile!.url, + uri: user.uri, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, + lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, + bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner, false) : null, + bannerBlurhash: user.banner?.blurhash ?? null, + bannerColor: null, // 後方互換性のため + isLocked: user.isLocked, + isSilenced: user.isSilenced ?? falsy, + isSuspended: user.isSuspended ?? falsy, + description: profile!.description, + location: profile!.location, + birthday: profile!.birthday, + lang: profile!.lang, + fields: profile!.fields, + followersCount: followersCount ?? 0, + followingCount: followingCount ?? 0, + notesCount: user.notesCount, + pinnedNoteIds: pins.map(pin => pin.noteId), + pinnedNotes: this.noteEntityService.packMany(pins.map(pin => pin.note!), me, { + detail: true, + }), + pinnedPageId: profile!.pinnedPageId, + pinnedPage: profile!.pinnedPageId ? this.pageEntityService.pack(profile!.pinnedPageId, me) : null, + publicReactions: profile!.publicReactions, + ffVisibility: profile!.ffVisibility, + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? this.userSecurityKeysRepository.countBy({ + userId: user.id, + }).then(result => result >= 1) + : false, + } : {}), + + ...(opts.detail && isMe ? { + avatarId: user.avatarId, + bannerId: user.bannerId, + injectFeaturedNote: profile!.injectFeaturedNote, + receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, + alwaysMarkNsfw: profile!.alwaysMarkNsfw, + autoSensitive: profile!.autoSensitive, + carefulBot: profile!.carefulBot, + autoAcceptFollowed: profile!.autoAcceptFollowed, + noCrawle: profile!.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ + where: { userId: user.id, isSpecified: true }, + take: 1, + }).then(count => count > 0), + hasUnreadMentions: this.noteUnreadsRepository.count({ + where: { userId: user.id, isMentioned: true }, + take: 1, + }).then(count => count > 0), + hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAntenna: this.getHasUnreadAntenna(user.id), + hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), + hasUnreadNotification: this.getHasUnreadNotification(user.id), + hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), + integrations: profile!.integrations, + mutedWords: profile!.mutedWords, + mutedInstances: profile!.mutedInstances, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, + showTimelineReplies: user.showTimelineReplies ?? falsy, + } : {}), + + ...(opts.includeSecrets ? { + email: profile!.email, + emailVerified: profile!.emailVerified, + securityKeysList: profile!.twoFactorEnabled + ? this.userSecurityKeysRepository.find({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }) + : [], + } : {}), + + ...(relation ? { + isFollowing: relation.isFollowing, + isFollowed: relation.isFollowed, + hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, + hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, + isBlocking: relation.isBlocking, + isBlocked: relation.isBlocked, + isMuted: relation.isMuted, + } : {}), + } as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; + + return await awaitAll(packed); + } + + public packMany<D extends boolean = false>( + users: (User['id'] | User)[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: D, + includeSecrets?: boolean, + }, + ): Promise<IsUserDetailed<D>[]> { + return Promise.all(users.map(u => this.pack(u, me, options))); + } +} diff --git a/packages/backend/src/core/entities/UserGroupEntityService.ts b/packages/backend/src/core/entities/UserGroupEntityService.ts new file mode 100644 index 0000000000..acd26ea1eb --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserGroupJoiningsRepository, UserGroupsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class UserGroupEntityService { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: UserGroup['id'] | UserGroup, + ): Promise<Packed<'UserGroup'>> { + const userGroup = typeof src === 'object' ? src : await this.userGroupsRepository.findOneByOrFail({ id: src }); + + const users = await this.userGroupJoiningsRepository.findBy({ + userGroupId: userGroup.id, + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + ownerId: userGroup.userId, + userIds: users.map(x => x.userId), + }; + } +} + diff --git a/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts new file mode 100644 index 0000000000..50ff2231ab --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserGroupInvitationsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserEntityService } from './UserEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; + +@Injectable() +export class UserGroupInvitationEntityService { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + } + + public async pack( + src: UserGroupInvitation['id'] | UserGroupInvitation, + ) { + const invitation = typeof src === 'object' ? src : await this.userGroupInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + group: await this.userGroupEntityService.pack(invitation.userGroup ?? invitation.userGroupId), + }; + } + + public packMany( + invitations: any[], + ) { + return Promise.all(invitations.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts new file mode 100644 index 0000000000..05434d9c8a --- /dev/null +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserList } from '@/models/entities/UserList.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class UserListEntityService { + constructor( + @Inject(DI.userListsRepository) + private userListsRepository: UserListsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + private userEntityService: UserEntityService, + ) { + } + + public async pack( + src: UserList['id'] | UserList, + ): Promise<Packed<'UserList'>> { + const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); + + const users = await this.userListJoiningsRepository.findBy({ + userListId: userList.id, + }); + + return { + id: userList.id, + createdAt: userList.createdAt.toISOString(), + name: userList.name, + userIds: users.map(x => x.userId), + }; + } +} + diff --git a/packages/backend/src/core/queue/QueueModule.ts b/packages/backend/src/core/queue/QueueModule.ts new file mode 100644 index 0000000000..3a271ea37f --- /dev/null +++ b/packages/backend/src/core/queue/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<T>(config: Config, name: string, limitPerSec = -1) { + return new Bull<T>(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<Record<string, unknown>>; +export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>; +export type DeliverQueue = Bull.Queue<DeliverJobData>; +export type InboxQueue = Bull.Queue<InboxJobData>; +export type DbQueue = Bull.Queue<DbJobData>; +export type ObjectStorageQueue = Bull.Queue<ObjectStorageJobData>; +export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>; + +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 new file mode 100644 index 0000000000..7ce8fe6cfc --- /dev/null +++ b/packages/backend/src/core/remote/RemoteLoggerService.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Logger from '@/logger.js'; + +@Injectable() +export class RemoteLoggerService { + public logger: Logger; + + constructor( + ) { + this.logger = new Logger('remote', 'cyan'); + } +} diff --git a/packages/backend/src/core/remote/ResolveUserService.ts b/packages/backend/src/core/remote/ResolveUserService.ts new file mode 100644 index 0000000000..4ef91bc8e6 --- /dev/null +++ b/packages/backend/src/core/remote/ResolveUserService.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 { UsersRepository } from '@/models/index.js'; +import type { IRemoteUser, User } from '@/models/entities/User.js'; +import { 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 { + #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<User> { + 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; + } + + 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 new file mode 100644 index 0000000000..24ccaf528b --- /dev/null +++ b/packages/backend/src/core/remote/WebfingerService.ts @@ -0,0 +1,48 @@ +import { URL } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { 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<IWebFinger> { + const url = this.#genUrl(query); + + return await this.httpRequestService.getJson(url, 'application/jrd+json, application/json') as IWebFinger; + } + + #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 new file mode 100644 index 0000000000..178631ac55 --- /dev/null +++ b/packages/backend/src/core/remote/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<AudienceInfo> { + 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<CacheableUser | null>(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, + }; + } + + #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; + } + + #isPublic(id: string) { + return [ + 'https://www.w3.org/ns/activitystreams#Public', + 'as#Public', + 'Public', + ].includes(id); + } + + #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 new file mode 100644 index 0000000000..37f58c6b0c --- /dev/null +++ b/packages/backend/src/core/remote/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 { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import { 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 { + #publicKeyCache: Cache<UserPublickey | null>; + #publicKeyByUserIdCache: Cache<UserPublickey | null>; + + 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<UserPublickey | null>(Infinity); + this.#publicKeyByUserIdCache = new Cache<UserPublickey | null>(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<Note | null> { + 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<MessagingMessage | null> { + 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<CacheableUser | null> { + 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 new file mode 100644 index 0000000000..a6ee857526 --- /dev/null +++ b/packages/backend/src/core/remote/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 { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import { 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<string>(); + + /* + 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 new file mode 100644 index 0000000000..531a774c50 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApInboxService.ts @@ -0,0 +1,735 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { 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 { 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 { + #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<void> { + 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}`); + } + } + + async #follow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> { + 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'; + } + + async #like(actor: CacheableRemoteUser, activity: ILike): Promise<string> { + 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(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + return 'skip: already reacted'; + } else { + throw e; + } + }).then(() => 'ok'); + } + + async #read(actor: CacheableRemoteUser, activity: IRead): Promise<string> { + 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})`; + } + + async #accept(actor: CacheableRemoteUser, activity: IAccept): Promise<string> { + 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)}`; + } + + async #acceptFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> { + // ※ 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'; + } + + async #add(actor: CacheableRemoteUser, activity: IAdd): Promise<void> { + 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}`); + } + + async #announce(actor: CacheableRemoteUser, activity: IAnnounce): Promise<void> { + const uri = getApId(activity); + + this.#logger.info(`Announce: ${uri}`); + + const targetUri = getApId(activity.object); + + this.#announceNote(actor, activity, targetUri); + } + + async #announceNote(actor: CacheableRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> { + 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); + } 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)) return 'skip: invalid actor for this activity'; + + 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(); + } + } + + async #block(actor: CacheableRemoteUser, activity: IBlock): Promise<string> { + // ※ 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'; + } + + async #create(actor: CacheableRemoteUser, activity: ICreate): Promise<void> { + 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)}`); + } + } + + async #createNote(resolver: Resolver, actor: CacheableRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> { + 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 (e) { + if (e instanceof StatusError && e.isClientError) { + return `skip ${e.statusCode}`; + } else { + throw e; + } + } finally { + unlock(); + } + } + + async #delete(actor: CacheableRemoteUser, activity: IDelete): Promise<string> { + 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}`; + } + } + + async #deleteActor(actor: CacheableRemoteUser, uri: string): Promise<string> { + 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}`; + } + + async #deleteNote(actor: CacheableRemoteUser, uri: string): Promise<string> { + 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(); + } + } + + async #flag(actor: CacheableRemoteUser, activity: IFlag): Promise<string> { + // 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'; + } + + async #reject(actor: CacheableRemoteUser, activity: IReject): Promise<string> { + 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)}`; + } + + async #rejectFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> { + // ※ 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'; + } + + async #remove(actor: CacheableRemoteUser, activity: IRemove): Promise<void> { + 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}`); + } + + async #undo(actor: CacheableRemoteUser, activity: IUndo): Promise<string> { + 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)}`; + } + + async #undoAccept(actor: CacheableRemoteUser, activity: IAccept): Promise<string> { + 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: フォローされていない'; + } + + async #undoAnnounce(actor: CacheableRemoteUser, activity: IAnnounce): Promise<string> { + 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'; + } + + async #undoBlock(actor: CacheableRemoteUser, activity: IBlock): Promise<string> { + 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'; + } + + async #undoFollow(actor: CacheableRemoteUser, activity: IFollow): Promise<string> { + 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: リクエストもフォローもされていない'; + } + + async #undoLike(actor: CacheableRemoteUser, activity: ILike): Promise<string> { + 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'; + } + + async #update(actor: CacheableRemoteUser, activity: IUpdate): Promise<string> { + 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).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 new file mode 100644 index 0000000000..82fd7c5f18 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApLoggerService.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..3c3b98b139 --- /dev/null +++ b/packages/backend/src/core/remote/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 { 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 new file mode 100644 index 0000000000..b065c1acdd --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApRendererService.ts @@ -0,0 +1,702 @@ +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 { 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 { LdSignatureService } from './LdSignatureService.js'; +import { ApMfmService } from './ApMfmService.js'; +import type { IActivity } 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: Note) { + 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<Record<string, unknown>> { + 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, + content, + _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?:/)) + ? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>` + : 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<IActivity> { + 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?: Record<string, unknown>[]) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; + } + + async #getEmojis(names: string[]): Promise<Emoji[]> { + 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 new file mode 100644 index 0000000000..a449a77dc8 --- /dev/null +++ b/packages/backend/src/core/remote/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 { 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<string, string>; +}; + +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, + ) { + } + + #createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): 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, + }; + } + + #createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): 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, + }; + } + + #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, + }; + } + + #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'); + } + + #lcObjectKey(src: Record<string, string>): Record<string, string> { + const dst: Record<string, string> = {}; + for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; + } + + #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> { + 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 new file mode 100644 index 0000000000..9d8e177758 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/ApResolverService.ts @@ -0,0 +1,190 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ILocalUser } from '@/models/entities/User.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; +import { 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<string>; + 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, + ) { + this.history = new Set(); + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { + 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<IObject> { + 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'); + } + + 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<IObject> { + 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))); + } 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 <followee id> + 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 ${type} unhandled`); + } + } +} diff --git a/packages/backend/src/core/remote/activitypub/LdSignatureService.ts b/packages/backend/src/core/remote/activitypub/LdSignatureService.ts new file mode 100644 index 0000000000..ea0d2daf3d --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/LdSignatureService.ts @@ -0,0 +1,150 @@ +import * as crypto from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import jsonld from 'jsonld'; +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<any> { + 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<boolean> { + 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); + const transformedData = { ...data }; + delete transformedData['signature']; + const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + const documentHash = this.sha256(cannonidedData); + const verifyData = `${optionsHash}${documentHash}`; + return verifyData; + } + + public async normalize(data: any) { + const customLoader = this.getLoader(); + return await jsonld.normalize(data, { + documentLoader: customLoader, + }); + } + + private getLoader() { + return async (url: string): Promise<any> => { + 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 new file mode 100644 index 0000000000..aee0d3629c --- /dev/null +++ b/packages/backend/src/core/remote/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<string, unknown> = { + '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/misc/get-note-html.ts b/packages/backend/src/core/remote/activitypub/misc/get-note-html.ts new file mode 100644 index 0000000000..af23a04a71 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/misc/get-note-html.ts @@ -0,0 +1,8 @@ +import * as mfm from 'mfm-js'; +import type { Note } from '@/models/entities/Note.js'; +import { toHtml } from '../../../../mfm/to-html.js'; + +export default function(note: Note) { + if (!note.text) return ''; + return toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)); +} diff --git a/packages/backend/src/core/remote/activitypub/models/ApImageService.ts b/packages/backend/src/core/remote/activitypub/models/ApImageService.ts new file mode 100644 index 0000000000..d4e01923a2 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApImageService.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { DriveFilesRepository } from '@/models/index.js'; +import { 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 { + #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<DriveFile> { + // 投稿者が凍結されていたらスキップ + 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<DriveFile> { + // 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 new file mode 100644 index 0000000000..898da07a26 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApMentionService.ts @@ -0,0 +1,41 @@ +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 { 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 } 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) { + const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string)); + + const resolver = this.apResolverService.createResolver(); + + const limit = promiseLimit<CacheableUser | null>(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 new file mode 100644 index 0000000000..c74949c595 --- /dev/null +++ b/packages/backend/src/core/remote/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 { MessagingMessagesRepository, PollsRepository, EmojisRepository } from '@/models/index.js'; +import type { UsersRepository } from '@/models/index.js'; +import { 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 { + #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<Note | null> { + return await this.apDbResolverService.getNoteFromApId(object); + } + + /** + * Noteを作成します。 + */ + public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> { + 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); + 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); + 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<DriveFile>))) + .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<null> => { + 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<Note | null> { + 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<Emoji[]> { + 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<Emoji>).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 new file mode 100644 index 0000000000..1ca6463484 --- /dev/null +++ b/packages/backend/src/core/remote/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 { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import { 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; + #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 + */ + #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<CacheableUser | null> { + 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<User> { + 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).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<void> { + 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<User>; + + 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).catch(err => this.#logger.error(err)); + } + + /** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ + public async resolvePerson(uri: string, resolver?: Resolver): Promise<CacheableUser> { + 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']) { + 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}`); + + const 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<Note | null>(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 new file mode 100644 index 0000000000..97f26d1681 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/ApQuestionService.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { NotesRepository, PollsRepository } from '@/models/index.js'; +import { 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 { + #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<IPoll> { + 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) { + 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 + const 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 new file mode 100644 index 0000000000..50794a937d --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/icon.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000000..f6c3bb8c88 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/models/identifier.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000000..803846a0b0 --- /dev/null +++ b/packages/backend/src/core/remote/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/remote/activitypub/type.ts b/packages/backend/src/core/remote/activitypub/type.ts new file mode 100644 index 0000000000..de7eb0ed83 --- /dev/null +++ b/packages/backend/src/core/remote/activitypub/type.ts @@ -0,0 +1,295 @@ +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; + 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'; |