fix user list API schema, access checks, and caching
This commit is contained in:
parent
2a948b7710
commit
73cc50fe90
22 changed files with 233 additions and 135 deletions
|
|
@ -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))),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue