mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
334 lines
13 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|