diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index db77f4b564..c7d65087f9 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In, IsNull, Not } from 'typeorm'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, NoteThreadMutingsRepository, ChannelFollowingsRepository, UserListMembershipsRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, NoteThreadMutingsRepository, ChannelFollowingsRepository, UserListMembershipsRepository, UserListFavoritesRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiUserListMembership } from '@/models/UserListMembership.js'; import { isLocalUser, isRemoteUser } from '@/models/User.js'; @@ -41,6 +41,20 @@ export class CacheService implements OnApplicationShutdown { public readonly userBlockedCache: ManagedQuantumKVCache>; // NOTE: 「被」Blockキャッシュ public readonly userListMembershipsCache: ManagedQuantumKVCache>; public readonly listUserMembershipsCache: ManagedQuantumKVCache>; + + /** + * Maps user IDs (key) to the set of list IDs (value) that are favorited by that user + */ + public readonly userListFavoritesCache: ManagedQuantumKVCache>; + + /** + * Maps list IDs (key) to the set of user IDs (value) who have favorited this list. + */ + public readonly listUserFavoritesCache: ManagedQuantumKVCache>; + + /** + * Maps user IDs (key) to the set of user IDs (value) who's renotes are muted by that user. + */ public readonly renoteMutingsCache: ManagedQuantumKVCache>; public readonly threadMutingsCache: ManagedQuantumKVCache>; public readonly noteMutingsCache: ManagedQuantumKVCache>; @@ -84,6 +98,9 @@ export class CacheService implements OnApplicationShutdown { @Inject(DI.userListMembershipsRepository) private readonly userListMembershipsRepository: UserListMembershipsRepository, + @Inject(DI.userListFavoritesRepository) + private readonly userListFavoritesRepository: UserListFavoritesRepository, + private readonly internalEventService: InternalEventService, private readonly cacheManagementService: CacheManagementService, ) { @@ -173,6 +190,32 @@ export class CacheService implements OnApplicationShutdown { }, new Map>)), }); + this.userListFavoritesCache = cacheManagementService.createQuantumKVCache>('userListFavorites', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: async userId => await this.userListFavoritesRepository.findBy({ userId }).then(fs => new Set(fs.map(f => f.userListId))), + bulkFetcher: async userIds => await this.userListFavoritesRepository + .createQueryBuilder('favorite') + .select('"favorite"."userId"', 'userId') + .addSelect('array_agg("favorite"."userListId")', 'userListIds') + .where({ userId: In(userIds) }) + .groupBy('favorite.userId') + .getRawMany<{ userId: string, userListIds: string[] }>() + .then(fs => fs.map(f => [f.userId, new Set(f.userListIds)])), + }); + + this.listUserFavoritesCache = cacheManagementService.createQuantumKVCache>('listUserFavorites', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: async userListId => await this.userListFavoritesRepository.findBy({ userListId }).then(fs => new Set(fs.map(f => f.userId))), + bulkFetcher: async userListIds => await this.userListFavoritesRepository + .createQueryBuilder('favorite') + .select('"favorite"."userListId"', 'userListId') + .addSelect('array_agg("favorite"."userId")', 'userIds') + .where({ userListId: In(userListIds) }) + .groupBy('favorite.userListId') + .getRawMany<{ userListId: string, userIds: string[] }>() + .then(fs => fs.map(f => [f.userListId, new Set(f.userIds)])), + }); + this.renoteMutingsCache = this.cacheManagementService.createQuantumKVCache>('renoteMutings', { lifetime: 1000 * 60 * 30, // 30m fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 17ecdfca54..29ea6fdc4d 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -21,11 +21,14 @@ import type { RoleService } from '@/core/RoleService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { InternalEventService } from '@/global/InternalEventService.js'; import { CacheService } from '@/core/CacheService.js'; +import { CacheManagementService, type ManagedQuantumKVCache } from '@/global/CacheManagementService.js'; @Injectable() export class UserListService implements OnModuleInit { public static TooManyUsersError = class extends Error {}; + public readonly userListsCache: ManagedQuantumKVCache; + private roleService: RoleService; constructor( @@ -53,7 +56,15 @@ export class UserListService implements OnModuleInit { private systemAccountService: SystemAccountService, private readonly internalEventService: InternalEventService, private readonly cacheService: CacheService, - ) {} + + cacheManagementService: CacheManagementService, + ) { + this.userListsCache = cacheManagementService.createQuantumKVCache('userLists', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: async id => await this.userListsRepository.findOneBy({ id }), + bulkFetcher: async ids => await this.userListsRepository.findBy({ id: In(ids) }).then(ls => ls.map(l => [l.id, l])), + }); + } @bindThis async onModuleInit() { @@ -120,14 +131,10 @@ export class UserListService implements OnModuleInit { @bindThis public async bulkAddMember(target: { id: MiUser['id'] }, memberships: { userListId: MiUserList['id'], withReplies?: boolean }[]): Promise { const userListIds = memberships.map(m => m.userListId); + const userLists = await this.userListsCache.fetchMany(userListIds); // Map userListId => userListUserId - const listUserIds = await this.userListsRepository - .find({ - where: { id: In(userListIds) }, - select: { id: true, userId: true }, - }) - .then(ls => new Map(ls.map(l => [l.id, l.userId]))); + const listUserIds = new Map(userLists.values.map(l => [l.id, l.userId])); const toInsert = memberships.map(membership => ({ id: this.idService.gen(), diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 1693f67861..2722d52195 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -12,6 +12,7 @@ import type { MiUserList } from '@/models/UserList.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; +import { UserListService } from '@/core/UserListService.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,26 +27,36 @@ export class UserListEntityService { private userEntityService: UserEntityService, private idService: IdService, private readonly cacheService: CacheService, + private readonly userListService: UserListService, ) { } @bindThis public async pack( src: MiUserList['id'] | MiUserList, + meId: string | null | undefined, ): Promise> { const srcId = typeof(src) === 'object' ? src.id : src; - const [userList, users] = await Promise.all([ - typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }), + const [userList, users, favorites] = await Promise.all([ + typeof src === 'object' ? src : this.userListService.userListsCache.fetch(src), this.cacheService.listUserMembershipsCache.fetch(srcId), + this.cacheService.listUserFavoritesCache.fetch(srcId), ]); return { id: userList.id, createdAt: this.idService.parse(userList.id).date.toISOString(), + createdBy: userList.userId, name: userList.name, userIds: users.keys().toArray(), isPublic: userList.isPublic, + isLiked: meId != null + ? favorites.has(meId) + : undefined, + likedCount: userList.isPublic || meId === userList.userId + ? favorites.size + : undefined, }; } diff --git a/packages/backend/src/models/json-schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts index dc9af25602..75dcc991eb 100644 --- a/packages/backend/src/models/json-schema/user-list.ts +++ b/packages/backend/src/models/json-schema/user-list.ts @@ -17,13 +17,18 @@ export const packedUserListSchema = { optional: false, nullable: false, format: 'date-time', }, + createdBy: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, name: { type: 'string', optional: false, nullable: false, }, userIds: { type: 'array', - nullable: false, optional: true, + nullable: false, optional: false, items: { type: 'string', nullable: false, optional: false, @@ -35,5 +40,15 @@ export const packedUserListSchema = { nullable: false, optional: false, }, + isLiked: { + type: 'boolean', + nullable: false, + optional: true, + }, + likedCount: { + type: 'number', + nullable: false, + optional: true, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 52c6dd1f4c..435a47d8ba 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -9,6 +9,7 @@ import { IdService } from '@/core/IdService.js'; import type { UserListsRepository, AntennasRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { UserListService } from '@/core/UserListService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { TimeService } from '@/global/TimeService.js'; @@ -99,6 +100,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private globalEventService: GlobalEventService, private readonly timeService: TimeService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) { @@ -115,14 +117,15 @@ export default class extends Endpoint { // eslint- let userList; if (ps.src === 'list' && ps.userListId) { - userList = await this.userListsRepository.findOneBy({ - id: ps.userListId, - userId: me.id, - }); + userList = await this.userListService.userListsCache.fetchMaybe(ps.userListId); if (userList == null) { throw new ApiError(meta.errors.noSuchUserList); } + + if (!userList.isPublic && userList.userId !== me.id) { + throw new ApiError(meta.errors.noSuchUserList); + } } const now = this.timeService.date; diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 6ed10cb618..e9000aa8cd 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AntennasRepository, UserListsRepository } from '@/models/_.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { UserListService } from '@/core/UserListService.js'; import { TimeService } from '@/global/TimeService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -96,6 +97,7 @@ export default class extends Endpoint { // eslint- private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, private readonly timeService: TimeService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { if (ps.keywords && ps.excludeKeywords) { @@ -116,14 +118,15 @@ export default class extends Endpoint { // eslint- let userList; if ((ps.src === 'list' || antenna.src === 'list') && ps.userListId) { - userList = await this.userListsRepository.findOneBy({ - id: ps.userListId, - userId: me.id, - }); + userList = await this.userListService.userListsCache.fetchMaybe(ps.userListId); if (userList == null) { throw new ApiError(meta.errors.noSuchUserList); } + + if (!userList.isPublic && userList.userId !== me.id) { + throw new ApiError(meta.errors.noSuchUserList); + } } await this.antennasRepository.update(antenna.id, { 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 7fb671a446..517cc822db 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 @@ -14,6 +14,8 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { UserListService } from '@/core/UserListService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -87,20 +89,23 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private readonly userListService: UserListService, + private readonly roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const list = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); + const list = await this.userListService.userListsCache.fetchMaybe(ps.listId); if (list == null) { throw new ApiError(meta.errors.noSuchList); } + if (!list.isPublic && list.userId !== me.id) { + throw new ApiError(meta.errors.noSuchList); + } + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb(list, { untilId, diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index 94986d22ea..52246033e2 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -96,13 +96,9 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const listExist = await this.userListsRepository.exists({ - where: { - id: ps.listId, - isPublic: true, - }, - }); + const listExist = await this.userListService.userListsCache.fetchMaybe(ps.listId); if (!listExist) throw new ApiError(meta.errors.noSuchList); + if (!listExist.isPublic && listExist.userId !== me.id) throw new ApiError(meta.errors.noSuchList); const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); @@ -158,7 +154,7 @@ export default class extends Endpoint { // eslint- throw err; } } - return await this.userListEntityService.pack(userList); + return await this.userListEntityService.pack(userList, me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index c3ea392e89..061253a6a9 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -77,7 +77,7 @@ export default class extends Endpoint { // eslint- name: ps.name, } as MiUserList); - return await this.userListEntityService.pack(userList); + return await this.userListEntityService.pack(userList, me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts index 941ce77877..eb5eb6829d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/delete.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserListService } from '@/core/UserListService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -46,18 +48,29 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + + private readonly cacheService: CacheService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); + const [userList, listMembership, listFavorites] = await Promise.all([ + this.userListService.userListsCache.fetchMaybe(ps.listId), + this.cacheService.listUserMembershipsCache.fetch(ps.listId), + this.cacheService.listUserFavoritesCache.fetch(ps.listId), + ]); - if (userList == null) { + if (userList == null || userList.userId !== me.id) { throw new ApiError(meta.errors.noSuchList); } - await this.userListsRepository.delete(userList.id); + await Promise.all([ + this.userListsRepository.delete(userList.id), + this.userListService.userListsCache.delete(userList.id), + this.cacheService.listUserFavoritesCache.delete(userList.id), + this.cacheService.listUserMembershipsCache.delete(userList.id), + this.cacheService.userListFavoritesCache.deleteMany(listFavorites), + this.cacheService.userListMembershipsCache.deleteMany(listMembership.keys()), + ]); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts index fa898b0dc7..2f16ad81f1 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/favorite.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/favorite.ts @@ -9,6 +9,8 @@ import type { UserListFavoritesRepository, UserListsRepository } from '@/models/ import { IdService } from '@/core/IdService.js'; import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserListService } from '@/core/UserListService.js'; export const meta = { requireCredential: true, @@ -50,28 +52,27 @@ export default class extends Endpoint { @Inject(DI.userListFavoritesRepository) private userListFavoritesRepository: UserListFavoritesRepository, + + private readonly cacheService: CacheService, + private readonly userListService: UserListService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const userListExist = await this.userListsRepository.exists({ - where: { - id: ps.listId, - isPublic: true, - }, - }); + const [userListExist, myFavorites, listFavorites] = await Promise.all([ + this.userListService.userListsCache.fetchMaybe(ps.listId), + this.cacheService.userListFavoritesCache.fetch(me.id), + this.cacheService.listUserFavoritesCache.fetch(ps.listId), + ]); if (!userListExist) { throw new ApiError(meta.errors.noSuchList); } - const exist = await this.userListFavoritesRepository.exists({ - where: { - userId: me.id, - userListId: ps.listId, - }, - }); + if (!userListExist.isPublic && userListExist.userId !== me.id) { + throw new ApiError(meta.errors.noSuchList); + } - if (exist) { + if (myFavorites.has(ps.listId) || listFavorites.has(me.id)) { throw new ApiError(meta.errors.alreadyFavorited); } @@ -80,6 +81,10 @@ export default class extends Endpoint { userId: me.id, userListId: ps.listId, }); + + // Update caches directly since the Set instances are shared + myFavorites.add(ps.listId); + listFavorites.add(me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts index 18373fdf07..559f08b654 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/get-memberships.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UserListsRepository, UserListFavoritesRepository, UserListMembershipsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { UserListService } from '@/core/UserListService.js'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '../../../error.js'; @@ -85,21 +86,20 @@ export default class extends Endpoint { private userListEntityService: UserListEntityService, private queryService: QueryService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { // Fetch the list - const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { - id: ps.listId, - userId: me.id, - } : { - id: ps.listId, - isPublic: true, - }); + const userList = await this.userListService.userListsCache.fetchMaybe(ps.listId); if (userList == null) { throw new ApiError(meta.errors.noSuchList); } + if (!userList.isPublic && userList.userId !== me?.id) { + throw new ApiError(meta.errors.noSuchList); + } + const query = this.queryService.makePaginationQuery(this.userListMembershipsRepository.createQueryBuilder('membership'), ps.sinceId, ps.untilId) .andWhere('membership.userListId = :userListId', { userListId: userList.id }) .innerJoinAndSelect('membership.user', 'user'); diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts index 7f17863a63..976da9512d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/list.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -88,7 +88,7 @@ export default class extends Endpoint { isPublic: true, }); - return await Promise.all(userLists.map(x => this.userListEntityService.pack(x))); + return await Promise.all(userLists.map(x => this.userListEntityService.pack(x, me?.id))); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index 1eb4d4ef42..835bee84c0 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -63,12 +63,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); + const userList = await this.userListService.userListsCache.fetchMaybe(ps.listId); - if (userList == null) { + if (userList == null || userList.userId !== me.id) { throw new ApiError(meta.errors.noSuchList); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 4171b0a35b..6ddc98aa73 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -91,14 +91,11 @@ export default class extends Endpoint { // eslint- const [user, blockings, userList, exist] = await Promise.all([ this.cacheService.findOptionalUserById(ps.userId), this.cacheService.userBlockingCache.fetch(ps.userId), - this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }), + this.userListService.userListsCache.fetchMaybe(ps.listId), this.cacheService.listUserMembershipsCache.fetch(ps.listId).then(ms => ms.has(ps.userId)), ]); - if (userList == null) { + if (userList == null || userList.userId !== me.id) { throw new ApiError(meta.errors.noSuchList); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index c7f4128b56..5e10f39be5 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UserListsRepository, UserListFavoritesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; +import { UserListService } from '@/core/UserListService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -59,41 +60,21 @@ export default class extends Endpoint { private userListFavoritesRepository: UserListFavoritesRepository, private userListEntityService: UserListEntityService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { - const additionalProperties: Partial<{ likedCount: number, isLiked: boolean }> = {}; // Fetch the list - const userList = await this.userListsRepository.findOneBy(!ps.forPublic && me !== null ? { - id: ps.listId, - userId: me.id, - } : { - id: ps.listId, - isPublic: true, - }); + const userList = await this.userListService.userListsCache.fetchMaybe(ps.listId); if (userList == null) { throw new ApiError(meta.errors.noSuchList); } - if (ps.forPublic && userList.isPublic) { - additionalProperties.likedCount = await this.userListFavoritesRepository.countBy({ - userListId: ps.listId, - }); - if (me !== null) { - additionalProperties.isLiked = await this.userListFavoritesRepository.exists({ - where: { - userId: me.id, - userListId: ps.listId, - }, - }); - } else { - additionalProperties.isLiked = false; - } + if (!userList.isPublic && userList.userId !== me?.id) { + throw new ApiError(meta.errors.noSuchList); } - return { - ...await this.userListEntityService.pack(userList), - ...additionalProperties, - }; + + return await this.userListEntityService.pack(userList, me?.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts index 4d38f7d0a7..2cac1d7557 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/unfavorite.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UserListFavoritesRepository, UserListsRepository } from '@/models/_.js'; import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserListService } from '@/core/UserListService.js'; export const meta = { requireCredential: true, @@ -49,29 +51,37 @@ export default class extends Endpoint { @Inject(DI.userListFavoritesRepository) private userListFavoritesRepository: UserListFavoritesRepository, + + private readonly cacheService: CacheService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { - const userListExist = await this.userListsRepository.exists({ - where: { - id: ps.listId, - isPublic: true, - }, - }); + const [userListExist, myFavorites, listFavorites] = await Promise.all([ + this.userListService.userListsCache.fetchMaybe(ps.listId), + this.cacheService.userListFavoritesCache.fetch(me.id), + this.cacheService.listUserFavoritesCache.fetch(ps.listId), + ]); if (!userListExist) { throw new ApiError(meta.errors.noSuchList); } - const exist = await this.userListFavoritesRepository.findOneBy({ - userListId: ps.listId, - userId: me.id, - }); + if (!userListExist.isPublic && userListExist.userId !== me.id) { + throw new ApiError(meta.errors.noSuchList); + } - if (exist === null) { + if (!myFavorites.has(ps.listId) && !listFavorites.has(me.id)) { throw new ApiError(meta.errors.notFavorited); } - await this.userListFavoritesRepository.delete({ id: exist.id }); + await this.userListFavoritesRepository.delete({ + userId: me.id, + userListId: ps.listId, + }); + + // Update caches directly since the Set instances are shared + myFavorites.delete(ps.listId); + listFavorites.delete(me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts index 0539fadd35..4ff99eaf4e 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update-membership.ts @@ -62,12 +62,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { // Fetch the list - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); + const userList = await this.userListService.userListsCache.fetchMaybe(ps.listId); - if (userList == null) { + if (userList == null || userList.userId !== me.id) { throw new ApiError(meta.errors.noSuchList); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts index ad2f8c02e0..67f4d69325 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/update.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -8,6 +8,7 @@ import type { UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserListEntityService } from '@/core/entities/UserListEntityService.js'; import { DI } from '@/di-symbols.js'; +import { UserListService } from '@/core/UserListService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -57,23 +58,24 @@ export default class extends Endpoint { // eslint- private userListsRepository: UserListsRepository, private userListEntityService: UserListEntityService, + private readonly userListService: UserListService, ) { super(meta, paramDef, async (ps, me) => { - const userList = await this.userListsRepository.findOneBy({ - id: ps.listId, - userId: me.id, - }); + const userList = await this.userListService.userListsCache.fetchMaybe(ps.listId); - if (userList == null) { + if (userList == null || userList.userId !== me.id) { throw new ApiError(meta.errors.noSuchList); } - await this.userListsRepository.update(userList.id, { - name: ps.name, - isPublic: ps.isPublic, - }); + await Promise.all([ + this.userListsRepository.update(userList.id, { + name: ps.name, + isPublic: ps.isPublic, + }), + this.userListService.userListsCache.delete(userList.id), + ]); - return await this.userListEntityService.pack(userList.id); + return await this.userListEntityService.pack(userList.id, me.id); }); } } 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 3bba4fb002..b9e2f92161 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { UserListService } from '@/core/UserListService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { isPackedPureRenote } from '@/misc/is-renote.js'; @@ -25,6 +26,7 @@ class UserListChannel extends Channel { constructor( private userListsRepository: UserListsRepository, private userListMembershipsRepository: UserListMembershipsRepository, + private readonly userListService: UserListService, noteEntityService: NoteEntityService, id: string, @@ -43,13 +45,9 @@ class UserListChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); // Check existence and owner - const listExist = await this.userListsRepository.exists({ - where: { - id: this.listId, - userId: this.user!.id, - }, - }); + const listExist = await this.userListService.userListsCache.fetchMaybe(this.listId); if (!listExist) return; + if (!listExist.isPublic && listExist.userId !== this.user?.id) return; // Subscribe stream this.subscriber?.on(`userListStream:${this.listId}`, this.send); @@ -97,6 +95,7 @@ export class UserListChannelService implements MiChannelService { private userListMembershipsRepository: UserListMembershipsRepository, private noteEntityService: NoteEntityService, + private readonly userListService: UserListService, ) { } @@ -105,6 +104,7 @@ export class UserListChannelService implements MiChannelService { return new UserListChannel( this.userListsRepository, this.userListMembershipsRepository, + this.userListService, this.noteEntityService, id, connection, diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index 751b32f46f..339257a2ec 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -62,29 +62,38 @@ function fetchList(): void { } function like() { + const listInstance = list.value; + if (!listInstance) return; + os.apiWithDialog('users/lists/favorite', { - listId: list.value.id, + listId: listInstance.id, }).then(() => { - list.value.isLiked = true; - list.value.likedCount++; + listInstance.isLiked = true; + listInstance.likedCount++; }); } function unlike() { + const listInstance = list.value; + if (!listInstance) return; + os.apiWithDialog('users/lists/unfavorite', { - listId: list.value.id, + listId: listInstance.id, }).then(() => { - list.value.isLiked = false; - list.value.likedCount--; + listInstance.isLiked = false; + listInstance.likedCount--; }); } async function create() { + const listInstance = list.value; + if (!listInstance) return; + const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); if (canceled) return; - await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.value.id }); + await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: listInstance.id }); } watch(() => props.listId, fetchList, { immediate: true }); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d5295a2dc8..b2d31bcfe1 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -10356,9 +10356,13 @@ export type components = { id: string; /** Format: date-time */ createdAt: string; + /** Format: id */ + createdBy: string; name: string; - userIds?: string[]; + userIds: string[]; isPublic: boolean; + isLiked?: boolean; + likedCount?: number; }; Achievement: { name: components['schemas']['AchievementName'];