Files
fluxer/fluxer_api/src/api/admin/services/AdminUserService.ts
T

334 lines
13 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import type {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {AdminUserBanService} from '@app/api/admin/services/AdminUserBanService';
import {AdminUserDeletionService} from '@app/api/admin/services/AdminUserDeletionService';
import {AdminUserLookupService} from '@app/api/admin/services/AdminUserLookupService';
import {AdminUserProfileService} from '@app/api/admin/services/AdminUserProfileService';
import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurityService';
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {getBillingRepository} from '@app/api/middleware/ServiceRegistry';
import {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
CancelBulkMessageDeletionRequest,
ListUserChangeLogRequest,
ListUserDmChannelsRequest,
ListUserGroupDmChannelsRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type Stripe from 'stripe';
interface AdminUserServiceDeps {
apiContext: ApiContext;
guildRepository: IGuildRepositoryAggregate;
channelRepository: IChannelRepository;
discriminatorService: IDiscriminatorService;
entityAssetService: EntityAssetService;
auditService: AdminAuditService;
userCacheService: UserCacheService;
banManagementService: AdminBanManagementService;
kvDeletionQueue: KVAccountDeletionQueueService;
bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
stripe: Stripe | null;
reportService: ReportService;
storeEntitlementService: StoreEntitlementService;
}
export class AdminUserService {
readonly lookupService: AdminUserLookupService;
readonly profileService: AdminUserProfileService;
readonly securityService: AdminUserSecurityService;
readonly banService: AdminUserBanService;
readonly deletionService: AdminUserDeletionService;
private readonly updatePropagator: AdminUserUpdatePropagator;
private readonly contactChangeLogService: UserContactChangeLogService;
private readonly auditService: AdminAuditService;
private readonly userRepository: IUserRepository;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly channelRepository: IChannelRepository;
private readonly bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService;
private readonly cacheService: ICacheService;
constructor(deps: AdminUserServiceDeps) {
const {users, cache, gateway, contactChangeLog} = deps.apiContext.services;
this.updatePropagator = new AdminUserUpdatePropagator({
userCacheService: deps.userCacheService,
userRepository: users,
guildRepository: deps.guildRepository,
gatewayService: gateway,
});
this.userRepository = users;
this.guildRepository = deps.guildRepository;
this.channelRepository = deps.channelRepository;
this.auditService = deps.auditService;
this.bulkMessageDeletionQueue = deps.bulkMessageDeletionQueue;
this.cacheService = cache;
this.lookupService = new AdminUserLookupService({
apiContext: deps.apiContext,
});
this.profileService = new AdminUserProfileService({
apiContext: deps.apiContext,
discriminatorService: deps.discriminatorService,
entityAssetService: deps.entityAssetService,
auditService: deps.auditService,
updatePropagator: this.updatePropagator,
guildRepository: deps.guildRepository,
});
this.securityService = new AdminUserSecurityService({
apiContext: deps.apiContext,
auditService: deps.auditService,
updatePropagator: this.updatePropagator,
});
this.banService = new AdminUserBanService({
apiContext: deps.apiContext,
auditService: deps.auditService,
updatePropagator: this.updatePropagator,
});
this.deletionService = new AdminUserDeletionService({
apiContext: deps.apiContext,
auditService: deps.auditService,
banManagementService: deps.banManagementService,
reportService: deps.reportService,
updatePropagator: this.updatePropagator,
kvDeletionQueue: deps.kvDeletionQueue,
stripe: deps.stripe,
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
storeEntitlementService: deps.storeEntitlementService,
ncmecRepository: new NcmecRepository(),
});
this.contactChangeLogService = contactChangeLog;
}
async listUserDmChannels(data: ListUserDmChannelsRequest) {
const userId = createUserID(data.user_id);
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const channels = await this.userRepository.listHistoricalDmChannelsPaginated(userId, {
limit: data.limit,
beforeChannelId: data.before ? createChannelID(data.before) : undefined,
afterChannelId: data.after ? createChannelID(data.after) : undefined,
});
const channelModels = await this.channelRepository.listChannels(channels.map((channel) => channel.channelId));
const channelModelById = new Map(
channelModels.filter((c): c is NonNullable<typeof c> => c != null).map((c) => [c.id, c]),
);
const guildIds = channelModels
.map((channel) => channel?.guildId)
.filter((guildId): guildId is NonNullable<typeof guildId> => guildId !== null && guildId !== undefined);
const guilds = await this.guildRepository.listGuilds(guildIds);
const guildNsfwLevels = new Map(guilds.map((guild) => [guild.id.toString(), guild.nsfwLevel]));
const channelNsfwById = new Map(channelModels.map((channel) => [channel?.id.toString(), channel?.isNsfw ?? null]));
const channelGuildIdById = new Map(
channelModels.map((channel) => [channel?.id.toString(), channel?.guildId ?? null]),
);
const allRecipientIds = new Set<UserID>();
for (const channel of channels) {
for (const recipientId of channel.recipientIds) {
allRecipientIds.add(recipientId);
}
}
for (const model of channelModels) {
if (model?.ownerId) {
allRecipientIds.add(model.ownerId);
}
}
const resolvedUsers = await this.resolveUsers([...allRecipientIds]);
return {
channels: channels.map((channel) => {
const model = channelModelById.get(channel.channelId);
return {
channel_id: channel.channelId.toString(),
channel_type: channel.channelType,
channel_nsfw: channelNsfwById.get(channel.channelId.toString()) ?? null,
guild_nsfw_level: (() => {
const guildId = channelGuildIdById.get(channel.channelId.toString());
if (!guildId) {
return null;
}
return guildNsfwLevels.get(guildId.toString()) ?? null;
})(),
recipient_ids: channel.recipientIds.map((recipientId) => recipientId.toString()),
recipients: channel.recipientIds
.map((recipientId) => resolvedUsers.get(recipientId))
.filter((u): u is NonNullable<typeof u> => u != null),
last_message_id: channel.lastMessageId?.toString() ?? null,
is_open: channel.open,
name: model?.name ?? null,
icon: model?.iconHash ?? null,
owner_id: model?.ownerId?.toString() ?? null,
};
}),
};
}
private async resolveUsers(userIds: Array<UserID>): Promise<
Map<
UserID,
{
id: string;
username: string;
discriminator: string;
global_name: string | null;
avatar: string | null;
}
>
> {
const results = new Map<
UserID,
{
id: string;
username: string;
discriminator: string;
global_name: string | null;
avatar: string | null;
}
>();
const users = await Promise.all(userIds.map((id) => this.userRepository.findUnique(id)));
for (let i = 0; i < userIds.length; i++) {
const user = users[i];
if (user) {
results.set(userIds[i], {
id: user.id.toString(),
username: user.username,
discriminator: String(user.discriminator).padStart(4, '0'),
global_name: user.globalName,
avatar: user.avatarHash,
});
}
}
return results;
}
async listUserGroupDmChannels(data: ListUserGroupDmChannelsRequest) {
const userId = createUserID(data.user_id);
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const summaries = await this.userRepository.listPrivateChannelSummaries(userId);
const groupDmSummaries = summaries.filter((s) => s.isGroupDm);
if (groupDmSummaries.length === 0) {
return {channels: []};
}
const channelModels = await this.channelRepository.listChannels(groupDmSummaries.map((s) => s.channelId));
const allUserIds = new Set<UserID>();
for (const model of channelModels) {
if (model) {
for (const recipientId of model.recipientIds) {
allUserIds.add(recipientId);
}
if (model.ownerId) {
allUserIds.add(model.ownerId);
}
}
}
const resolvedUsers = await this.resolveUsers([...allUserIds]);
return {
channels: groupDmSummaries.map((summary, i) => {
const model = channelModels[i];
const recipientIds = model ? [...model.recipientIds] : [];
return {
channel_id: summary.channelId.toString(),
channel_type: summary.channelType,
channel_nsfw: model?.isNsfw ?? null,
guild_nsfw_level: null,
recipient_ids: recipientIds.map((id) => id.toString()),
recipients: recipientIds
.map((id) => resolvedUsers.get(id))
.filter((u): u is NonNullable<typeof u> => u != null),
last_message_id: summary.lastMessageId?.toString() ?? null,
is_open: summary.open,
name: model?.name ?? null,
icon: model?.iconHash ?? null,
owner_id: model?.ownerId?.toString() ?? null,
};
}),
};
}
async listUserChangeLog(data: ListUserChangeLogRequest, acls: ReadonlySet<string>) {
if (!acls.has(AdminACLs.USER_VIEW_CONTACT_LOG) && !acls.has(AdminACLs.WILDCARD)) {
return {entries: [], next_page_token: null};
}
const canViewEmail = acls.has(AdminACLs.USER_VIEW_EMAIL) || acls.has(AdminACLs.WILDCARD);
const entries = await this.contactChangeLogService.listLogs({
userId: createUserID(data.user_id),
limit: data.limit,
beforeEventId: data.page_token,
});
const lastEntry = entries.length === data.limit && entries.length > 0 ? entries.at(-1) : null;
const nextPageToken = lastEntry?.event_id != null ? lastEntry.event_id.toString() : null;
return {
entries: entries.map((entry) => {
const isEmailField = entry.field === 'email';
const shouldRedact = isEmailField && !canViewEmail;
return {
event_id: entry.event_id != null ? entry.event_id.toString() : String(entry.user_id),
field: entry.field,
old_value: shouldRedact ? '[redacted]' : (entry.old_value ?? null),
new_value: shouldRedact ? '[redacted]' : (entry.new_value ?? null),
reason: entry.reason ?? null,
actor_user_id: entry.actor_user_id ? entry.actor_user_id.toString() : null,
event_at: entry.event_at.toISOString(),
};
}),
next_page_token: nextPageToken,
};
}
async cancelBulkMessageDeletion(
data: CancelBulkMessageDeletionRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const userId = createUserID(data.user_id);
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
const updatedUser = await this.userRepository.patchUpsert(
userId,
{
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
},
user.toRow(),
);
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
await this.auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'cancel_bulk_message_deletion',
auditLogReason,
metadata: new Map(),
});
return {
user: await mapUserToAdminResponse(updatedUser, this.cacheService, acls),
};
}
}