fix user list API schema, access checks, and caching

This commit is contained in:
Hazelnoot 2025-10-23 19:22:30 -04:00
parent 2a948b7710
commit 73cc50fe90
22 changed files with 233 additions and 135 deletions

View file

@ -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<Set<string>>; // NOTE: 「被」Blockキャッシュ
public readonly userListMembershipsCache: ManagedQuantumKVCache<Map<string, MiUserListMembership>>;
public readonly listUserMembershipsCache: ManagedQuantumKVCache<Map<string, MiUserListMembership>>;
/**
* Maps user IDs (key) to the set of list IDs (value) that are favorited by that user
*/
public readonly userListFavoritesCache: ManagedQuantumKVCache<Set<string>>;
/**
* Maps list IDs (key) to the set of user IDs (value) who have favorited this list.
*/
public readonly listUserFavoritesCache: ManagedQuantumKVCache<Set<string>>;
/**
* Maps user IDs (key) to the set of user IDs (value) who's renotes are muted by that user.
*/
public readonly renoteMutingsCache: ManagedQuantumKVCache<Set<string>>;
public readonly threadMutingsCache: ManagedQuantumKVCache<Set<string>>;
public readonly noteMutingsCache: ManagedQuantumKVCache<Set<string>>;
@ -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<string, Map<string, MiUserListMembership>>)),
});
this.userListFavoritesCache = cacheManagementService.createQuantumKVCache<Set<string>>('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<Set<string>>('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<Set<string>>('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))),

View file

@ -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<MiUserList>;
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<void> {
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(),

View file

@ -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<Packed<'UserList'>> {
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,
};
}

View file

@ -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;

View file

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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;

View file

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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, {

View file

@ -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<typeof meta, typeof paramDef> { // 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,

View file

@ -96,13 +96,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // eslint-
throw err;
}
}
return await this.userListEntityService.pack(userList);
return await this.userListEntityService.pack(userList, me.id);
});
}
}

View file

@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name,
} as MiUserList);
return await this.userListEntityService.pack(userList);
return await this.userListEntityService.pack(userList, me.id);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> { // 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()),
]);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> {
@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<typeof meta, typeof paramDef> {
userId: me.id,
userListId: ps.listId,
});
// Update caches directly since the Set instances are shared
myFavorites.add(ps.listId);
listFavorites.add(me.id);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> {
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');

View file

@ -88,7 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
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)));
});
}
}

View file

@ -63,12 +63,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
}

View file

@ -91,14 +91,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
}

View file

@ -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<typeof meta, typeof paramDef> {
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);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> {
@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);
});
}
}

View file

@ -62,12 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
}

View file

@ -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<typeof meta, typeof paramDef> { // 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);
});
}
}

View file

@ -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<true> {
private userListMembershipsRepository: UserListMembershipsRepository,
private noteEntityService: NoteEntityService,
private readonly userListService: UserListService,
) {
}
@ -105,6 +104,7 @@ export class UserListChannelService implements MiChannelService<true> {
return new UserListChannel(
this.userListsRepository,
this.userListMembershipsRepository,
this.userListService,
this.noteEntityService,
id,
connection,

View file

@ -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 });

View file

@ -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'];