feat(api): add moderation events and visibility actions (#3219)

This commit is contained in:
Hampus
2026-10-05 13:24:19 +02:00
committed by GitHub
parent 532e828fe6
commit 05257d6439
39 changed files with 2464 additions and 88 deletions
@@ -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,
+6
View File
@@ -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([]);
});
});
});
+3
View File
@@ -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,
+2 -1
View File
@@ -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": {
+17 -1
View File
@@ -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};
}
+5
View File
@@ -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;
}
+19 -4
View File
@@ -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'));
}
+20 -1
View File
@@ -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');