diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-04-08 21:27:21 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-04-08 21:27:21 +0900 |
| commit | a096f621cf5a47c3330935c2b9b5bfe54dfc0091 (patch) | |
| tree | b3b6a1a1ce5105091bebc80b96cfd5a73402da80 /packages/backend/src | |
| parent | Merge pull request #10402 from misskey-dev/develop (diff) | |
| parent | [ci skip] Update CHANGELOG.md (diff) | |
| download | misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.tar.gz misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.tar.bz2 misskey-a096f621cf5a47c3330935c2b9b5bfe54dfc0091.zip | |
Merge pull request #10506 from misskey-dev/develop
13.11.0
Diffstat (limited to '')
135 files changed, 2248 insertions, 1977 deletions
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 801f1db741..cb713b25ad 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; import Redis from 'ioredis'; import { DataSource } from 'typeorm'; -import { createRedisConnection } from '@/redis.js'; import { DI } from './di-symbols.js'; import { loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; -const config = loadConfig(); - const $config: Provider = { provide: DI.config, - useValue: config, + useValue: loadConfig(), }; const $db: Provider = { @@ -28,18 +25,31 @@ const $db: Provider = { const $redis: Provider = { provide: DI.redis, useFactory: (config) => { - const redisClient = createRedisConnection(config); - return redisClient; + return new Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db ?? 0, + }); }, inject: [DI.config], }; -const $redisSubscriber: Provider = { - provide: DI.redisSubscriber, +const $redisForPubsub: Provider = { + provide: DI.redisForPubsub, useFactory: (config) => { - const redisSubscriber = createRedisConnection(config); - redisSubscriber.subscribe(config.host); - return redisSubscriber; + const redis = new Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + redis.subscribe(config.host); + return redis; }, inject: [DI.config], }; @@ -47,14 +57,14 @@ const $redisSubscriber: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisSubscriber], - exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], + providers: [$config, $db, $redis, $redisForPubsub], + exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @Inject(DI.db) private db: DataSource, @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis, ) {} async onApplicationShutdown(signal: string): Promise<void> { @@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown { await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), - this.redisSubscriber.disconnect(), + this.redisForPubsub.disconnect(), ]); } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 73f45e92e1..e4f7601fa9 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -25,6 +25,14 @@ export type Source = { disableCache?: boolean; extra?: { [x: string]: string }; }; + dbReplications?: boolean; + dbSlaves?: { + host: string; + port: number; + db: string; + user: string; + pass: string; + }[]; redis: { host: string; port: number; @@ -33,6 +41,22 @@ export type Source = { db?: number; prefix?: string; }; + redisForPubsub?: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; + redisForJobQueue?: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; elasticsearch: { host: string; port: number; @@ -91,6 +115,8 @@ export type Mixin = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; + redisForPubsub: NonNullable<Source['redisForPubsub']>; + redisForJobQueue: NonNullable<Source['redisForJobQueue']>; }; export type Config = Source & Mixin; @@ -151,6 +177,8 @@ export function loadConfig() { : null; if (!config.redis.prefix) config.redis.prefix = mixin.host; + if (config.redisForPubsub == null) config.redisForPubsub = config.redis; + if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; return Object.assign(config, mixin); } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 0000000000..3f2a19b771 --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; + +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { LocalUser } from '@/models/entities/User.js'; +import { User } from '@/models/entities/User.js'; +import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; + +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { RelayService } from '@/core/RelayService.js'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private globalEventService: GlobalEventService, + private userFollowingService: UserFollowingService, + private accountUpdateService: AccountUpdateService, + private relayService: RelayService, + ) { + } + + /** + * Move a local account to a remote account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> { + // Make sure that the destination is a remote account. + if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); + if (!dst.uri) throw new Error('destination uri is empty'); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial<User>; + update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; + update.movedToUri = dst.uri; + await this.usersRepository.update(src.id, update); + + const srcPerson = await this.apRendererService.renderPerson(src); + const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); + await this.apDeliverManagerService.deliverToFollowers(src, updateAct); + this.relayService.deliverToRelays(src, updateAct); + + // Deliver Move activity to the followers of the old account + const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); + await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true }); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: src.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, dst); + await this.userFollowingService.unfollow(following.follower, src); + } catch { + /* empty */ + } + } + + return iObj; + } + + /** + * Create an alias of an old remote account. + * + * The user's new profile will be published to the followers. + */ + @bindThis + public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> { + await this.usersRepository.update(me.id, updates); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack<true, true>(me.id, me, { + detail: true, + includeSecrets: true, + }); + this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + + if (me.isLocked === false) { + await this.userFollowingService.acceptAllFollowRequests(me); + } + + this.accountUpdateService.publishToFollowers(me.id); + + return iObj; + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index d8ba7b169d..b146fc66be 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -29,7 +29,7 @@ export class AccountUpdateService { public async publishToFollowers(userId: User['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); - + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index aaa26a8321..35266ac16d 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,7 +12,7 @@ import { PushNotificationService } from '@/core/PushNotificationService.js'; import * as Acct from '@/misc/acct.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { MutingsRepository, NotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -24,8 +24,11 @@ export class AntennaService implements OnApplicationShutdown { private antennas: Antenna[]; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown { this.antennasFetched = false; this.antennas = []; - this.redisSubscriber.on('message', this.onRedisMessage); + this.redisForPubsub.on('message', this.onRedisMessage); } @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onRedisMessage); + this.redisForPubsub.off('message', this.onRedisMessage); } @bindThis @@ -92,54 +92,13 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || (antenna.userId === noteUser.id); - - this.antennaNotesRepository.insert({ - id: this.idService.genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); - + this.redisClient.xadd( + `antennaTimeline:${antenna.id}`, + 'MAXLEN', '~', '200', + `${this.idService.parse(note.id).date.getTime()}-*`, + 'note', note.id); + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); - - if (!read) { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: antenna.userId, - }, - select: ['muteeId'], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); - if (unread) { - this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna); - this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { - antenna: { id: antenna.id, name: antenna.name }, - note: await this.noteEntityService.pack(note), - }); - } - }, 2000); - } } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts new file mode 100644 index 0000000000..d74f3e8788 --- /dev/null +++ b/packages/backend/src/core/CacheService.ts @@ -0,0 +1,172 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; +import type { LocalUser, User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { StreamMessages } from '@/server/api/stream/types.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class CacheService implements OnApplicationShutdown { + public userByIdCache: MemoryKVCache<User>; + public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>; + public localUserByIdCache: MemoryKVCache<LocalUser>; + public uriPersonCache: MemoryKVCache<User | null>; + public userProfileCache: RedisKVCache<UserProfile>; + public userMutingsCache: RedisKVCache<Set<string>>; + public userBlockingCache: RedisKVCache<Set<string>>; + public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ + public renoteMutingsCache: RedisKVCache<Set<string>>; + public userFollowingsCache: RedisKVCache<Set<string>>; + public userFollowingChannelsCache: RedisKVCache<Set<string>>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + + private userEntityService: UserEntityService, + ) { + //this.onMessage = this.onMessage.bind(this); + + this.userByIdCache = new MemoryKVCache<User>(Infinity); + this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity); + this.localUserByIdCache = new MemoryKVCache<LocalUser>(Infinity); + this.uriPersonCache = new MemoryKVCache<User | null>(Infinity); + + this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 + }); + + this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockedCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocked', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.renoteMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'renoteMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForPubsub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise<void> { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as StreamMessages['internal']['payload']; + switch (type) { + case 'userChangeSuspendedState': + case 'remoteUserUpdated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }); + this.userByIdCache.set(user.id, user); + for (const [k, v] of this.uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + this.uriPersonCache.set(k, user); + } + } + if (this.userEntityService.isLocalUser(user)) { + this.localUserByNativeTokenCache.set(user.token!, user); + this.localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; + this.localUserByNativeTokenCache.delete(body.oldToken); + this.localUserByNativeTokenCache.set(body.newToken, user); + break; + } + case 'follow': { + const follower = this.userByIdCache.get(body.followerId); + if (follower) follower.followingCount++; + const followee = this.userByIdCache.get(body.followeeId); + if (followee) followee.followersCount++; + break; + } + default: + break; + } + } + } + + @bindThis + public findUserById(userId: User['id']) { + return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined) { + this.redisForPubsub.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index d67e80fc1d..8775536e4a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; @@ -38,9 +39,9 @@ import { S3Service } from './S3Service.js'; import { SignupService } from './SignupService.js'; import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; import { UserBlockingService } from './UserBlockingService.js'; -import { UserCacheService } from './UserCacheService.js'; +import { CacheService } from './CacheService.js'; import { UserFollowingService } from './UserFollowingService.js'; -import { UserKeypairStoreService } from './UserKeypairStoreService.js'; +import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; import { UserMutingService } from './UserMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; @@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; @@ -159,9 +161,9 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; -const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; +const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; -const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; +const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; @@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -282,9 +285,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -399,9 +403,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -517,9 +522,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -633,9 +639,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index a62854c61c..604a94707f 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,24 +1,28 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; +import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository, Note } from '@/models/index.js'; +import type { EmojisRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Config } from '@/config.js'; -import { ReactionService } from '@/core/ReactionService.js'; import { query } from '@/misc/prelude/url.js'; @Injectable() export class CustomEmojiService { - private cache: KVCache<Emoji | null>; + private cache: MemoryKVCache<Emoji | null>; + public localEmojisCache: RedisSingleCache<Map<string, Emoji>>; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.config) private config: Config, @@ -32,9 +36,16 @@ export class CustomEmojiService { private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, - private reactionService: ReactionService, ) { - this.cache = new KVCache<Emoji | null>(1000 * 60 * 60 * 12); + this.cache = new MemoryKVCache<Emoji | null>(1000 * 60 * 60 * 12); + + this.localEmojisCache = new RedisSingleCache<Map<string, Emoji>>(this.redisClient, 'localEmojis', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), + toRedisConverter: (value) => JSON.stringify(value.values()), + fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換 + }); } @bindThis @@ -60,7 +71,7 @@ export class CustomEmojiService { }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { - await this.db.queryResultCache?.remove(['meta_emojis']); + this.localEmojisCache.refresh(); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(emoji.id), @@ -71,6 +82,146 @@ export class CustomEmojiService { } @bindThis + public async update(id: Emoji['id'], data: { + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + }): Promise<void> { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); + if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + license: data.license, + }); + + this.localEmojisCache.refresh(); + + const updated = await this.emojiEntityService.packDetailed(emoji.id); + + if (emoji.name === data.name) { + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: [updated], + }); + } else { + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: updated, + }); + } + } + + @bindThis + public async addAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(aliases))], + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + aliases: aliases, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async removeAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter(x => !aliases.includes(x)), + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setCategoryBulk(ids: Emoji['id'][], category: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + category: category, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async delete(id: Emoji['id']) { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + + await this.emojisRepository.delete(emoji.id); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + } + + @bindThis + public async deleteBulk(ids: Emoji['id'][]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.delete(emoji.id); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: await this.emojiEntityService.packDetailedMany(emojis), + }); + } + + @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { // クエリに使うホスト let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) @@ -84,7 +235,7 @@ export class CustomEmojiService { } @bindThis - private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + public parseEmojiStr(emojiName: string, noteUserHost: string | null) { const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); if (!match) return { name: null, host: null }; @@ -143,30 +294,6 @@ export class CustomEmojiService { return res; } - @bindThis - public aggregateNoteEmojis(notes: Note[]) { - let emojis: { name: string | null; host: string | null; }[] = []; - for (const note of notes) { - emojis = emojis.concat(note.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - if (note.renote) { - emojis = emojis.concat(note.renote.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - if (note.renote.user) { - emojis = emojis.concat(note.renote.user.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - } - } - const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; - emojis = emojis.concat(customReactions); - if (note.user) { - emojis = emojis.concat(note.user.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - } - } - return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; - } - /** * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します */ diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 2acb5f2303..327283106f 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -36,8 +36,5 @@ export class DeleteAccountService { await this.usersRepository.update(user.id, { isDeleted: true, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); } } diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index b85791e43f..1d0c87280f 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -9,23 +10,40 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class FederatedInstanceService { - private cache: KVCache<Instance>; + public federatedInstanceCache: RedisKVCache<Instance | null>; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, private utilityService: UtilityService, private idService: IdService, ) { - this.cache = new KVCache<Instance>(1000 * 60 * 60); + this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', { + lifetime: 1000 * 60 * 60 * 24, // 24h + memoryCacheLifetime: 1000 * 60 * 30, // 30m + fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => { + const parsed = JSON.parse(value); + return { + ...parsed, + firstRetrievedAt: new Date(parsed.firstRetrievedAt), + latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, + infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + }; + }, + }); } @bindThis public async fetch(host: string): Promise<Instance> { host = this.utilityService.toPuny(host); - const cached = this.cache.get(host); + const cached = await this.federatedInstanceCache.get(host); if (cached) return cached; const index = await this.instancesRepository.findOneBy({ host }); @@ -37,10 +55,10 @@ export class FederatedInstanceService { firstRetrievedAt: new Date(), }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); - this.cache.set(host, i); + this.federatedInstanceCache.set(host, i); return i; } else { - this.cache.set(host, index); + this.federatedInstanceCache.set(host, index); return index; } } @@ -49,10 +67,10 @@ export class FederatedInstanceService { public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> { host = this.utilityService.toPuny(host); - const cached = this.cache.get(host); + const cached = await this.federatedInstanceCache.get(host); if (cached == null) return; - this.cache.set(host, { + this.federatedInstanceCache.set(host, { ...cached, ...data, }); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index d261a6c657..25c064a2b4 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -14,7 +14,6 @@ import type { MainStreamTypes, NoteStreamTypes, UserListStreamTypes, - UserStreamTypes, } from '@/server/api/stream/types.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; @@ -50,11 +49,6 @@ export class GlobalEventService { } @bindThis - public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { - this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); - } - - @bindThis public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void { this.publish('broadcast', type, typeof value === 'undefined' ? null : value); } diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 31c0819e50..94084ad84f 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid } from '@/misc/id/aid.js'; +import { genAid, parseAid } from '@/misc/id/aid.js'; import { genMeid } from '@/misc/id/meid.js'; import { genMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; @@ -32,4 +32,17 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + @bindThis + public parse(id: string): { date: Date; } { + switch (this.method) { + case 'aid': return parseAid(id); + // TODO + //case 'meid': + //case 'meidg': + //case 'ulid': + //case 'objectid': + default: throw new Error('unrecognized id generation method'); + } + } } diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index ef87051a74..4fb3fc5b4f 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import type { LocalUser } from '@/models/entities/User.js'; import type { UsersRepository } from '@/models/index.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const; @Injectable() export class InstanceActorService { - private cache: KVCache<LocalUser>; + private cache: MemorySingleCache<LocalUser>; constructor( @Inject(DI.usersRepository) @@ -19,12 +19,12 @@ export class InstanceActorService { private createSystemUserService: CreateSystemUserService, ) { - this.cache = new KVCache<LocalUser>(Infinity); + this.cache = new MemorySingleCache<LocalUser>(Infinity); } @bindThis public async getInstanceActor(): Promise<LocalUser> { - const cached = this.cache.get(null); + const cached = this.cache.get(); if (cached) return cached; const user = await this.usersRepository.findOneBy({ @@ -33,11 +33,11 @@ export class InstanceActorService { }) as LocalUser | undefined; if (user) { - this.cache.set(null, user); + this.cache.set(user); return user; } else { const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser; - this.cache.set(null, created); + this.cache.set(created); return created; } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 4b792c083d..2b6160c82e 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.db) private db: DataSource, @@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown { }, 1000 * 60 * 5); } - this.redisSubscriber.on('message', this.onMessage); + this.redisForPubsub.on('message', this.onMessage); } @bindThis @@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { clearInterval(this.intervalId); - this.redisSubscriber.off('message', this.onMessage); + this.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 7d08053761..5c4d13f178 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; +import Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; @@ -19,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import { checkWordMute } from '@/misc/check-word-mute.js'; import type { Channel } from '@/models/entities/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -46,7 +47,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; -const mutedWordsCache = new KVCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); +const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -150,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -321,6 +325,14 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (data.channel) { + this.redisClient.xadd( + `channelTimeline:${data.channel.id}`, + 'MAXLEN', '~', '1000', + `${this.idService.parse(note.id).date.getTime()}-*`, + 'note', note.id); + } + setImmediate('post created', { signal: this.#shutdownController.signal }).then( () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, @@ -461,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.incNotesCountOfUser(user); // Word mute - mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ where: { enableWordMute: true, }, @@ -490,18 +502,6 @@ export class NoteCreateService implements OnApplicationShutdown { }); } - // Channel - if (note.channelId) { - this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { - for (const following of followings) { - this.noteReadService.insertNoteUnread(following.followerId, note, { - isSpecified: false, - isMentioned: false, - }); - } - }); - } - if (data.reply) { this.saveReply(data.reply, note); } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 22d72815ec..1129bd159c 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -1,28 +1,20 @@ import { setTimeout } from 'node:timers/promises'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull, Not } from 'typeorm'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { Channel } from '@/models/entities/Channel.js'; import type { Packed } from '@/misc/json-schema.js'; import type { Note } from '@/models/entities/Note.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository, AntennaNotesRepository } from '@/models/index.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { NotificationService } from './NotificationService.js'; -import { AntennaService } from './AntennaService.js'; -import { PushNotificationService } from './PushNotificationService.js'; @Injectable() export class NoteReadService implements OnApplicationShutdown { #shutdownController = new AbortController(); constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @@ -32,21 +24,8 @@ export class NoteReadService implements OnApplicationShutdown { @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - - private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private notificationService: NotificationService, - private antennaService: AntennaService, - private pushNotificationService: PushNotificationService, ) { } @@ -57,7 +36,6 @@ export class NoteReadService implements OnApplicationShutdown { isMentioned: boolean; }): Promise<void> { //#region ミュートしているなら無視 - // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする const mute = await this.mutingsRepository.findBy({ muterId: userId, }); @@ -77,7 +55,6 @@ export class NoteReadService implements OnApplicationShutdown { userId: userId, isSpecified: params.isSpecified, isMentioned: params.isMentioned, - noteChannelId: note.channelId, noteUserId: note.userId, }; @@ -95,9 +72,6 @@ export class NoteReadService implements OnApplicationShutdown { if (params.isSpecified) { this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); } - if (note.channelId) { - this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); - } }, () => { /* aborted, ignore it */ }); } @@ -105,23 +79,9 @@ export class NoteReadService implements OnApplicationShutdown { public async read( userId: User['id'], notes: (Note | Packed<'Note'>)[], - info?: { - following: Set<User['id']>; - followingChannels: Set<Channel['id']>; - }, ): Promise<void> { - const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({ - where: { - followerId: userId, - }, - select: ['followeeId'], - })).map(x => x.followeeId)); - - const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; - const readChannelNotes: (Note | Packed<'Note'>)[] = []; - const readAntennaNotes: (Note | Packed<'Note'>)[] = []; for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { @@ -129,25 +89,13 @@ export class NoteReadService implements OnApplicationShutdown { } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { readSpecifiedNotes.push(note); } - - if (note.channelId && followingChannels.has(note.channelId)) { - readChannelNotes.push(note); - } - - if (note.user != null) { // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { - readAntennaNotes.push(note); - } - } - } } - if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) { // Remove the record await this.noteUnreadsRepository.delete({ userId: userId, - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), }); // TODO: ↓まとめてクエリしたい @@ -171,49 +119,6 @@ export class NoteReadService implements OnApplicationShutdown { this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); } }); - - this.noteUnreadsRepository.countBy({ - userId: userId, - noteChannelId: Not(IsNull()), - }).then(channelNoteCount => { - if (channelNoteCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllChannels'); - } - }); - - this.notificationService.readNotificationByQuery(userId, { - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - } - - if (readAntennaNotes.length > 0) { - await this.antennaNotesRepository.update({ - antennaId: In(myAntennas.map(a => a.id)), - noteId: In(readAntennaNotes.map(n => n.id)), - }, { - read: true, - }); - - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await this.antennaNotesRepository.countBy({ - antennaId: antenna.id, - read: false, - }); - - if (count === 0) { - this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); - this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); - } - } - - this.userEntityService.getHasUnreadAntenna(userId).then(unread => { - if (!unread) { - this.globalEventService.publishMainStream(userId, 'readAllAntennas'); - this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); - } - }); } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 48f2c65847..c44dddea41 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,8 +1,9 @@ import { setTimeout } from 'node:timers/promises'; +import Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { MutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -11,21 +12,22 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { #shutdownController = new AbortController(); constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -34,54 +36,36 @@ export class NotificationService implements OnApplicationShutdown { private idService: IdService, private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, + private cacheService: CacheService, ) { } @bindThis - public async readNotification( + public async readAllNotification( userId: User['id'], - notificationIds: Notification['id'][], + force = false, ) { - if (notificationIds.length === 0) return; - - // Update documents - const result = await this.notificationsRepository.update({ - notifieeId: userId, - id: In(notificationIds), - isRead: false, - }, { - isRead: true, - }); + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); + + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - if (result.affected === 0) return; - - if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); - else return this.postReadNotifications(userId, notificationIds); - } + if (latestNotificationId == null) return; - @bindThis - public async readNotificationByQuery( - userId: User['id'], - query: Record<string, any>, - ) { - const notificationIds = await this.notificationsRepository.findBy({ - ...query, - notifieeId: userId, - isRead: false, - }).then(notifications => notifications.map(notification => notification.id)); + this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - return this.readNotification(userId, notificationIds); + if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { + return this.postReadAllNotifications(userId); + } } @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); - return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); - } - - @bindThis - private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { - return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); } @bindThis @@ -90,45 +74,43 @@ export class NotificationService implements OnApplicationShutdown { type: Notification['type'], data: Partial<Notification>, ): Promise<Notification | null> { - if (data.notifierId && (notifieeId === data.notifierId)) { - return null; - } + const profile = await this.cacheService.userProfileCache.fetch(notifieeId); + const isMuted = profile.mutingNotificationTypes.includes(type); + if (isMuted) return null; - const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); + if (data.notifierId) { + if (notifieeId === data.notifierId) { + return null; + } - const isMuted = profile?.mutingNotificationTypes.includes(type); + const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); + if (mutings.has(data.notifierId)) { + return null; + } + } - // Create notification - const notification = await this.notificationsRepository.insert({ + const notification = { id: this.idService.genId(), createdAt: new Date(), - notifieeId: notifieeId, type: type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく - isRead: isMuted, ...data, - } as Partial<Notification>) - .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); + } as Notification; + + const redisIdPromise = this.redisClient.xadd( + `notificationTimeline:${notifieeId}`, + 'MAXLEN', '~', '300', + `${this.idService.parse(notification.id).date.getTime()}-*`, + 'data', JSON.stringify(notification)); - const packed = await this.notificationEntityService.pack(notification, {}); + const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); // Publish notification event this.globalEventService.publishMainStream(notifieeId, 'notification', packed); // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); - if (fresh == null) return; // 既に削除されているかもしれない - if (fresh.isRead) return; - - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await this.mutingsRepository.findBy({ - muterId: notifieeId, - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion + setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); + if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 32c38ad480..69020f7e84 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -15,10 +15,6 @@ type PushNotificationsTypes = { antenna: { id: string, name: string }; note: Packed<'Note'>; }; - 'readNotifications': { notificationIds: string[] }; - 'readAllNotifications': undefined; - 'readAntenna': { antennaId: string }; - 'readAllAntennas': undefined; }; // Reduce length because push message servers have character limits @@ -72,14 +68,6 @@ export class PushNotificationService { }); for (const subscription of subscriptions) { - // Continue if sendReadMessage is false - if ([ - 'readNotifications', - 'readAllNotifications', - 'readAntenna', - 'readAllAntennas', - ].includes(type) && !subscription.sendReadMessage) continue; - const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index edd843977b..8733a7d7eb 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End function q<T>(config: Config, name: string, limitPerSec = -1) { return new Bull<T>(name, { redis: { - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - db: config.redis.db ?? 0, + port: config.redisForJobQueue.port, + host: config.redisForJobQueue.host, + family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, + password: config.redisForJobQueue.pass, + db: config.redisForJobQueue.db ?? 0, }, - prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue', limiter: limitPerSec > 0 ? { max: limitPerSec, duration: 1000, diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index b3aea878d6..a274b19e4b 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { RemoteUser, User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; @@ -20,6 +19,7 @@ import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; const FALLBACK = '❤'; @@ -60,9 +60,6 @@ export class ReactionService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -74,6 +71,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -104,7 +102,6 @@ export class ReactionService { if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { reaction = '❤️'; } else { - // TODO: cache reaction = await this.toDbReaction(reaction, user.host); } @@ -158,20 +155,22 @@ export class ReactionService { // カスタム絵文字リアクションだったら絵文字情報も送る const decodedReaction = this.decodeReaction(reaction); - const emoji = await this.emojisRepository.findOne({ - where: { - name: decodedReaction.name, - host: decodedReaction.host ?? IsNull(), - }, - select: ['name', 'host', 'originalUrl', 'publicUrl'], - }); + const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name) + : await this.emojisRepository.findOne( + { + where: { + name: decodedReaction.name, + host: decodedReaction.host, + }, + }); this.globalEventService.publishNoteStream(note.id, 'reacted', { reaction: decodedReaction.reaction, - emoji: emoji != null ? { - name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + emoji: customEmoji != null ? { + name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, + url: customEmoji.publicUrl || customEmoji.originalUrl, } : null, userId: user.id, }); @@ -310,10 +309,12 @@ export class ReactionService { const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); if (custom) { const name = custom[1]; - const emoji = await this.emojisRepository.findOneBy({ - host: reacterHost ?? IsNull(), - name, - }); + const emoji = reacterHost == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) + : await this.emojisRepository.findOneBy({ + host: reacterHost, + name, + }); if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; } diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 4537f1b81a..9d34d82be2 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -3,7 +3,7 @@ import { IsNull } from 'typeorm'; import type { LocalUser, User } from '@/models/entities/User.js'; import type { RelaysRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Relay } from '@/models/entities/Relay.js'; import { QueueService } from '@/core/QueueService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; @@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const; @Injectable() export class RelayService { - private relaysCache: KVCache<Relay[]>; + private relaysCache: MemorySingleCache<Relay[]>; constructor( @Inject(DI.usersRepository) @@ -30,7 +30,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new KVCache<Relay[]>(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache<Relay[]>(1000 * 60 * 10); } @bindThis @@ -109,7 +109,7 @@ export class RelayService { public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> { if (activity == null) return; - const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ + const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ status: 'accepted', })); if (relays.length === 0) return; diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 7b63e43cb1..c8ebe1adb7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -57,15 +57,15 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown { - private rolesCache: KVCache<Role[]>; - private roleAssignmentByUserIdCache: KVCache<RoleAssignment[]>; + private rolesCache: MemorySingleCache<Role[]>; + private roleAssignmentByUserIdCache: MemoryKVCache<RoleAssignment[]>; public static AlreadyAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {}; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,17 +77,17 @@ export class RoleService implements OnApplicationShutdown { private roleAssignmentsRepository: RoleAssignmentsRepository, private metaService: MetaService, - private userCacheService: UserCacheService, + private cacheService: CacheService, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); - this.rolesCache = new KVCache<Role[]>(Infinity); - this.roleAssignmentByUserIdCache = new KVCache<RoleAssignment[]>(Infinity); + this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1); + this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1); - this.redisSubscriber.on('message', this.onMessage); + this.redisForPubsub.on('message', this.onMessage); } @bindThis @@ -98,7 +98,7 @@ export class RoleService implements OnApplicationShutdown { const { type, body } = obj.message as StreamMessages['internal']['payload']; switch (type) { case 'roleCreated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { cached.push({ ...body, @@ -110,7 +110,7 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleUpdated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { const i = cached.findIndex(x => x.id === body.id); if (i > -1) { @@ -125,9 +125,9 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleDeleted': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { - this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); + this.rolesCache.set(cached.filter(x => x.id !== body.id)); } break; } @@ -214,9 +214,9 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; } @@ -231,11 +231,11 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -301,7 +301,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getModeratorIds(includeAdmins = true): Promise<User['id'][]> { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)), @@ -321,7 +321,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getAdministratorIds(): Promise<User['id'][]> { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const administratorRoles = roles.filter(r => r.isAdministrator); const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(administratorRoles.map(r => r.id)), @@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 629278d915..01ce12ffdd 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -42,7 +42,7 @@ export class S3Service { accessKeyId: meta.objectStorageAccessKey, secretAccessKey: meta.objectStorageSecretKey, } : undefined, - region: meta.objectStorageRegion ?? undefined, + region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない tls: meta.objectStorageUseSSL, forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted requestHandler: new NodeHttpHandler(handlerOption), diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 33b51537a6..b3e306346e 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -1,40 +1,30 @@ -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Redis from 'ioredis'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { IdService } from '@/core/IdService.js'; import type { User } from '@/models/entities/User.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { bindThis } from '@/decorators.js'; -import { KVCache } from '@/misc/cache.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; @Injectable() -export class UserBlockingService implements OnApplicationShutdown { +export class UserBlockingService implements OnModuleInit { private logger: Logger; - - // キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ - private blockingsByUserIdCache: KVCache<User['id'][]>; + private userFollowingService: UserFollowingService; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - + private moduleRef: ModuleRef, + @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -47,47 +37,20 @@ export class UserBlockingService implements OnApplicationShutdown { @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, private webhookService: WebhookService, private apRendererService: ApRendererService, - private perUserFollowingChart: PerUserFollowingChart, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('user-block'); - - this.blockingsByUserIdCache = new KVCache<User['id'][]>(Infinity); - - this.redisSubscriber.on('message', this.onMessage); } - @bindThis - private async onMessage(_: string, data: string): Promise<void> { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; - switch (type) { - case 'blockingCreated': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, [...cached, ...[body.blockeeId]]); - } - break; - } - case 'blockingDeleted': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, cached.filter(x => x !== body.blockeeId)); - } - break; - } - default: - break; - } - } + onModuleInit() { + this.userFollowingService = this.moduleRef.get('UserFollowingService'); } @bindThis @@ -95,8 +58,8 @@ export class UserBlockingService implements OnApplicationShutdown { await Promise.all([ this.cancelRequest(blocker, blockee), this.cancelRequest(blockee, blocker), - this.unFollow(blocker, blockee), - this.unFollow(blockee, blocker), + this.userFollowingService.unfollow(blocker, blockee), + this.userFollowingService.unfollow(blockee, blocker), this.removeFromList(blockee, blocker), ]); @@ -111,6 +74,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.insert(blocking); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingCreated', { blockerId: blocker.id, blockeeId: blockee.id, @@ -148,7 +114,6 @@ export class UserBlockingService implements OnApplicationShutdown { this.userEntityService.pack(followee, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); @@ -174,54 +139,6 @@ export class UserBlockingService implements OnApplicationShutdown { } @bindThis - private async unFollow(follower: User, followee: User) { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (following == null) { - return; - } - - await Promise.all([ - this.followingsRepository.delete(following.id), - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - this.perUserFollowingChart.update(follower, followee, false), - ]); - - // Publish unfollow event - if (this.userEntityService.isLocalUser(follower)) { - this.userEntityService.pack(followee, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); - this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローをしていたらUndoFollow送信 - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox, false); - } - - // リモートからフォローをされていたらRejectFollow送信 - if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - } - - @bindThis private async removeFromList(listOwner: User, user: User) { const userLists = await this.userListsRepository.findBy({ userId: listOwner.id, @@ -254,6 +171,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.delete(blocking.id); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingDeleted', { blockerId: blocker.id, blockeeId: blockee.id, @@ -268,17 +188,6 @@ export class UserBlockingService implements OnApplicationShutdown { @bindThis public async checkBlocked(blockerId: User['id'], blockeeId: User['id']): Promise<boolean> { - const blockedUserIds = await this.blockingsByUserIdCache.fetch(blockerId, () => this.blockingsRepository.find({ - where: { - blockerId, - }, - select: ['blockeeId'], - }).then(records => records.map(record => record.blockeeId))); - return blockedUserIds.includes(blockeeId); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + return (await this.cacheService.userBlockingCache.fetch(blockerId)).has(blockeeId); } } diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts deleted file mode 100644 index 631eb44062..0000000000 --- a/packages/backend/src/core/UserCacheService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; -import type { UsersRepository } from '@/models/index.js'; -import { KVCache } from '@/misc/cache.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class UserCacheService implements OnApplicationShutdown { - public userByIdCache: KVCache<User>; - public localUserByNativeTokenCache: KVCache<LocalUser | null>; - public localUserByIdCache: KVCache<LocalUser>; - public uriPersonCache: KVCache<User | null>; - - constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private userEntityService: UserEntityService, - ) { - //this.onMessage = this.onMessage.bind(this); - - this.userByIdCache = new KVCache<User>(Infinity); - this.localUserByNativeTokenCache = new KVCache<LocalUser | null>(Infinity); - this.localUserByIdCache = new KVCache<LocalUser>(Infinity); - this.uriPersonCache = new KVCache<User | null>(Infinity); - - this.redisSubscriber.on('message', this.onMessage); - } - - @bindThis - private async onMessage(_: string, data: string): Promise<void> { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; - switch (type) { - case 'userChangeSuspendedState': - case 'remoteUserUpdated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }); - this.userByIdCache.set(user.id, user); - for (const [k, v] of this.uriPersonCache.cache.entries()) { - if (v.value?.id === user.id) { - this.uriPersonCache.set(k, user); - } - } - if (this.userEntityService.isLocalUser(user)) { - this.localUserByNativeTokenCache.set(user.token, user); - this.localUserByIdCache.set(user.id, user); - } - break; - } - case 'userTokenRegenerated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; - this.localUserByNativeTokenCache.delete(body.oldToken); - this.localUserByNativeTokenCache.set(body.newToken, user); - break; - } - case 'follow': { - const follower = this.userByIdCache.get(body.followerId); - if (follower) follower.followingCount++; - const followee = this.userByIdCache.get(body.followeeId); - if (followee) followee.followersCount++; - break; - } - default: - break; - } - } - } - - @bindThis - public findById(userId: User['id']) { - return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); - } -} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b51b553c70..d7bb8f3920 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -18,6 +19,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { MetaService } from '@/core/MetaService.js'; +import { CacheService } from '@/core/CacheService.js'; import Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -36,8 +38,12 @@ type Remote = RemoteUser | { type Both = Local | Remote; @Injectable() -export class UserFollowingService { +export class UserFollowingService implements OnModuleInit { + private userBlockingService: UserBlockingService; + constructor( + private moduleRef: ModuleRef, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -53,8 +59,8 @@ export class UserFollowingService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, - private userBlockingService: UserBlockingService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, @@ -68,6 +74,10 @@ export class UserFollowingService { ) { } + onModuleInit() { + this.userBlockingService = this.moduleRef.get('UserBlockingService'); + } + @bindThis public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> { const [follower, followee] = await Promise.all([ @@ -172,6 +182,8 @@ export class UserFollowingService { } }); + this.cacheService.userFollowingsCache.refresh(follower.id); + const req = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -225,7 +237,6 @@ export class UserFollowingService { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); @@ -279,6 +290,8 @@ export class UserFollowingService { await this.followingsRepository.delete(following.id); + this.cacheService.userFollowingsCache.refresh(follower.id); + this.decrementFollowing(follower, followee); // Publish unfollow event @@ -286,7 +299,6 @@ export class UserFollowingService { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); @@ -579,7 +591,6 @@ export class UserFollowingService { detail: true, }); - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packedFollowee); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts new file mode 100644 index 0000000000..22a9fb2b8e --- /dev/null +++ b/packages/backend/src/core/UserKeypairService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { User } from '@/models/entities/User.js'; +import type { UserKeypairsRepository } from '@/models/index.js'; +import { RedisKVCache } from '@/misc/cache.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserKeypairService { + private cache: RedisKVCache<UserKeypair>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.userKeypairsRepository) + private userKeypairsRepository: UserKeypairsRepository, + ) { + this.cache = new RedisKVCache<UserKeypair>(this.redisClient, 'userKeypair', { + lifetime: 1000 * 60 * 60 * 24, // 24h + memoryCacheLifetime: Infinity, + fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); + } + + @bindThis + public async getUserKeypair(userId: User['id']): Promise<UserKeypair> { + return await this.cache.fetch(userId); + } +} diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts deleted file mode 100644 index 61c9293f86..0000000000 --- a/packages/backend/src/core/UserKeypairStoreService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { User } from '@/models/entities/User.js'; -import type { UserKeypairsRepository } from '@/models/index.js'; -import { KVCache } from '@/misc/cache.js'; -import type { UserKeypair } from '@/models/entities/UserKeypair.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserKeypairStoreService { - private cache: KVCache<UserKeypair>; - - constructor( - @Inject(DI.userKeypairsRepository) - private userKeypairsRepository: UserKeypairsRepository, - ) { - this.cache = new KVCache<UserKeypair>(Infinity); - } - - @bindThis - public async getUserKeypair(userId: User['id']): Promise<UserKeypair> { - return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); - } -} diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index e98f11709f..d201ec6c04 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -1,34 +1,47 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, MutingsRepository } from '@/models/index.js'; +import { In } from 'typeorm'; +import type { MutingsRepository, Muting } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserMutingService { constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, private idService: IdService, - private queueService: QueueService, - private globalEventService: GlobalEventService, + private cacheService: CacheService, ) { } @bindThis - public async mute(user: User, target: User): Promise<void> { + public async mute(user: User, target: User, expiresAt: Date | null = null): Promise<void> { await this.mutingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), + expiresAt: expiresAt ?? null, muterId: user.id, muteeId: target.id, }); + + this.cacheService.userMutingsCache.refresh(user.id); + } + + @bindThis + public async unmute(mutings: Muting[]): Promise<void> { + if (mutings.length === 0) return; + + await this.mutingsRepository.delete({ + id: In(mutings.map(m => m.id)), + }); + + const muterIds = [...new Set(mutings.map(m => m.muterId))]; + for (const muterId of muterIds) { + this.cacheService.userMutingsCache.refresh(muterId); + } } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index eccfeb0e7d..5869905db0 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -37,7 +37,7 @@ export class VideoProcessingService { }); }); - return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280); + return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); } finally { cleanup(); } diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index ac1e413de6..85594f8557 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { private webhooks: Webhook[] = []; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, ) { //this.onMessage = this.onMessage.bind(this); - this.redisSubscriber.on('message', this.onMessage); + this.redisForPubsub.on('message', this.onMessage); } @bindThis @@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index c3b3875613..4b032be89a 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { RemoteUser, User } from '@/models/entities/User.js'; @@ -31,8 +31,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService { - private publicKeyCache: KVCache<UserPublickey | null>; - private publicKeyByUserIdCache: KVCache<UserPublickey | null>; + private publicKeyCache: MemoryKVCache<UserPublickey | null>; + private publicKeyByUserIdCache: MemoryKVCache<UserPublickey | null>; constructor( @Inject(DI.config) @@ -47,11 +47,11 @@ export class ApDbResolverService { @Inject(DI.userPublickeysRepository) private userPublickeysRepository: UserPublickeysRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, private apPersonService: ApPersonService, ) { - this.publicKeyCache = new KVCache<UserPublickey | null>(Infinity); - this.publicKeyByUserIdCache = new KVCache<UserPublickey | null>(Infinity); + this.publicKeyCache = new MemoryKVCache<UserPublickey | null>(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache<UserPublickey | null>(Infinity); } @bindThis @@ -107,11 +107,11 @@ export class ApDbResolverService { if (parsed.local) { if (parsed.type !== 'users') return null; - return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, }).then(x => x ?? undefined)) ?? null; } else { - return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, })); } @@ -138,7 +138,7 @@ export class ApDbResolverService { if (key == null) return null; return { - user: await this.userCacheService.findById(key.userId) as RemoteUser, + user: await this.cacheService.findUserById(key.userId) as RemoteUser, key, }; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 055bffe731..3fca0bb1fd 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, 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'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; @Injectable() export class ApInboxService { @@ -80,7 +80,7 @@ export class ApInboxService { ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -139,6 +139,8 @@ export class ApInboxService { await this.block(actor, activity); } else if (isFlag(activity)) { await this.flag(actor, activity); + } else if (isMove(activity)) { + //await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -147,15 +149,15 @@ export class ApInboxService { @bindThis private async follow(actor: RemoteUser, activity: IFollow): Promise<string> { const followee = await this.apDbResolverService.getUserFromApId(activity.object); - + if (followee == null) { return 'skip: followee not found'; } - + if (followee.host != null) { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } - + await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -183,16 +185,16 @@ export class ApInboxService { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); throw err; }); - + if (isFollow(object)) return await this.acceptFollow(actor, object); - + return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -225,18 +227,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.addPinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -405,10 +407,10 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + // 削除対象objectのtype let formerType: string | undefined; - + if (typeof activity.object === 'string') { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; @@ -420,19 +422,19 @@ export class ApInboxService { formerType = toSingle(object.type); } } - + const uri = getApId(activity.object); - + // type不明でもactorとobjectが同じならばそれはPersonに違いない if (!formerType && actor.uri === uri) { formerType = 'Person'; } - + // それでもなかったらおそらくNote if (!formerType) { formerType = 'Note'; } - + if (validPost.includes(formerType)) { return await this.deleteNote(actor, uri); } else if (validActor.includes(formerType)) { @@ -445,44 +447,44 @@ export class ApInboxService { @bindThis private async deleteActor(actor: RemoteUser, uri: string): Promise<string> { this.logger.info(`Deleting the Actor: ${uri}`); - + if (actor.uri !== uri) { return `skip: delete actor ${actor.uri} !== ${uri}`; } - + const user = await this.usersRepository.findOneBy({ id: actor.id }); if (user == null) { return 'skip: actor not found'; } else if (user.isDeleted) { return 'skip: already deleted'; } - + const job = await this.queueService.createDeleteAccountJob(actor); - + await this.usersRepository.update(actor.id, { isDeleted: true, }); - + return `ok: queued ${job.name} ${job.id}`; } @bindThis private async deleteNote(actor: RemoteUser, uri: string): Promise<string> { this.logger.info(`Deleting the Note: ${uri}`); - + const unlock = await this.appLockService.getApLock(uri); - + try { const note = await this.apDbResolverService.getNoteFromApId(uri); - + if (note == null) { return 'message not found'; } - + if (note.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.noteDeleteService.delete(actor, note); return 'ok: note deleted'; } finally { @@ -536,23 +538,23 @@ export class ApInboxService { @bindThis private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise<string> { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - + if (follower == null) { return 'skip: follower not found'; } - + if (!this.userEntityService.isLocalUser(follower)) { return 'skip: follower is not a local user'; } - + // relay const match = activity.id?.match(/follow-relay\/(\w+)/); if (match) { return await this.relayService.relayRejected(match[1]); } - + await this.userFollowingService.remoteReject(actor, follower); return 'ok'; } @@ -562,18 +564,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.removePinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -582,24 +584,24 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + const uri = activity.id ?? activity; - + this.logger.info(`Undo: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object); - + return `skip: unknown object type ${getApType(object)}`; } @@ -609,17 +611,17 @@ export class ApInboxService { if (follower == null) { return 'skip: follower not found'; } - + const following = await this.followingsRepository.findOneBy({ followerId: follower.id, followeeId: actor.id, }); - + if (following) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } - + return 'skip: フォローされていない'; } @@ -708,16 +710,16 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { return 'skip: invalid actor'; } - + this.logger.debug('Update'); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri!, resolver, object); return 'ok: Person updated'; @@ -728,4 +730,59 @@ export class ApInboxService { return `skip: Unknown type: ${getApType(object)}`; } } + + @bindThis + private async move(actor: RemoteUser, activity: IMove): Promise<string> { + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return 'skip: invalid activity target'; + let new_acc = await this.apPersonService.resolvePerson(targetUri); + let old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // update them if they're remote + if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); + if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); + + // retrieve updated users + new_acc = await this.apPersonService.resolvePerson(targetUri); + old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // check if alsoKnownAs of the new account is valid + let isValidMove = true; + if (old_acc.uri) { + if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { + isValidMove = false; + } + } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { + isValidMove = false; + } + if (!isValidMove) { + return 'skip: accounts invalid'; + } + + // add target uri to movedToUri in order to indicate that the user has moved + await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: old_acc.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, new_acc); + await this.userFollowingService.unfollow(following.follower, old_acc); + } catch { + /* empty */ + } + } + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0d03e3d904..0b22aa9bcf 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -14,16 +14,18 @@ import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { PollVote } from '@/models/entities/PollVote.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.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'; import type { IIdentifier } from './models/identifier.js'; @Injectable() @@ -50,10 +52,11 @@ export class ApRendererService { @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private ldSignatureService: LdSignatureService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, ) { @@ -272,11 +275,7 @@ export class ApRendererService { if (reaction.startsWith(':')) { const name = reaction.replaceAll(':', ''); - // TODO: cache - const emoji = await this.emojisRepository.findOneBy({ - name, - host: IsNull(), - }); + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); if (emoji) object.tag = [this.renderEmoji(emoji)]; } @@ -294,6 +293,22 @@ export class ApRendererService { } @bindThis + public renderMove( + src: { id: User['id']; host: User['host']; uri: User['host'] }, + dst: { id: User['id']; host: User['host']; uri: User['host'] }, + ): IMove { + const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; + const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; + return { + id: `${this.config.url}/moves/${src.id}/${dst.id}`, + actor, + type: 'Move', + object: actor, + target, + }; + } + + @bindThis public async renderNote(note: Note, dive = true): Promise<IPost> { const getPromisedFiles = async (ids: string[]) => { if (!ids || ids.length === 0) return []; @@ -473,7 +488,7 @@ export class ApRendererService { ...hashtagTags, ]; - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const person = { type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', @@ -499,6 +514,14 @@ export class ApRendererService { attachment: attachment.length ? attachment : undefined, } as any; + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } @@ -640,7 +663,7 @@ export class ApRendererService { @bindThis public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise<IActivity> { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const ldSignature = this.ldSignatureService.use(); ldSignature.debug = false; @@ -701,13 +724,9 @@ export class ApRendererService { private async getEmojis(names: string[]): Promise<Emoji[]> { if (names == null || names.length === 0) return []; - const emojis = await Promise.all( - names.map(name => this.emojisRepository.findOneBy({ - name, - host: IsNull(), - })), - ); + const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); + const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); - return emojis.filter(emoji => emoji != null) as Emoji[]; + return emojis; } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 71fbc29476..5005612ab8 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -131,7 +131,7 @@ export class ApRequestService { @Inject(DI.config) private config: Config, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, ) { @@ -143,7 +143,7 @@ export class ApRequestService { public async signedPost(user: { id: User['id'] }, url: string, object: any) { const body = JSON.stringify(object); - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedPost({ key: { @@ -170,7 +170,7 @@ export class ApRequestService { */ @bindThis public async signedGet(url: string, user: { id: User['id'] }) { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedGet({ key: { diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 28bcbc6fab..5ca5f6e843 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -1,5 +1,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/index.js'; import type { Config } from '@/config.js'; @@ -341,15 +342,17 @@ export class ApNoteService { if (!tags) return []; const eomjiTags = toArray(tags).filter(isEmoji); + + const existingEmojis = await this.emojisRepository.findBy({ + host, + name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), + }); return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + const name = tag.name!.replaceAll(':', ''); tag.icon = toSingle(tag.icon); - const exists = await this.emojisRepository.findOneBy({ - host, - name, - }); + const exists = existingEmojis.find(x => x.name === name); if (exists) { if ((tag.updated != null && exists.updatedAt == null) diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 41f7eafa41..21797cfcb7 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; -import type { UserCacheService } from '@/core/UserCacheService.js'; +import type { CacheService } from '@/core/CacheService.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import type Logger from '@/logger.js'; @@ -31,6 +31,7 @@ import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -49,12 +50,13 @@ const summaryLength = 2048; export class ApPersonService implements OnModuleInit { private utilityService: UtilityService; private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; private idService: IdService; private globalEventService: GlobalEventService; private metaService: MetaService; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; - private userCacheService: UserCacheService; + private cacheService: CacheService; private apResolverService: ApResolverService; private apNoteService: ApNoteService; private apImageService: ApImageService; @@ -97,7 +99,7 @@ export class ApPersonService implements OnModuleInit { //private metaService: MetaService, //private federatedInstanceService: FederatedInstanceService, //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private userCacheService: UserCacheService, + //private cacheService: CacheService, //private apResolverService: ApResolverService, //private apNoteService: ApNoteService, //private apImageService: ApImageService, @@ -113,12 +115,13 @@ export class ApPersonService implements OnModuleInit { onModuleInit() { this.utilityService = this.moduleRef.get('UtilityService'); this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); this.metaService = this.moduleRef.get('MetaService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.userCacheService = this.moduleRef.get('UserCacheService'); + this.cacheService = this.moduleRef.get('CacheService'); this.apResolverService = this.moduleRef.get('ApResolverService'); this.apNoteService = this.moduleRef.get('ApNoteService'); this.apImageService = this.moduleRef.get('ApImageService'); @@ -207,14 +210,14 @@ export class ApPersonService implements OnModuleInit { public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.userCacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri); if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(this.config.url + '/')) { const id = uri.split('/').pop(); const u = await this.usersRepository.findOneBy({ id }); - if (u) this.userCacheService.uriPersonCache.set(uri, u); + if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } @@ -222,7 +225,7 @@ export class ApPersonService implements OnModuleInit { const exist = await this.usersRepository.findOneBy({ uri }); if (exist) { - this.userCacheService.uriPersonCache.set(uri, exist); + this.cacheService.uriPersonCache.set(uri, exist); return exist; } //#endregion @@ -278,6 +281,8 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -356,32 +361,44 @@ export class ApPersonService implements OnModuleInit { const avatarId = avatar ? avatar.id : null; const bannerId = banner ? banner.id : null; + const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; + const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; + const avatarBlurhash = avatar ? avatar.blurhash : null; + const bannerBlurhash = banner ? banner.blurhash : null; await this.usersRepository.update(user!.id, { avatarId, bannerId, + avatarUrl, + bannerUrl, + avatarBlurhash, + bannerBlurhash, }); - user!.avatarId = avatarId; - user!.bannerId = bannerId; - //#endregion + user!.avatarId = avatarId; + user!.bannerId = bannerId; + user!.avatarUrl = avatarUrl; + user!.bannerUrl = bannerUrl; + user!.avatarBlurhash = avatarBlurhash; + user!.bannerBlurhash = bannerBlurhash; + //#endregion - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { - this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; - }); + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { + this.logger.info(`extractEmojis: ${err}`); + return [] as Emoji[]; + }); - const emojiNames = emojis.map(emoji => emoji.name); + const emojiNames = emojis.map(emoji => emoji.name); - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); - //#endregion + await this.usersRepository.update(user!.id, { + emojis: emojiNames, + }); + //#endregion - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); - return user!; + return user!; } /** @@ -458,15 +475,21 @@ export class ApPersonService implements OnModuleInit { isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo ?? null, + alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, } as Partial<User>; if (avatar) { updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; } if (banner) { updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; } // Update user diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 8851946330..625135da6c 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -157,6 +157,8 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -300,6 +302,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index e02daefd64..328511f5df 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { AntennasRepository } from '@/models/index.js'; import type { Packed } from '@/misc/json-schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import { bindThis } from '@/decorators.js'; @@ -10,9 +10,6 @@ export class AntennaEntityService { constructor( @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, ) { } @@ -22,8 +19,6 @@ export class AntennaEntityService { ): Promise<Packed<'Antenna'>> { const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; - return { id: antenna.id, createdAt: antenna.createdAt.toISOString(), @@ -38,7 +33,7 @@ export class AntennaEntityService { withReplies: antenna.withReplies, withFile: antenna.withFile, isActive: antenna.isActive, - hasUnreadNote, + hasUnreadNote: false, // TODO }; } } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 6048492f09..72e9b25544 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,13 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; +import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; +import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { @@ -18,13 +19,19 @@ export class ChannelEntityService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, ) { } @@ -33,6 +40,7 @@ export class ChannelEntityService { public async pack( src: Channel['id'] | Channel, me?: { id: User['id'] } | null | undefined, + detailed?: boolean, ): Promise<Packed<'Channel'>> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); const meId = me ? me.id : null; @@ -46,6 +54,17 @@ export class ChannelEntityService { followeeId: channel.id, }) : null; + const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ + userId: meId, + channelId: channel.id, + }) : null; + + const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ + where: { + id: In(channel.pinnedNoteIds), + }, + }) : []; + return { id: channel.id, createdAt: channel.createdAt.toISOString(), @@ -54,13 +73,19 @@ export class ChannelEntityService { description: channel.description, userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + pinnedNoteIds: channel.pinnedNoteIds, usersCount: channel.usersCount, notesCount: channel.notesCount, ...(me ? { isFollowing: following != null, + isFavorited: favorite != null, hasUnreadNote, } : {}), + + ...(detailed ? { + pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + } : {}), }; } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 2d40f444cb..d82f36d971 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -266,6 +266,7 @@ export class DriveFileEntityService { fileIds: DriveFile['id'][], options?: PackOptions, ): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> { + if (fileIds.length === 0) return new Map(); const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); const packedFiles = await this.packMany(files, options); const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); @@ -280,6 +281,7 @@ export class DriveFileEntityService { fileIds: DriveFile['id'][], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { + if (fileIds.length === 0) return []; const filesMap = await this.packManyByIdsMap(fileIds, options); return fileIds.map(id => filesMap.get(id)).filter(isNotNull); } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 5660600692..26debd6adc 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit { // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない } + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + if (note.createdAt.getTime() + 1000 > Date.now()) { + return undefined; + } + const reaction = await this.noteReactionsRepository.findOneBy({ userId: meId, noteId: note.id, @@ -283,7 +288,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); + const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); const host = note.userHost; let text = note.text; @@ -395,7 +400,8 @@ export class NoteEntityService implements OnModuleInit { const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); if (meId) { const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...notes.map(n => n.id), ...renoteIds]; + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds]; const myReactions = await this.noteReactionsRepository.findBy({ userId: meId, noteId: In(targets), @@ -406,10 +412,10 @@ export class NoteEntityService implements OnModuleInit { } } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); - const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); + const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, @@ -421,6 +427,30 @@ export class NoteEntityService implements OnModuleInit { } @bindThis + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; + } + + @bindThis public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> { // 指定したユーザーの指定したノートのリノートがいくつあるか数える const query = this.notesRepository.createQueryBuilder('note') diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 70e56cb3d7..0dc63d969f 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; +import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; import type { Note } from '@/models/entities/Note.js'; @@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, @@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit { @bindThis public async pack( - src: Notification['id'] | Notification, + src: Notification, + meId: User['id'], + // eslint-disable-next-line @typescript-eslint/ban-types options: { - _hint_?: { - packedNotes: Map<Note['id'], Packed<'Note'>>; - }; + + }, + hint?: { + packedNotes: Map<Note['id'], Packed<'Note'>>; + packedUsers: Map<User['id'], Packed<'User'>>; }, ): Promise<Packed<'Notification'>> { - const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const notification = src; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( - options._hint_?.packedNotes != null - ? options._hint_.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId!, { id: meId }, { detail: true, }) ) : undefined; + const userIfNeed = notification.notifierId != null ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + detail: false, + }) + ) : undefined; return await awaitAll({ id: notification.id, - createdAt: notification.createdAt.toISOString(), + createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - isRead: notification.isRead, userId: notification.notifierId, - user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, + ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, @@ -87,33 +101,39 @@ export class NotificationEntityService implements OnModuleInit { }); } - /** - * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId - */ @bindThis public async packMany( notifications: Notification[], meId: User['id'], ) { if (notifications.length === 0) return []; - - for (const notification of notifications) { - if (meId !== notification.notifieeId) { - // because we call note packMany with meId, all notifieeId should be same as meId - throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); - } - } - const notes = notifications.map(x => x.note).filter(isNotNull); + let validNotifications = notifications; + + const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], + }) : []; const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { detail: true, }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - return await Promise.all(notifications.map(x => this.pack(x, { - _hint_: { - packedNotes, - }, + validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + + const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { + packedNotes, + packedUsers, }))); } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b693883e06..e02f7535d4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; +import Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; @@ -8,13 +9,14 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import { KVCache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -24,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js'; type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -46,13 +48,14 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { @Injectable() export class UserEntityService implements OnModuleInit { + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private antennaService: AntennaService; private roleService: RoleService; - private userInstanceCache: KVCache<Instance | null>; + private federatedInstanceService: FederatedInstanceService; constructor( private moduleRef: ModuleRef, @@ -60,6 +63,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -90,9 +96,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -108,9 +111,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, @@ -121,16 +121,17 @@ export class UserEntityService implements OnModuleInit { //private antennaService: AntennaService, //private roleService: RoleService, ) { - this.userInstanceCache = new KVCache<Instance | null>(1000 * 60 * 60 * 3); } 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.antennaService = this.moduleRef.get('AntennaService'); this.roleService = this.moduleRef.get('RoleService'); + this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); } //#region Validators @@ -223,6 +224,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { + /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ @@ -231,37 +233,22 @@ export class UserEntityService implements OnModuleInit { }) : null; return unread != null; - } - - @bindThis - public async getHasUnreadChannel(userId: User['id']): Promise<boolean> { - const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); - - const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ - userId: userId, - noteChannelId: In(channels.map(x => x.followeeId)), - }) : null; - - return unread != null; + */ + return false; // TODO } @bindThis public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - const mutedUserIds = mute.map(m => m.muteeId); + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - const count = await this.notificationsRepository.count({ - where: { - notifieeId: userId, - ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), - isRead: false, - }, - take: 1, - }); + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - return count > 0; + return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); } @bindThis @@ -286,27 +273,6 @@ export class UserEntityService implements OnModuleInit { } @bindThis - public async getAvatarUrl(user: User): Promise<string> { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else if (user.avatarId) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else { - return this.getIdenticonUrl(user); - } - } - - @bindThis - public getAvatarUrlSync(user: User): string { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else { - return this.getIdenticonUrl(user); - } - } - - @bindThis public getIdenticonUrl(user: User): string { return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } @@ -325,19 +291,23 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - let user: User; + const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); - if (typeof src === 'object') { - user = src; - if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; - if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; - } else { - user = await this.usersRepository.findOneOrFail({ - where: { id: src }, - relations: { - avatar: true, - banner: true, - }, + // migration + if (user.avatarId != null && user.avatarUrl === null) { + const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); + user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + this.usersRepository.update(user.id, { + avatarUrl: user.avatarUrl, + avatarBlurhash: avatar.blurhash, + }); + } + if (user.bannerId != null && user.bannerUrl === null) { + const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); + user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + this.usersRepository.update(user.id, { + bannerUrl: user.bannerUrl, + bannerBlurhash: banner.blurhash, }); } @@ -372,14 +342,11 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: this.getAvatarUrlSync(user), - avatarBlurhash: user.avatar?.blurhash ?? null, + avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), + avatarBlurhash: user.avatarBlurhash, isBot: user.isBot ?? falsy, isCat: user.isCat ?? falsy, - instance: user.host ? this.userInstanceCache.fetch(user.host, - () => this.instancesRepository.findOneBy({ host: user.host! }), - v => v != null, - ).then(instance => instance ? { + instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, @@ -399,11 +366,13 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, + alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null, - bannerBlurhash: user.banner?.blurhash ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, @@ -467,7 +436,7 @@ export class UserEntityService implements OnModuleInit { }).then(count => count > 0), hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id), - hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadChannel: false, // 後方互換性のため hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 0879735b1d..482e8f83e1 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -2,7 +2,7 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), redis: Symbol('redis'), - redisSubscriber: Symbol('redisSubscriber'), + redisForPubsub: Symbol('redisForPubsub'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -33,7 +33,6 @@ export const DI = { emojisRepository: Symbol('emojisRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), - notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), renoteMutingsRepository: Symbol('renoteMutingsRepository'), @@ -54,14 +53,13 @@ export const DI = { clipNotesRepository: Symbol('clipNotesRepository'), clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), - antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), - channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), + channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), adsRepository: Symbol('adsRepository'), diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index b249cf4480..d35414acf7 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,18 +1,187 @@ +import Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; +export class RedisKVCache<T> { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemoryKVCache<T>; + private fetcher: (key: string) => Promise<T>; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T; + + constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { + lifetime: RedisKVCache<T>['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache<T>['fetcher']; + toRedisConverter: RedisKVCache<T>['toRedisConverter']; + fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; + }) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = opts.lifetime; + this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(key: string, value: T): Promise<void> { + this.memoryCache.set(key, value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + 'ex', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(key: string): Promise<T | undefined> { + const memoryCached = this.memoryCache.get(key); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); + if (cached == null) return undefined; + return this.fromRedisConverter(cached); + } + + @bindThis + public async delete(key: string): Promise<void> { + this.memoryCache.delete(key); + await this.redisClient.del(`kvcache:${this.name}:${key}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(key: string): Promise<T> { + const cachedValue = await this.get(key); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(key); + this.set(key, value); + return value; + } + + @bindThis + public async refresh(key: string) { + const value = await this.fetcher(key); + this.set(key, value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } +} + +export class RedisSingleCache<T> { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemorySingleCache<T>; + private fetcher: () => Promise<T>; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T; + + constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { + lifetime: RedisSingleCache<T>['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisSingleCache<T>['fetcher']; + toRedisConverter: RedisSingleCache<T>['toRedisConverter']; + fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; + }) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = opts.lifetime; + this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(value: T): Promise<void> { + this.memoryCache.set(value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + 'ex', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(): Promise<T | undefined> { + const memoryCached = this.memoryCache.get(); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`singlecache:${this.name}`); + if (cached == null) return undefined; + return this.fromRedisConverter(cached); + } + + @bindThis + public async delete(): Promise<void> { + this.memoryCache.delete(); + await this.redisClient.del(`singlecache:${this.name}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(): Promise<T> { + const cachedValue = await this.get(); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(); + this.set(value); + return value; + } + + @bindThis + public async refresh() { + const value = await this.fetcher(); + this.set(value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } +} + // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? -export class KVCache<T> { - public cache: Map<string | null, { date: number; value: T; }>; +export class MemoryKVCache<T> { + public cache: Map<string, { date: number; value: T; }>; private lifetime: number; - constructor(lifetime: KVCache<never>['lifetime']) { + constructor(lifetime: MemoryKVCache<never>['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; } @bindThis - public set(key: string | null, value: T): void { + public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), value, @@ -20,7 +189,7 @@ export class KVCache<T> { } @bindThis - public get(key: string | null): T | undefined { + public get(key: string): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; if ((Date.now() - cached.date) > this.lifetime) { @@ -31,7 +200,7 @@ export class KVCache<T> { } @bindThis - public delete(key: string | null) { + public delete(key: string) { this.cache.delete(key); } @@ -40,7 +209,7 @@ export class KVCache<T> { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetch(key: string | null, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { + public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -65,7 +234,7 @@ export class KVCache<T> { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetchMaybe(key: string | null, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { + public async fetchMaybe(key: string, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -88,12 +257,12 @@ export class KVCache<T> { } } -export class Cache<T> { +export class MemorySingleCache<T> { private cachedAt: number | null = null; private value: T | undefined; private lifetime: number; - constructor(lifetime: Cache<never>['lifetime']) { + constructor(lifetime: MemorySingleCache<never>['lifetime']) { this.lifetime = lifetime; } diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 19c8546f95..93a9929aa7 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -23,3 +23,8 @@ export function genAid(date: Date): string { counter++; return getTime(t) + getNoise(); } + +export function parseAid(id: string): { date: Date; } { + const time = parseInt(id.slice(0, 8), 36) + TIME2000; + return { date: new Date(time) }; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index d00c8813c7..7be7b81904 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = { inject: [DI.db], }; -const $notificationsRepository: Provider = { - provide: DI.notificationsRepository, - useFactory: (db: DataSource) => db.getRepository(Notification), - inject: [DI.db], -}; - const $metasRepository: Provider = { provide: DI.metasRepository, useFactory: (db: DataSource) => db.getRepository(Meta), @@ -298,12 +292,6 @@ const $antennasRepository: Provider = { inject: [DI.db], }; -const $antennaNotesRepository: Provider = { - provide: DI.antennaNotesRepository, - useFactory: (db: DataSource) => db.getRepository(AntennaNote), - inject: [DI.db], -}; - const $promoNotesRepository: Provider = { provide: DI.promoNotesRepository, useFactory: (db: DataSource) => db.getRepository(PromoNote), @@ -340,9 +328,9 @@ const $channelFollowingsRepository: Provider = { inject: [DI.db], }; -const $channelNotePiningsRepository: Provider = { - provide: DI.channelNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), +const $channelFavoritesRepository: Provider = { + provide: DI.channelFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelFavorite), inject: [DI.db], }; @@ -432,7 +420,6 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, $renoteMutingsRepository, @@ -453,14 +440,13 @@ const $roleAssignmentsRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -500,7 +486,6 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, $renoteMutingsRepository, @@ -521,14 +506,13 @@ const $roleAssignmentsRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, diff --git a/packages/backend/src/models/entities/AntennaNote.ts b/packages/backend/src/models/entities/AntennaNote.ts deleted file mode 100644 index 5524a89367..0000000000 --- a/packages/backend/src/models/entities/AntennaNote.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from '../id.js'; -import { Note } from './Note.js'; -import { Antenna } from './Antenna.js'; - -@Entity() -@Index(['noteId', 'antennaId'], { unique: true }) -export class AntennaNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; - - @Index() - @Column({ - ...id(), - comment: 'The antenna ID.', - }) - public antennaId: Antenna['id']; - - @ManyToOne(type => Antenna, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public antenna: Antenna | null; - - @Index() - @Column('boolean', { - default: false, - }) - public read: boolean; -} diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index a6e32d54f7..2d346fdf9d 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -59,6 +59,11 @@ export class Channel { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public pinnedNoteIds: string[]; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/ChannelNotePining.ts b/packages/backend/src/models/entities/ChannelFavorite.ts index ab5796626a..cfb2c892cf 100644 --- a/packages/backend/src/models/entities/ChannelNotePining.ts +++ b/packages/backend/src/models/entities/ChannelFavorite.ts @@ -1,21 +1,24 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './Note.js'; +import { User } from './User.js'; import { Channel } from './Channel.js'; @Entity() -@Index(['channelId', 'noteId'], { unique: true }) -export class ChannelNotePining { +@Index(['userId', 'channelId'], { unique: true }) +export class ChannelFavorite { @PrimaryColumn(id()) public id: string; + @Index() @Column('timestamp with time zone', { - comment: 'The created date of the ChannelNotePining.', + comment: 'The created date of the ChannelFavorite.', }) public createdAt: Date; @Index() - @Column(id()) + @Column({ + ...id(), + }) public channelId: Channel['id']; @ManyToOne(type => Channel, { @@ -24,12 +27,15 @@ export class ChannelNotePining { @JoinColumn() public channel: Channel | null; - @Column(id()) - public noteId: Note['id']; + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; - @ManyToOne(type => Note, { + @ManyToOne(type => User, { onDelete: 'CASCADE', }) @JoinColumn() - public note: Note | null; + public user: User | null; } diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 51117efba5..aa6f997124 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -1,54 +1,19 @@ -import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; -import { notificationTypes, obsoleteNotificationTypes } from '@/types.js'; -import { id } from '../id.js'; +import { notificationTypes } from '@/types.js'; import { User } from './User.js'; import { Note } from './Note.js'; import { FollowRequest } from './FollowRequest.js'; import { AccessToken } from './AccessToken.js'; -@Entity() -export class Notification { - @PrimaryColumn(id()) - public id: string; +export type Notification = { + id: string; - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the Notification.', - }) - public createdAt: Date; - - /** - * 通知の受信者 - */ - @Index() - @Column({ - ...id(), - comment: 'The ID of recipient user of the Notification.', - }) - public notifieeId: User['id']; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifiee: User | null; + // RedisのためDateではなくstring + createdAt: string; /** * 通知の送信者(initiator) */ - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of sender user of the Notification.', - }) - public notifierId: User['id'] | null; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifier: User | null; + notifierId: User['id'] | null; /** * 通知の種類。 @@ -64,104 +29,37 @@ export class Notification { * achievementEarned - 実績を獲得 * app - アプリ通知 */ - @Index() - @Column('enum', { - enum: [ - ...notificationTypes, - ...obsoleteNotificationTypes, - ], - comment: 'The type of the Notification.', - }) - public type: typeof notificationTypes[number]; + type: typeof notificationTypes[number]; - /** - * 通知が読まれたかどうか - */ - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the Notification is read.', - }) - public isRead: boolean; - - @Column({ - ...id(), - nullable: true, - }) - public noteId: Note['id'] | null; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; + noteId: Note['id'] | null; - @Column({ - ...id(), - nullable: true, - }) - public followRequestId: FollowRequest['id'] | null; + followRequestId: FollowRequest['id'] | null; - @ManyToOne(type => FollowRequest, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followRequest: FollowRequest | null; + reaction: string | null; - @Column('varchar', { - length: 128, nullable: true, - }) - public reaction: string | null; + choice: number | null; - @Column('integer', { - nullable: true, - }) - public choice: number | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public achievement: string | null; + achievement: string | null; /** * アプリ通知のbody */ - @Column('varchar', { - length: 2048, nullable: true, - }) - public customBody: string | null; + customBody: string | null; /** * アプリ通知のheader * (省略時はアプリ名で表示されることを期待) */ - @Column('varchar', { - length: 256, nullable: true, - }) - public customHeader: string | null; + customHeader: string | null; /** * アプリ通知のicon(URL) * (省略時はアプリアイコンで表示されることを期待) */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public customIcon: string | null; + customIcon: string | null; /** * アプリ通知のアプリ(のトークン) */ - @Index() - @Column({ - ...id(), - nullable: true, - }) - public appAccessTokenId: AccessToken['id'] | null; - - @ManyToOne(type => AccessToken, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public appAccessToken: AccessToken | null; + appAccessTokenId: AccessToken['id'] | null; } diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 0a8b89ea06..04dfa21107 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,19 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', @@ -100,6 +113,26 @@ export class User { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + length: 512, nullable: true, + }) + public avatarUrl: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public avatarBlurhash: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public bannerBlurhash: string | null; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 17083d7a01..48d6e15f2a 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -4,13 +4,12 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; @@ -33,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -73,13 +71,12 @@ export { Announcement, AnnouncementRead, Antenna, - AntennaNote, App, AttestationChallenge, AuthSession, Blocking, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, Clip, ClipNote, ClipFavorite, @@ -102,7 +99,6 @@ export { NoteReaction, NoteThreadMuting, NoteUnread, - Notification, Page, PageLike, PasswordResetRequest, @@ -141,13 +137,12 @@ export type AdsRepository = Repository<Ad>; export type AnnouncementsRepository = Repository<Announcement>; export type AnnouncementReadsRepository = Repository<AnnouncementRead>; export type AntennasRepository = Repository<Antenna>; -export type AntennaNotesRepository = Repository<AntennaNote>; export type AppsRepository = Repository<App>; export type AttestationChallengesRepository = Repository<AttestationChallenge>; export type AuthSessionsRepository = Repository<AuthSession>; export type BlockingsRepository = Repository<Blocking>; export type ChannelFollowingsRepository = Repository<ChannelFollowing>; -export type ChannelNotePiningsRepository = Repository<ChannelNotePining>; +export type ChannelFavoritesRepository = Repository<ChannelFavorite>; export type ClipsRepository = Repository<Clip>; export type ClipNotesRepository = Repository<ClipNote>; export type ClipFavoritesRepository = Repository<ClipFavorite>; @@ -170,7 +165,6 @@ export type NoteFavoritesRepository = Repository<NoteFavorite>; export type NoteReactionsRepository = Repository<NoteReaction>; export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>; export type NoteUnreadsRepository = Repository<NoteUnread>; -export type NotificationsRepository = Repository<Notification>; export type PagesRepository = Repository<Page>; export type PageLikesRepository = Repository<PageLike>; export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index 7f4f2a48b8..745b39a6ba 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -42,10 +42,22 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, userId: { type: 'string', nullable: true, optional: false, format: 'id', }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + format: 'id', + }, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index d3f2405cdd..e88ca61ba0 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -14,10 +14,6 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'date-time', }, - isRead: { - type: 'boolean', - optional: false, nullable: false, - }, type: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e8a7212c52..7d40979e3d 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + format: 'uri', + nullable: true, + optional: false, + }, createdAt: { type: 'string', nullable: false, optional: false, @@ -311,10 +323,6 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - hasUnreadChannel: { - type: 'boolean', - nullable: false, optional: false, - }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index d5428805d1..bb21ed827e 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -12,13 +12,12 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; @@ -41,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -156,7 +154,6 @@ export const entities = [ DriveFolder, Poll, PollVote, - Notification, Emoji, Hashtag, SwSubscription, @@ -168,14 +165,13 @@ export const entities = [ ClipNote, ClipFavorite, Antenna, - AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, RegistryItem, Ad, PasswordResetRequest, @@ -204,6 +200,22 @@ export function createPostgresDataSource(config: Config) { statement_timeout: 1000 * 10, ...config.db.extra, }, + replication: config.dbReplications ? { + master: { + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + }, + slaves: config.dbSlaves!.map(rep => ({ + host: rep.host, + port: rep.port, + username: rep.user, + password: rep.pass, + database: rep.db, + })), + } : undefined, synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index f4cd560fc9..2476d71a5e 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -4,10 +4,10 @@ import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import { bindThis } from '@/decorators.js'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -20,7 +20,7 @@ export class CheckExpiredMutingsProcessorService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -37,13 +37,7 @@ export class CheckExpiredMutingsProcessorService { .getMany(); if (expired.length > 0) { - await this.mutingsRepository.delete({ - id: In(expired.map(m => m.id)), - }); - - for (const m of expired) { - this.globalEventService.publishUserEvent(m.muterId, 'unmute', m.mutee!); - } + await this.userMutingService.unmute(expired); } this.logger.succ('All expired mutings checked.'); diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 9534454fd7..1936e8df23 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; +import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; @@ -20,18 +20,12 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.mutedNotesRepository) private mutedNotesRepository: MutedNotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.roleAssignmentsRepository) private roleAssignmentsRepository: RoleAssignmentsRepository, @@ -49,10 +43,6 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); - this.notificationsRepository.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), - }); - this.mutedNotesRepository.delete({ id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), reason: 'word', diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index f637bf8818..0e99b7bcd2 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; @@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js'; @Injectable() export class DeliverProcessorService { private logger: Logger; - private suspendedHostsCache: KVCache<Instance[]>; + private suspendedHostsCache: MemorySingleCache<Instance[]>; private latest: string | null; constructor( @@ -46,7 +46,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new KVCache<Instance[]>(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache<Instance[]>(1000 * 60 * 60); } @bindThis @@ -60,14 +60,14 @@ export class DeliverProcessorService { } // isSuspendedなら中断 - let suspendedHosts = this.suspendedHostsCache.get(null); + let suspendedHosts = this.suspendedHostsCache.get(); if (suspendedHosts == null) { suspendedHosts = await this.instancesRepository.find({ where: { isSuspended: true, }, }); - this.suspendedHostsCache.set(null, suspendedHosts); + this.suspendedHostsCache.set(suspendedHosts); } if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { return 'skip (suspended)'; diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index ed96e9a525..cf78d8330c 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -86,6 +86,10 @@ export class ImportCustomEmojisProcessorService { continue; } const emojiInfo = record.emoji; + if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) { + this.logger.error(`invalid emojiname: ${emojiInfo.name}`); + continue; + } const emojiPath = outputPath + '/' + record.fileName; await this.emojisRepository.delete({ name: emojiInfo.name, diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts index 39b1b95658..84a5c21c49 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -39,6 +39,7 @@ export class WebhookDeliverProcessorService { 'X-Misskey-Host': this.config.host, 'X-Misskey-Hook-Id': job.data.webhookId, 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', }, body: JSON.stringify({ hookId: job.data.webhookId, diff --git a/packages/backend/src/redis.ts b/packages/backend/src/redis.ts deleted file mode 100644 index 690f4715dd..0000000000 --- a/packages/backend/src/redis.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Redis from 'ioredis'; -import { Config } from '@/config.js'; - -export function createRedisConnection(config: Config): Redis.Redis { - return new Redis({ - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db ?? 0, - }); -} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index da8d0114e5..5799622074 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -12,7 +12,7 @@ import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { QueueService } from '@/core/QueueService.js'; import type { LocalUser, User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import type { Following } from '@/models/entities/Following.js'; import { countIf } from '@/misc/prelude/array.js'; import type { Note } from '@/models/entities/Note.js'; @@ -58,7 +58,7 @@ export class ActivityPubServerService { private userEntityService: UserEntityService, private apRendererService: ApRendererService, private queueService: QueueService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private queryService: QueryService, ) { //this.createServer = this.createServer.bind(this); @@ -540,7 +540,7 @@ export class ActivityPubServerService { return; } - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { reply.header('Cache-Control', 'public, max-age=180'); diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 86019d4166..666a91fcee 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; @@ -118,17 +118,17 @@ export class NodeinfoServerService { }; }; - const cache = new KVCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); + const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); reply.header('Cache-Control', 'public, max-age=600'); return { version: '2.1', ...base }; }); fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); delete (base as any).software.repository; diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 3f116845cb..9257fee13e 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -149,13 +149,12 @@ export class ServerService implements OnApplicationShutdown { host: (host == null) || (host === this.config.host) ? IsNull() : host, isSuspended: false, }, - relations: ['avatar'], }); reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(this.userEntityService.getAvatarUrlSync(user)); + reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); } diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index a1895e3705..6548c475b2 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import { KVCache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { App } from '@/models/entities/App.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import isNativeToken from '@/misc/is-native-token.js'; import { bindThis } from '@/decorators.js'; @@ -18,7 +18,7 @@ export class AuthenticationError extends Error { @Injectable() export class AuthenticateService { - private appCache: KVCache<App>; + private appCache: MemoryKVCache<App>; constructor( @Inject(DI.usersRepository) @@ -30,9 +30,9 @@ export class AuthenticateService { @Inject(DI.appsRepository) private appsRepository: AppsRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, ) { - this.appCache = new KVCache<App>(Infinity); + this.appCache = new MemoryKVCache<App>(Infinity); } @bindThis @@ -42,7 +42,7 @@ export class AuthenticateService { } if (isNativeToken(token)) { - const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>); if (user == null) { @@ -67,7 +67,7 @@ export class AuthenticateService { lastUsedAt: new Date(), }); - const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, + const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, () => this.usersRepository.findOneBy({ id: accessToken.userId, }) as Promise<LocalUser>); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 835e884193..5a53b3faf7 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; import * as ep___channels_update from './endpoints/channels/update.js'; +import * as ep___channels_favorite from './endpoints/channels/favorite.js'; +import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; +import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -217,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -265,7 +270,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -424,6 +428,9 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; +const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; +const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; +const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; @@ -546,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; +const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; +const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -594,7 +603,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; @@ -757,6 +765,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -879,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -927,7 +940,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, @@ -1084,6 +1096,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1206,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1254,7 +1271,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 13526f277d..e0e5b71a82 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -9,6 +9,7 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -21,8 +22,8 @@ export class StreamingApiServerService { @Inject(DI.config) private config: Config, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,7 +46,7 @@ export class StreamingApiServerService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private globalEventService: GlobalEventService, + private cacheService: CacheService, private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, @@ -73,8 +74,6 @@ export class StreamingApiServerService { return; } - const connection = request.accept(); - const ev = new EventEmitter(); async function onRedisMessage(_: string, data: string): Promise<void> { @@ -82,22 +81,22 @@ export class StreamingApiServerService { ev.emit(parsed.channel, parsed.message); } - this.redisSubscriber.on('message', onRedisMessage); + this.redisForPubsub.on('message', onRedisMessage); const main = new MainStreamConnection( - this.followingsRepository, - this.mutingsRepository, - this.renoteMutingsRepository, - this.blockingsRepository, - this.channelFollowingsRepository, - this.userProfilesRepository, this.channelsService, - this.globalEventService, this.noteReadService, this.notificationService, - connection, ev, user, miapp, + this.cacheService, + ev, user, miapp, ); + await main.init(); + + const connection = request.accept(); + + main.init2(connection); + const intervalId = user ? setInterval(() => { this.usersRepository.update(user.id, { lastActiveDate: new Date(), @@ -112,7 +111,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); main.dispose(); - this.redisSubscriber.off('message', onRedisMessage); + this.redisForPubsub.off('message', onRedisMessage); if (intervalId) clearInterval(intervalId); }); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f6fc79fc70..fd268c7912 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -95,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; import * as ep___channels_update from './endpoints/channels/update.js'; +import * as ep___channels_favorite from './endpoints/channels/favorite.js'; +import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; +import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -217,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; +import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -265,7 +270,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -422,6 +426,9 @@ const eps = [ ['channels/timeline', ep___channels_timeline], ['channels/unfollow', ep___channels_unfollow], ['channels/update', ep___channels_update], + ['channels/favorite', ep___channels_favorite], + ['channels/unfavorite', ep___channels_unfavorite], + ['channels/my-favorites', ep___channels_myFavorites], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -544,6 +551,8 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], + //['i/move', ep___i_move], + //['i/known-as', ep___i_knownAs], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -592,7 +601,6 @@ const eps = [ ['notes/user-list-timeline', ep___notes_userListTimeline], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/read', ep___notifications_read], ['page-push', ep___pagePush], ['pages/create', ep___pages_create], ['pages/delete', ep___pages_delete], diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index e9f72676f0..16232813a8 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -61,11 +61,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { await this.usersRepository.update(user.id, { isDeleted: true, }); - - if (this.userEntityService.isLocalUser(user)) { - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index 4e4f845b0b..6e604ed885 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: [...new Set(emoji.aliases.concat(ps.aliases))], - }); - } - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index fea11a67d6..82dca9cc70 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -90,8 +90,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { license: emoji.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - await this.db.queryResultCache?.remove(['meta_emojis']); - this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(copied.id), }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index 84aad020af..9f8263629b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -1,11 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -24,38 +19,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.delete(emoji.id); - await this.db.queryResultCache?.remove(['meta_emojis']); - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); - } - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: await this.emojiEntityService.packDetailedMany(emojis), - }); + await this.customEmojiService.deleteBulk(ps.ids); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 90a5856a1b..429c819fe0 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -1,12 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ApiError } from '../../../error.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -31,38 +25,14 @@ export const paramDef = { required: ['id'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - - await this.emojisRepository.delete(emoji.id); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [await this.emojiEntityService.packDetailed(emoji)], - }); - - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); + await this.customEmojiService.delete(ps.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 3935183502..83f882cac5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), - }); - } - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 6a875f9c83..1d3a432bb7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,34 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - aliases: ps.aliases, - }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index d3b999c0ed..453968c7a9 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -28,34 +24,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - category: ps.category, - }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index bc0475e05c..f63348b60b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -45,51 +41,19 @@ export const paramDef = { required: ['id', 'name', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - const sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() }); - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists); - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), + await this.customEmojiService.update(ps.id, { name: ps.name, - category: ps.category, + category: ps.category ?? null, aliases: ps.aliases, - license: ps.license, + license: ps.license ?? null, }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - const updated = await this.emojiEntityService.packDetailed(emoji.id); - - if (emoji.name === ps.name) { - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: [updated], - }); - } else { - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [await this.emojiEntityService.packDetailed(emoji)], - }); - - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: updated, - }); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 3ad6c7c484..3c99225272 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -36,9 +36,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - private userEntityService: UserEntityService, private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, @@ -65,15 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { targetId: user.id, }); - // Terminate streaming - if (this.userEntityService.isLocalUser(user)) { - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } - (async () => { await this.userSuspendService.doPostSuspend(user).catch(e => {}); await this.unFollowAll(user).catch(e => {}); - await this.readAllNotify(user).catch(e => {}); })(); }); } @@ -96,14 +87,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { await this.userFollowingService.unfollow(follower, followee, true); } } - - @bindThis - private async readAllNotify(notifier: User) { - await this.notificationsRepository.update({ - notifierId: notifier.id, - isRead: false, - }, { - isRead: true, - }); - } } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 039ba1115a..f08c20ae48 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -50,15 +52,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private noteReadService: NoteReadService, @@ -73,29 +76,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new ApiError(meta.errors.noSuchAntenna); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + const noteIdsRes = await this.redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); - const notes = await query - .take(ps.limit) - .getMany(); + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); if (notes.length > 0) { this.noteReadService.read(me.id, notes); diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts new file mode 100644 index 0000000000..f52b45ccf3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4938f5f3-6167-4c04-9149-6607b7542861', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 91693918f2..8ab59991c7 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -41,7 +41,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -58,8 +57,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'followChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts new file mode 100644 index 0000000000..60525ed060 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.channelFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.channel', 'channel'); + + const favorites = await query + .getMany(); + + return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 8718615db2..070d14631e 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -51,7 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new ApiError(meta.errors.noSuchChannel); } - return await this.channelEntityService.pack(channel, me); + return await this.channelEntityService.pack(channel, me, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index cdaa400137..2556557b24 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/index.js'; +import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -48,12 +50,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, @@ -67,30 +73,60 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new ApiError(meta.errors.noSuchChannel); } - //#region Construct query - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.channelId = :channelId', { channelId: channel.id }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .leftJoinAndSelect('note.channel', 'channel'); + let timeline: Note[] = []; - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - //#endregion + const noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.take(ps.limit).getMany(); + } else { + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); - const timeline = await query.take(ps.limit).getMany(); + if (noteIds.length === 0) { + return []; + } + + //#region Construct query + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); + } if (me) this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts new file mode 100644 index 0000000000..0c3f6c4855 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '353c68dd-131a-476c-aa99-88a345e83668', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.delete({ + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index ac2ef825be..855ba47f8c 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -38,8 +38,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -54,8 +52,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'unfollowChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index a86cc2565a..084b3f919e 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -3,8 +3,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['channels'], @@ -47,6 +47,12 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + pinnedNoteIds: { + type: 'array', + items: { + type: 'string', format: 'misskey:id', + }, + }, }, required: ['channelId'], } as const; @@ -64,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { private channelEntityService: ChannelEntityService, private roleService: RoleService, - ) { + ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, @@ -97,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { await this.channelsRepository.update(channel.id, { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), ...(banner ? { bannerId: banner.id } : {}), }); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 6818d31cc4..dcb415b752 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -75,16 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); if (me) { diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 0711fe4a57..13cc709d31 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -58,10 +58,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { category: 'ASC', name: 'ASC', }, - cache: { - id: 'meta_emojis', - milliseconds: 3600000, // 1 hour - }, }); return { diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 0000000000..964704d82b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: ms('1day'), + max: 30, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + notRemote: { + message: 'User is not remote. You can only migrate from other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + alsoKnownAs: { type: 'string' }, + }, + required: ['alsoKnownAs'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check parameter + if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + + let unfiltered = ps.alsoKnownAs; + const updates = {} as Partial<User>; + + if (!unfiltered) { + updates.alsoKnownAs = null; + } else { + // Parse user's input into the old account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + const toUrl: string | null = knownAs.uri; + if (!toUrl) throw new ApiError(meta.errors.uriNull); + // Only allow moving from a remote account + if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); + + updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; + } + + return await this.accountMoveService.createAlias(me, updates); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 0000000000..ac76e1f620 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,140 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + limit: { + duration: ms('1day'), + max: 5, + }, + + errors: { + noSuchMoveTarget: { + message: 'No such move target.', + code: 'NO_SUCH_MOVE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', + }, + remoteAccountForbids: { + message: + 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', + code: 'REMOTE_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + notRemote: { + message: 'User is not remote. You can only migrate to other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + rootForbidden: { + message: 'The root can\'t migrate.', + code: 'NOT_ROOT_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + localUriNull: { + message: 'Local User ActivityPup URI is null.', + code: 'URI_NULL', + id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', + }, + alreadyMoved: { + message: 'Account was already moved to another account.', + code: 'ALREADY_MOVED', + id: 'b234a14e-9ebe-4581-8000-074b3c215962', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { + constructor( + @Inject(DI.config) + private config: Config, + + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + ) { + super(meta, paramDef, async (ps, me) => { + // check parameter + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + // abort if user is the root + if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); + // abort if user has already moved + if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + let unfiltered = ps.moveToAccount; + if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget); + + // parse user's input into the destination account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // retrieve the destination account + let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchMoveTarget); + }); + const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id); + if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull); + + // update local db + await this.apPersonService.updatePerson(remoteMoveTo.uri); + // retrieve updated user + moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri); + // only allow moving to a remote account + if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + + let allowed = false; + + const fromUrl = `${this.config.url}/users/${me.id}`; + // make sure that the user has indicated the old account as an alias + moveTo.alsoKnownAs?.forEach((elem) => { + if (fromUrl.includes(elem)) allowed = true; + }); + + // abort if unintended + if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + + return await this.accountMoveService.moveToRemote(me, moveTo); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index e3897d38bd..f27b4e86d4 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,6 +1,7 @@ -import { Brackets } from 'typeorm'; +import { Brackets, In } from 'typeorm'; +import Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -8,6 +9,8 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { Notification } from '@/models/entities/Notification.js'; export const meta = { tags: ['account', 'notifications'], @@ -38,8 +41,6 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - following: { type: 'boolean', default: false }, - unreadOnly: { type: 'boolean', default: false }, markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { @@ -56,21 +57,22 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, private queryService: QueryService, @@ -89,85 +91,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - - const suspendedQuery = this.usersRepository.createQueryBuilder('users') - .select('users.id') - .where('users.isSuspended = TRUE'); - - const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId) - .andWhere('notification.notifieeId = :meId', { meId: me.id }) - .leftJoinAndSelect('notification.notifier', 'notifier') - .leftJoinAndSelect('notification.note', 'note') - .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') - .leftJoinAndSelect('notifier.banner', 'notifierBanner') - .leftJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); - - // muted users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - query.setParameters(mutingQuery.getParameters()); + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 - // muted instances - query.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - query.setParameters(mutingInstanceQuery.getParameters()); - - // suspended users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - - if (ps.following) { - query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); + if (notificationsRes.length === 0) { + return []; } + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[]; + if (includeTypes && includeTypes.length > 0) { - query.andWhere('notification.type IN (:...includeTypes)', { includeTypes }); + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); } else if (excludeTypes && excludeTypes.length > 0) { - query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes }); + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); } - if (ps.unreadOnly) { - query.andWhere('notification.isRead = false'); + if (notifications.length === 0) { + return []; } - const notifications = await query.take(ps.limit).getMany(); - // Mark all as read - if (notifications.length > 0 && ps.markAsRead) { - this.notificationService.readNotification(me.id, notifications.map(x => x.id)); + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); } - const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + const noteIds = notifications + .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); - if (notes.length > 0) { + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); this.noteReadService.read(me.id, notes); } diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index f942f43cc8..23ff63f5e9 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ) { super(meta, paramDef, async (ps, me) => { const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); - const oldToken = freshUser.token; + const oldToken = freshUser.token!; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); @@ -54,11 +54,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // Publish event this.globalEventService.publishInternalEvent('userTokenRegenerated', { id: me.id, oldToken, newToken }); this.globalEventService.publishMainStream(me.id, 'myTokenRegenerated'); - - // Terminate streaming - setTimeout(() => { - this.globalEventService.publishUserEvent(me.id, 'terminate', {}); - }, 5000); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 5e1dddb6b7..93daeb0cd7 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -35,9 +35,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { id: ps.tokenId, userId: me.id, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(me.id, 'terminate'); } }); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b1eaab3908..be1c72b207 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -18,6 +18,8 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -147,11 +149,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { private pagesRepository: PagesRepository, private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, private hashtagService: HashtagService, private roleService: RoleService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); @@ -168,8 +172,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; - if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; if (ps.mutedWords !== undefined) { // TODO: ちゃんと数える const length = JSON.stringify(ps.mutedWords).length; @@ -215,6 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; } if (ps.bannerId) { @@ -222,6 +228,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; } if (ps.pinnedPageId) { @@ -276,9 +286,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { includeSecrets: isSecure, }); + const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + this.cacheService.userProfileCache.set(user.id, updatedProfile); + // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneByOrFail({ userId: user.id })); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 if (user.isLocked && ps.isLocked === false) { diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 9099eea52e..6e24e1024d 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,12 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; import type { MutingsRepository } from '@/models/index.js'; -import type { Muting } from '@/models/entities/Muting.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -62,9 +60,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, private getterService: GetterService, - private idService: IdService, + private userMutingService: UserMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -94,16 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { return; } - // Create mute - await this.mutingsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, - muterId: muter.id, - muteeId: mutee.id, - } as Muting); - - this.globalEventService.publishUserEvent(me.id, 'mute', mutee); + await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null); }); } } diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 612c4a4c04..90b74590be 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MutingsRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], @@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { @@ -76,12 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new ApiError(meta.errors.notMuting); } - // Delete mute - await this.mutingsRepository.delete({ - id: exist.id, - }); - - this.globalEventService.publishUserEvent(me.id, 'unmute', mutee); + await this.userMutingService.unmute([exist]); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 0a8f2292ac..5fbc7aba58 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -49,16 +49,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .andWhere('note.visibility = \'public\'') .andWhere('note.localOnly = FALSE') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.local) { query.andWhere('note.userHost IS NULL'); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index ea7a825f9d..26f2d6772d 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -57,16 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { })); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 6bf17b222a..bdb06498bc 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -53,16 +53,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) .andWhere('note.visibility = \'public\'') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 9118d33936..c11c1eac40 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -73,16 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateRepliesQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 8a7ec65ab4..89abd91c7e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -91,16 +91,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .setParameters(followingQuery.getParameters()); this.queryService.generateChannelQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 8c1c07a9f4..afdafc7c55 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -80,16 +80,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateChannelQuery(query, me); this.queryService.generateRepliesQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index dcb0d0adcb..4e9f604d8d 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -60,16 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index f758bfe9b1..4772c4f809 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { order: { id: -1, }, - relations: ['user', 'user.avatar', 'user.banner', 'note'], + relations: ['user', 'note'], }); return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 026a1baa3e..d406855660 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -62,16 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 4df95962c8..f2af71d55f 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -46,16 +46,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index da1a4bcc46..2956bf1cbd 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -71,16 +71,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 5db5b6267f..fb5abd917f 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -85,16 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { query .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index d0036f0fb7..93517ab10c 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -59,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { this.noteThreadMutingsRepository.count({ where: { userId: me.id, - threadId: note.threadId || note.id, + threadId: note.threadId ?? note.id, }, take: 1, }), diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d9e72d2603..c6ee1e5c2b 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -70,16 +70,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (followees.length > 0) { const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 9b23103fd4..afc9bc4213 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -84,16 +84,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); this.queryService.generateVisibilityQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index 09134cf48f..e601bf9d5b 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -1,9 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { NotificationsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications', 'account'], @@ -23,24 +21,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - // Update documents - await this.notificationsRepository.update({ - notifieeId: me.id, - isRead: false, - }, { - isRead: true, - }); - - // 全ての通知を読みましたよというイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllNotifications'); - this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined); + this.notificationService.readAllNotification(me.id, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts deleted file mode 100644 index 6262c47fd0..0000000000 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -export const meta = { - tags: ['notifications', 'account'], - - requireCredential: true, - - kind: 'write:notifications', - - description: 'Mark a notification as read.', - - errors: { - noSuchNotification: { - message: 'No such notification.', - code: 'NO_SUCH_NOTIFICATION', - id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e', - }, - }, -} as const; - -export const paramDef = { - oneOf: [ - { - type: 'object', - properties: { - notificationId: { type: 'string', format: 'misskey:id' }, - }, - required: ['notificationId'], - }, - { - type: 'object', - properties: { - notificationIds: { - type: 'array', - items: { type: 'string', format: 'misskey:id' }, - maxItems: 100, - }, - }, - required: ['notificationIds'], - }, - ], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint<typeof meta, typeof paramDef> { - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, me) => { - if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]); - return this.notificationService.readNotification(me.id, ps.notificationIds); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index 051a005b67..b285269617 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -92,8 +92,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { muterId: muter.id, muteeId: mutee.id, } as RenoteMuting); - - // publishUserEvent(user.id, 'mute', mutee); }); } } diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts index 51a895fb7e..70901a1406 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -80,8 +80,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { await this.renoteMutingsRepository.delete({ id: exist.id, }); - - // publishUserEvent(user.id, 'unmute', mutee); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index aab32cc58c..aaf94734a3 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -74,16 +74,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.userId = :userId', { userId: user.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 32935325aa..e67aec9ecd 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -23,16 +23,16 @@ export default abstract class Channel { return this.connection.following; } - protected get muting() { - return this.connection.muting; + protected get userIdsWhoMeMuting() { + return this.connection.userIdsWhoMeMuting; } - protected get renoteMuting() { - return this.connection.renoteMuting; + protected get userIdsWhoMeMutingRenotes() { + return this.connection.userIdsWhoMeMutingRenotes; } - protected get blocking() { - return this.connection.blocking; + protected get userIdsWhoBlockingMe() { + return this.connection.userIdsWhoBlockingMe; } protected get followingChannels() { diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index e2a42fbfe9..d48dea7258 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -35,11 +35,11 @@ class AntennaChannel extends Channel { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 12caa7f233..9e5b40997b 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -47,11 +47,11 @@ class ChannelChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index d79247cd6e..5454836fe1 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -64,11 +64,11 @@ class GlobalTimelineChannel extends Channel { if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 98dc858ded..0268fdedde 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -46,11 +46,11 @@ class HashtagChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index c623fef64a..ee874ad81e 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -24,7 +24,6 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { - // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -38,7 +37,7 @@ class HomeTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user!, { @@ -71,18 +70,18 @@ class HomeTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index f54767bc9d..4f7b4e78b6 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -72,7 +72,7 @@ class HybridTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 if (note.reply && !this.user!.showTimelineReplies) { @@ -82,11 +82,11 @@ class HybridTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index eb0642900d..836c5aae6c 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -61,11 +61,11 @@ class LocalTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 4dd16b530a..139320ce35 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -26,7 +26,7 @@ class MainChannel extends Channel { case 'notification': { // Ignore notifications from instances the user has muted if (isUserFromMutedInstance(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; - if (data.body.userId && this.muting.has(data.body.userId)) return; + if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { const note = await this.noteEntityService.pack(data.body.note.id, this.user, { @@ -40,7 +40,7 @@ class MainChannel extends Channel { case 'mention': { if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; - if (this.muting.has(data.body.userId)) return; + if (this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8a42e99a54..8802fc5ab8 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -89,11 +89,11 @@ class UserListChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 7c6eb9a20a..a6f9145952 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,13 +1,11 @@ import type { User } from '@/models/entities/User.js'; -import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import type { UserProfile } from '@/models/entities/UserProfile.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserProfile } from '@/models/index.js'; import type { ChannelsService } from './ChannelsService.js'; import type * as websocket from 'websocket'; import type { EventEmitter } from 'events'; @@ -19,106 +17,71 @@ import type { StreamEventEmitter, StreamMessages } from './types.js'; */ export default class Connection { public user?: User; - public userProfile?: UserProfile | null; - public following: Set<User['id']> = new Set(); - public muting: Set<User['id']> = new Set(); - public renoteMuting: Set<User['id']> = new Set(); - public blocking: Set<User['id']> = new Set(); // "被"blocking - public followingChannels: Set<ChannelModel['id']> = new Set(); public token?: AccessToken; private wsConnection: websocket.connection; public subscriber: StreamEventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; + public userProfile: UserProfile | null = null; + public following: Set<string> = new Set(); + public followingChannels: Set<string> = new Set(); + public userIdsWhoMeMuting: Set<string> = new Set(); + public userIdsWhoBlockingMe: Set<string> = new Set(); + public userIdsWhoMeMutingRenotes: Set<string> = new Set(); + private fetchIntervalId: NodeJS.Timer | null = null; constructor( - private followingsRepository: FollowingsRepository, - private mutingsRepository: MutingsRepository, - private renoteMutingsRepository: RenoteMutingsRepository, - private blockingsRepository: BlockingsRepository, - private channelFollowingsRepository: ChannelFollowingsRepository, - private userProfilesRepository: UserProfilesRepository, private channelsService: ChannelsService, - private globalEventService: GlobalEventService, private noteReadService: NoteReadService, private notificationService: NotificationService, + private cacheService: CacheService, - wsConnection: websocket.connection, subscriber: EventEmitter, user: User | null | undefined, token: AccessToken | null | undefined, ) { - this.wsConnection = wsConnection; this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; + } - //this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); - //this.onUserEvent = this.onUserEvent.bind(this); - //this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this); - //this.onBroadcastMessage = this.onBroadcastMessage.bind(this); - - this.wsConnection.on('message', this.onWsConnectionMessage); - - this.subscriber.on('broadcast', data => { - this.onBroadcastMessage(data); - }); + @bindThis + public async fetch() { + if (this.user == null) return; + const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + this.cacheService.userProfileCache.fetch(this.user.id), + this.cacheService.userFollowingsCache.fetch(this.user.id), + this.cacheService.userFollowingChannelsCache.fetch(this.user.id), + this.cacheService.userMutingsCache.fetch(this.user.id), + this.cacheService.userBlockedCache.fetch(this.user.id), + this.cacheService.renoteMutingsCache.fetch(this.user.id), + ]); + this.userProfile = userProfile; + this.following = following; + this.followingChannels = followingChannels; + this.userIdsWhoMeMuting = userIdsWhoMeMuting; + this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; + this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; + } - if (this.user) { - this.updateFollowing(); - this.updateMuting(); - this.updateRenoteMuting(); - this.updateBlocking(); - this.updateFollowingChannels(); - this.updateUserProfile(); + @bindThis + public async init() { + if (this.user != null) { + await this.fetch(); - this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); } } @bindThis - private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう - switch (data.type) { - case 'follow': - this.following.add(data.body.id); - break; - - case 'unfollow': - this.following.delete(data.body.id); - break; - - case 'mute': - this.muting.add(data.body.id); - break; - - case 'unmute': - this.muting.delete(data.body.id); - break; - - // TODO: renote mute events - // TODO: block events - - case 'followChannel': - this.followingChannels.add(data.body.id); - break; - - case 'unfollowChannel': - this.followingChannels.delete(data.body.id); - break; - - case 'updateUserProfile': - this.userProfile = data.body; - break; - - case 'terminate': - this.wsConnection.close(); - this.dispose(); - break; + public async init2(wsConnection: websocket.connection) { + this.wsConnection = wsConnection; + this.wsConnection.on('message', this.onWsConnectionMessage); - default: - break; - } + this.subscriber.on('broadcast', data => { + this.onBroadcastMessage(data); + }); } /** @@ -186,17 +149,13 @@ export default class Connection { if (note == null) return; if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note], { - following: this.following, - followingChannels: this.followingChannels, - }); + this.noteReadService.read(this.user.id, [note]); } } @bindThis private onReadNotification(payload: any) { - if (!payload.id) return; - this.notificationService.readNotification(this.user!.id, [payload.id]); + this.notificationService.readAllNotification(this.user!.id); } /** @@ -322,78 +281,12 @@ export default class Connection { } } - @bindThis - private async updateFollowing() { - const followings = await this.followingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.following = new Set<string>(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateMuting() { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: this.user!.id, - }, - select: ['muteeId'], - }); - - this.muting = new Set<string>(mutings.map(x => x.muteeId)); - } - - @bindThis - private async updateRenoteMuting() { - const renoteMutings = await this.renoteMutingsRepository.find({ - where: { - muterId: this.user!.id, - }, - select: ['muteeId'], - }); - - this.renoteMuting = new Set<string>(renoteMutings.map(x => x.muteeId)); - } - - @bindThis - private async updateBlocking() { // ここでいうBlockingは被Blockingの意 - const blockings = await this.blockingsRepository.find({ - where: { - blockeeId: this.user!.id, - }, - select: ['blockerId'], - }); - - this.blocking = new Set<string>(blockings.map(x => x.blockerId)); - } - - @bindThis - private async updateFollowingChannels() { - const followings = await this.channelFollowingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.followingChannels = new Set<string>(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateUserProfile() { - this.userProfile = await this.userProfilesRepository.findOneBy({ - userId: this.user!.id, - }); - } - /** * ストリームが切れたとき */ @bindThis public dispose() { + if (this.fetchIntervalId) clearInterval(this.fetchIntervalId); for (const c of this.channels.filter(c => c.dispose)) { if (c.dispose) c.dispose(); } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index b8f50e0546..ed73897e73 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -19,7 +19,7 @@ import type { EventEmitter } from 'events'; //#region Stream type-body definitions export interface InternalStreamTypes { userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; - userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; + userTokenRegenerated: { id: User['id']; oldToken: string; newToken: string; }; remoteUserUpdated: { id: User['id']; }; follow: { followerId: User['id']; followeeId: User['id']; }; unfollow: { followerId: User['id']; followeeId: User['id']; }; @@ -38,6 +38,11 @@ export interface InternalStreamTypes { antennaDeleted: Antenna; antennaUpdated: Antenna; metaUpdated: Meta; + followChannel: { userId: User['id']; channelId: Channel['id']; }; + unfollowChannel: { userId: User['id']; channelId: Channel['id']; }; + updateUserProfile: UserProfile; + mute: { muterId: User['id']; muteeId: User['id']; }; + unmute: { muterId: User['id']; muteeId: User['id']; }; } export interface BroadcastTypes { @@ -56,18 +61,6 @@ export interface BroadcastTypes { }; } -export interface UserStreamTypes { - terminate: Record<string, unknown>; - followChannel: Channel; - unfollowChannel: Channel; - updateUserProfile: UserProfile; - mute: User; - unmute: User; - follow: Packed<'UserDetailedNotMe'>; - unfollow: Packed<'User'>; - userAdded: Packed<'User'>; -} - export interface MainStreamTypes { notification: Packed<'Notification'>; mention: Packed<'Note'>; @@ -97,8 +90,6 @@ export interface MainStreamTypes { readAllAntennas: undefined; unreadAntenna: Antenna; readAllAnnouncements: undefined; - readAllChannels: undefined; - unreadChannel: Note['id']; myTokenRegenerated: undefined; signin: Signin; registryUpdated: { @@ -202,10 +193,6 @@ export type StreamMessages = { name: 'broadcast'; payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>; }; - user: { - name: `user:${User['id']}`; - payload: EventUnionFromDictionary<SerializedAll<UserStreamTypes>>; - }; main: { name: `mainStream:${User['id']}`; payload: EventUnionFromDictionary<SerializedAll<MainStreamTypes>>; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index fb76f07e48..99ae1b7af6 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -419,7 +419,7 @@ export class ClientServerService { reply.header('Cache-Control', 'public, max-age=15'); return await reply.view('user', { user, profile, me, - avatarUrl: await this.userEntityService.getAvatarUrl(user), + avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), sub: request.params.sub, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, @@ -464,7 +464,7 @@ export class ClientServerService { return await reply.view('note', { note: _note, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })), + avatarUrl: _note.user.avatarUrl, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note), instanceName: meta.name ?? 'Misskey', @@ -503,7 +503,7 @@ export class ClientServerService { return await reply.view('page', { page: _page, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })), + avatarUrl: _page.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -527,7 +527,7 @@ export class ClientServerService { return await reply.view('flash', { flash: _flash, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })), + avatarUrl: _flash.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -551,7 +551,7 @@ export class ClientServerService { return await reply.view('clip', { clip: _clip, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })), + avatarUrl: _clip.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -573,7 +573,7 @@ export class ClientServerService { return await reply.view('gallery-post', { post: _post, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })), + avatarUrl: _post.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index a14609adf9..0c0e92cc04 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -58,7 +58,7 @@ export class FeedService { generator: 'Misskey', description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, - image: await this.userEntityService.getAvatarUrl(user), + image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index a9a0dfd4ee..d772ac3184 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -36,7 +36,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.10.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.12.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists |