diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2024-06-01 11:27:03 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-06-01 11:27:03 +0900 |
| commit | fce66b85b603caac79e1bfa87b5f4621b1ba9d4e (patch) | |
| tree | d22952ee3f8e30057977a99a33823f4d52990fbc /packages/backend/src/core | |
| parent | Merge pull request #13493 from misskey-dev/develop (diff) | |
| parent | fix(backend): use insertOne insteadof insert/findOneOrFail combination (#13908) (diff) | |
| download | sharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.gz sharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.tar.bz2 sharkey-fce66b85b603caac79e1bfa87b5f4621b1ba9d4e.zip | |
Merge pull request #13917 from misskey-dev/develop
Release 2024.5.0 (master)
Diffstat (limited to 'packages/backend/src/core')
52 files changed, 978 insertions, 372 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 5bd885df40..b6b591d240 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -305,7 +305,7 @@ export class AccountMoveService { let resultUser: MiLocalUser | MiRemoteUser | null = null; if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(dst.uri); } dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; @@ -321,7 +321,7 @@ export class AccountMoveService { if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(srcUri); } diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index b298a70929..40a9db01c0 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -4,13 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; +import { Brackets, EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -29,6 +30,7 @@ export class AnnouncementService { private idService: IdService, private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, + private announcementEntityService: AnnouncementEntityService, ) { } @@ -65,7 +67,7 @@ export class AnnouncementService { @bindThis public async create(values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> { - const announcement = await this.announcementsRepository.insert({ + const announcement = await this.announcementsRepository.insertOne({ id: this.idService.gen(), updatedAt: null, title: values.title, @@ -77,9 +79,9 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, userId: values.userId, - }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + }); - const packed = (await this.packMany([announcement]))[0]; + const packed = await this.announcementEntityService.pack(announcement); if (values.userId) { this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { @@ -178,6 +180,24 @@ export class AnnouncementService { } @bindThis + public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> { + const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId }); + if (me) { + if (announcement.userId && announcement.userId !== me.id) { + throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId }); + } + + const read = await this.announcementReadsRepository.findOneBy({ + announcementId: announcement.id, + userId: me.id, + }); + return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me); + } else { + return this.announcementEntityService.pack(announcement, null); + } + } + + @bindThis public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> { try { await this.announcementReadsRepository.insert({ @@ -193,29 +213,4 @@ export class AnnouncementService { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } } - - @bindThis - public async packMany( - announcements: MiAnnouncement[], - me?: { id: MiUser['id'] } | null | undefined, - options?: { - reads?: MiAnnouncementRead[]; - }, - ): Promise<Packed<'Announcement'>[]> { - const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: this.idService.parse(announcement.id).date.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - text: announcement.text, - title: announcement.title, - imageUrl: announcement.imageUrl, - icon: announcement.icon, - display: announcement.display, - needConfirmationToRead: announcement.needConfirmationToRead, - silence: announcement.silence, - forYou: announcement.userId === me?.id, - isRead: reads.some(read => read.announcementId === announcement.id), - })); - } } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 4f956a43ed..793d8974b3 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -92,7 +92,7 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> { + public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> { const antennas = await this.getAntennas(); const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); @@ -110,10 +110,12 @@ export class AntennaService implements OnApplicationShutdown { // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている @bindThis - public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> { + public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> { if (note.visibility === 'specified') return false; if (note.visibility === 'followers') return false; + if (antenna.excludeBots && noteUser.isBot) return false; + if (antenna.localOnly && noteUser.host != null) return false; if (!antenna.withReplies && note.replyId != null) return false; diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 21e31d79a4..8b54bbe012 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -55,10 +55,10 @@ export class AvatarDecorationService implements OnApplicationShutdown { @bindThis public async create(options: Partial<MiAvatarDecoration>, moderator?: MiUser): Promise<MiAvatarDecoration> { - const created = await this.avatarDecorationsRepository.insert({ + const created = await this.avatarDecorationsRepository.insertOne({ id: this.idService.gen(), ...options, - }).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('avatarDecorationCreated', created); diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 75843b9773..12251595e2 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts index bb8be26ce6..9fd1ebad87 100644 --- a/packages/backend/src/core/ClipService.ts +++ b/packages/backend/src/core/ClipService.ts @@ -45,13 +45,13 @@ export class ClipService { throw new ClipService.TooManyClipsError(); } - const clip = await this.clipsRepository.insert({ + const clip = await this.clipsRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: name, isPublic: isPublic, description: description, - }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); + }); return clip; } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 2c27d33c06..be80df6f1c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -84,6 +84,7 @@ import ApRequestChart from './chart/charts/ap-request.js'; import { ChartManagementService } from './chart/ChartManagementService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; @@ -127,7 +128,7 @@ import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; import { ApResolverService } from './activitypub/ApResolverService.js'; -import { LdSignatureService } from './activitypub/LdSignatureService.js'; +import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; import { WebfingerService } from './WebfingerService.js'; @@ -223,6 +224,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -266,7 +268,7 @@ const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmSer const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService }; const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService }; const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService }; -const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService }; +const $JsonLdService: Provider = { provide: 'JsonLdService', useExisting: JsonLdService }; const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService }; const $RemoteUserResolveService: Provider = { provide: 'RemoteUserResolveService', useExisting: RemoteUserResolveService }; const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService }; @@ -363,6 +365,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -406,7 +409,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -499,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -542,7 +546,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, @@ -635,6 +639,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -678,7 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -770,6 +775,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -813,7 +819,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index edb9335b6e..7e11b9cdca 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -20,7 +20,7 @@ import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; +const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { @@ -68,7 +68,7 @@ export class CustomEmojiService implements OnApplicationShutdown { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; }, moderator?: MiUser): Promise<MiEmoji> { - const emoji = await this.emojisRepository.insert({ + const emoji = await this.emojisRepository.insertOne({ id: this.idService.gen(), updatedAt: new Date(), name: data.name, @@ -82,7 +82,7 @@ export class CustomEmojiService implements OnApplicationShutdown { isSensitive: data.isSensitive, localOnly: data.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }); if (data.host == null) { this.localEmojisCache.refresh(); @@ -346,10 +346,11 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> { const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); - const res = {} as any; + const res = {} as Record<string, string>; for (let i = 0; i < emojiNames.length; i++) { - if (emojis[i] != null) { - res[emojiNames[i]] = emojis[i]; + const resolvedEmoji = emojis[i]; + if (resolvedEmoji != null) { + res[emojiNames[i]] = resolvedEmoji; } } return res; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 1bc1df1dda..37c5d1adf7 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -220,7 +220,7 @@ export class DriveService { file.size = size; file.storedInternal = false; - return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + return await this.driveFilesRepository.insertOne(file); } else { // use internal storage const accessKey = randomUUID(); const thumbnailAccessKey = 'thumbnail-' + randomUUID(); @@ -254,7 +254,7 @@ export class DriveService { file.md5 = hash; file.size = size; - return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + return await this.driveFilesRepository.insertOne(file); } } @@ -497,14 +497,20 @@ export class DriveService { if (user && !force) { // Check if there is a file with the same hash - const much = await this.driveFilesRepository.findOneBy({ + const matched = 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; + if (matched) { + this.registerLogger.info(`file with same hash is found: ${matched.id}`); + if (sensitive && !matched.isSensitive) { + // The file is federated as sensitive for this time, but was federated as non-sensitive before. + // Therefore, update the file to sensitive. + await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true }); + matched.isSensitive = true; + } + return matched; } } @@ -609,7 +615,7 @@ export class DriveService { file.type = info.type.mime; file.storedInternal = false; - file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + file = await this.driveFilesRepository.insertOne(file); } catch (err) { // duplicate key error (when already registered) if (isDuplicateKeyValueError(err)) { diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 9c239b4dfc..d5058f37c2 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; @@ -61,8 +61,8 @@ export class FanoutTimelineEndpointService { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); - const shouldPrepend = ps.sinceId && !ps.untilId; - const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; + const ascending = ps.sinceId && !ps.untilId; + const idCompare: (a: string, b: string) => number = ascending ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); @@ -95,7 +95,7 @@ export class FanoutTimelineEndpointService { if (ps.excludePureRenotes) { const parentFilter = filter; - filter = (note) => !isPureRenote(note) && parentFilter(note); + filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note); } if (ps.me) { @@ -116,7 +116,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; return parentFilter(note); @@ -142,9 +142,7 @@ export class FanoutTimelineEndpointService { if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { // 十分Redisからとれた - const result = redisTimeline.slice(0, ps.limit); - if (shouldPrepend) result.reverse(); - return result; + return redisTimeline.slice(0, ps.limit); } } @@ -152,8 +150,7 @@ export class FanoutTimelineEndpointService { const remainingToRead = ps.limit - redisTimeline.length; let dbUntil: string | null; let dbSince: string | null; - if (shouldPrepend) { - redisTimeline.reverse(); + if (ascending) { dbUntil = ps.untilId; dbSince = noteIds[noteIds.length - 1]; } else { @@ -161,7 +158,7 @@ export class FanoutTimelineEndpointService { dbSince = ps.sinceId; } const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); - return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb]; + return [...redisTimeline, ...gotFromDb]; } return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 66db2067d9..6799f2c5bb 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -55,11 +55,11 @@ export class FederatedInstanceService implements OnApplicationShutdown { const index = await this.instancesRepository.findOneBy({ host }); if (index == null) { - const i = await this.instancesRepository.insert({ + const i = await this.instancesRepository.insertOne({ id: this.idService.gen(), host, firstRetrievedAt: new Date(), - }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); + }); this.federatedInstanceCache.set(host, i); return i; diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index bc270bd28f..aa16468ecb 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -51,21 +51,35 @@ export class FetchInstanceMetadataService { } @bindThis - public async tryLock(host: string): Promise<boolean> { - const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET'); - return mutex !== '1'; + // public for test + public async tryLock(host: string): Promise<string | null> { + // TODO: マイグレーションなのであとで消す (2024.3.1) + this.redisClient.del(`fetchInstanceMetadata:mutex:${host}`); + + return await this.redisClient.set( + `fetchInstanceMetadata:mutex:v2:${host}`, '1', + 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 + 'GET' // 古い値を返す(なかったらnull) + ); } @bindThis - public unlock(host: string): Promise<'OK'> { - return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0'); + // public for test + public unlock(host: string): Promise<number> { + return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`); } @bindThis public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> { const host = instance.host; - // Acquire mutex to ensure no parallel runs - if (!await this.tryLock(host)) return; + + // finallyでunlockされてしまうのでtry内でロックチェックをしない + // (returnであってもfinallyは実行される) + if (!force && await this.tryLock(host) === '1') { + // 1が返ってきていたらロックされているという意味なので、何もしない + return; + } + try { if (!force) { const _instance = await this.federatedInstanceService.fetch(host); @@ -140,7 +154,7 @@ export class FetchInstanceMetadataService { throw new Error('No wellknown links'); } - const links = wellknown.links as any[]; + const links = wellknown.links as ({ rel: string, href: string; })[]; const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index b8babcb3a7..169285f033 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -14,11 +14,12 @@ 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 { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { encode } from 'blurhash'; import { createTempDir } from '@/misc/create-temp.js'; import { AiService } from '@/core/AiService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; export type FileInfo = { @@ -49,9 +50,13 @@ const TYPE_SVG = { @Injectable() export class FileInfoService { + private logger: Logger; + constructor( private aiService: AiService, + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('file-info'); } /** @@ -318,6 +323,34 @@ export class FileInfoService { } /** + * ビデオファイルにビデオトラックがあるかどうかチェック + * (ない場合:m4a, webmなど) + * + * @param path ファイルパス + * @returns ビデオトラックがあるかどうか(エラー発生時は常に`true`を返す) + */ + @bindThis + private hasVideoTrackOnVideoFile(path: string): Promise<boolean> { + const sublogger = this.logger.createSubLogger('ffprobe'); + sublogger.info(`Checking the video file. File path: ${path}`); + return new Promise((resolve) => { + try { + FFmpeg.ffprobe(path, (err, metadata) => { + if (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err); + resolve(true); + return; + } + resolve(metadata.streams.some((stream) => stream.codec_type === 'video')); + }); + } catch (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error); + resolve(true); + } + }); + } + + /** * Detect MIME Type and extension */ @bindThis @@ -339,6 +372,20 @@ export class FileInfoService { return TYPE_SVG; } + if ((type.mime.startsWith('video') || type.mime === 'application/ogg') && !(await this.hasVideoTrackOnVideoFile(path))) { + const newMime = `audio/${type.mime.split('/')[1]}`; + if (newMime === 'audio/mp4') { + return { + mime: 'audio/mp4', + ext: 'm4a', + }; + } + return { + mime: newMime, + ext: type.ext, + }; + } + return { mime: this.fixMime(type.mime), ext: type.ext, diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index c62ee5a642..9786f8b8bb 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,10 +6,11 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window } from 'happy-dom'; +import { Window, XMLSerializer } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; @@ -33,6 +34,8 @@ export class MfmService { // some AP servers like Pixelfed use br tags as well as newlines html = html.replace(/<br\s?\/?>\r?\n/gi, '\n'); + const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x))); + const dom = parse5.parseFragment(html); let text = ''; @@ -85,7 +88,7 @@ export class MfmService { const href = node.attrs.find(x => x.name === 'href'); // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { @@ -244,6 +247,8 @@ export class MfmService { const doc = window.document; + const body = doc.createElement('p'); + 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); @@ -454,8 +459,8 @@ export class MfmService { }, }; - appendChildren(nodes, doc.body); + appendChildren(nodes, body); - return `<p>${doc.body.innerHTML}</p>`; + return new XMLSerializer().serializeToString(body); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 81ae2908d3..e5580f36d1 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -306,7 +306,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // Check blocking - if (data.renote && !this.isQuote(data)) { + if (this.isRenote(data) && !this.isQuote(data)) { if (data.renote.userHost === null) { if (data.renote.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id); @@ -473,6 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { noteVisibility: insert.visibility, userId: user.id, userHost: user.host, + channelId: insert.channelId, }); await transactionalEntityManager.insert(MiPoll, poll); @@ -641,7 +642,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // If it is renote - if (data.renote) { + if (this.isRenote(data)) { const type = this.isQuote(data) ? 'quote' : 'renote'; // Notify @@ -725,9 +726,20 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } { - // sync with misc/is-quote.ts - return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll); + private isRenote(note: Option): note is Option & { renote: MiNote } { + return note.renote != null; + } + + @bindThis + private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & ( + { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } + ) { + // NOTE: SYNC WITH misc/is-quote.ts + return note.text != null || + note.reply != null || + note.cw != null || + note.poll != null || + (note.files != null && note.files.length > 0); } @bindThis @@ -795,7 +807,7 @@ export class NoteCreateService implements OnApplicationShutdown { private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { if (data.localOnly) return null; - const content = data.renote && !this.isQuote(data) + const content = this.isRenote(data) && !this.isQuote(data) ? 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); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index fdf843c3e8..801ed02e00 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { isPureRenote } from '@/misc/is-pure-renote.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; @Injectable() export class NoteDeleteService { @@ -79,7 +79,7 @@ export class NoteDeleteService { let renote: MiNote | null = null; // if deleted note is renote - if (isPureRenote(note)) { + if (isRenote(note) && !isQuote(note)) { renote = await this.notesRepository.findOneBy({ id: note.renoteId, }); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 3b706d9854..6a845b951d 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -101,7 +101,7 @@ export class PushNotificationService implements OnApplicationShutdown { type, body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body, userId, - dateTime: (new Date()).getTime(), + dateTime: Date.now(), }), { proxy: this.config.proxy, }).catch((err: any) => { diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index e9dc9b57af..8dd3d64f5b 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -53,11 +53,11 @@ export class RelayService { @bindThis public async addRelay(inbox: string): Promise<MiRelay> { - const relay = await this.relaysRepository.insert({ + const relay = await this.relaysRepository.insertOne({ id: this.idService.gen(), inbox, status: 'requesting', - }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); + }); const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 439bc08845..7f939b99c7 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -281,7 +281,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { @bindThis private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> { - const game = await this.reversiGamesRepository.insert({ + const game = await this.reversiGamesRepository.insertOne({ id: this.idService.gen(), user1Id: parentId, user2Id: childId, @@ -294,10 +294,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw: 'random', isLlotheo: false, noIrregularRules: options.noIrregularRules, - }).then(x => this.reversiGamesRepository.findOneOrFail({ - where: { id: x.identifiers[0].id }, - relations: ['user1', 'user2'], - })); + }, { relations: ['user1', 'user2'] }); this.cacheGame(game); const packed = await this.reversiGameEntityService.packDetail(game); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 09f3097114..d6eea70297 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -205,45 +205,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { try { switch (value.type) { + // ~かつ~ case 'and': { return value.values.every(v => this.evalCond(user, roles, v)); } + // ~または~ case 'or': { return value.values.some(v => this.evalCond(user, roles, v)); } + // ~ではない case 'not': { return !this.evalCond(user, roles, value.value); } + // マニュアルロールがアサインされている case 'roleAssignedTo': { return roles.some(r => r.id === value.roleId); } + // ローカルユーザのみ case 'isLocal': { return this.userEntityService.isLocalUser(user); } + // リモートユーザのみ case 'isRemote': { return this.userEntityService.isRemoteUser(user); } + // サスペンド済みユーザである + case 'isSuspended': { + return user.isSuspended; + } + // 鍵アカウントユーザである + case 'isLocked': { + return user.isLocked; + } + // botユーザである + case 'isBot': { + return user.isBot; + } + // 猫である + case 'isCat': { + return user.isCat; + } + // 「ユーザを見つけやすくする」が有効なアカウント + case 'isExplorable': { + return user.isExplorable; + } + // ユーザが作成されてから指定期間経過した case 'createdLessThan': { return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000)); } + // ユーザが作成されてから指定期間経っていない case 'createdMoreThan': { return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000)); } + // フォロワー数が指定値以下 case 'followersLessThanOrEq': { return user.followersCount <= value.value; } + // フォロワー数が指定値以上 case 'followersMoreThanOrEq': { return user.followersCount >= value.value; } + // フォロー数が指定値以下 case 'followingLessThanOrEq': { return user.followingCount <= value.value; } + // フォロー数が指定値以上 case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + // ノート数が指定値以下 case 'notesLessThanOrEq': { return user.notesCount <= value.value; } + // ノート数が指定値以上 case 'notesMoreThanOrEq': { return user.notesCount >= value.value; } @@ -437,12 +471,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } } - const created = await this.roleAssignmentsRepository.insert({ + const created = await this.roleAssignmentsRepository.insertOne({ id: this.idService.gen(now), expiresAt: expiresAt, roleId: roleId, userId: userId, - }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + }); this.rolesRepository.update(roleId, { lastUsedAt: new Date(), @@ -524,7 +558,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> { const date = new Date(); - const created = await this.rolesRepository.insert({ + const created = await this.rolesRepository.insertOne({ id: this.idService.gen(date.getTime()), updatedAt: date, lastUsedAt: date, @@ -542,7 +576,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canEditMembersByModerator: values.canEditMembersByModerator, displayOrder: values.displayOrder, policies: values.policies, - }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('roleCreated', created); diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 0a492c06e4..406ea04031 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -511,7 +511,13 @@ export class UserFollowingService implements OnModuleInit { if (blocking) throw new Error('blocking'); if (blocked) throw new Error('blocked'); - const followRequest = await this.followRequestsRepository.insert({ + // Remove old follow requests before creating a new one. + await this.followRequestsRepository.delete({ + followeeId: followee.id, + followerId: follower.id, + }); + + const followRequest = await this.followRequestsRepository.insertOne({ id: this.idService.gen(), followerId: follower.id, followeeId: followee.id, @@ -525,7 +531,7 @@ export class UserFollowingService implements OnModuleInit { 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)) { diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 42fbed2110..ec9f4484a4 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -10,7 +10,7 @@ import { generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; -import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers'; +import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -49,7 +49,7 @@ export class WebAuthnService { const instance = await this.metaService.fetch(); return { origin: this.config.url, - rpId: this.config.host, + rpId: this.config.hostname, rpName: instance.name ?? this.config.host, rpIcon: instance.iconUrl ?? undefined, }; @@ -65,13 +65,12 @@ export class WebAuthnService { const registrationOptions = await generateRegistrationOptions({ rpName: relyingParty.rpName, rpID: relyingParty.rpId, - userID: userId, + userID: isoUint8Array.fromUTF8String(userId), userName: userName, userDisplayName: userDisplayName, attestationType: 'indirect', - excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), authenticatorSelection: { @@ -87,7 +86,7 @@ export class WebAuthnService { @bindThis public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{ - credentialID: Uint8Array; + credentialID: string; credentialPublicKey: Uint8Array; attestationObject: Uint8Array; fmt: AttestationFormat; @@ -144,6 +143,7 @@ export class WebAuthnService { @bindThis public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> { + const relyingParty = await this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -153,9 +153,9 @@ export class WebAuthnService { } const authenticationOptions = await generateAuthenticationOptions({ - allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + rpID: relyingParty.rpId, + allowCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), userVerification: 'preferred', @@ -219,7 +219,7 @@ export class WebAuthnService { expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, authenticator: { - credentialID: Buffer.from(key.id, 'base64url'), + credentialID: key.id, credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), counter: key.counter, transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 1621c41bcc..d0d206760c 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -28,6 +28,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserR import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { isNotNull } from '@/misc/is-not-null.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -36,9 +37,8 @@ import { ApResolverService } from './ApResolverService.js'; import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js'; @Injectable() export class ApInboxService { @@ -90,13 +90,15 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> { + public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> { + let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { + const results = [] as [string, string | void][]; 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); + results.push([getApId(item), await this.performOneActivity(actor, act)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -105,8 +107,13 @@ export class ApInboxService { } } } + + const hasReason = results.some(([, reason]) => (reason != null && !reason.startsWith('ok'))); + if (hasReason) { + result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); + } } else { - await this.performOneActivity(actor, activity); + result = await this.performOneActivity(actor, activity); } // ついでにリモートユーザーの情報が古かったら更新しておく @@ -117,42 +124,43 @@ export class ApInboxService { }); } } + return result; } @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<void> { + public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> { if (actor.isSuspended) return; if (isCreate(activity)) { - await this.create(actor, activity); + return await this.create(actor, activity); } else if (isDelete(activity)) { - await this.delete(actor, activity); + return await this.delete(actor, activity); } else if (isUpdate(activity)) { - await this.update(actor, activity); + return await this.update(actor, activity); } else if (isFollow(activity)) { - await this.follow(actor, activity); + return await this.follow(actor, activity); } else if (isAccept(activity)) { - await this.accept(actor, activity); + return await this.accept(actor, activity); } else if (isReject(activity)) { - await this.reject(actor, activity); + return await this.reject(actor, activity); } else if (isAdd(activity)) { - await this.add(actor, activity).catch(err => this.logger.error(err)); + return await this.add(actor, activity); } else if (isRemove(activity)) { - await this.remove(actor, activity).catch(err => this.logger.error(err)); + return await this.remove(actor, activity); } else if (isAnnounce(activity)) { - await this.announce(actor, activity); + return await this.announce(actor, activity); } else if (isLike(activity)) { - await this.like(actor, activity); + return await this.like(actor, activity); } else if (isUndo(activity)) { - await this.undo(actor, activity); + return await this.undo(actor, activity); } else if (isBlock(activity)) { - await this.block(actor, activity); + return await this.block(actor, activity); } else if (isFlag(activity)) { - await this.flag(actor, activity); + return await this.flag(actor, activity); } else if (isMove(activity)) { - await this.move(actor, activity); + return await this.move(actor, activity); } else { - this.logger.warn(`unrecognized activity type: ${activity.type}`); + return `unrecognized activity type: ${activity.type}`; } } @@ -234,38 +242,49 @@ export class ApInboxService { } @bindThis - private async add(actor: MiRemoteUser, activity: IAdd): Promise<void> { + private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return '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'); + if (note == null) return 'note not found'; await this.notePiningService.addPinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis - private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<void> { + private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> { const uri = getApId(activity); this.logger.info(`Announce: ${uri}`); + const resolver = this.apResolverService.createResolver(); + + if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + + const target = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + return e; + }); + + if (isPost(target)) return await this.announceNote(actor, activity, target); - await this.announceNote(actor, activity, targetUri); + return `skip: unknown object type ${getApType(target)}`; } @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> { const uri = getApId(activity); if (actor.isSuspended) { @@ -288,24 +307,21 @@ export class ApInboxService { // Announce対象をresolve let renote; try { - renote = await this.apNoteService.resolveNote(targetUri); - if (renote == null) throw new Error('announce target is null'); + renote = await this.apNoteService.resolveNote(target); + if (renote == null) return 'announce target is null'; } catch (err) { // 対象が4xxならスキップ if (err instanceof StatusError) { if (!err.isRetryable) { - this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); - return; + return `Ignored announce target ${target.id} - ${err.statusCode}`; } - - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); + return `Error in announce target ${target.id} - ${err.statusCode}`; } throw err; } if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - this.logger.warn('skip: invalid actor for this activity'); - return; + return 'skip: invalid actor for this activity'; } this.logger.info(`Creating the (Re)Note: ${uri}`); @@ -314,8 +330,7 @@ export class ApInboxService { const createdAt = activity.published ? new Date(activity.published) : null; if (createdAt && createdAt < this.idService.parse(renote.id).date) { - this.logger.warn('skip: malformed createdAt'); - return; + return 'skip: malformed createdAt'; } await this.noteCreateService.create(actor, { @@ -349,11 +364,15 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise<void> { + private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); + if (!activity.object) return 'skip: activity has no object property'; + const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + // copy audiences between activity <=> object. if (typeof activity.object === 'object') { const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); @@ -380,7 +399,7 @@ export class ApInboxService { if (isPost(object)) { await this.createNote(resolver, actor, object, false, activity); } else { - this.logger.warn(`Unknown type: ${getApType(object)}`); + return `Unknown type: ${getApType(object)}`; } } @@ -422,7 +441,7 @@ export class ApInboxService { @bindThis private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } // 削除対象objectのtype @@ -581,29 +600,29 @@ export class ApInboxService { } @bindThis - private async remove(actor: MiRemoteUser, activity: IRemove): Promise<void> { + private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return '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'); + if (note == null) return 'note not found'; await this.notePiningService.removePinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } const uri = activity.id ?? activity; @@ -614,7 +633,7 @@ export class ApInboxService { const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); - throw e; + return e; }); // don't queue because the sender may attempt again when timeout diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index d7fb977a99..4fc724b548 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -28,8 +28,9 @@ import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; -import { LdSignatureService } from './LdSignatureService.js'; +import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; +import { CONTEXT } from './misc/contexts.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; @Injectable() @@ -56,7 +57,7 @@ export class ApRendererService { private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, - private ldSignatureService: LdSignatureService, + private jsonLdService: JsonLdService, private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, @@ -166,6 +167,7 @@ export class ApRendererService { mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, + sensitive: file.isSensitive, }; } @@ -617,48 +619,16 @@ export class ApRendererService { x.id = `${this.config.url}/${randomUUID()}`; } - return Object.assign({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - Key: 'sec:Key', - // 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_summary': 'misskey:_misskey_summary', - 'isCat': 'misskey:isCat', - // vcard - vcard: 'http://www.w3.org/2006/vcard/ns#', - }, - ], - }, x as T & { id: string }); + return Object.assign({ '@context': CONTEXT }, x as T & { id: string }); } @bindThis public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> { const keypair = await this.userKeypairService.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`); + const jsonLd = this.jsonLdService.use(); + jsonLd.debug = false; + activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/JsonLdService.ts index 9de184336f..100d4fa19f 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/JsonLdService.ts @@ -7,14 +7,14 @@ import * as crypto from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; -import { CONTEXTS } from './misc/contexts.js'; +import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; import type { JsonLdDocument } from 'jsonld'; -import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; +import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js'; -// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 +// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017 -class LdSignature { +class JsonLd { public debug = false; public preLoad = true; public loderTimeout = 5000; @@ -89,10 +89,18 @@ class LdSignature { } @bindThis - public async normalize(data: JsonLdDocument): Promise<string> { + public async compact(data: any, context: any = CONTEXT): Promise<JsonLdDocument> { const customLoader = this.getLoader(); // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 + return (await import('jsonld')).default.compact(data, context, { + documentLoader: customLoader, + }); + } + + @bindThis + public async normalize(data: JsonLdDocument): Promise<string> { + const customLoader = this.getLoader(); return (await import('jsonld')).default.normalize(data, { documentLoader: customLoader, }); @@ -104,11 +112,11 @@ class LdSignature { if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`); if (this.preLoad) { - if (url in CONTEXTS) { + if (url in PRELOADED_CONTEXTS) { if (this.debug) console.debug(`HIT: ${url}`); return { contextUrl: undefined, - document: CONTEXTS[url], + document: PRELOADED_CONTEXTS[url], documentUrl: url, }; } @@ -125,7 +133,7 @@ class LdSignature { } @bindThis - private async fetchDocument(url: string): Promise<JsonLd> { + private async fetchDocument(url: string): Promise<JsonLdObject> { const json = await this.httpRequestService.send( url, { @@ -146,7 +154,7 @@ class LdSignature { } }); - return json as JsonLd; + return json as JsonLdObject; } @bindThis @@ -158,14 +166,14 @@ class LdSignature { } @Injectable() -export class LdSignatureService { +export class JsonLdService { constructor( private httpRequestService: HttpRequestService, ) { } @bindThis - public use(): LdSignature { - return new LdSignature(this.httpRequestService); + public use(): JsonLd { + return new JsonLd(this.httpRequestService); } } diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 88afdefcd3..feb8c42c56 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { JsonLd } from 'jsonld/jsonld-spec.js'; +import type { Context, JsonLd } from 'jsonld/jsonld-spec.js'; /* eslint:disable:quotemark indent */ const id_v1 = { @@ -526,7 +526,42 @@ const activitystreams = { }, } satisfies JsonLd; -export const CONTEXTS: Record<string, JsonLd> = { +const context_iris = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', +]; + +const extension_context_definition = { + Key: 'sec:Key', + // 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_summary': 'misskey:_misskey_summary', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', +} satisfies Context; + +export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition]; + +export const PRELOADED_CONTEXTS: Record<string, JsonLd> = { 'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/security/v1': security_v1, 'https://www.w3.org/ns/activitystreams': activitystreams, diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 89b6ef23d0..3691967270 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import type { IObject } from '../type.js'; +import { isDocument, type IObject } from '../type.js'; @Injectable() export class ApImageService { @@ -39,7 +39,7 @@ export class ApImageService { * Imageを作成します。 */ @bindThis - public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> { + public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); @@ -47,16 +47,18 @@ export class ApImageService { const image = await this.apResolverService.createResolver().resolve(value); + if (!isDocument(image)) return null; + if (image.url == null) { - throw new Error('invalid image: url not provided'); + return null; } if (typeof image.url !== 'string') { - throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); + return null; } if (!checkHttps(image.url)) { - throw new Error('invalid image: unexpected schema of url: ' + image.url); + return null; } this.logger.info(`Creating the Image: ${image.url}`); @@ -86,12 +88,11 @@ export class ApImageService { /** * Imageを解決します。 * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + * ImageをリモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> { - // TODO + public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> { + // TODO: Misskeyに対象のImageが登録されていればそれを返す // リモートサーバーからフェッチしてきて登録 return await this.createImage(actor, value); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index b2fd435f93..c6e6b3a1e8 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -4,7 +4,6 @@ */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import promiseLimit from 'promise-limit'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { PollsRepository, EmojisRepository } from '@/models/_.js'; @@ -82,20 +81,20 @@ export class ApNoteService { const expectHost = this.utilityService.extractDbHost(uri); if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `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)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); if (object.attributedTo && actualHost !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { - return new Error('invalid Note: published timestamp is malformed'); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); } return null; @@ -129,7 +128,7 @@ export class ApNoteService { value, object, }); - throw new Error('invalid note'); + throw err; } const note = object as IPost; @@ -209,15 +208,13 @@ export class ApNoteService { } // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit<MiDriveFile>(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } // リプライ const reply: MiNote | null = note.inReplyTo @@ -410,7 +407,7 @@ export class ApNoteService { this.logger.info(`register emoji host=${host}, name=${name}`); - return await this.emojisRepository.insert({ + return await this.emojisRepository.insertOne({ id: this.idService.gen(), host, name, @@ -419,7 +416,7 @@ export class ApNoteService { publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }); })); } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index b43dddad61..5b6c6c8ca6 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -25,6 +25,7 @@ export interface IObject { endTime?: Date; icon?: any; image?: any; + mediaType?: string; url?: ApObject | string; href?: string; tag?: IObject | IObject[]; @@ -240,14 +241,14 @@ export interface IKey extends IObject { } export interface IApDocument extends IObject { - type: 'Document'; - name: string | null; - mediaType: string; + type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; } -export interface IApImage extends IObject { +export const isDocument = (object: IObject): object is IApDocument => + ['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object)); + +export interface IApImage extends IApDocument { type: 'Image'; - name: string | null; } export interface ICreate extends IActivity { @@ -327,3 +328,4 @@ export const isAnnounce = (object: IObject): object is IAnnounce => getApType(ob export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; +export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note'; diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index aa0cb9dc2b..af5485a46e 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -14,7 +14,8 @@ import { EntitySchema, LessThan, Between } from 'typeorm'; import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import type { Repository, DataSource } from 'typeorm'; +import { MiRepository, miRepository } from '@/models/_.js'; +import type { DataSource, Repository } from 'typeorm'; const COLUMN_PREFIX = '___' as const; const UNIQUE_TEMP_COLUMN_PREFIX = 'unique_temp___' as const; @@ -145,10 +146,10 @@ export default abstract class Chart<T extends Schema> { 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; }>; + //private repositoryForHour: Repository<RawRecord<T>> & MiRepository<RawRecord<T>>; + //private repositoryForDay: Repository<RawRecord<T>> & MiRepository<RawRecord<T>>; + private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; + private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; /** * 1日に一回程度実行されれば良いような計算処理を入れる(主にCASCADE削除などアプリケーション側で感知できない変動によるズレの修正用) @@ -211,6 +212,10 @@ export default abstract class Chart<T extends Schema> { } { const createEntity = (span: 'hour' | 'day'): EntitySchema => new EntitySchema({ name: + span === 'hour' ? `ChartX${name}` : + span === 'day' ? `ChartDayX${name}` : + new Error('not happen') as never, + tableName: span === 'hour' ? `__chart__${camelToSnake(name)}` : span === 'day' ? `__chart_day__${camelToSnake(name)}` : new Error('not happen') as never, @@ -271,8 +276,8 @@ export default abstract class Chart<T extends Schema> { this.logger = logger; 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); + this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); + this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); } @bindThis @@ -387,11 +392,11 @@ export default abstract class Chart<T extends Schema> { } // 新規ログ挿入 - log = await repository.insert({ + log = await repository.insertOne({ date: date, ...(group ? { group: group } : {}), ...columns, - }).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord<T>; + }) as RawRecord<T>; this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); @@ -459,13 +464,15 @@ export default abstract class Chart<T extends Schema> { } } - // bake unique count + // bake cardinality for (const [k, v] of Object.entries(finalDiffs)) { if (this.schema[k].uniqueIncrement) { const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>; const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) 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; + const cardinalityOfHour = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size; + const cardinalityOfDay = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size; + queryForHour[name] = cardinalityOfHour; + queryForDay[name] = cardinalityOfDay; } } @@ -637,7 +644,7 @@ export default abstract class Chart<T extends Schema> { // 要求された範囲にログがひとつもなかったら if (logs.length === 0) { // もっとも新しいログを持ってくる - // (すくなくともひとつログが無いと隙間埋めできないため) + // (すくなくともひとつログが無いと補間できないため) const recentLog = await repository.findOne({ where: group ? { group: group, @@ -654,7 +661,7 @@ export default abstract class Chart<T extends Schema> { // 要求された範囲の最も古い箇所に位置するログが存在しなかったら } else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) { // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する - // (隙間埋めできないため) + // (補間できないため) const outdatedLog = await repository.findOne({ where: { date: LessThan(Chart.dateToTimestamp(gt)), @@ -683,7 +690,7 @@ export default abstract class Chart<T extends Schema> { 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)); diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 49f256d870..b0e1d1ab36 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -10,6 +10,8 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,6 +28,11 @@ export class AbuseUserReportEntityService { @bindThis public async pack( src: MiAbuseUserReport['id'] | MiAbuseUserReport, + hint?: { + packedReporter?: Packed<'UserDetailedNotMe'>, + packedTargetUser?: Packed<'UserDetailedNotMe'>, + packedAssignee?: Packed<'UserDetailedNotMe'>, + }, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); @@ -37,13 +44,13 @@ export class AbuseUserReportEntityService { reporterId: report.reporterId, targetUserId: report.targetUserId, assigneeId: report.assigneeId, - reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { schema: 'UserDetailedNotMe', }), - targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { schema: 'UserDetailedNotMe', }), - assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, @@ -51,9 +58,24 @@ export class AbuseUserReportEntityService { } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiAbuseUserReport[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); + const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); + const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(isNotNull); + const _userMap = await this.userEntityService.packMany( + [..._reporters, ..._targetUsers, ..._assignees], + null, + { schema: 'UserDetailedNotMe' }, + ).then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + reports.map(report => { + const packedReporter = _userMap.get(report.reporterId); + const packedTargetUser = _userMap.get(report.targetUserId); + const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; + return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); + }), + ); } } diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts new file mode 100644 index 0000000000..90b04d0229 --- /dev/null +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class AnnouncementEntityService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private idService: IdService, + ) { + } + + @bindThis + public async pack( + src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null }, + me?: { id: MiUser['id'] } | null | undefined, + ): Promise<Packed<'Announcement'>> { + const announcement = typeof src === 'object' + ? src + : await this.announcementsRepository.findOneByOrFail({ + id: src, + }) as MiAnnouncement & { isRead?: boolean | null }; + + if (me && announcement.isRead === undefined) { + announcement.isRead = await this.announcementReadsRepository + .countBy({ + announcementId: announcement.id, + userId: me.id, + }) + .then((count: number) => count > 0); + } + + return { + id: announcement.id, + createdAt: this.idService.parse(announcement.id).date.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forYou: announcement.userId === me?.id, + needConfirmationToRead: announcement.needConfirmationToRead, + silence: announcement.silence, + isRead: announcement.isRead !== null ? announcement.isRead : undefined, + }; + } + + @bindThis + public async packMany( + announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[], + me?: { id: MiUser['id'] } | null | undefined, + ) : Promise<Packed<'Announcement'>[]> { + return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 64d6a3c978..e770028af3 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -38,11 +38,12 @@ export class AntennaEntityService { users: antenna.users, caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, - notify: antenna.notify, + excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, isActive: antenna.isActive, hasUnreadNote: false, // TODO + notify: false, // 後方互換性のため }; } } diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c8c1520ceb..1e699032e2 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -29,6 +29,9 @@ export class BlockingEntityService { public async pack( src: MiBlocking['id'] | MiBlocking, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + blockee?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Blocking'>> { const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); @@ -36,17 +39,20 @@ export class BlockingEntityService { id: blocking.id, createdAt: this.idService.parse(blocking.id).date.toISOString(), blockeeId: blocking.blockeeId, - blockee: this.userEntityService.pack(blocking.blockeeId, me, { + blockee: hint?.blockee ?? this.userEntityService.pack(blocking.blockeeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - blockings: any[], + public async packMany( + blockings: MiBlocking[], me: { id: MiUser['id'] }, ) { - return Promise.all(blockings.map(x => this.pack(x, me))); + const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId); + const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) }))); } } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 26fcd6714d..3855a28436 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; +import type { ClipNotesRepository, ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; @@ -20,6 +20,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, @@ -32,6 +35,9 @@ export class ClipEntityService { public async pack( src: MiClip['id'] | MiClip, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Clip'>> { const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); @@ -41,21 +47,25 @@ export class ClipEntityService { createdAt: this.idService.parse(clip.id).date.toISOString(), lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, - user: this.userEntityService.pack(clip.user ?? clip.userId), + user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, + notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, }); } @bindThis - public packMany( + public async packMany( clips: MiClip[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x, me))); + const _users = clips.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) }))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 8affe2b3bf..02ff2e7754 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -222,6 +222,9 @@ export class DriveFileEntityService { public async packNullable( src: MiDriveFile['id'] | MiDriveFile, options?: PackOptions, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'DriveFile'> | null> { const opts = Object.assign({ detail: false, @@ -248,8 +251,8 @@ export class DriveFileEntityService { 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, + userId: file.userId, + user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); } @@ -258,7 +261,10 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { - const items = await Promise.all(files.map(f => this.packNullable(f, options))); + const _user = files.map(({ user, userId }) => user ?? userId).filter(isNotNull); + const _userMap = await this.userEntityService.packMany(_user) + .then(users => new Map(users.map(user => [user.id, user]))); + const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); return items.filter(isNotNull); } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index db4cf6d360..d110f7afc6 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -33,6 +33,9 @@ export class FlashEntityService { public async pack( src: MiFlash['id'] | MiFlash, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Flash'>> { const meId = me ? me.id : null; const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); @@ -42,7 +45,7 @@ export class FlashEntityService { createdAt: this.idService.parse(flash.id).date.toISOString(), updatedAt: flash.updatedAt.toISOString(), userId: flash.userId, - user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 title: flash.title, summary: flash.summary, script: flash.script, @@ -52,11 +55,14 @@ export class FlashEntityService { } @bindThis - public packMany( - flashs: MiFlash[], + public async packMany( + flashes: MiFlash[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(flashs.map(x => this.pack(x, me))); + const _users = flashes.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); } } diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts index 763b75101f..0101ec8aa7 100644 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -10,6 +10,7 @@ import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiFollowRequest } from '@/models/FollowRequest.js'; import { bindThis } from '@/decorators.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,14 +27,36 @@ export class FollowRequestEntityService { public async pack( src: MiFollowRequest['id'] | MiFollowRequest, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedFollower?: Packed<'UserLite'>, + packedFollowee?: Packed<'UserLite'>, + }, ) { 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), + follower: hint?.packedFollower ?? await this.userEntityService.pack(request.followerId, me), + followee: hint?.packedFollowee ?? await this.userEntityService.pack(request.followeeId, me), }; } + + @bindThis + public async packMany( + requests: MiFollowRequest[], + me?: { id: MiUser['id'] } | null | undefined, + ) { + const _followers = requests.map(({ follower, followerId }) => follower ?? followerId); + const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId); + const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + requests.map(req => { + const packedFollower = _userMap.get(req.followerId); + const packedFollowee = _userMap.get(req.followeeId); + return this.pack(req, me, { packedFollower, packedFollowee }); + }), + ); + } } diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index 24cd33e3f7..d2dbaf2270 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -78,6 +78,10 @@ export class FollowingEntityService { populateFollowee?: boolean; populateFollower?: boolean; }, + hint?: { + packedFollowee?: Packed<'UserDetailedNotMe'>, + packedFollower?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Following'>> { const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); @@ -88,25 +92,35 @@ export class FollowingEntityService { createdAt: this.idService.parse(following.id).date.toISOString(), followeeId: following.followeeId, followerId: following.followerId, - followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { + followee: opts.populateFollowee ? hint?.packedFollowee ?? this.userEntityService.pack(following.followee ?? following.followeeId, me, { schema: 'UserDetailedNotMe', }) : undefined, - follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { + follower: opts.populateFollower ? hint?.packedFollower ?? this.userEntityService.pack(following.follower ?? following.followerId, me, { schema: 'UserDetailedNotMe', }) : undefined, }); } @bindThis - public packMany( - followings: any[], + public async packMany( + followings: MiFollowing[], me?: { id: MiUser['id'] } | null | undefined, opts?: { populateFollowee?: boolean; populateFollower?: boolean; }, ) { - return Promise.all(followings.map(x => this.pack(x, me, opts))); + const _followees = opts?.populateFollowee ? followings.map(({ followee, followeeId }) => followee ?? followeeId) : []; + const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : []; + const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + followings.map(following => { + const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined; + const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined; + return this.pack(following, me, opts, { packedFollowee, packedFollower }); + }), + ); } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index 101182a9e5..9746a4c1af 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -35,6 +35,9 @@ export class GalleryPostEntityService { public async pack( src: MiGalleryPost['id'] | MiGalleryPost, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'GalleryPost'>> { const meId = me ? me.id : null; const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); @@ -44,7 +47,7 @@ export class GalleryPostEntityService { createdAt: this.idService.parse(post.id).date.toISOString(), updatedAt: post.updatedAt.toISOString(), userId: post.userId, - user: this.userEntityService.pack(post.user ?? post.userId, me), + user: hint?.packedUser ?? this.userEntityService.pack(post.user ?? post.userId, me), title: post.title, description: post.description, fileIds: post.fileIds, @@ -58,11 +61,14 @@ export class GalleryPostEntityService { } @bindThis - public packMany( + public async packMany( posts: MiGalleryPost[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(posts.map(x => this.pack(x, me))); + const _users = posts.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) }))); } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index e46bd8b963..9117b13914 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -39,7 +39,8 @@ export class InstanceEntityService { followingCount: instance.followingCount, followersCount: instance.followersCount, isNotResponding: instance.isNotResponding, - isSuspended: instance.isSuspended, + isSuspended: instance.suspensionState !== 'none', + suspensionState: instance.suspensionState, isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts index 891543bc0f..26f57e1299 100644 --- a/packages/backend/src/core/entities/InviteCodeEntityService.ts +++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts @@ -12,6 +12,7 @@ import type { MiUser } from '@/models/User.js'; import type { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -29,6 +30,10 @@ export class InviteCodeEntityService { public async pack( src: MiRegistrationTicket['id'] | MiRegistrationTicket, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedCreatedBy?: Packed<'UserLite'>, + packedUsedBy?: Packed<'UserLite'>, + }, ): Promise<Packed<'InviteCode'>> { const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({ where: { @@ -42,18 +47,28 @@ export class InviteCodeEntityService { code: target.code, expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null, createdAt: this.idService.parse(target.id).date.toISOString(), - createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null, - usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null, + createdBy: target.createdBy ? hints?.packedCreatedBy ?? await this.userEntityService.pack(target.createdBy, me) : null, + usedBy: target.usedBy ? hints?.packedUsedBy ?? await this.userEntityService.pack(target.usedBy, me) : null, usedAt: target.usedAt ? target.usedAt.toISOString() : null, used: !!target.usedAt, }); } @bindThis - public packMany( - targets: any[], + public async packMany( + tickets: MiRegistrationTicket[], me: { id: MiUser['id'] }, ) { - return Promise.all(targets.map(x => this.pack(x, me))); + const _createdBys = tickets.map(({ createdBy, createdById }) => createdBy ?? createdById).filter(isNotNull); + const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(isNotNull); + const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + tickets.map(ticket => { + const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined; + const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined; + return this.pack(ticket, me, { packedCreatedBy, packedUsedBy }); + }), + ); } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index b50d76288f..5dfec589e1 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -67,6 +67,7 @@ export class MetaEntityService { feedbackUrl: instance.feedbackUrl, impressumUrl: instance.impressumUrl, privacyPolicyUrl: instance.privacyPolicyUrl, + inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, @@ -111,6 +112,7 @@ export class MetaEntityService { policies: { ...DEFAULT_POLICIES, ...instance.policies }, mediaProxy: this.config.mediaProxy, + enableUrlPreview: instance.urlPreviewEnabled, }; return packed; diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts index 205e147bd1..bf1b2a002c 100644 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -8,9 +8,10 @@ import { DI } from '@/di-symbols.js'; import type { ModerationLogsRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { } from '@/models/Blocking.js'; -import type { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiModerationLog } from '@/models/ModerationLog.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -27,6 +28,9 @@ export class ModerationLogEntityService { @bindThis public async pack( src: MiModerationLog['id'] | MiModerationLog, + hint?: { + packedUser?: Packed<'UserDetailedNotMe'>, + }, ) { const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); @@ -36,17 +40,20 @@ export class ModerationLogEntityService { type: log.type, info: log.info, userId: log.userId, - user: this.userEntityService.pack(log.user ?? log.userId, null, { + user: hint?.packedUser ?? this.userEntityService.pack(log.user ?? log.userId, null, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiModerationLog[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _users = reports.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) }))); } } diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 0a52f429a2..d361a20271 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -30,6 +30,9 @@ export class MutingEntityService { public async pack( src: MiMuting['id'] | MiMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'>, + }, ): Promise<Packed<'Muting'>> { const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); @@ -38,18 +41,21 @@ export class MutingEntityService { createdAt: this.idService.parse(muting.id).date.toISOString(), expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 5b6affc6a5..2ce72c50b8 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -290,6 +290,7 @@ export class NoteEntityService implements OnModuleInit { _hint_?: { myReactions: Map<MiNote['id'], string | null>; packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>; + packedUsers: Map<MiUser['id'], Packed<'UserLite'>> }; }, ): Promise<Packed<'Note'>> { @@ -319,12 +320,13 @@ export class NoteEntityService implements OnModuleInit { .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), userId: note.userId, - user: this.userEntityService.pack(note.user ?? note.userId, me), + user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, cw: note.cw, visibility: note.visibility, @@ -333,6 +335,7 @@ export class NoteEntityService implements OnModuleInit { visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, + reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0), reactions: this.reactionService.convertLegacyReactions(note.reactions), reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined, @@ -448,12 +451,20 @@ export class NoteEntityService implements OnModuleInit { // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); + const users = [ + ...notes.map(({ user, userId }) => user ?? userId), + ...notes.map(({ replyUserId }) => replyUserId).filter(isNotNull), + ...notes.map(({ renoteUserId }) => renoteUserId).filter(isNotNull), + ]; + const packedUsers = await this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, packedFiles, + packedUsers, }, }))); } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 3f4fa3cf96..46ec13704c 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -52,6 +52,9 @@ export class NoteReactionEntityService implements OnModuleInit { options?: { withNote: boolean; }, + hints?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'NoteReaction'>> { const opts = Object.assign({ withNote: false, @@ -62,7 +65,7 @@ export class NoteReactionEntityService implements OnModuleInit { return { id: reaction.id, createdAt: this.idService.parse(reaction.id).date.toISOString(), - user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + user: hints?.packedUser ?? 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), @@ -81,7 +84,9 @@ export class NoteReactionEntityService implements OnModuleInit { const opts = Object.assign({ withNote: false, }, options); - - return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts))); + const _users = reactions.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); } } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 65c69a49a7..142d9e81db 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -40,6 +40,9 @@ export class PageEntityService { public async pack( src: MiPage['id'] | MiPage, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise<Packed<'Page'>> { const meId = me ? me.id : null; const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); @@ -91,7 +94,7 @@ export class PageEntityService { createdAt: this.idService.parse(page.id).date.toISOString(), updatedAt: page.updatedAt.toISOString(), userId: page.userId, - user: this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 content: page.content, variables: page.variables, title: page.title, @@ -110,11 +113,14 @@ export class PageEntityService { } @bindThis - public packMany( + public async packMany( pages: MiPage[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(pages.map(x => this.pack(x, me))); + const _users = pages.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) }))); } } diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts index 0b05a5db80..e4e154109a 100644 --- a/packages/backend/src/core/entities/RenoteMutingEntityService.ts +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -30,6 +30,9 @@ export class RenoteMutingEntityService { public async pack( src: MiRenoteMuting['id'] | MiRenoteMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'> + }, ): Promise<Packed<'RenoteMuting'>> { const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); @@ -37,18 +40,21 @@ export class RenoteMutingEntityService { id: muting.id, createdAt: this.idService.parse(muting.id).date.toISOString(), muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiRenoteMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index 32cbe631e4..df042e75c1 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -28,13 +28,15 @@ export class ReversiGameEntityService { @bindThis public async packDetail( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise<Packed<'ReversiGameDetailed'>> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -49,10 +51,10 @@ export class ReversiGameEntityService { user2Ready: game.user2Ready, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -68,22 +70,35 @@ export class ReversiGameEntityService { } @bindThis - public packDetailMany( - xs: MiReversiGame[], + public async packDetailMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packDetail(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packDetail(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } @bindThis public async packLite( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise<Packed<'ReversiGameLite'>> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -94,10 +109,10 @@ export class ReversiGameEntityService { isEnded: game.isEnded, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -111,10 +126,21 @@ export class ReversiGameEntityService { } @bindThis - public packLiteMany( - xs: MiReversiGame[], + public async packLiteMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packLite(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packLite(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 14761357a5..b80a1ec206 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -14,9 +15,30 @@ 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 type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js'; -import { MiNotification } from '@/models/Notification.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js'; +import { + birthdaySchema, + descriptionSchema, + localUsernameSchema, + locationSchema, + nameSchema, + passwordSchema, +} from '@/models/User.js'; +import type { + BlockingsRepository, + FollowingsRepository, + FollowRequestsRepository, + MiFollowing, + MiUserNotePining, + MiUserProfile, + MutingsRepository, + NoteUnreadsRepository, + RenoteMutingsRepository, + UserMemoRepository, + UserNotePiningsRepository, + UserProfilesRepository, + UserSecurityKeysRepository, + UsersRepository, +} from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -46,11 +68,23 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { return !isLocalUser(user); } +export type UserRelation = { + id: MiUser['id'] + following: MiFollowing | null, + isFollowing: boolean + isFollowed: boolean + hasPendingFollowRequestFromYou: boolean + hasPendingFollowRequestToYou: boolean + isBlocking: boolean + isBlocked: boolean + isMuted: boolean + isRenoteMuted: boolean +} + @Injectable() export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; - private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private announcementService: AnnouncementService; @@ -89,9 +123,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @@ -101,12 +132,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - @Inject(DI.userMemosRepository) private userMemosRepository: UserMemoRepository, ) { @@ -115,7 +140,6 @@ export class UserEntityService implements OnModuleInit { onModuleInit() { this.apPersonService = this.moduleRef.get('ApPersonService'); 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.announcementService = this.moduleRef.get('AnnouncementService'); @@ -138,7 +162,7 @@ export class UserEntityService implements OnModuleInit { public isRemoteUser = isRemoteUser; @bindThis - public async getRelation(me: MiUser['id'], target: MiUser['id']) { + public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise<UserRelation> { const [ following, isFollowed, @@ -212,6 +236,80 @@ export class UserEntityService implements OnModuleInit { } @bindThis + public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise<Map<MiUser['id'], UserRelation>> { + const [ + followers, + followees, + followersRequests, + followeesRequests, + blockers, + blockees, + muters, + renoteMuters, + ] = await Promise.all([ + this.followingsRepository.findBy({ followerId: me }) + .then(f => new Map(f.map(it => [it.followeeId, it]))), + this.followingsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followeeId') + .where('f.followerId = :me', { me }) + .getRawMany<{ f_followeeId: string }>() + .then(it => it.map(it => it.f_followeeId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockeeId') + .where('b.blockerId = :me', { me }) + .getRawMany<{ b_blockeeId: string }>() + .then(it => it.map(it => it.b_blockeeId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockerId') + .where('b.blockeeId = :me', { me }) + .getRawMany<{ b_blockerId: string }>() + .then(it => it.map(it => it.b_blockerId)), + this.mutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), + this.renoteMutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), + ]); + + return new Map( + targets.map(target => { + const following = followers.get(target) ?? null; + + return [ + target, + { + id: target, + following: following, + isFollowing: following != null, + isFollowed: followees.includes(target), + hasPendingFollowRequestFromYou: followersRequests.includes(target), + hasPendingFollowRequestToYou: followeesRequests.includes(target), + isBlocking: blockers.includes(target), + isBlocked: blockees.includes(target), + isMuted: muters.includes(target), + isRenoteMuted: renoteMuters.includes(target), + }, + ]; + }), + ); + } + + @bindThis public async getHasUnreadAntenna(userId: MiUser['id']): Promise<boolean> { /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); @@ -303,6 +401,9 @@ export class UserEntityService implements OnModuleInit { schema?: S, includeSecrets?: boolean, userProfile?: MiUserProfile, + userRelations?: Map<MiUser['id'], UserRelation>, + userMemos?: Map<MiUser['id'], string | null>, + pinNotes?: Map<MiUser['id'], MiUserNotePining[]>, }, ): Promise<Packed<S>> { const opts = Object.assign({ @@ -317,13 +418,41 @@ export class UserEntityService implements OnModuleInit { const isMe = meId === user.id; const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; - const relation = meId && !isMe && isDetailed ? await this.getRelation(meId, user.id) : null; - const pins = isDetailed ? await this.userNotePiningsRepository.createQueryBuilder('pin') - .where('pin.userId = :userId', { userId: user.id }) - .innerJoinAndSelect('pin.note', 'note') - .orderBy('pin.id', 'DESC') - .getMany() : []; - const profile = isDetailed ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) : null; + const profile = isDetailed + ? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id })) + : null; + + let relation: UserRelation | null = null; + if (meId && !isMe && isDetailed) { + if (opts.userRelations) { + relation = opts.userRelations.get(user.id) ?? null; + } else { + relation = await this.getRelation(meId, user.id); + } + } + + let memo: string | null = null; + if (isDetailed && meId) { + if (opts.userMemos) { + memo = opts.userMemos.get(user.id) ?? null; + } else { + memo = await this.userMemosRepository.findOneBy({ userId: meId, targetUserId: user.id }) + .then(row => row?.memo ?? null); + } + } + + let pins: MiUserNotePining[] = []; + if (isDetailed) { + if (opts.pinNotes) { + pins = opts.pinNotes.get(user.id) ?? []; + } else { + pins = await this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('pin.note', 'note') + .orderBy('pin.id', 'DESC') + .getMany(); + } + } const followingCount = profile == null ? null : (profile.followingVisibility === 'public') || isMe ? user.followingCount : @@ -416,9 +545,7 @@ export class UserEntityService implements OnModuleInit { twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ - userId: user.id, - }).then(result => result >= 1) + ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) : false, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, @@ -430,10 +557,7 @@ export class UserEntityService implements OnModuleInit { isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, }))), - memo: meId == null ? null : await this.userMemosRepository.findOneBy({ - userId: meId, - targetUserId: user.id, - }).then(row => row?.memo ?? null), + memo: memo, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), @@ -514,7 +638,7 @@ export class UserEntityService implements OnModuleInit { return await awaitAll(packed); } - public packMany<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>( + public async packMany<S extends 'MeDetailed' | 'UserDetailedNotMe' | 'UserDetailed' | 'UserLite' = 'UserLite'>( users: (MiUser['id'] | MiUser)[], me?: { id: MiUser['id'] } | null | undefined, options?: { @@ -522,6 +646,69 @@ export class UserEntityService implements OnModuleInit { includeSecrets?: boolean, }, ): Promise<Packed<S>[]> { - return Promise.all(users.map(u => this.pack(u, me, options))); + // -- IDのみの要素を補完して完全なエンティティ一覧を作る + + const _users = users.filter((user): user is MiUser => typeof user !== 'string'); + if (_users.length !== users.length) { + _users.push( + ...await this.usersRepository.findBy({ + id: In(users.filter((user): user is string => typeof user === 'string')), + }), + ); + } + const _userIds = _users.map(u => u.id); + + // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 + + let profilesMap: Map<MiUser['id'], MiUserProfile> = new Map(); + let userRelations: Map<MiUser['id'], UserRelation> = new Map(); + let userMemos: Map<MiUser['id'], string | null> = new Map(); + let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map(); + + if (options?.schema !== 'UserLite') { + profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) + .then(profiles => new Map(profiles.map(p => [p.userId, p]))); + + const meId = me ? me.id : null; + if (meId) { + userMemos = await this.userMemosRepository.findBy({ userId: meId }) + .then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))); + + if (_userIds.length > 0) { + userRelations = await this.getRelations(meId, _userIds); + pinNotes = await this.userNotePiningsRepository.createQueryBuilder('pin') + .where('pin.userId IN (:...userIds)', { userIds: _userIds }) + .innerJoinAndSelect('pin.note', 'note') + .getMany() + .then(pinsNotes => { + const map = new Map<MiUser['id'], MiUserNotePining[]>(); + for (const note of pinsNotes) { + const notes = map.get(note.userId) ?? []; + notes.push(note); + map.set(note.userId, notes); + } + for (const [, notes] of map.entries()) { + // pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく + notes.sort((a, b) => b.id.localeCompare(a.id)); + } + return map; + }); + } + } + } + + return Promise.all( + _users.map(u => this.pack( + u, + me, + { + ...options, + userProfile: profilesMap.get(u.id), + userRelations: userRelations, + userMemos: userMemos, + pinNotes: pinNotes, + }, + )), + ); } } diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 09cab24521..b77249c5cb 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -50,11 +50,14 @@ export class UserListEntityService { public async packMembershipsMany( memberships: MiUserListMembership[], ) { + const _users = memberships.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users) + .then(users => new Map(users.map(u => [u.id, u]))); return Promise.all(memberships.map(async x => ({ id: x.id, createdAt: this.idService.parse(x.id).date.toISOString(), userId: x.userId, - user: await this.userEntityService.pack(x.userId), + user: _userMap.get(x.userId) ?? await this.userEntityService.pack(x.userId), withReplies: x.withReplies, }))); } |