mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): add moderation events and visibility actions (#3219)
This commit is contained in:
@@ -178,7 +178,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const {public_comment, notify_reporter} = ctx.req.valid('json');
|
||||
const {public_comment, notify_reporter, resolution} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.resolveReport(
|
||||
createReportID(report_id),
|
||||
@@ -186,6 +186,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
public_comment || null,
|
||||
auditLogReason,
|
||||
notify_reporter,
|
||||
resolution,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -261,7 +261,8 @@ export class AdminMessageService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async listMessageResponsesForAdmin(params: {
|
||||
|
||||
@@ -34,11 +34,12 @@ import type {User} from '@app/api/models/User';
|
||||
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import {seconds} from 'itty-time';
|
||||
@@ -56,6 +57,8 @@ interface AdminReportServiceDeps {
|
||||
ncmecSubmissionService: NcmecSubmissionService;
|
||||
}
|
||||
|
||||
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
|
||||
|
||||
interface ReportNsfwLookupCache {
|
||||
channelNsfwByChannelId: Map<string, boolean | null>;
|
||||
guildNsfwLevelByGuildId: Map<string, number | null>;
|
||||
@@ -105,10 +108,14 @@ export class AdminReportService {
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
notifyReporter: boolean,
|
||||
resolution?: StaffReportResolution,
|
||||
) {
|
||||
const {reportService, auditService} = this.deps;
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
|
||||
outcome: resolution,
|
||||
resolvedBy: 'staff',
|
||||
});
|
||||
let reporterDmSent = false;
|
||||
let reporterEmailSent = false;
|
||||
const reporter =
|
||||
@@ -147,6 +154,7 @@ export class AdminReportService {
|
||||
['notify_reporter', notifyReporter ? 'true' : 'false'],
|
||||
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
|
||||
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
|
||||
...(resolution ? [['resolution', resolution] as [string, string]] : []),
|
||||
]),
|
||||
});
|
||||
return {
|
||||
@@ -418,7 +426,8 @@ export class AdminReportService {
|
||||
|
||||
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
|
||||
return {...access, includeHidden: true};
|
||||
}
|
||||
|
||||
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
|
||||
@@ -617,7 +626,11 @@ export class AdminReportService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
|
||||
const user = stored
|
||||
? {username: stored.username, global_name: stored.globalName, discriminator: stored.discriminator.toString()}
|
||||
: cached;
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {
|
||||
tag: `${user.username}#${discriminator}`,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
||||
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
|
||||
import {isEnforcementDeletionReason} from '@app/api/user/ProfileVisibility';
|
||||
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
@@ -246,7 +247,7 @@ export class AdminUserDeletionService {
|
||||
let knownIps: ReadonlySet<string> = new Set();
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
|
||||
}
|
||||
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
@@ -381,8 +382,13 @@ export class AdminUserDeletionService {
|
||||
return knownIps;
|
||||
}
|
||||
|
||||
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
|
||||
const {user, adminUserId} = params;
|
||||
private async resolvePendingReportsAgainstUser(params: {
|
||||
user: User;
|
||||
adminUserId: UserID;
|
||||
reasonCode: number;
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, reasonCode} = params;
|
||||
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
|
||||
const {reportService, auditService} = this.deps;
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
@@ -423,7 +429,10 @@ export class AdminUserDeletionService {
|
||||
for (const hitId of pendingReportIds) {
|
||||
const reportId = createReportID(BigInt(hitId));
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
|
||||
outcome,
|
||||
resolvedBy: 'system',
|
||||
});
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
|
||||
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
by: 'staff',
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface MessageResponseAccessContext {
|
||||
sourceGuildId: GuildID | null;
|
||||
messageHistoryCutoff: string | null;
|
||||
canReadMessageHistory: boolean;
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
interface ExtractedMentions {
|
||||
@@ -105,6 +106,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -146,6 +148,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -177,6 +180,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
@@ -245,6 +249,7 @@ export class MessageResponseDataService {
|
||||
? new Date(params.access.messageHistoryCutoff).getTime()
|
||||
: null,
|
||||
can_read_message_history: params.access.canReadMessageHistory,
|
||||
include_hidden: params.access.includeHidden ?? false,
|
||||
media_endpoint: Config.endpoints.media,
|
||||
media_proxy_secret_key: Config.mediaProxy.secretKey,
|
||||
attachment_url_secret_base64: Config.mediaProxy.attachmentUrls.secretsBase64[0],
|
||||
|
||||
@@ -52,7 +52,7 @@ import type {Webhook} from '@app/api/models/Webhook';
|
||||
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {assertMayStartConversation} from '@app/api/user/NewConversationLimit';
|
||||
import {isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {isContentHidden, isDirectDeliverySuppressed} from '@app/api/user/UserHelpers';
|
||||
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
|
||||
import {
|
||||
ChannelTypes,
|
||||
@@ -942,6 +942,7 @@ export class MessageSendService {
|
||||
}
|
||||
}
|
||||
const suppressDmRecipientDelivery = dmRecipientId !== null && isDirectDeliverySuppressed(user);
|
||||
const suppressDelivery = suppressDmRecipientDelivery || isContentHidden(user, messageId);
|
||||
const channelHadMessages = channel.lastMessageId !== null;
|
||||
const {message, enqueueDeferredEmbeds} = await this.deps.persistenceService.createMessage({
|
||||
messageId,
|
||||
@@ -973,7 +974,7 @@ export class MessageSendService {
|
||||
messageId,
|
||||
mentionChannels: mentionData?.mentionChannels,
|
||||
});
|
||||
if (!suppressDmRecipientDelivery) {
|
||||
if (!suppressDelivery) {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'update_dm_recipients',
|
||||
@@ -1000,7 +1001,7 @@ export class MessageSendService {
|
||||
await this.settlePostCreateWork(messageId, [
|
||||
{
|
||||
step: 'dispatch',
|
||||
promise: suppressDmRecipientDelivery
|
||||
promise: suppressDelivery
|
||||
? this.deps.dispatchService.dispatchMessageCreateToUser({
|
||||
channel,
|
||||
message,
|
||||
@@ -1031,7 +1032,7 @@ export class MessageSendService {
|
||||
guildOwnerId: guild?.owner_id ? createUserID(BigInt(guild.owner_id)) : null,
|
||||
dmRecipientId,
|
||||
channelHadMessages,
|
||||
delivered: !suppressDmRecipientDelivery,
|
||||
delivered: !suppressDelivery,
|
||||
userRepository: this.deps.userRepository,
|
||||
});
|
||||
void enqueueDeferredEmbeds().catch((error) => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
describe('messages from an author inside a hide window', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let author: TestAccount;
|
||||
let channelId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
const setup = await setupTestGuildWithMembers(harness, 1);
|
||||
author = setup.members[0]!;
|
||||
channelId = setup.channels[0]!.id;
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setHiddenSince(since: Date | null): Promise<void> {
|
||||
await getUserRepository().patchUpsert(createUserID(BigInt(author.userId)), {content_hidden_since: since});
|
||||
}
|
||||
|
||||
function createEvents(messageId: string) {
|
||||
const guild = vi
|
||||
.mocked(NoopGatewayService.prototype.dispatchGuild)
|
||||
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
|
||||
const presence = vi
|
||||
.mocked(NoopGatewayService.prototype.dispatchPresence)
|
||||
.mock.calls.filter(([call]) => call.event === 'MESSAGE_CREATE' && (call.data as {id: string}).id === messageId);
|
||||
return {guild, presence: presence.map(([call]) => call.userId.toString())};
|
||||
}
|
||||
|
||||
test('reach only the author and fan out again once the window is cleared', async () => {
|
||||
await setHiddenSince(new Date(Date.now() - 60_000));
|
||||
const hidden = await sendMessage(harness, author.token, channelId, 'inside the window');
|
||||
expect(hidden.content).toBe('inside the window');
|
||||
expect(createEvents(hidden.id)).toEqual({guild: [], presence: [author.userId]});
|
||||
|
||||
await setHiddenSince(null);
|
||||
const shown = await sendMessage(harness, author.token, channelId, 'after restore');
|
||||
const events = createEvents(shown.id);
|
||||
expect(events.guild).toHaveLength(1);
|
||||
expect(events.presence).toEqual([]);
|
||||
});
|
||||
|
||||
test('a window that starts later leaves current messages alone', async () => {
|
||||
await setHiddenSince(new Date(Date.now() + 3_600_000));
|
||||
const message = await sendMessage(harness, author.token, channelId, 'before the window');
|
||||
expect(createEvents(message.id).guild).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ export interface UserRow {
|
||||
deletion_audit_log_reason: Nullish<string>;
|
||||
deletion_scheduled_by?: Nullish<UserID>;
|
||||
deletion_scheduled_at?: Nullish<Date>;
|
||||
content_hidden_since?: Nullish<Date>;
|
||||
acls: Nullish<Set<string>>;
|
||||
traits: Nullish<Set<string>>;
|
||||
first_refund_at: Nullish<Date>;
|
||||
@@ -139,6 +140,7 @@ export const USER_COLUMNS = [
|
||||
'deletion_audit_log_reason',
|
||||
'deletion_scheduled_by',
|
||||
'deletion_scheduled_at',
|
||||
'content_hidden_since',
|
||||
'acls',
|
||||
'traits',
|
||||
'first_refund_at',
|
||||
@@ -202,6 +204,7 @@ export const EMPTY_USER_ROW: UserRow = {
|
||||
deletion_audit_log_reason: null,
|
||||
deletion_scheduled_by: null,
|
||||
deletion_scheduled_at: null,
|
||||
content_hidden_since: null,
|
||||
acls: null,
|
||||
traits: null,
|
||||
first_refund_at: null,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {GuildEmoji} from '@app/api/models/GuildEmoji';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {GuildRole} from '@app/api/models/GuildRole';
|
||||
import type {GuildSticker} from '@app/api/models/GuildSticker';
|
||||
import {hiddenGuildMember, isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
|
||||
import type {
|
||||
GuildEmojiResponse,
|
||||
@@ -132,6 +133,11 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): GuildStickerRe
|
||||
}
|
||||
|
||||
function mapMemberWithUser(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
|
||||
const response = mapMemberFields(member, userPartial);
|
||||
return isHiddenPartial(userPartial) ? hiddenGuildMember(response) : response;
|
||||
}
|
||||
|
||||
function mapMemberFields(member: GuildMember, userPartial: UserPartialResponse): GuildMemberResponse {
|
||||
const now = Date.now();
|
||||
const isTimedOut = member.communicationDisabledUntil != null && member.communicationDisabledUntil.getTime() > now;
|
||||
return {
|
||||
|
||||
@@ -8,10 +8,13 @@ import {mapGuildBansToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
|
||||
import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
|
||||
import type {BanBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {emitGuildMemberBanned, emitGuildMemberUnbanned} from '@app/api/infrastructure/activity/ModerationEvents';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '@app/api/models/Guild';
|
||||
import type {GuildBan} from '@app/api/models/GuildBan';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
@@ -29,6 +32,8 @@ import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMembe
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
const GUILD_MODERATION_PERMISSIONS =
|
||||
Permissions.ADMINISTRATOR | Permissions.BAN_MEMBERS | Permissions.KICK_MEMBERS | Permissions.MANAGE_GUILD;
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
@@ -67,6 +72,7 @@ export class GuildModerationService {
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
skipGuildAuditLog?: boolean;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
@@ -79,6 +85,7 @@ export class GuildModerationService {
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
skipGuildAuditLog,
|
||||
by = 'moderator',
|
||||
} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
@@ -106,6 +113,8 @@ export class GuildModerationService {
|
||||
if (banDurationSeconds && banDurationSeconds > 0) {
|
||||
expiresAt = new Date(Date.now() + banDurationSeconds * 1000);
|
||||
}
|
||||
const guildBeforeBan = await this.guildRepository.findUnique(guildId);
|
||||
const targetModerator = await this.isGuildModerator(guildId, targetId, guildBeforeBan, targetMember !== null);
|
||||
const ban = await this.guildRepository.upsertBan({
|
||||
guild_id: guildId,
|
||||
user_id: targetId,
|
||||
@@ -131,6 +140,16 @@ export class GuildModerationService {
|
||||
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
|
||||
});
|
||||
}
|
||||
await emitGuildMemberBanned({
|
||||
guildId,
|
||||
userId: targetId,
|
||||
moderatorId: userId,
|
||||
by,
|
||||
memberCount: guildBeforeBan?.memberCount ?? 0,
|
||||
targetModerator,
|
||||
bannedAt: ban.bannedAt,
|
||||
expiresAt: ban.expiresAt,
|
||||
});
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_BAN_ADD',
|
||||
@@ -177,16 +196,18 @@ export class GuildModerationService {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
by?: BanBy;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId} = params;
|
||||
const {userId, guildId, targetId, by = 'moderator'} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
}
|
||||
await this.guildRepository.deleteBan(guildId, targetId);
|
||||
await emitGuildMemberUnbanned({guildId, userId: targetId, moderatorId: userId, by});
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
@@ -225,6 +246,23 @@ export class GuildModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async isGuildModerator(
|
||||
guildId: GuildID,
|
||||
targetId: UserID,
|
||||
guild: Guild | null,
|
||||
isMember: boolean,
|
||||
): Promise<boolean> {
|
||||
if (guild?.ownerId === targetId) return true;
|
||||
if (!isMember) return false;
|
||||
try {
|
||||
const permissions = await this.gatewayService.getUserPermissions({guildId, userId: targetId});
|
||||
return (permissions & GUILD_MODERATION_PERMISSIONS) !== 0n;
|
||||
} catch (error) {
|
||||
Logger.debug({error, guildId: guildId.toString()}, 'Could not read target permissions for a guild ban');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
|
||||
return {
|
||||
user_id: ban.userId.toString(),
|
||||
|
||||
@@ -57,6 +57,15 @@ export function currentAccountChangeSource(): ChangeSource {
|
||||
return sourceContext.getStore() ?? requestContext.getStore()?.source ?? 'other';
|
||||
}
|
||||
|
||||
export function anonymousActivityMeta(): Meta {
|
||||
const context = requestContext.getStore();
|
||||
return {
|
||||
...workerMeta(),
|
||||
channel: context?.channel ?? processChannel,
|
||||
request_id: context?.requestId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function workerMeta(): Meta {
|
||||
return {ip: null, country: null, ua: null, locale: null, channel: 'worker', request_id: null};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ export type Id = string;
|
||||
export type Flags64 = string;
|
||||
export type Channel = "stable" | "canary" | "worker" | "internal" | "import" | "other";
|
||||
export type Meta = { ip: string | null, country: string | null, ua: string | null, locale: string | null, channel: Channel, request_id: string | null, };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked };
|
||||
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked" | "guild_member_banned" | "guild_member_unbanned" | "report_resolved";
|
||||
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved });
|
||||
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved };
|
||||
export type Registration = { user_id: Id, method: RegMethod, email: string | null, username: string, username_user_chosen: boolean, global_name: string | null, locale: string | null, timezone: string | null, invite_code: string | null, flags: Flags64, };
|
||||
export type RegMethod = "password" | "unclaimed" | "oauth" | "other";
|
||||
export type EmailChanged = { user_id: Id, new_email: string, was_unclaimed: boolean, has_ever_purchased: boolean, };
|
||||
@@ -32,8 +32,14 @@ export type ChannelType = "dm" | "group_dm" | "guild";
|
||||
export type FriendRequest = { user_id: Id, target_id: Id, delivered: boolean, };
|
||||
export type UserBlocked = { blocker_id: Id, blocked_id: Id, };
|
||||
export type HttpErrors = { ip: string, window_ms: number, s401: number, s403: number, s404: number, s429: number, other_4xx: number, auth_failures: number, token_hashes: Array<string>, };
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, });
|
||||
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, };
|
||||
export type GuildMemberBanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, guild_member_count: number, target_moderator: boolean, expires_at_ms: number | null, };
|
||||
export type GuildMemberUnbanned = { guild_id: Id, user_id: Id, moderator_id: Id, by: BanBy, };
|
||||
export type BanBy = "moderator" | "staff";
|
||||
export type ReportResolved = { report_id: Id, reporter_id: Id, category: string, target_type: ReportTarget, reported_user_id: Id | null, outcome: ReportOutcome, resolved_by: ResolvedBy, };
|
||||
export type ReportOutcome = "actioned" | "no_violation" | "duplicate" | "auto_resolved" | "unspecified";
|
||||
export type ResolvedBy = "staff" | "system";
|
||||
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, });
|
||||
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, };
|
||||
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, };
|
||||
export type OutcomeStatus = "applied" | "noop" | "conflict" | "expired" | "ineligible" | "exempt" | "unsupported" | "failed";
|
||||
export type Observed = { flags: Flags64, deleted: boolean, };
|
||||
@@ -71,6 +77,9 @@ export const EVENT_KINDS: ReadonlyArray<EventKind> = [
|
||||
'friend_request',
|
||||
'http_errors',
|
||||
'user_blocked',
|
||||
'guild_member_banned',
|
||||
'guild_member_unbanned',
|
||||
'report_resolved',
|
||||
];
|
||||
export const EVENT_MAJOR: Record<EventKind, number> = {
|
||||
registration: 1,
|
||||
@@ -91,6 +100,9 @@ export const EVENT_MAJOR: Record<EventKind, number> = {
|
||||
friend_request: 1,
|
||||
http_errors: 1,
|
||||
user_blocked: 1,
|
||||
guild_member_banned: 1,
|
||||
guild_member_unbanned: 1,
|
||||
report_resolved: 1,
|
||||
};
|
||||
export const EVENT_TTL: Record<EventKind, string> = {
|
||||
registration: 'never',
|
||||
@@ -111,6 +123,9 @@ export const EVENT_TTL: Record<EventKind, string> = {
|
||||
friend_request: '259200',
|
||||
http_errors: '3600',
|
||||
user_blocked: '259200',
|
||||
guild_member_banned: '3024000',
|
||||
guild_member_unbanned: '3024000',
|
||||
report_resolved: 'never',
|
||||
};
|
||||
export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
|
||||
registration: 'fact',
|
||||
@@ -131,6 +146,9 @@ export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
|
||||
friend_request: 'signal',
|
||||
http_errors: 'signal',
|
||||
user_blocked: 'signal',
|
||||
guild_member_banned: 'fact',
|
||||
guild_member_unbanned: 'fact',
|
||||
report_resolved: 'fact',
|
||||
};
|
||||
export const effectsConsumer = (p: number): string => `effects-${String(p).padStart(2, '0')}`;
|
||||
export function keyToken(key: string): string {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import {anonymousActivityMeta} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import type {BanBy, ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import type {IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import {ReportType} from '@app/api/report/IReportRepository';
|
||||
|
||||
interface GuildBanFacts {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
moderatorId: UserID;
|
||||
by: BanBy;
|
||||
memberCount: number;
|
||||
targetModerator: boolean;
|
||||
bannedAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export async function emitGuildMemberBanned(facts: GuildBanFacts): Promise<void> {
|
||||
const target = facts.userId.toString();
|
||||
await emitActivity(
|
||||
'guild_member_banned',
|
||||
target,
|
||||
{
|
||||
guild_id: facts.guildId.toString(),
|
||||
user_id: target,
|
||||
moderator_id: facts.moderatorId.toString(),
|
||||
by: facts.by,
|
||||
guild_member_count: Math.max(0, Math.trunc(facts.memberCount)),
|
||||
target_moderator: facts.targetModerator,
|
||||
expires_at_ms: facts.expiresAt?.getTime() ?? null,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
`${facts.guildId}:${target}:${facts.bannedAt.getTime()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function emitGuildMemberUnbanned(facts: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
moderatorId: UserID;
|
||||
by: BanBy;
|
||||
}): Promise<void> {
|
||||
const target = facts.userId.toString();
|
||||
await emitActivity(
|
||||
'guild_member_unbanned',
|
||||
target,
|
||||
{
|
||||
guild_id: facts.guildId.toString(),
|
||||
user_id: target,
|
||||
moderator_id: facts.moderatorId.toString(),
|
||||
by: facts.by,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
);
|
||||
}
|
||||
|
||||
const REPORT_TARGETS: Record<number, ReportTarget> = {
|
||||
[ReportType.MESSAGE]: 'message',
|
||||
[ReportType.USER]: 'user',
|
||||
[ReportType.GUILD]: 'guild',
|
||||
};
|
||||
|
||||
export async function emitReportResolved(
|
||||
report: Pick<IARSubmission, 'reportId' | 'reporterId' | 'reportedUserId' | 'category' | 'reportType'>,
|
||||
outcome: ReportOutcome,
|
||||
resolvedBy: ResolvedBy,
|
||||
): Promise<void> {
|
||||
const key = report.reportedUserId ?? report.reporterId;
|
||||
const targetType = REPORT_TARGETS[report.reportType];
|
||||
if (key === null || targetType === undefined) return;
|
||||
await emitActivity(
|
||||
'report_resolved',
|
||||
key.toString(),
|
||||
{
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: (report.reporterId ?? 0n).toString(),
|
||||
category: report.category,
|
||||
target_type: targetType,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
outcome,
|
||||
resolved_by: resolvedBy,
|
||||
},
|
||||
anonymousActivityMeta(),
|
||||
report.reportId.toString(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {addMemberRole, createGuild, createRole, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
|
||||
import type {Event} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
class CapturingPublisher implements ActivityPublisher {
|
||||
readonly events: Array<Event> = [];
|
||||
|
||||
async publish(_subject: string, payload: string): Promise<void> {
|
||||
this.events.push(JSON.parse(payload) as Event);
|
||||
}
|
||||
|
||||
of<K extends Event['kind']>(kind: K): Array<Extract<Event, {kind: K}>> {
|
||||
return this.events.filter((event): event is Extract<Event, {kind: K}> => event.kind === kind);
|
||||
}
|
||||
}
|
||||
|
||||
describe('moderation activity events', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let publisher: CapturingPublisher;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetActivityEventsForTests();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function ban(token: string, path: string): Promise<void> {
|
||||
await createBuilder(harness, token).put(path).body({}).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
}
|
||||
|
||||
test('a moderator ban and unban publish facts about the target without addresses', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
|
||||
const [banned] = publisher.of('guild_member_banned');
|
||||
expect(banned?.key).toBe(target.userId);
|
||||
expect(banned?.data).toEqual({
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
moderator_id: owner.userId,
|
||||
by: 'moderator',
|
||||
guild_member_count: 2,
|
||||
target_moderator: false,
|
||||
expires_at_ms: null,
|
||||
});
|
||||
expect(banned?.meta).toMatchObject({ip: null, country: null, ua: null, locale: null});
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/bans/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
const [unbanned] = publisher.of('guild_member_unbanned');
|
||||
expect(unbanned?.data).toEqual({
|
||||
guild_id: guild.id,
|
||||
user_id: target.userId,
|
||||
moderator_id: owner.userId,
|
||||
by: 'moderator',
|
||||
});
|
||||
expect(unbanned?.meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
test('banning a member who can moderate the guild marks the target as a moderator', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const target = members[0]!;
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Mods',
|
||||
permissions: Permissions.BAN_MEMBERS.toString(),
|
||||
});
|
||||
await addMemberRole(harness, owner.token, guild.id, target.userId, role.id);
|
||||
await ban(owner.token, `/guilds/${guild.id}/bans/${target.userId}`);
|
||||
expect(publisher.of('guild_member_banned')[0]?.data.target_moderator).toBe(true);
|
||||
});
|
||||
|
||||
test('a staff ban says it came from staff', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'guild:ban_member',
|
||||
]);
|
||||
const target = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, admin.token, 'Staff ban guild');
|
||||
await ban(admin.token, `/admin/guilds/${guild.id}/bans/${target.userId}`);
|
||||
expect(publisher.of('guild_member_banned')[0]?.data).toMatchObject({
|
||||
user_id: target.userId,
|
||||
by: 'staff',
|
||||
target_moderator: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('report resolution', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'report:resolve',
|
||||
'user:temp_ban',
|
||||
]);
|
||||
});
|
||||
|
||||
async function fileReport(): Promise<{reporter: TestAccount; reported: TestAccount; reportId: string}> {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const reported = await createTestAccount(harness);
|
||||
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({user_id: reported.userId, category: 'harassment'})
|
||||
.execute();
|
||||
return {reporter, reported, reportId: report.report_id};
|
||||
}
|
||||
|
||||
async function resolve(reportId: string, body: Record<string, unknown>): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', notify_reporter: false, ...body})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
test('a staff dismissal publishes the outcome staff chose', async () => {
|
||||
const {reporter, reported, reportId} = await fileReport();
|
||||
await resolve(reportId, {resolution: 'no_violation'});
|
||||
const [resolved] = publisher.of('report_resolved');
|
||||
expect(resolved?.key).toBe(reported.userId);
|
||||
expect(resolved?.data).toEqual({
|
||||
report_id: reportId,
|
||||
reporter_id: reporter.userId,
|
||||
category: 'harassment',
|
||||
target_type: 'user',
|
||||
reported_user_id: reported.userId,
|
||||
outcome: 'no_violation',
|
||||
resolved_by: 'staff',
|
||||
});
|
||||
expect(resolved?.meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
test('without a chosen outcome the reported account state decides between actioned and unspecified', async () => {
|
||||
const first = await fileReport();
|
||||
await resolve(first.reportId, {});
|
||||
const second = await fileReport();
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${second.reported.userId}/ban`)
|
||||
.body({duration_hours: 24, notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await resolve(second.reportId, {});
|
||||
expect(publisher.of('report_resolved').map((event) => event.data.outcome)).toEqual(['unspecified', 'actioned']);
|
||||
});
|
||||
|
||||
test('an unknown resolution is rejected', async () => {
|
||||
const {reportId} = await fileReport();
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', resolution: 'maybe'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(publisher.of('report_resolved')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ export class User {
|
||||
readonly deletionAuditLogReason: string | null;
|
||||
readonly deletionScheduledBy: UserID | null;
|
||||
readonly deletionScheduledAt: Date | null;
|
||||
readonly contentHiddenSince: Date | null;
|
||||
readonly acls: Set<string>;
|
||||
private readonly _traits: Set<string>;
|
||||
readonly firstRefundAt: Date | null;
|
||||
@@ -132,6 +133,7 @@ export class User {
|
||||
this.deletionAuditLogReason = row.deletion_audit_log_reason ?? null;
|
||||
this.deletionScheduledBy = row.deletion_scheduled_by ?? null;
|
||||
this.deletionScheduledAt = row.deletion_scheduled_at ?? null;
|
||||
this.contentHiddenSince = row.content_hidden_since ?? null;
|
||||
this.acls = row.acls ?? new Set();
|
||||
this._traits = row.traits ?? new Set();
|
||||
this.firstRefundAt = row.first_refund_at ?? null;
|
||||
@@ -223,6 +225,7 @@ export class User {
|
||||
deletion_audit_log_reason: this.deletionAuditLogReason,
|
||||
deletion_scheduled_by: this.deletionScheduledBy,
|
||||
deletion_scheduled_at: this.deletionScheduledAt,
|
||||
content_hidden_since: this.contentHiddenSince,
|
||||
acls: this.acls.size > 0 ? this.acls : null,
|
||||
traits: this._traits.size > 0 ? this._traits : null,
|
||||
first_refund_at: this.firstRefundAt,
|
||||
|
||||
@@ -30759,7 +30759,8 @@
|
||||
"value": "32",
|
||||
"description": "Bot requires manual approval for friend requests"
|
||||
},
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}
|
||||
{"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"},
|
||||
{"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden"}
|
||||
]
|
||||
},
|
||||
"RefreshedAttachmentUrl": {
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import type {DSAReportTicketRow} from '@app/api/database/types/ReportTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import type {ReportTarget} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import type {ReportOutcome, ReportTarget, ResolvedBy} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {emitReportResolved} from '@app/api/infrastructure/activity/ModerationEvents';
|
||||
import type {IEmailDnsValidationService} from '@app/api/infrastructure/IEmailDnsValidationService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
import {ReportStatus, ReportType} from '@app/api/report/IReportRepository';
|
||||
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {isUnderEnforcement} from '@app/api/user/ProfileVisibility';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {InviteTypes, MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
@@ -901,6 +903,7 @@ export class ReportService {
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
resolution: {outcome?: ReportOutcome; resolvedBy?: ResolvedBy} = {},
|
||||
): Promise<IARSubmission> {
|
||||
const report = await this.reportRepository.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
if (this.reportSearchService && 'updateReport' in this.reportSearchService) {
|
||||
@@ -908,9 +911,22 @@ export class ReportService {
|
||||
Logger.error({error, reportId: report.reportId}, 'Failed to update report in search index');
|
||||
});
|
||||
}
|
||||
const outcome = resolution.outcome ?? (await this.observedOutcome(report));
|
||||
await emitReportResolved(report, outcome, resolution.resolvedBy ?? 'staff');
|
||||
return report;
|
||||
}
|
||||
|
||||
private async observedOutcome(report: IARSubmission): Promise<ReportOutcome> {
|
||||
if (!report.reportedUserId) return 'unspecified';
|
||||
try {
|
||||
const reported = await this.userRepository.findUnique(report.reportedUserId);
|
||||
return reported && isUnderEnforcement(reported) ? 'actioned' : 'unspecified';
|
||||
} catch (error) {
|
||||
Logger.warn({error, reportId: report.reportId}, 'Could not read the reported account for a resolved report');
|
||||
return 'unspecified';
|
||||
}
|
||||
}
|
||||
|
||||
private async gatherMessageContext(
|
||||
channelId: ChannelID,
|
||||
targetMessageId: MessageID,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {isTemporarilyBanned} from '@app/api/user/UserHelpers';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {
|
||||
HIDDEN_USER_DISCRIMINATOR,
|
||||
HIDDEN_USER_USERNAME,
|
||||
PublicUserFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
type ProfileStanding = Pick<
|
||||
User,
|
||||
'flags' | 'isSystem' | 'tempBannedUntil' | 'pendingDeletionAt' | 'deletionReasonCode'
|
||||
>;
|
||||
|
||||
const NON_ENFORCEMENT_DELETION_REASONS: ReadonlySet<number> = new Set([
|
||||
DeletionReasons.USER_REQUESTED,
|
||||
DeletionReasons.OTHER,
|
||||
DeletionReasons.INACTIVITY,
|
||||
]);
|
||||
|
||||
export function isEnforcementDeletionReason(code: number | null): boolean {
|
||||
return code != null && !NON_ENFORCEMENT_DELETION_REASONS.has(code);
|
||||
}
|
||||
|
||||
function isPendingEnforcementDeletion(user: Pick<User, 'pendingDeletionAt' | 'deletionReasonCode'>): boolean {
|
||||
return user.pendingDeletionAt != null && isEnforcementDeletionReason(user.deletionReasonCode);
|
||||
}
|
||||
|
||||
export function isUnderEnforcement(user: Omit<ProfileStanding, 'isSystem'>, now = Date.now()): boolean {
|
||||
return (
|
||||
(user.flags & UserFlags.SPAMMER) !== 0n || isTemporarilyBanned(user, now) || isPendingEnforcementDeletion(user)
|
||||
);
|
||||
}
|
||||
|
||||
export function isProfileHidden(user: ProfileStanding, now = Date.now()): boolean {
|
||||
if (user.isSystem) return false;
|
||||
return (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n || isUnderEnforcement(user, now);
|
||||
}
|
||||
|
||||
export function hiddenUserPartial(partial: UserPartialResponse): UserPartialResponse {
|
||||
return {
|
||||
...partial,
|
||||
username: HIDDEN_USER_USERNAME,
|
||||
discriminator: HIDDEN_USER_DISCRIMINATOR.toString().padStart(4, '0'),
|
||||
global_name: null,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
flags: partial.flags | PublicUserFlags.PROFILE_HIDDEN,
|
||||
};
|
||||
}
|
||||
|
||||
export function isHiddenPartial(partial: Pick<UserPartialResponse, 'flags'>): boolean {
|
||||
return (partial.flags & PublicUserFlags.PROFILE_HIDDEN) !== 0;
|
||||
}
|
||||
|
||||
export function hiddenGuildMember(member: GuildMemberResponse): GuildMemberResponse {
|
||||
return {...member, nick: null, avatar: null, banner: null, accent_color: null};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
export function isAccountClosed(user: Pick<User, 'flags' | 'deletionStartedAt'>): boolean {
|
||||
return (user.flags & UserFlags.DELETED) !== 0n || user.deletionStartedAt != null;
|
||||
@@ -35,6 +36,10 @@ export function canOwnerRunBots(owner: Pick<User, 'flags' | 'deletionStartedAt'
|
||||
return !isAccountClosed(owner) && !isAccountDisabled(owner);
|
||||
}
|
||||
|
||||
export function isContentHidden(user: Pick<User, 'contentHiddenSince'>, messageId: bigint): boolean {
|
||||
return user.contentHiddenSince !== null && snowflakeToDate(messageId) >= user.contentHiddenSince;
|
||||
}
|
||||
|
||||
export function isDirectDeliverySuppressed(user: Pick<User, 'isBot' | 'flags'>): boolean {
|
||||
return !user.isBot && (user.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {isAccountLimited} from '@app/api/user/AccountLimit';
|
||||
import {hiddenUserPartial, isProfileHidden} from '@app/api/user/ProfileVisibility';
|
||||
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
|
||||
import type {ChannelMessageNotifications} from '@fluxer/constants/src/NotificationConstants';
|
||||
import {
|
||||
@@ -70,10 +71,18 @@ function sortUserIds(userIds: Iterable<UserID>): Array<string> {
|
||||
}
|
||||
|
||||
export function mapUserToPartialResponse(user: User): UserPartialResponse {
|
||||
const partial = mapUserToOwnPartialResponse(user);
|
||||
return isProfileHidden(user) && !isDeletedForDisplay(user) ? hiddenUserPartial(partial) : partial;
|
||||
}
|
||||
|
||||
function isDeletedForDisplay(user: User): boolean {
|
||||
return (user.flags & UserFlags.DELETED) !== 0n && user.pendingDeletionAt === null && !user.isSystem;
|
||||
}
|
||||
|
||||
function mapUserToOwnPartialResponse(user: User): UserPartialResponse {
|
||||
const isBot = user.isBot;
|
||||
const avatarHash = stripAvatarForUser(user);
|
||||
const isDeleted = (user.flags & UserFlags.DELETED) !== 0n && user.pendingDeletionAt === null && !user.isSystem;
|
||||
if (isDeleted) {
|
||||
if (isDeletedForDisplay(user)) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: DELETED_USER_USERNAME,
|
||||
@@ -119,7 +128,7 @@ export function hasPartialUserFieldsChanged(oldUser: User, newUser: User): boole
|
||||
|
||||
export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
const isStaff = (user.flags & UserFlags.STAFF) !== 0n;
|
||||
const partialResponse = mapUserToPartialResponse(user);
|
||||
const partialResponse = mapUserToOwnPartialResponse(user);
|
||||
const isActuallyPremium = user.isPremium();
|
||||
const traitSet = new Set<string>();
|
||||
for (const trait of user.traits ?? []) {
|
||||
@@ -193,6 +202,9 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
}
|
||||
|
||||
export function mapUserToProfileResponse(user: User, options?: {restrictProfile?: boolean}): UserProfileResponse {
|
||||
if (isProfileHidden(user)) {
|
||||
return {bio: null, pronouns: null, banner: null, banner_color: null, accent_color: null};
|
||||
}
|
||||
if (options?.restrictProfile) {
|
||||
return {
|
||||
bio: null,
|
||||
@@ -236,9 +248,12 @@ export function mapUserToOAuthResponse(
|
||||
|
||||
export function mapGuildMemberToProfileResponse(
|
||||
guildMember: GuildMember | null | undefined,
|
||||
options?: {restrictProfile?: boolean},
|
||||
options?: {restrictProfile?: boolean; hidden?: boolean},
|
||||
): UserProfileResponse | null {
|
||||
if (!guildMember) return null;
|
||||
if (options?.hidden) {
|
||||
return {bio: null, pronouns: null, banner: null, accent_color: null};
|
||||
}
|
||||
if (options?.restrictProfile) {
|
||||
return {
|
||||
bio: null,
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import type {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {type ChannelID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
|
||||
import {withAccountChangeSource} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import type {
|
||||
@@ -21,42 +25,85 @@ import {
|
||||
isNewConversationLimitExempt,
|
||||
setNewConversationLimit,
|
||||
} from '@app/api/user/NewConversationLimit';
|
||||
import {
|
||||
type PartialUserChangePropagationDeps,
|
||||
propagatePartialUserChange,
|
||||
} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {getSameIpDecisionKey, isPublicIpAddress, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
export type ActionOf<T extends ActionEnvelope['type']> = Extract<ActionEnvelope, {type: T}>;
|
||||
|
||||
export interface AccountUpdateDispatch {
|
||||
userUpdated(user: User): Promise<void>;
|
||||
profileChanged(user: User): Promise<void>;
|
||||
contentVisibilityChanged(user: User): Promise<void>;
|
||||
messagesRemoved(channelId: ChannelID, authorId: UserID, messageIds: Array<MessageID>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AccountStateDeps {
|
||||
users: Pick<IUserRepository, 'findUnique' | 'compareAndSetFlags'>;
|
||||
users: Pick<IUserRepository, 'findUnique' | 'compareAndSetFlags' | 'patchUpsert'>;
|
||||
dispatch: AccountUpdateDispatch;
|
||||
ipBans: Pick<AdminRepository, 'isIpBanned' | 'banIpTemp'>;
|
||||
cache: Pick<ICacheService, 'publish' | 'get' | 'set' | 'delete'>;
|
||||
messages: Pick<AdminMessageDeletionService, 'deleteAllUserMessages'>;
|
||||
authored: Pick<IChannelRepository, 'listMessagesByAuthor'>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const FLAGS_WRITE_ATTEMPTS = 3;
|
||||
const AUTHORED_PAGE_SIZE = 200;
|
||||
const MIN_TEMP_BAN_SECONDS = 60;
|
||||
|
||||
function gatewayDispatch(gateway: Pick<IGatewayService, 'dispatchPresence'>): AccountUpdateDispatch {
|
||||
type ProfilePropagation = Omit<PartialUserChangePropagationDeps, 'gatewayService'>;
|
||||
|
||||
function gatewayDispatch(
|
||||
gateway: IGatewayService,
|
||||
profile: ProfilePropagation,
|
||||
channels: Pick<IChannelRepository, 'findUnique'>,
|
||||
): AccountUpdateDispatch {
|
||||
return {
|
||||
async userUpdated(user) {
|
||||
await gateway.dispatchPresence({userId: user.id, event: 'USER_UPDATE', data: mapUserToPrivateResponse(user)});
|
||||
},
|
||||
async profileChanged(user) {
|
||||
await propagatePartialUserChange({...profile, gatewayService: gateway}, user);
|
||||
},
|
||||
async contentVisibilityChanged(user) {
|
||||
await profile.userCacheService.invalidateUserCache(user.id);
|
||||
},
|
||||
async messagesRemoved(channelId, authorId, messageIds) {
|
||||
const channel = await channels.findUnique(channelId);
|
||||
if (!channel) return;
|
||||
for (const messageId of messageIds) {
|
||||
await dispatchChannelEvent({
|
||||
gatewayService: gateway,
|
||||
channel,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {channel_id: channelId.toString(), id: messageId.toString(), author_id: authorId.toString()},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountStateDepsFromContext(ctx: ApiContext, ipBans: AccountStateDeps['ipBans']): AccountStateDeps {
|
||||
export function accountStateDepsFromContext(
|
||||
ctx: ApiContext,
|
||||
ipBans: AccountStateDeps['ipBans'],
|
||||
profile: Omit<ProfilePropagation, 'userRepository'>,
|
||||
messages: AccountStateDeps['messages'],
|
||||
channels: Pick<IChannelRepository, 'findUnique' | 'listMessagesByAuthor'>,
|
||||
): AccountStateDeps {
|
||||
return {
|
||||
users: ctx.services.users,
|
||||
dispatch: gatewayDispatch(ctx.services.gateway),
|
||||
dispatch: gatewayDispatch(ctx.services.gateway, {...profile, userRepository: ctx.services.users}, channels),
|
||||
ipBans,
|
||||
cache: ctx.services.cache,
|
||||
messages,
|
||||
authored: channels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +160,95 @@ export async function applySetAccountLimit(
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyHideProfile(deps: AccountStateDeps, env: ActionOf<'hide_profile'>): Promise<ActionOutcome> {
|
||||
return withAccountChangeSource('action', async () => {
|
||||
const userId = createUserID(BigInt(env.user_id));
|
||||
let user = await deps.users.findUnique(userId);
|
||||
for (let attempt = 0; attempt < FLAGS_WRITE_ATTEMPTS; attempt++) {
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
if (env.on && isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const hidden = (user.flags & UserFlags.PROFILE_HIDDEN) !== 0n;
|
||||
if (hidden === env.on) return outcomeOf(env, 'noop', user);
|
||||
const target = env.on ? user.flags | UserFlags.PROFILE_HIDDEN : user.flags & ~UserFlags.PROFILE_HIDDEN;
|
||||
const updated = await deps.users.compareAndSetFlags(user, target);
|
||||
if (updated) {
|
||||
await deps.dispatch.userUpdated(updated);
|
||||
await deps.dispatch.profileChanged(updated);
|
||||
return outcomeOf(env, 'applied', updated);
|
||||
}
|
||||
user = await deps.users.findUnique(userId);
|
||||
}
|
||||
throw new Error('User flags kept changing during apply');
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyHideRecentMessages(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'hide_recent_messages'>,
|
||||
): Promise<ActionOutcome> {
|
||||
return withAccountChangeSource('action', async () => {
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (isIneligible(user)) return outcomeOf(env, 'ineligible', user);
|
||||
const current = user.contentHiddenSince?.getTime() ?? null;
|
||||
if (!env.on) {
|
||||
if (current === null) return outcomeOf(env, 'noop', user);
|
||||
const shown = await deps.users.patchUpsert(user.id, {content_hidden_since: null}, user.toRow());
|
||||
await deps.dispatch.contentVisibilityChanged(shown);
|
||||
return outcomeOf(env, 'applied', shown);
|
||||
}
|
||||
if (isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const since = current === null ? env.since_ms : Math.min(current, env.since_ms);
|
||||
let hidden = user;
|
||||
if (since !== current) {
|
||||
hidden = await deps.users.patchUpsert(user.id, {content_hidden_since: new Date(since)}, user.toRow());
|
||||
await deps.dispatch.contentVisibilityChanged(hidden);
|
||||
}
|
||||
const removed = await removeAuthoredMessagesSince(deps, user.id, since);
|
||||
return outcomeOf(env, since === current ? 'noop' : 'applied', hidden, `messages=${removed}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAuthoredMessagesSince(deps: AccountStateDeps, authorId: UserID, sinceMs: number): Promise<number> {
|
||||
let cursor: MessageID | undefined;
|
||||
let removed = 0;
|
||||
while (true) {
|
||||
const refs = await deps.authored.listMessagesByAuthor(authorId, AUTHORED_PAGE_SIZE, cursor);
|
||||
const inWindow = refs.filter(({messageId}) => snowflakeToDate(messageId).getTime() >= sinceMs);
|
||||
const byChannel = new Map<ChannelID, Array<MessageID>>();
|
||||
for (const {channelId, messageId} of inWindow) {
|
||||
const ids = byChannel.get(channelId);
|
||||
if (ids) ids.push(messageId);
|
||||
else byChannel.set(channelId, [messageId]);
|
||||
}
|
||||
for (const [channelId, messageIds] of byChannel) {
|
||||
await deps.dispatch.messagesRemoved(channelId, authorId, messageIds);
|
||||
}
|
||||
removed += inWindow.length;
|
||||
if (inWindow.length < refs.length || refs.length < AUTHORED_PAGE_SIZE) return removed;
|
||||
cursor = refs[refs.length - 1].messageId;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyDeleteUserMessages(
|
||||
deps: AccountStateDeps,
|
||||
env: ActionOf<'delete_user_messages'>,
|
||||
): Promise<ActionOutcome> {
|
||||
if (!env.on) return outcomeOf(env, 'unsupported', null, 'a message purge cannot be reversed');
|
||||
const user = await deps.users.findUnique(createUserID(BigInt(env.user_id)));
|
||||
if (!user) return outcomeOf(env, 'ineligible');
|
||||
if (user.isBot) return outcomeOf(env, 'ineligible', user);
|
||||
if (isAccountLimitExempt(user)) return outcomeOf(env, 'exempt', user);
|
||||
const purge = await deps.messages.deleteAllUserMessages(
|
||||
{user_id: user.id, dry_run: false},
|
||||
SYSTEM_USER_ID,
|
||||
`Automated action ${env.id}`,
|
||||
);
|
||||
if (purge.message_count === 0) return outcomeOf(env, 'noop', user);
|
||||
return outcomeOf(env, 'applied', user, `messages=${purge.message_count} job=${purge.job_id ?? ''}`);
|
||||
}
|
||||
|
||||
function parseBanTarget(value: string): ReturnType<typeof parseIpAddress> {
|
||||
const direct = parseIpAddress(value);
|
||||
if (direct) return direct;
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {User} from '@app/api/models/User';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {isProfileHidden} from '@app/api/user/ProfileVisibility';
|
||||
import type {EmailChangeService} from '@app/api/user/services/EmailChangeService';
|
||||
import type {UserAccountService} from '@app/api/user/services/UserAccountService';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
@@ -301,8 +302,12 @@ export class UserAccountRequestService {
|
||||
}
|
||||
}
|
||||
const restrictProfile = profile.restrictProfile;
|
||||
const hidden = isProfileHidden(profileUser);
|
||||
const userProfile = mapUserToProfileResponse(profileUser, {restrictProfile});
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {restrictProfile});
|
||||
const guildMemberProfile = mapGuildMemberToProfileResponse(profile.guildMemberDomain ?? null, {
|
||||
restrictProfile,
|
||||
hidden,
|
||||
});
|
||||
const timezoneOffset = profile.timezoneVisible ? getCurrentTimeZoneOffsetMinutes(profileUser.timezone) : null;
|
||||
const mutualFriends = profile.mutualFriends
|
||||
? profile.mutualFriends.map((user) =>
|
||||
@@ -313,7 +318,8 @@ export class UserAccountRequestService {
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
const connectedAccounts = profile.connections ? this.mapConnectionsToResponse(profile.connections) : undefined;
|
||||
const connectedAccounts =
|
||||
profile.connections && !hidden ? this.mapConnectionsToResponse(profile.connections) : undefined;
|
||||
return {
|
||||
user: mapUserToPartialResponseWithCache({
|
||||
user: profileUser,
|
||||
|
||||
@@ -821,6 +821,7 @@ export class UserContentService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const data = (await this.buildMessageResponsesForUser(userId, [message]))[0];
|
||||
if (!data) return;
|
||||
await this.gatewayService
|
||||
.dispatchPresence({
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getMember, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {fetchUser, fetchUserMe, fetchUserProfile} from '@app/api/user/tests/UserTestUtils';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
interface AdminUser {
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
bio: string | null;
|
||||
pronouns: string | null;
|
||||
pending_deletion_at: string | null;
|
||||
}
|
||||
|
||||
describe('hidden profiles', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
let viewer: TestAccount;
|
||||
let target: TestAccount;
|
||||
let guildId: string;
|
||||
let targetName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'user:lookup',
|
||||
'user:temp_ban',
|
||||
'user:delete',
|
||||
'user:update:flags',
|
||||
]);
|
||||
const setup = await setupTestGuildWithMembers(harness, 1);
|
||||
viewer = setup.owner;
|
||||
target = setup.members[0]!;
|
||||
guildId = setup.guild.id;
|
||||
await createBuilder(harness, target.token)
|
||||
.patch('/users/@me')
|
||||
.body({global_name: 'Shown Name', bio: 'shown bio', pronouns: 'they/them'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, target.token)
|
||||
.patch(`/guilds/${guildId}/members/@me`)
|
||||
.body({nick: 'Shown Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
targetName = (await fetchUser(harness, target.userId, viewer.token)).json.username;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function expectShown(): Promise<void> {
|
||||
const {json} = await fetchUser(harness, target.userId, viewer.token);
|
||||
expect(json).toMatchObject({username: targetName, global_name: 'Shown Name'});
|
||||
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0);
|
||||
const profile = await fetchUserProfile(harness, target.userId, viewer.token);
|
||||
expect(profile.json.user_profile).toMatchObject({bio: 'shown bio', pronouns: 'they/them'});
|
||||
const member = await getMember(harness, viewer.token, guildId, target.userId);
|
||||
expect(member.nick).toBe('Shown Nick');
|
||||
}
|
||||
|
||||
async function expectHidden(): Promise<void> {
|
||||
const {json} = await fetchUser(harness, target.userId, viewer.token);
|
||||
expect(json).toMatchObject({username: 'HiddenUser', discriminator: '0000', global_name: null, avatar: null});
|
||||
expect(json.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN);
|
||||
const profile = await fetchUserProfile(harness, target.userId, viewer.token);
|
||||
expect(profile.json.user_profile).toMatchObject({bio: null, pronouns: null, banner: null, accent_color: null});
|
||||
const member = await getMember(harness, viewer.token, guildId, target.userId);
|
||||
expect(member).toMatchObject({nick: null, avatar: null, banner: null});
|
||||
expect(member.user.username).toBe('HiddenUser');
|
||||
}
|
||||
|
||||
async function expectStaffSeeStoredProfile(): Promise<void> {
|
||||
const {users} = await createBuilder<{users: Array<AdminUser>}>(harness, admin.token)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(users[0]).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
|
||||
}
|
||||
|
||||
function memberUpdates(): Array<GuildMemberResponse> {
|
||||
const dispatch = vi.mocked(NoopGatewayService.prototype.dispatchGuild);
|
||||
return dispatch.mock.calls
|
||||
.map(([params]) => params)
|
||||
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId)
|
||||
.map((params) => params.data as GuildMemberResponse)
|
||||
.filter((member) => member.user.id === target.userId);
|
||||
}
|
||||
|
||||
test('a normal account shows its stored profile', async () => {
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('a staff ban hides the profile until the unban restores it, and clients are told both ways', async () => {
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const presence = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${target.userId}/ban`)
|
||||
.body({duration_hours: 24, notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectHidden();
|
||||
await expectStaffSeeStoredProfile();
|
||||
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([['HiddenUser', null]]);
|
||||
expect(presence.mock.calls.some(([params]) => params.event === 'USER_UPDATE')).toBe(true);
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/users/${target.userId}/ban`)
|
||||
.body({notify_user: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
expect(memberUpdates().map((member) => [member.user.username, member.nick])).toEqual([
|
||||
['HiddenUser', null],
|
||||
[targetName, 'Shown Nick'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the spammer flag hides the profile and clearing it restores it', async () => {
|
||||
const flags = (body: Record<string, Array<string>>) =>
|
||||
createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await flags({add_flags: [UserFlags.SPAMMER.toString()]});
|
||||
await expectHidden();
|
||||
await expectStaffSeeStoredProfile();
|
||||
await flags({remove_flags: [UserFlags.SPAMMER.toString()]});
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('a pending deletion for abuse hides the profile and cancelling it restores it', async () => {
|
||||
const {user} = await createBuilder<{user: AdminUser}>(harness, admin.token)
|
||||
.put(`/admin/users/${target.userId}/deletion`)
|
||||
.body({
|
||||
reason_code: DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
days_until_deletion: 30,
|
||||
notify_user: false,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectHidden();
|
||||
await expectStaffSeeStoredProfile();
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/users/${target.userId}/deletion`)
|
||||
.body({expected_pending_deletion_at: user.pending_deletion_at})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
});
|
||||
|
||||
test('the hidden profile flag hides the profile from others while the owner keeps seeing it', async () => {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body({add_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectHidden();
|
||||
const own = await fetchUserMe(harness, target.token);
|
||||
expect(own.json).toMatchObject({username: targetName, global_name: 'Shown Name', bio: 'shown bio'});
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/users/${target.userId}/flags`)
|
||||
.body({remove_flags: [UserFlags.PROFILE_HIDDEN.toString()]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await expectShown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {LimitConfigService, resetGlobalLimitConfigServiceForTesting} from '@app/api/limits/LimitConfigService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {GuildMember} from '@app/api/models/GuildMember';
|
||||
import {User} from '@app/api/models/User';
|
||||
import {isProfileHidden, isUnderEnforcement} from '@app/api/user/ProfileVisibility';
|
||||
import {
|
||||
hasPartialUserFieldsChanged,
|
||||
mapGuildMemberToProfileResponse,
|
||||
mapUserToPartialResponse,
|
||||
mapUserToPrivateResponse,
|
||||
mapUserToProfileResponse,
|
||||
} from '@app/api/user/UserMappers';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {PublicUserFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
|
||||
const NOW = Date.now();
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
function user(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1174109840998400001n),
|
||||
username: 'ada',
|
||||
discriminator: 7,
|
||||
global_name: 'Ada Lovelace',
|
||||
avatar_hash: 'a1b2c3',
|
||||
avatar_color: 42,
|
||||
banner_hash: 'b4n',
|
||||
banner_color: 7,
|
||||
bio: 'analytical engine enjoyer',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 99,
|
||||
flags: 0n,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const STATES: Array<[string, Partial<UserRow>, boolean]> = [
|
||||
['a normal account', {}, false],
|
||||
['a staff ban', {flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW + HOUR)}, true],
|
||||
['a lifted ban', {flags: 0n, temp_banned_until: null}, false],
|
||||
['a ban that ran out', {flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW - HOUR)}, false],
|
||||
['a self-disabled account', {flags: UserFlags.DISABLED}, false],
|
||||
['the spammer flag', {flags: UserFlags.SPAMMER}, true],
|
||||
[
|
||||
'a pending deletion for abuse',
|
||||
{
|
||||
flags: UserFlags.DELETED,
|
||||
pending_deletion_at: new Date(NOW + 30 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
'a self-requested deletion',
|
||||
{
|
||||
flags: UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: new Date(NOW + 14 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.USER_REQUESTED,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
'an inactivity deletion',
|
||||
{
|
||||
flags: UserFlags.DELETED,
|
||||
pending_deletion_at: new Date(NOW + 30 * 24 * HOUR),
|
||||
deletion_reason_code: DeletionReasons.INACTIVITY,
|
||||
},
|
||||
false,
|
||||
],
|
||||
['a cancelled deletion', {flags: 0n, pending_deletion_at: null, deletion_reason_code: null}, false],
|
||||
['a hidden profile', {flags: UserFlags.PROFILE_HIDDEN}, true],
|
||||
['a hidden system account', {flags: UserFlags.PROFILE_HIDDEN, system: true}, false],
|
||||
];
|
||||
|
||||
describe('profile visibility', () => {
|
||||
beforeAll(() => {
|
||||
new LimitConfigService(new InstanceConfigRepository(), new InMemoryProvider()).setAsGlobalInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalLimitConfigServiceForTesting();
|
||||
});
|
||||
|
||||
it.each(STATES)('%s', (_name, overrides, hidden) => {
|
||||
const subject = user(overrides);
|
||||
expect(isProfileHidden(subject, NOW)).toBe(hidden);
|
||||
const partial = mapUserToPartialResponse(subject);
|
||||
if (hidden) {
|
||||
expect(partial).toMatchObject({
|
||||
id: subject.id.toString(),
|
||||
username: 'HiddenUser',
|
||||
discriminator: '0000',
|
||||
global_name: null,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
});
|
||||
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(PublicUserFlags.PROFILE_HIDDEN);
|
||||
expect(mapUserToProfileResponse(subject)).toEqual({
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
banner: null,
|
||||
banner_color: null,
|
||||
accent_color: null,
|
||||
});
|
||||
} else {
|
||||
expect(partial.flags & PublicUserFlags.PROFILE_HIDDEN).toBe(0);
|
||||
expect(partial.username).toBe('ada');
|
||||
expect(partial.global_name).toBe('Ada Lovelace');
|
||||
expect(partial.avatar).toBe('a1b2c3');
|
||||
expect(mapUserToProfileResponse(subject)).toMatchObject({bio: 'analytical engine enjoyer', pronouns: 'she/her'});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the stored profile for the account owner', () => {
|
||||
const own = mapUserToPrivateResponse(user({flags: UserFlags.PROFILE_HIDDEN}));
|
||||
expect(own).toMatchObject({
|
||||
username: 'ada',
|
||||
global_name: 'Ada Lovelace',
|
||||
avatar: 'a1b2c3',
|
||||
bio: 'analytical engine enjoyer',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats hiding and restoring as a partial change so clients are told both ways', () => {
|
||||
const open = user();
|
||||
const banned = user({flags: UserFlags.DISABLED, temp_banned_until: new Date(NOW + HOUR)});
|
||||
expect(hasPartialUserFieldsChanged(open, banned)).toBe(true);
|
||||
expect(hasPartialUserFieldsChanged(banned, open)).toBe(true);
|
||||
expect(hasPartialUserFieldsChanged(open, user({flags: UserFlags.HAS_SESSION_STARTED}))).toBe(false);
|
||||
});
|
||||
|
||||
it('only counts staff enforcement as enforcement', () => {
|
||||
expect(isUnderEnforcement(user({flags: UserFlags.PROFILE_HIDDEN}), NOW)).toBe(false);
|
||||
expect(isUnderEnforcement(user({flags: UserFlags.SPAMMER}), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides guild-specific profile details for a hidden member', async () => {
|
||||
const member = new GuildMember({
|
||||
guild_id: createGuildID(5n),
|
||||
user_id: createUserID(1174109840998400001n),
|
||||
joined_at: new Date(NOW - HOUR),
|
||||
nick: 'Countess',
|
||||
avatar_hash: 'm4v',
|
||||
banner_hash: 'm8n',
|
||||
bio: 'member bio',
|
||||
pronouns: 'she/her',
|
||||
accent_color: 12,
|
||||
join_source_type: null,
|
||||
source_invite_code: null,
|
||||
inviter_id: null,
|
||||
deaf: false,
|
||||
mute: false,
|
||||
communication_disabled_until: null,
|
||||
role_ids: null,
|
||||
is_premium_sanitized: false,
|
||||
temporary: false,
|
||||
profile_flags: null,
|
||||
version: 1,
|
||||
});
|
||||
const cache = (partial: ReturnType<typeof mapUserToPartialResponse>) =>
|
||||
({getUserPartialResponse: async () => partial}) as unknown as Pick<UserCacheService, 'getUserPartialResponse'>;
|
||||
const hidden = await mapGuildMemberToResponse(
|
||||
member,
|
||||
cache(mapUserToPartialResponse(user({flags: UserFlags.SPAMMER}))),
|
||||
createRequestCache(),
|
||||
);
|
||||
expect(hidden).toMatchObject({nick: null, avatar: null, banner: null, accent_color: null});
|
||||
expect(hidden.user.username).toBe('HiddenUser');
|
||||
const shown = await mapGuildMemberToResponse(member, cache(mapUserToPartialResponse(user())), createRequestCache());
|
||||
expect(shown).toMatchObject({nick: 'Countess', avatar: 'm4v', banner: 'm8n', accent_color: 12});
|
||||
expect(mapGuildMemberToProfileResponse(member, {hidden: true})).toEqual({
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
banner: null,
|
||||
accent_color: null,
|
||||
});
|
||||
expect(mapGuildMemberToProfileResponse(member)).toMatchObject({bio: 'member bio', pronouns: 'she/her'});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
type AccountStateDeps,
|
||||
applyDeleteUserMessages,
|
||||
applyHideProfile,
|
||||
applyHideRecentMessages,
|
||||
applyLimitNewConversations,
|
||||
applySetAccountLimit,
|
||||
applyTempBanIp,
|
||||
@@ -70,6 +73,12 @@ export function applyAction(deps: AccountActionDeps, env: ActionEnvelope): Promi
|
||||
return applyTempBanIp(deps.state, env);
|
||||
case 'limit_new_conversations':
|
||||
return applyLimitNewConversations(deps.state, env);
|
||||
case 'hide_profile':
|
||||
return applyHideProfile(deps.state, env);
|
||||
case 'hide_recent_messages':
|
||||
return applyHideRecentMessages(deps.state, env);
|
||||
case 'delete_user_messages':
|
||||
return applyDeleteUserMessages(deps.state, env);
|
||||
default:
|
||||
return Promise.resolve(outcomeOf(env as ActionEnvelope, 'unsupported'));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {AdminMessageShredService} from '@app/api/admin/services/AdminMessageShredService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createApiContext} from '@app/api/CreateApiContext';
|
||||
import {setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution';
|
||||
@@ -282,9 +285,25 @@ export async function startWorkerMain(): Promise<void> {
|
||||
});
|
||||
startSharedListWatch(jsConnectionManager.getJetStreamClient());
|
||||
if (activeWorkerLanes.some((lane) => lane.name === 'lifecycle')) {
|
||||
const apiContext = createApiContext();
|
||||
const auditService = new AdminAuditService(getAdminRepository(), apiContext.services.snowflake);
|
||||
const messagePurge = new AdminMessageDeletionService({
|
||||
channelRepository: dependencies.channelRepository,
|
||||
messageShredService: new AdminMessageShredService({apiContext, auditService}),
|
||||
auditService,
|
||||
});
|
||||
startAccountActionConsumer({
|
||||
js: jsConnectionManager.getJetStreamClient(),
|
||||
state: accountStateDepsFromContext(createApiContext(), getAdminRepository()),
|
||||
state: accountStateDepsFromContext(
|
||||
apiContext,
|
||||
getAdminRepository(),
|
||||
{
|
||||
userCacheService: dependencies.userCacheService,
|
||||
guildRepository: dependencies.guildRepository,
|
||||
},
|
||||
messagePurge,
|
||||
dependencies.channelRepository,
|
||||
),
|
||||
});
|
||||
Logger.info('Account action consumer started');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {AdminMessageDeletionService} from '@app/api/admin/services/AdminMessageDeletionService';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
@@ -20,6 +29,7 @@ import {
|
||||
stopAccountActionConsumer,
|
||||
} from '@app/api/worker/AccountActionConsumer';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AckPolicy, DeliverPolicy, type JsMsg, jetstream, jetstreamManager} from '@nats-io/jetstream';
|
||||
import {connect} from '@nats-io/transport-node';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -80,14 +90,26 @@ class FakeUsers {
|
||||
this.rows.set(user.id.toString(), next);
|
||||
return new User(next);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patch: Partial<UserRow>): Promise<User> {
|
||||
const next = {...this.rows.get(userId.toString())!, ...patch};
|
||||
this.rows.set(userId.toString(), next);
|
||||
return new User(next);
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
users: FakeUsers;
|
||||
cached: Map<string, unknown>;
|
||||
presence: Array<unknown>;
|
||||
profiles: Array<unknown>;
|
||||
bans: Array<{ip: string; ttl: number}>;
|
||||
refreshes: number;
|
||||
authored: Array<{channelId: ChannelID; messageId: MessageID}>;
|
||||
visibility: Array<UserID>;
|
||||
removed: Array<{channelId: ChannelID; authorId: UserID; messageIds: Array<MessageID>}>;
|
||||
audits: Array<{adminUserId: UserID; action: string; targetId: bigint; auditLogReason: string | null}>;
|
||||
shreds: Array<{userId: bigint; entries: number; adminUserId: UserID; auditLogReason: string | null}>;
|
||||
deps: AccountActionDeps;
|
||||
}
|
||||
|
||||
@@ -97,16 +119,66 @@ function harness(): Harness {
|
||||
users,
|
||||
cached: new Map(),
|
||||
presence: [],
|
||||
profiles: [],
|
||||
bans: [],
|
||||
refreshes: 0,
|
||||
authored: [],
|
||||
visibility: [],
|
||||
removed: [],
|
||||
audits: [],
|
||||
shreds: [],
|
||||
deps: null as never,
|
||||
};
|
||||
const authored = {
|
||||
listMessagesByAuthor: async (_authorId: UserID, limit: number, before?: MessageID) =>
|
||||
[...h.authored]
|
||||
.sort((a, b) => (a.messageId > b.messageId ? -1 : 1))
|
||||
.filter((m) => before === undefined || m.messageId < before)
|
||||
.slice(0, limit),
|
||||
};
|
||||
const messages = new AdminMessageDeletionService({
|
||||
channelRepository: authored,
|
||||
messageShredService: {
|
||||
queueMessageShred: async (
|
||||
data: {user_id: bigint; entries: Array<unknown>},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) => {
|
||||
h.shreds.push({userId: data.user_id, entries: data.entries.length, adminUserId, auditLogReason});
|
||||
return {success: true, job_id: '77', requested: data.entries.length};
|
||||
},
|
||||
},
|
||||
auditService: {
|
||||
createAuditLog: async (log: {
|
||||
adminUserId: UserID;
|
||||
action: string;
|
||||
targetId: bigint;
|
||||
auditLogReason: string | null;
|
||||
}) => {
|
||||
h.audits.push({
|
||||
adminUserId: log.adminUserId,
|
||||
action: log.action,
|
||||
targetId: log.targetId,
|
||||
auditLogReason: log.auditLogReason,
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof AdminMessageDeletionService>[0]);
|
||||
const state: AccountStateDeps = {
|
||||
users: users as unknown as AccountStateDeps['users'],
|
||||
dispatch: {
|
||||
userUpdated: async (user) => {
|
||||
h.presence.push(user.id);
|
||||
},
|
||||
profileChanged: async (user) => {
|
||||
h.profiles.push(user.id);
|
||||
},
|
||||
contentVisibilityChanged: async (user) => {
|
||||
h.visibility.push(user.id);
|
||||
},
|
||||
messagesRemoved: async (channelId, authorId, messageIds) => {
|
||||
h.removed.push({channelId, authorId, messageIds});
|
||||
},
|
||||
},
|
||||
ipBans: {
|
||||
isIpBanned: async () => false,
|
||||
@@ -126,6 +198,8 @@ function harness(): Harness {
|
||||
h.cached.delete(key);
|
||||
},
|
||||
} as unknown as AccountStateDeps['cache'],
|
||||
messages,
|
||||
authored: authored as unknown as AccountStateDeps['authored'],
|
||||
now: () => NOW,
|
||||
};
|
||||
h.deps = {js: {} as AccountActionDeps['js'], state, now: () => NOW};
|
||||
@@ -310,6 +384,245 @@ describe('account action apply', () => {
|
||||
expect(h.bans).toEqual([]);
|
||||
});
|
||||
|
||||
it('hides the profile reversibly and tells clients both ways', async () => {
|
||||
h.users.put({flags: UserFlags.ACCOUNT_LIMITED});
|
||||
const hide = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true});
|
||||
expect(await applyAction(h.deps, hide)).toMatchObject({status: 'applied', action_type: 'hide_profile'});
|
||||
expect(h.users.current().flags).toBe(UserFlags.ACCOUNT_LIMITED | UserFlags.PROFILE_HIDDEN);
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('noop');
|
||||
const show = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false});
|
||||
expect((await applyAction(h.deps, show)).status).toBe('applied');
|
||||
expect(h.users.current().flags).toBe(UserFlags.ACCOUNT_LIMITED);
|
||||
expect((await applyAction(h.deps, show)).status).toBe('noop');
|
||||
expect(h.presence).toHaveLength(2);
|
||||
expect(h.profiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never hides staff or trusted profiles and skips bots and deleted accounts', async () => {
|
||||
const hide = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true});
|
||||
for (const flags of [UserFlags.STAFF, UserFlags.LIMIT_EXEMPT]) {
|
||||
h.users.put({flags});
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('exempt');
|
||||
}
|
||||
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hide)).status).toBe('ineligible');
|
||||
}
|
||||
h.users.put({flags: UserFlags.STAFF | UserFlags.PROFILE_HIDDEN});
|
||||
const show = envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false});
|
||||
expect((await applyAction(h.deps, show)).status).toBe('applied');
|
||||
expect(h.profiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
function hideEnvelope(on: boolean, sinceMs = NOW - 86_400_000) {
|
||||
return envelope<'hide_recent_messages'>({type: 'hide_recent_messages', user_id: USER_ID, since_ms: sinceMs, on});
|
||||
}
|
||||
|
||||
function authorAt(timestampMs: number, channel: number): MessageID {
|
||||
const messageId = createMessageID(createSnowflakeFromTimestamp(timestampMs) + BigInt(h.authored.length));
|
||||
h.authored.push({channelId: createChannelID(BigInt(channel)), messageId});
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function removedIds(): Array<MessageID> {
|
||||
return h.removed.flatMap((entry) => entry.messageIds).sort((a, b) => (a < b ? -1 : 1));
|
||||
}
|
||||
|
||||
it('hides every message from the window onward and tells each channel they are gone', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
const before = [authorAt(since - 60_000, 100), authorAt(since - 1, 101)];
|
||||
const inside: Array<MessageID> = [authorAt(since, 100)];
|
||||
for (let i = 0; i < 250; i++) inside.push(authorAt(since + 1_000 + i, 100 + (i % 3)));
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(true, since));
|
||||
expect(outcome).toEqual({
|
||||
action_id: 'a:07:4242:0',
|
||||
action_type: 'hide_recent_messages',
|
||||
status: 'applied',
|
||||
detail: 'messages=251',
|
||||
observed: {flags: '0', deleted: false},
|
||||
user_id: USER_ID,
|
||||
});
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
expect(h.visibility).toEqual([createUserID(BigInt(USER_ID))]);
|
||||
expect(removedIds()).toEqual([...inside].sort((a, b) => (a < b ? -1 : 1)));
|
||||
for (const id of before) expect(removedIds()).not.toContain(id);
|
||||
for (const entry of h.removed) {
|
||||
expect(entry.authorId).toBe(createUserID(BigInt(USER_ID)));
|
||||
for (const id of entry.messageIds) {
|
||||
expect(h.authored.find((m) => m.messageId === id)?.channelId).toBe(entry.channelId);
|
||||
}
|
||||
}
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the wider window when asked again and resends the deletes as a noop', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
const later = await applyAction(h.deps, hideEnvelope(true, since + 60_000));
|
||||
expect(later).toMatchObject({status: 'noop', detail: 'messages=1'});
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
const wider = await applyAction(h.deps, hideEnvelope(true, since - 60_000));
|
||||
expect(wider.status).toBe('applied');
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since - 60_000);
|
||||
expect(h.visibility).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a hide with nothing in the window as applied with no deletes', async () => {
|
||||
authorAt(NOW - 2 * 86_400_000, 100);
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(true));
|
||||
expect(outcome).toMatchObject({status: 'applied', detail: 'messages=0'});
|
||||
expect(h.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores by clearing the window and sends no gateway events', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
h.users.put({content_hidden_since: new Date(since)});
|
||||
const outcome = await applyAction(h.deps, hideEnvelope(false));
|
||||
expect(outcome).toMatchObject({status: 'applied', detail: null, observed: {flags: '0', deleted: false}});
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
expect(h.removed).toEqual([]);
|
||||
expect(h.presence).toHaveLength(0);
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
expect((await applyAction(h.deps, hideEnvelope(false))).status).toBe('noop');
|
||||
expect(h.visibility).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never hides staff, trusted or system messages and skips bots and deleted accounts', async () => {
|
||||
authorAt(NOW - 1_000, 100);
|
||||
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.LIMIT_EXEMPT}, {system: true}] satisfies Array<
|
||||
Partial<UserRow>
|
||||
>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hideEnvelope(true))).status).toBe('exempt');
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
}
|
||||
for (const overrides of [{bot: true}, {flags: UserFlags.DELETED}] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, hideEnvelope(true))).status).toBe('ineligible');
|
||||
}
|
||||
h.users.rows.clear();
|
||||
expect(await applyAction(h.deps, hideEnvelope(true))).toMatchObject({status: 'ineligible', observed: null});
|
||||
expect(h.removed).toEqual([]);
|
||||
expect(h.visibility).toEqual([]);
|
||||
h.users.put({flags: UserFlags.STAFF, content_hidden_since: new Date(NOW - 1_000)});
|
||||
expect((await applyAction(h.deps, hideEnvelope(false))).status).toBe('applied');
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the message hide and the profile mask independent', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since + 5_000, 100);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
await applyAction(h.deps, envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: true}));
|
||||
expect(h.users.current().flags).toBe(UserFlags.PROFILE_HIDDEN);
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
await applyAction(h.deps, hideEnvelope(false));
|
||||
expect(h.users.current().flags).toBe(UserFlags.PROFILE_HIDDEN);
|
||||
expect(h.users.current().contentHiddenSince).toBeNull();
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
await applyAction(h.deps, envelope<'hide_profile'>({type: 'hide_profile', user_id: USER_ID, on: false}));
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.users.current().contentHiddenSince?.getTime()).toBe(since);
|
||||
});
|
||||
|
||||
it('still purges a hidden account through the admin purge', async () => {
|
||||
const since = NOW - 86_400_000;
|
||||
authorAt(since - 5_000, 100);
|
||||
authorAt(since + 5_000, 101);
|
||||
await applyAction(h.deps, hideEnvelope(true, since));
|
||||
const purge = await applyAction(
|
||||
h.deps,
|
||||
envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on: true}),
|
||||
);
|
||||
expect(purge).toMatchObject({status: 'applied', detail: 'messages=2 job=77'});
|
||||
expect(h.shreds).toEqual([expect.objectContaining({entries: 2})]);
|
||||
});
|
||||
|
||||
function purgeEnvelope(on = true) {
|
||||
return envelope<'delete_user_messages'>({type: 'delete_user_messages', user_id: USER_ID, on});
|
||||
}
|
||||
|
||||
function author(count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
h.authored.push({
|
||||
channelId: createChannelID(BigInt(100 + (i % 3))),
|
||||
messageId: createMessageID(BigInt(1000 + i)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('purges every message through the admin purge and audits it as the system', async () => {
|
||||
author(450);
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope());
|
||||
expect(outcome).toEqual({
|
||||
action_id: 'a:07:4242:0',
|
||||
action_type: 'delete_user_messages',
|
||||
status: 'applied',
|
||||
detail: 'messages=450 job=77',
|
||||
observed: {flags: '0', deleted: false},
|
||||
user_id: USER_ID,
|
||||
});
|
||||
expect(h.shreds).toEqual([
|
||||
{userId: BigInt(USER_ID), entries: 450, adminUserId: SYSTEM_USER_ID, auditLogReason: 'Automated action a:07:4242:0'},
|
||||
]);
|
||||
expect(h.audits).toEqual([
|
||||
{
|
||||
adminUserId: SYSTEM_USER_ID,
|
||||
action: 'delete_all_user_messages',
|
||||
targetId: BigInt(USER_ID),
|
||||
auditLogReason: 'Automated action a:07:4242:0',
|
||||
},
|
||||
]);
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
expect(h.presence).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('purges accounts already scheduled for deletion and paid accounts', async () => {
|
||||
author(2);
|
||||
for (const overrides of [
|
||||
{flags: UserFlags.DELETED | UserFlags.SPAMMER, pending_deletion_at: new Date(NOW + 86_400_000)},
|
||||
{has_ever_purchased: true, premium_type: 2},
|
||||
] satisfies Array<Partial<UserRow>>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('applied');
|
||||
}
|
||||
expect(h.shreds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports a noop when nothing is left to purge', async () => {
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope());
|
||||
expect(outcome).toMatchObject({status: 'noop', detail: null});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never purges staff, trusted, system, bot or missing accounts', async () => {
|
||||
author(3);
|
||||
for (const overrides of [{flags: UserFlags.STAFF}, {flags: UserFlags.LIMIT_EXEMPT}, {system: true}] satisfies Array<
|
||||
Partial<UserRow>
|
||||
>) {
|
||||
h.users.put(overrides);
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('exempt');
|
||||
}
|
||||
h.users.put({bot: true});
|
||||
expect((await applyAction(h.deps, purgeEnvelope())).status).toBe('ineligible');
|
||||
h.users.rows.clear();
|
||||
expect(await applyAction(h.deps, purgeEnvelope())).toMatchObject({status: 'ineligible', observed: null});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
expect(h.audits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers a purge with on false as unsupported, since it cannot be reversed', async () => {
|
||||
author(3);
|
||||
const outcome = await applyAction(h.deps, purgeEnvelope(false));
|
||||
expect(outcome).toMatchObject({status: 'unsupported', detail: 'a message purge cannot be reversed'});
|
||||
expect(h.shreds).toHaveLength(0);
|
||||
expect(h.audits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers expired actions and unknown shapes without touching the account', async () => {
|
||||
const expired = await applyAction(h.deps, limitEnvelope({expires_at_ms: NOW}));
|
||||
expect(expired.status).toBe('expired');
|
||||
|
||||
Reference in New Issue
Block a user