diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 801a2f664..7961dfee5 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -9855,6 +9855,17 @@ "default": true, "description": "Whether to notify the reporter by system DM and email", "type": "boolean" + }, + "resolution": { + "description": "How the report was resolved", + "x-enumNames": ["actioned", "no_violation", "duplicate"], + "x-enumDescriptions": [ + "The report was valid and action was taken", + "The report was reviewed and no violation was found", + "The report repeats one that was already handled" + ], + "enum": ["actioned", "no_violation", "duplicate"], + "type": "string" } }, "required": ["status"] @@ -13235,6 +13246,7 @@ "description": "Bot requires manual approval for friend requests" }, {"name": "SPAMMER", "value": "64", "description": "User is flagged as a spammer"}, + {"name": "PROFILE_HIDDEN", "value": "128", "description": "User profile details are hidden from other users"}, {"name": "DELETED", "value": "17179869184", "description": "User account has been deleted"}, {"name": "SELF_DELETED", "value": "68719476736", "description": "User account was self-deleted"}, {"name": "DISABLED", "value": "274877906944", "description": "User account is disabled"}, @@ -14102,7 +14114,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"} ] }, "MessageEmbedChildResponse": { diff --git a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts index b537bb4da..171c16b83 100644 --- a/fluxer_api/src/api/admin/controllers/ReportAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/ReportAdminController.ts @@ -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, ), ); }, diff --git a/fluxer_api/src/api/admin/services/AdminMessageService.ts b/fluxer_api/src/api/admin/services/AdminMessageService.ts index 770d4ed52..96780cb1b 100644 --- a/fluxer_api/src/api/admin/services/AdminMessageService.ts +++ b/fluxer_api/src/api/admin/services/AdminMessageService.ts @@ -261,7 +261,8 @@ export class AdminMessageService { private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise { 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: { diff --git a/fluxer_api/src/api/admin/services/AdminReportService.ts b/fluxer_api/src/api/admin/services/AdminReportService.ts index 2faec2f38..1791fb0a1 100644 --- a/fluxer_api/src/api/admin/services/AdminReportService.ts +++ b/fluxer_api/src/api/admin/services/AdminReportService.ts @@ -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; + interface ReportNsfwLookupCache { channelNsfwByChannelId: Map; guildNsfwLevelByGuildId: Map; @@ -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 { 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 { @@ -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}`, diff --git a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts index 39d652cef..f3ed0d09d 100644 --- a/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts +++ b/fluxer_api/src/api/admin/services/AdminUserDeletionService.ts @@ -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 = 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 { - const {user, adminUserId} = params; + private async resolvePendingReportsAgainstUser(params: { + user: User; + adminUserId: UserID; + reasonCode: number; + }): Promise { + 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; diff --git a/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts b/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts index e76446d1e..7e0d1f4af 100644 --- a/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts +++ b/fluxer_api/src/api/admin/services/guild/AdminGuildMembershipService.ts @@ -78,6 +78,7 @@ export class AdminGuildMembershipService { reason: data.reason ?? undefined, banDurationSeconds: data.ban_duration_seconds ?? undefined, skipGuildAuditLog: true, + by: 'staff', }, auditLogReason, ); diff --git a/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts b/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts index 21bad3d5e..01cea0fc1 100644 --- a/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageResponseDataService.ts @@ -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], diff --git a/fluxer_api/src/api/channel/services/message/MessageSendService.ts b/fluxer_api/src/api/channel/services/message/MessageSendService.ts index 72447a9a4..44341ab99 100644 --- a/fluxer_api/src/api/channel/services/message/MessageSendService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageSendService.ts @@ -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) => { diff --git a/fluxer_api/src/api/channel/tests/HiddenAuthorMessageDelivery.test.ts b/fluxer_api/src/api/channel/tests/HiddenAuthorMessageDelivery.test.ts new file mode 100644 index 000000000..a73881611 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/HiddenAuthorMessageDelivery.test.ts @@ -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 { + 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); + }); +}); diff --git a/fluxer_api/src/api/database/types/UserTypes.ts b/fluxer_api/src/api/database/types/UserTypes.ts index 70a1163bd..9257b95a6 100644 --- a/fluxer_api/src/api/database/types/UserTypes.ts +++ b/fluxer_api/src/api/database/types/UserTypes.ts @@ -75,6 +75,7 @@ export interface UserRow { deletion_audit_log_reason: Nullish; deletion_scheduled_by?: Nullish; deletion_scheduled_at?: Nullish; + content_hidden_since?: Nullish; acls: Nullish>; traits: Nullish>; first_refund_at: Nullish; @@ -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, diff --git a/fluxer_api/src/api/guild/GuildModel.ts b/fluxer_api/src/api/guild/GuildModel.ts index d684d9fa2..346b94d8a 100644 --- a/fluxer_api/src/api/guild/GuildModel.ts +++ b/fluxer_api/src/api/guild/GuildModel.ts @@ -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 { diff --git a/fluxer_api/src/api/guild/services/GuildModerationService.ts b/fluxer_api/src/api/guild/services/GuildModerationService.ts index a810c8919..be7aafd88 100644 --- a/fluxer_api/src/api/guild/services/GuildModerationService.ts +++ b/fluxer_api/src/api/guild/services/GuildModerationService.ts @@ -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 { @@ -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 { - 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 { + 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 { return { user_id: ban.userId.toString(), diff --git a/fluxer_api/src/api/infrastructure/activity/ActivityMeta.ts b/fluxer_api/src/api/infrastructure/activity/ActivityMeta.ts index 2d25f6005..b3f41e988 100644 --- a/fluxer_api/src/api/infrastructure/activity/ActivityMeta.ts +++ b/fluxer_api/src/api/infrastructure/activity/ActivityMeta.ts @@ -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}; } diff --git a/fluxer_api/src/api/infrastructure/activity/Contract.generated.ts b/fluxer_api/src/api/infrastructure/activity/Contract.generated.ts index 5e3faff44..56bd9cf16 100644 --- a/fluxer_api/src/api/infrastructure/activity/Contract.generated.ts +++ b/fluxer_api/src/api/infrastructure/activity/Contract.generated.ts @@ -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, }; -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 = [ 'friend_request', 'http_errors', 'user_blocked', + 'guild_member_banned', + 'guild_member_unbanned', + 'report_resolved', ]; export const EVENT_MAJOR: Record = { registration: 1, @@ -91,6 +100,9 @@ export const EVENT_MAJOR: Record = { 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 = { registration: 'never', @@ -111,6 +123,9 @@ export const EVENT_TTL: Record = { 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 = { registration: 'fact', @@ -131,6 +146,9 @@ export const EVENT_CLASS: Record = { 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 { diff --git a/fluxer_api/src/api/infrastructure/activity/ModerationEvents.ts b/fluxer_api/src/api/infrastructure/activity/ModerationEvents.ts new file mode 100644 index 000000000..e8d0babb3 --- /dev/null +++ b/fluxer_api/src/api/infrastructure/activity/ModerationEvents.ts @@ -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 { + 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 { + 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 = { + [ReportType.MESSAGE]: 'message', + [ReportType.USER]: 'user', + [ReportType.GUILD]: 'guild', +}; + +export async function emitReportResolved( + report: Pick, + outcome: ReportOutcome, + resolvedBy: ResolvedBy, +): Promise { + 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(), + ); +} diff --git a/fluxer_api/src/api/infrastructure/activity/tests/ModerationEvents.test.ts b/fluxer_api/src/api/infrastructure/activity/tests/ModerationEvents.test.ts new file mode 100644 index 000000000..93ce1e250 --- /dev/null +++ b/fluxer_api/src/api/infrastructure/activity/tests/ModerationEvents.test.ts @@ -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 = []; + + async publish(_subject: string, payload: string): Promise { + this.events.push(JSON.parse(payload) as Event); + } + + of(kind: K): Array> { + return this.events.filter((event): event is Extract => 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 { + 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): Promise { + 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([]); + }); + }); +}); diff --git a/fluxer_api/src/api/models/User.ts b/fluxer_api/src/api/models/User.ts index bf97d71c7..a43a764f2 100644 --- a/fluxer_api/src/api/models/User.ts +++ b/fluxer_api/src/api/models/User.ts @@ -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; private readonly _traits: Set; 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, diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 69c04f5d7..81e9b86ef 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -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": { diff --git a/fluxer_api/src/api/report/ReportService.ts b/fluxer_api/src/api/report/ReportService.ts index 937dbd4c9..929017a68 100644 --- a/fluxer_api/src/api/report/ReportService.ts +++ b/fluxer_api/src/api/report/ReportService.ts @@ -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 { 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 { + 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, diff --git a/fluxer_api/src/api/user/ProfileVisibility.ts b/fluxer_api/src/api/user/ProfileVisibility.ts new file mode 100644 index 000000000..ca8c8b2fb --- /dev/null +++ b/fluxer_api/src/api/user/ProfileVisibility.ts @@ -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 = 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): boolean { + return user.pendingDeletionAt != null && isEnforcementDeletionReason(user.deletionReasonCode); +} + +export function isUnderEnforcement(user: Omit, 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): 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}; +} diff --git a/fluxer_api/src/api/user/UserHelpers.ts b/fluxer_api/src/api/user/UserHelpers.ts index 80ff0bcbf..12883ff07 100644 --- a/fluxer_api/src/api/user/UserHelpers.ts +++ b/fluxer_api/src/api/user/UserHelpers.ts @@ -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): boolean { return (user.flags & UserFlags.DELETED) !== 0n || user.deletionStartedAt != null; @@ -35,6 +36,10 @@ export function canOwnerRunBots(owner: Pick, messageId: bigint): boolean { + return user.contentHiddenSince !== null && snowflakeToDate(messageId) >= user.contentHiddenSince; +} + export function isDirectDeliverySuppressed(user: Pick): boolean { return !user.isBot && (user.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER; } diff --git a/fluxer_api/src/api/user/UserMappers.ts b/fluxer_api/src/api/user/UserMappers.ts index 1e8bd0c05..071dab8e5 100644 --- a/fluxer_api/src/api/user/UserMappers.ts +++ b/fluxer_api/src/api/user/UserMappers.ts @@ -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): Array { } 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(); 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, diff --git a/fluxer_api/src/api/user/services/AccountStateApplier.ts b/fluxer_api/src/api/user/services/AccountStateApplier.ts index c539e7204..e973b949e 100644 --- a/fluxer_api/src/api/user/services/AccountStateApplier.ts +++ b/fluxer_api/src/api/user/services/AccountStateApplier.ts @@ -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 = Extract; export interface AccountUpdateDispatch { userUpdated(user: User): Promise; + profileChanged(user: User): Promise; + contentVisibilityChanged(user: User): Promise; + messagesRemoved(channelId: ChannelID, authorId: UserID, messageIds: Array): Promise; } export interface AccountStateDeps { - users: Pick; + users: Pick; dispatch: AccountUpdateDispatch; ipBans: Pick; cache: Pick; + messages: Pick; + authored: Pick; now?: () => number; } const FLAGS_WRITE_ATTEMPTS = 3; +const AUTHORED_PAGE_SIZE = 200; const MIN_TEMP_BAN_SECONDS = 60; -function gatewayDispatch(gateway: Pick): AccountUpdateDispatch { +type ProfilePropagation = Omit; + +function gatewayDispatch( + gateway: IGatewayService, + profile: ProfilePropagation, + channels: Pick, +): 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, + messages: AccountStateDeps['messages'], + channels: Pick, +): 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 { + 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 { + 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 { + 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>(); + 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 { + 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 { const direct = parseIpAddress(value); if (direct) return direct; diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index 22cb8808d..a9b857cd7 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -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, diff --git a/fluxer_api/src/api/user/services/UserContentService.ts b/fluxer_api/src/api/user/services/UserContentService.ts index 07d74b3c8..1f63c63ee 100644 --- a/fluxer_api/src/api/user/services/UserContentService.ts +++ b/fluxer_api/src/api/user/services/UserContentService.ts @@ -821,6 +821,7 @@ export class UserContentService { requestCache: RequestCache; }): Promise { const data = (await this.buildMessageResponsesForUser(userId, [message]))[0]; + if (!data) return; await this.gatewayService .dispatchPresence({ userId, diff --git a/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts b/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts new file mode 100644 index 000000000..e083d2dc1 --- /dev/null +++ b/fluxer_api/src/api/user/tests/HiddenProfileRoutes.test.ts @@ -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 { + 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 { + 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 { + const {users} = await createBuilder<{users: Array}>(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 { + 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>) => + 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(); + }); +}); diff --git a/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts b/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts new file mode 100644 index 000000000..7dcafdc01 --- /dev/null +++ b/fluxer_api/src/api/user/tests/ProfileVisibility.test.ts @@ -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 = {}): 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, 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) => + ({getUserPartialResponse: async () => partial}) as unknown as Pick; + 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'}); + }); +}); diff --git a/fluxer_api/src/api/worker/AccountActionConsumer.ts b/fluxer_api/src/api/worker/AccountActionConsumer.ts index dd03d1f3d..eab54e860 100644 --- a/fluxer_api/src/api/worker/AccountActionConsumer.ts +++ b/fluxer_api/src/api/worker/AccountActionConsumer.ts @@ -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')); } diff --git a/fluxer_api/src/api/worker/WorkerMain.ts b/fluxer_api/src/api/worker/WorkerMain.ts index 4ae68868f..b6921cf7a 100644 --- a/fluxer_api/src/api/worker/WorkerMain.ts +++ b/fluxer_api/src/api/worker/WorkerMain.ts @@ -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 { }); 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'); } diff --git a/fluxer_api/src/api/worker/tests/AccountActionConsumer.test.ts b/fluxer_api/src/api/worker/tests/AccountActionConsumer.test.ts index 35ea36a69..4ad815d8a 100644 --- a/fluxer_api/src/api/worker/tests/AccountActionConsumer.test.ts +++ b/fluxer_api/src/api/worker/tests/AccountActionConsumer.test.ts @@ -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): Promise { + const next = {...this.rows.get(userId.toString())!, ...patch}; + this.rows.set(userId.toString(), next); + return new User(next); + } } interface Harness { users: FakeUsers; cached: Map; presence: Array; + profiles: Array; bans: Array<{ip: string; ttl: number}>; refreshes: number; + authored: Array<{channelId: ChannelID; messageId: MessageID}>; + visibility: Array; + removed: Array<{channelId: ChannelID; authorId: UserID; messageIds: Array}>; + 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}, + 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[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>) { + 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 { + 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 = [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 + >) { + 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>) { + 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>) { + 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 + >) { + 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'); diff --git a/fluxer_common/src/user_flags.rs b/fluxer_common/src/user_flags.rs index e3310cdf4..b03366aea 100644 --- a/fluxer_common/src/user_flags.rs +++ b/fluxer_common/src/user_flags.rs @@ -4,14 +4,19 @@ const USER_FLAG_BUG_HUNTER: i64 = 1 << 3; const USER_FLAG_FRIENDLY_BOT: i64 = 1 << 4; const USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL: i64 = 1 << 5; const USER_FLAG_SPAMMER: i64 = 1 << 6; +pub const USER_FLAG_PROFILE_HIDDEN: i64 = 1 << 7; +const USER_FLAG_DELETED: i64 = 1 << 34; +const USER_FLAG_DISABLED: i64 = 1 << 38; pub const USER_FLAG_STAFF_HIDDEN: i64 = 1 << 57; const PUBLIC_USER_FLAGS: i64 = USER_FLAG_STAFF | USER_FLAG_PARTNER | USER_FLAG_BUG_HUNTER | USER_FLAG_FRIENDLY_BOT | USER_FLAG_FRIENDLY_BOT_MANUAL_APPROVAL - | USER_FLAG_SPAMMER; + | USER_FLAG_SPAMMER + | USER_FLAG_PROFILE_HIDDEN; const PUBLIC_USER_FLAGS_WITHOUT_STAFF: i64 = PUBLIC_USER_FLAGS & !USER_FLAG_STAFF; +const NON_ENFORCEMENT_DELETION_REASONS: [i32; 3] = [1, 2, 19]; pub fn visible_user_flags(flags: i64) -> i32 { let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 { @@ -21,3 +26,79 @@ pub fn visible_user_flags(flags: i64) -> i32 { }; (flags & visible_flags) as i32 } + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct AccountStanding { + pub flags: i64, + pub temp_banned_until_ms: Option, + pub pending_deletion_at_ms: Option, + pub deletion_reason_code: Option, +} + +impl AccountStanding { + pub fn deleted_for_display(&self) -> bool { + self.flags & USER_FLAG_DELETED != 0 && self.pending_deletion_at_ms.is_none() + } + + pub fn profile_hidden(&self, now_ms: i64) -> bool { + let banned = self.flags & USER_FLAG_DISABLED != 0 + && self + .temp_banned_until_ms + .is_some_and(|until| until > now_ms); + let deleting = self.pending_deletion_at_ms.is_some() + && self + .deletion_reason_code + .is_some_and(|code| !NON_ENFORCEMENT_DELETION_REASONS.contains(&code)); + !self.deleted_for_display() + && (self.flags & (USER_FLAG_PROFILE_HIDDEN | USER_FLAG_SPAMMER) != 0 + || banned + || deleting) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_790_000_000_000; + + fn standing(flags: i64) -> AccountStanding { + AccountStanding { + flags, + ..AccountStanding::default() + } + } + + #[test] + fn profile_hidden_follows_enforcement_state() { + assert!(!standing(0).profile_hidden(NOW)); + assert!(standing(USER_FLAG_PROFILE_HIDDEN).profile_hidden(NOW)); + assert!(standing(USER_FLAG_SPAMMER).profile_hidden(NOW)); + assert!(!standing(USER_FLAG_DISABLED).profile_hidden(NOW)); + let banned = AccountStanding { + temp_banned_until_ms: Some(NOW + 1), + ..standing(USER_FLAG_DISABLED) + }; + assert!(banned.profile_hidden(NOW)); + assert!(!banned.profile_hidden(NOW + 1)); + let deleting = |code| AccountStanding { + pending_deletion_at_ms: Some(NOW), + deletion_reason_code: Some(code), + ..standing(USER_FLAG_DELETED) + }; + assert!(deleting(3).profile_hidden(NOW)); + assert!(deleting(20).profile_hidden(NOW)); + for code in NON_ENFORCEMENT_DELETION_REASONS { + assert!(!deleting(code).profile_hidden(NOW), "{code}"); + } + assert!(!standing(USER_FLAG_DELETED | USER_FLAG_SPAMMER).profile_hidden(NOW)); + } + + #[test] + fn the_hidden_bit_is_public() { + assert_eq!( + visible_user_flags(USER_FLAG_PROFILE_HIDDEN | USER_FLAG_DISABLED), + USER_FLAG_PROFILE_HIDDEN as i32 + ); + } +} diff --git a/fluxer_messages/src/router_impl.rs b/fluxer_messages/src/router_impl.rs index 4bdcebdca..2153f33cc 100644 --- a/fluxer_messages/src/router_impl.rs +++ b/fluxer_messages/src/router_impl.rs @@ -74,9 +74,10 @@ impl RouterService for MessagesRouter { include_reactions, nonce, tts, + include_hidden, .. } => Some(format!( - "api-get:{channel_id}:{message_id}:{viewer_user_id}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{nonce:?}:{tts:?}" + "api-get:{channel_id}:{message_id}:{viewer_user_id}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{nonce:?}:{tts:?}:{include_hidden}" )), MessageRequest::BuildResponse { .. } => None, MessageRequest::BuildResponses { .. } => None, @@ -92,9 +93,10 @@ impl RouterService for MessagesRouter { can_read_message_history, media_endpoint, include_reactions, + include_hidden, .. } => Some(format!( - "api-list:{channel_id}:{viewer_user_id}:{limit}:{before_id:?}:{after_id:?}:{around_id:?}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}" + "api-list:{channel_id}:{viewer_user_id}:{limit}:{before_id:?}:{after_id:?}:{around_id:?}:{source_guild_id:?}:{message_history_cutoff_ms:?}:{can_read_message_history}:{media_endpoint}:{include_reactions:?}:{include_hidden}" )), MessageRequest::ExtractMentions { .. } => None, } diff --git a/fluxer_messages/src/shard_impl.rs b/fluxer_messages/src/shard_impl.rs index 7361397f0..f47c16244 100644 --- a/fluxer_messages/src/shard_impl.rs +++ b/fluxer_messages/src/shard_impl.rs @@ -41,6 +41,9 @@ const BUCKET_DURATION_MS: i64 = 864_000_000; const FLUXER_EPOCH_MS: i64 = 1_420_070_400_000; const SERVICE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const MESSAGE_REFERENCE_TYPE_DEFAULT: i32 = 0; +const MESSAGE_REFERENCE_TYPE_FORWARD: i32 = 1; +const HIDDEN_REFILL_ROUNDS: usize = 5; +const HIDDEN_REFILL_MAX_PAGE: u32 = 800; const MESSAGE_FLAG_IS_CROSSPOST: i64 = 1 << 1; fn effective_reference_type(reference: &MessageReference) -> i32 { @@ -75,6 +78,16 @@ fn reply_target(message: &Message) -> Option<(i64, i64)> { Some((reference.channel_id?, reference.message_id?)) } +fn copy_source(message: &Message) -> Option<(i64, i64)> { + let reference = message.message_reference.as_ref()?; + if !is_crosspost_copy(message) + && effective_reference_type(reference) != MESSAGE_REFERENCE_TYPE_FORWARD + { + return None; + } + Some((reference.channel_id?, reference.message_id?)) +} + const MESSAGE_FLAG_SUPPRESS_EMBEDS: i64 = 1 << 2; const EMBED_MEDIA_OWNED_ATTACHMENT_FLAG: i32 = 1 << 30; #[cfg(test)] @@ -124,6 +137,62 @@ enum MessagesStorage { Scylla(Box), #[cfg(test)] Deletions(DeletedMessageKeys), + #[cfg(test)] + Memory(std::sync::Arc>), +} + +#[cfg(test)] +fn memory_buckets( + messages: &[Message], + channel_id: i64, + min_bucket: i32, + max_bucket: i32, + limit: u32, + descending: bool, +) -> Vec { + let mut buckets = messages + .iter() + .filter(|message| message.channel_id == channel_id) + .map(|message| snowflake_to_bucket(message.message_id)) + .filter(|bucket| (min_bucket..=max_bucket).contains(bucket)) + .collect::>(); + buckets.sort_unstable(); + buckets.dedup(); + if descending { + buckets.reverse(); + } + buckets.truncate(limit as usize); + buckets +} + +#[cfg(test)] +fn memory_bucket( + messages: &[Message], + channel_id: i64, + bucket: i32, + bound: Option, + limit: i32, +) -> Vec { + let mut rows = messages + .iter() + .filter(|message| { + message.channel_id == channel_id + && snowflake_to_bucket(message.message_id) == bucket + && match bound { + Some(BucketBound::Before(id)) => message.message_id < id, + Some(BucketBound::After(id)) => message.message_id > id, + None => true, + } + }) + .cloned() + .collect::>(); + if matches!(bound, Some(BucketBound::After(_))) { + rows.sort_unstable_by_key(|message| message.message_id); + } else { + rows.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id)); + } + rows.truncate(limit.max(0) as usize); + rows } #[derive(Clone)] @@ -244,6 +313,45 @@ struct UserPartialServiceResponse { flags: Option, avatar_color: Option, mention_flags: Option, + #[serde(default)] + content_hidden_since: Option, +} + +#[derive(Debug, Default)] +struct UserLookup { + partials: HashMap, + requested: HashSet, +} + +impl UserLookup { + fn hides(&self, author_id: Option, message_id: i64) -> bool { + author_id + .and_then(|author_id| self.partials.get(&author_id)) + .and_then(|partial| partial.content_hidden_since) + .is_some_and(|since| snowflake_to_epoch_millis(message_id) >= since) + } + + fn merge(&mut self, other: UserLookup) { + self.requested.extend(other.requested); + self.partials.extend(other.partials); + } +} + +#[derive(Clone, Copy)] +enum PageCursor { + Latest, + Before(i64), + After(i64), +} + +impl PageCursor { + fn next(self, page: &[Message]) -> Option { + let ids = page.iter().map(|message| message.message_id); + match self { + PageCursor::Latest | PageCursor::Before(_) => ids.min().map(PageCursor::Before), + PageCursor::After(_) => ids.max().map(PageCursor::After), + } + } } #[derive(Debug, Deserialize)] @@ -288,6 +396,7 @@ struct ResponseBuildOptions { include_reactions: bool, nonce: Option, tts: bool, + include_hidden: bool, } #[derive(Debug, Default)] @@ -583,21 +692,112 @@ impl MessagesShard { .await } - async fn get_around( + async fn fetch_page( + &self, + channel_id: i64, + cursor: PageCursor, + limit: u32, + ) -> anyhow::Result> { + match cursor { + PageCursor::Latest => self.get_latest(channel_id, limit).await, + PageCursor::Before(before_id) => self.get_before(channel_id, before_id, limit).await, + PageCursor::After(after_id) => self.get_after(channel_id, after_id, limit).await, + } + } + + async fn visible_page( + &self, + channel_id: i64, + start: PageCursor, + limit: u32, + floor: Option, + options: &ResponseBuildOptions, + users: &mut UserLookup, + ) -> anyhow::Result> { + let mut out = Vec::new(); + let mut cursor = start; + let mut page_size = limit; + for _ in 0..HIDDEN_REFILL_ROUNDS { + if limit == 0 { + break; + } + let page = self.fetch_page(channel_id, cursor, page_size).await?; + let exhausted = page.len() < page_size as usize; + let reached_floor = + floor.is_some_and(|floor| page.iter().any(|message| message.message_id <= floor)); + let next = cursor.next(&page); + let hidden = self.hidden_message_ids(&page, options, users).await; + let refill = !hidden.is_empty(); + out.extend( + page.into_iter() + .filter(|message| !hidden.contains(&message.message_id)), + ); + let Some(next) = next else { + break; + }; + if !refill || exhausted || reached_floor || out.len() >= limit as usize { + break; + } + cursor = next; + page_size = page_size + .saturating_mul(2) + .min(HIDDEN_REFILL_MAX_PAGE.max(limit)); + } + if matches!(start, PageCursor::After(_)) { + out.sort_unstable_by_key(|message| message.message_id); + } else { + out.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id)); + } + out.truncate(limit as usize); + Ok(out) + } + + async fn visible_around( &self, channel_id: i64, around_id: i64, limit: u32, + options: &ResponseBuildOptions, + users: &mut UserLookup, ) -> anyhow::Result> { if limit == 0 { return Ok(Vec::new()); } let (newer_limit, older_limit) = around_window_limits(limit); + let mut target_users = UserLookup::default(); + let mut newer_users = UserLookup::default(); + let mut older_users = UserLookup::default(); let (target, newer, older) = tokio::try_join!( - self.get_by_id(channel_id, around_id), - self.get_after(channel_id, around_id, newer_limit), - self.get_before(channel_id, around_id, older_limit) + async { + let target = self.get_by_id(channel_id, around_id).await?; + let Some(target) = target else { + return anyhow::Ok(None); + }; + let hidden = self + .hidden_message_ids(std::slice::from_ref(&target), options, &mut target_users) + .await; + Ok((!hidden.contains(&target.message_id)).then_some(target)) + }, + self.visible_page( + channel_id, + PageCursor::After(around_id), + newer_limit, + None, + options, + &mut newer_users, + ), + self.visible_page( + channel_id, + PageCursor::Before(around_id), + older_limit, + None, + options, + &mut older_users, + ) )?; + users.merge(target_users); + users.merge(newer_users); + users.merge(older_users); let mut out = Vec::new(); let mut seen = HashSet::new(); for message in newer.into_iter().rev() { @@ -631,18 +831,31 @@ impl MessagesShard { if !options.can_read_message_history && options.message_history_cutoff_ms.is_none() { return Ok(Vec::new()); } + let mut users = UserLookup::default(); let mut messages = if let Some(around_id) = around_id { - self.get_around(channel_id, around_id, limit).await? + self.visible_around(channel_id, around_id, limit, &options, &mut users) + .await? } else if let (Some(before_id), Some(after_id)) = (before_id, after_id) { - let mut before = self.get_before(channel_id, before_id, limit).await?; + let mut before = self + .visible_page( + channel_id, + PageCursor::Before(before_id), + limit, + Some(after_id), + &options, + &mut users, + ) + .await?; before.retain(|message| message.message_id > after_id); before - } else if let Some(before_id) = before_id { - self.get_before(channel_id, before_id, limit).await? - } else if let Some(after_id) = after_id { - self.get_after(channel_id, after_id, limit).await? } else { - self.get_latest(channel_id, limit).await? + let cursor = match (before_id, after_id) { + (Some(before_id), _) => PageCursor::Before(before_id), + (None, Some(after_id)) => PageCursor::After(after_id), + (None, None) => PageCursor::Latest, + }; + self.visible_page(channel_id, cursor, limit, None, &options, &mut users) + .await? }; messages .retain(|message| self.is_message_visible_to_requester(message.message_id, &options)); @@ -652,7 +865,7 @@ impl MessagesShard { self.cleanup_orphaned_messages(orphaned_messages).await; messages.sort_unstable_by_key(|message| std::cmp::Reverse(message.message_id)); let context = self - .build_response_context(&messages, &options, true) + .build_response_context(&messages, &options, true, users) .await?; Ok(messages .iter() @@ -676,8 +889,15 @@ impl MessagesShard { self.cleanup_orphaned_messages(vec![message]).await; return Ok(None); } + let mut users = UserLookup::default(); + let hidden = self + .hidden_message_ids(std::slice::from_ref(&message), &options, &mut users) + .await; + if !hidden.is_empty() { + return Ok(None); + } let context = self - .build_response_context(std::slice::from_ref(&message), &options, true) + .build_response_context(std::slice::from_ref(&message), &options, true, users) .await?; Ok(Some( self.map_message_response(&message, &options, &context, true), @@ -697,7 +917,12 @@ impl MessagesShard { return Ok(None); } let context = self - .build_response_context(std::slice::from_ref(&message), &options, true) + .build_response_context( + std::slice::from_ref(&message), + &options, + true, + UserLookup::default(), + ) .await?; Ok(Some( self.map_message_response(&message, &options, &context, true), @@ -714,8 +939,16 @@ impl MessagesShard { .filter(|message| self.is_message_visible_to_requester(message.message_id, &options)) .partition(|message| message.author_id.is_some() || message.webhook_id.is_some()); self.cleanup_orphaned_messages(orphaned_messages).await; + let mut users = UserLookup::default(); + let hidden = self + .hidden_message_ids(&messages, &options, &mut users) + .await; + let messages = messages + .into_iter() + .filter(|message| !hidden.contains(&message.message_id)) + .collect::>(); let context = self - .build_response_context(&messages, &options, true) + .build_response_context(&messages, &options, true, users) .await?; Ok(messages .iter() @@ -723,6 +956,53 @@ impl MessagesShard { .collect()) } + async fn hidden_message_ids( + &self, + messages: &[Message], + options: &ResponseBuildOptions, + users: &mut UserLookup, + ) -> HashSet { + if options.include_hidden || messages.is_empty() { + return HashSet::new(); + } + let copies = messages + .iter() + .filter_map(|message| copy_source(message).map(|source| (message.message_id, source))) + .collect::>(); + let sources = stream::iter(copies) + .map(|(message_id, (channel_id, source_id))| async move { + match self.get_by_id(channel_id, source_id).await { + Ok(Some(source)) => Some((message_id, (source.author_id, source.message_id))), + _ => None, + } + }) + .buffer_unordered(ENRICHMENT_QUERY_CONCURRENCY) + .filter_map(|source| async move { source }) + .collect::>() + .await; + let user_ids = messages + .iter() + .flat_map(|message| { + message + .author_id + .into_iter() + .chain(message.mention_users.iter().copied()) + }) + .chain(sources.values().filter_map(|(author_id, _)| *author_id)) + .collect::>(); + self.load_user_partials(user_ids, users).await; + messages + .iter() + .filter(|message| { + users.hides(message.author_id, message.message_id) + || sources + .get(&message.message_id) + .is_some_and(|(author_id, source_id)| users.hides(*author_id, *source_id)) + }) + .map(|message| message.message_id) + .collect() + } + fn is_message_visible_to_requester( &self, message_id: i64, @@ -773,8 +1053,9 @@ impl MessagesShard { messages: &[Message], options: &ResponseBuildOptions, include_referenced_messages: bool, + mut users: UserLookup, ) -> anyhow::Result { - let referenced_messages = if include_referenced_messages { + let mut referenced_messages = if include_referenced_messages { self.fetch_referenced_messages(messages, options).await } else { HashMap::new() @@ -790,14 +1071,23 @@ impl MessagesShard { let reactions_future = self.fetch_reactions_for_messages(messages, options); let attachment_decay_future = self.fetch_attachment_decay(attachment_ids); let channel_mentions_future = self.resolve_channel_mentions(channel_ids, options); - let users_future = self.fetch_user_partials(user_ids); - let (reactions, attachment_decay, channel_mentions, users) = tokio::join!( + let users_future = self.load_user_partials(user_ids, &mut users); + let (reactions, attachment_decay, channel_mentions, ()) = tokio::join!( reactions_future, attachment_decay_future, channel_mentions_future, users_future ); let attachment_decay = attachment_decay?; + if !options.include_hidden { + referenced_messages + .retain(|_, referenced| !users.hides(referenced.author_id, referenced.message_id)); + } + let users = users + .partials + .into_values() + .map(|partial| (partial.user_id, map_user_partial(partial))) + .collect(); Ok(ResponseContext { users, reactions, @@ -938,19 +1228,18 @@ impl MessagesShard { .await } - async fn fetch_user_partials( - &self, - user_ids: HashSet, - ) -> HashMap { - if user_ids.is_empty() { - return HashMap::new(); + async fn load_user_partials(&self, user_ids: HashSet, users: &mut UserLookup) { + let mut missing = user_ids + .into_iter() + .filter(|user_id| users.requested.insert(*user_id)) + .collect::>(); + if missing.is_empty() { + return; } - let mut user_ids: Vec = user_ids.into_iter().collect(); - user_ids.sort_unstable(); - user_ids.dedup(); + missing.sort_unstable(); let payload = serde_json::json!({ "op": "GetPartialsByIds", - "user_ids": user_ids, + "user_ids": missing, }); let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default(); let response = self @@ -968,13 +1257,11 @@ impl MessagesShard { Some(UserServiceResponse::FoundPartial(partial)) => vec![partial], _ => Vec::new(), }; - partials - .into_iter() - .map(|partial| { - let id = partial.user_id; - (id, map_user_partial(partial)) - }) - .collect() + users.partials.extend( + partials + .into_iter() + .map(|partial| (partial.user_id, partial)), + ); } async fn resolve_channel_mentions( @@ -1497,6 +1784,13 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(None), + #[cfg(test)] + MessagesStorage::Memory(messages) => Ok(messages + .iter() + .find(|message| { + message.channel_id == channel_id && message.message_id == message_id + }) + .cloned()), } } @@ -1521,6 +1815,10 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(Vec::new()), + #[cfg(test)] + MessagesStorage::Memory(messages) => Ok(memory_buckets( + messages, channel_id, min_bucket, max_bucket, limit, true, + )), } } @@ -1545,6 +1843,10 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(Vec::new()), + #[cfg(test)] + MessagesStorage::Memory(messages) => Ok(memory_buckets( + messages, channel_id, min_bucket, max_bucket, limit, false, + )), } } @@ -1566,6 +1868,10 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(Vec::new()), + #[cfg(test)] + MessagesStorage::Memory(messages) => { + Ok(memory_bucket(messages, channel_id, bucket, None, limit)) + } } } @@ -1596,6 +1902,14 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(Vec::new()), + #[cfg(test)] + MessagesStorage::Memory(messages) => Ok(memory_bucket( + messages, + channel_id, + bucket, + Some(BucketBound::Before(before_id)), + limit, + )), } } @@ -1626,6 +1940,14 @@ impl MessagesStorage { } #[cfg(test)] MessagesStorage::Deletions(_) => Ok(Vec::new()), + #[cfg(test)] + MessagesStorage::Memory(messages) => Ok(memory_bucket( + messages, + channel_id, + bucket, + Some(BucketBound::After(after_id)), + limit, + )), } } @@ -1651,6 +1973,8 @@ impl MessagesStorage { .push((channel_id, bucket, message_id)); Ok(()) } + #[cfg(test)] + MessagesStorage::Memory(_) => Ok(()), } } @@ -1684,7 +2008,7 @@ impl MessagesStorage { .await } #[cfg(test)] - MessagesStorage::Deletions(_) => HashMap::new(), + MessagesStorage::Deletions(_) | MessagesStorage::Memory(_) => HashMap::new(), } } @@ -1701,7 +2025,7 @@ impl MessagesStorage { storage.fetch_attachment_decay_batch(attachment_ids).await } #[cfg(test)] - MessagesStorage::Deletions(_) => Ok(HashMap::new()), + MessagesStorage::Deletions(_) | MessagesStorage::Memory(_) => Ok(HashMap::new()), } } } @@ -2174,6 +2498,7 @@ impl ShardService for MessagesShard { include_reactions, nonce, tts, + include_hidden, } => { let channel_id = parse_i64(&channel_id, "channel_id")?; let message_id = parse_i64(&message_id, "message_id")?; @@ -2199,6 +2524,7 @@ impl ShardService for MessagesShard { include_reactions: include_reactions.unwrap_or(true), nonce, tts: tts.unwrap_or(false), + include_hidden, }, ) .await?; @@ -2219,6 +2545,7 @@ impl ShardService for MessagesShard { include_reactions, nonce, tts, + include_hidden, } => { let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?; let source_guild_id = source_guild_id @@ -2241,6 +2568,7 @@ impl ShardService for MessagesShard { include_reactions: include_reactions.unwrap_or(true), nonce, tts: tts.unwrap_or(false), + include_hidden, }, ) .await?; @@ -2259,6 +2587,7 @@ impl ShardService for MessagesShard { media_proxy_secret_key, attachment_url_secret_base64, include_reactions, + include_hidden, } => { let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?; let source_guild_id = source_guild_id @@ -2281,6 +2610,7 @@ impl ShardService for MessagesShard { include_reactions: include_reactions.unwrap_or(true), nonce: None, tts: false, + include_hidden, }, ) .await?; @@ -2300,6 +2630,7 @@ impl ShardService for MessagesShard { media_proxy_secret_key, attachment_url_secret_base64, include_reactions, + include_hidden, } => { let channel_id = parse_i64(&channel_id, "channel_id")?; let viewer_user_id = parse_i64(&viewer_user_id, "viewer_user_id")?; @@ -2339,6 +2670,7 @@ impl ShardService for MessagesShard { include_reactions: include_reactions.unwrap_or(true), nonce: None, tts: false, + include_hidden, }, ) .await?; @@ -3265,7 +3597,9 @@ impl From for Message { #[cfg(test)] mod tests { use super::*; - use fluxer_svc::transport::{InMemoryTransport, TransportSubscriber, reply_message}; + use fluxer_svc::transport::{ + InMemoryTransport, TransportMessage, TransportSubscriber, reply_message, + }; use serde_json::json; #[test] @@ -3281,6 +3615,7 @@ mod tests { flags: Some(USER_FLAG_DELETED), avatar_color: None, mention_flags: None, + content_hidden_since: None, }); assert_eq!(mapped.id, "0"); @@ -3330,6 +3665,7 @@ mod tests { flags: Some(USER_FLAG_DELETED), avatar_color: Some(0x336699), mention_flags: None, + content_hidden_since: None, }); assert_eq!(mapped.id, "42"); @@ -3773,6 +4109,7 @@ mod tests { include_reactions: false, nonce: None, tts: false, + include_hidden: false, } } @@ -4864,4 +5201,260 @@ mod tests { assert!(mapped.is_none()); } + + const HIDDEN_AUTHOR: i64 = 1_472_426_752_046_002_301; + const OTHER_AUTHOR: i64 = 1_472_426_752_046_002_302; + const CHANNEL: i64 = 10; + const WINDOW_MS: i64 = 1_790_000_000_000; + + fn snowflake_at(epoch_millis: i64, sequence: i64) -> i64 { + ((epoch_millis - FLUXER_EPOCH_MS) << 22) | sequence + } + + fn message_by(author_id: i64, message_id: i64, extra: serde_json::Value) -> Message { + let mut row = json!({ + "channel_id": {"__fluxer_type": "bigint", "value": CHANNEL.to_string()}, + "bucket": snowflake_to_bucket(message_id), + "message_id": {"__fluxer_type": "bigint", "value": message_id.to_string()}, + "author_id": {"__fluxer_type": "bigint", "value": author_id.to_string()}, + "content": format!("message {message_id}"), + }); + row.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + decode_postgres_message(row).unwrap() + } + + async fn user_service( + transport: &InMemoryTransport, + hidden_since: Option, + ) -> tokio::task::JoinHandle<()> { + let mut subscriber = transport.subscribe("svc.users").await.unwrap(); + let transport = transport.clone(); + tokio::spawn(async move { + while let Some(message) = subscriber.next().await { + let request: serde_json::Value = serde_json::from_slice(message.payload()).unwrap(); + let partials = request["user_ids"] + .as_array() + .unwrap() + .iter() + .map(|id| { + let id = id.as_i64().unwrap(); + json!({ + "user_id": id, + "username": format!("user{id}"), + "discriminator": 1, + "content_hidden_since": (id == HIDDEN_AUTHOR).then_some(hidden_since).flatten(), + }) + }) + .collect::>(); + let reply = serde_json::to_vec(&json!({"FoundPartials": partials})).unwrap(); + let _ = reply_message(&message, &transport, &reply).await; + } + }) + } + + fn memory_shard(messages: Vec) -> MessagesShard { + MessagesShard { + storage: MessagesStorage::Memory(std::sync::Arc::new(messages)), + transport: InMemoryTransport::new(), + } + } + + fn channel_history() -> (Vec, Vec, Vec) { + let mut messages = Vec::new(); + let mut hidden = Vec::new(); + let mut visible = Vec::new(); + for offset in 0..3 { + let id = snowflake_at(WINDOW_MS - 60_000 + offset, 0); + messages.push(message_by(HIDDEN_AUTHOR, id, json!({}))); + visible.push(id); + } + for offset in 0..2 { + let id = snowflake_at(WINDOW_MS - 30_000 + offset, 0); + messages.push(message_by(OTHER_AUTHOR, id, json!({}))); + visible.push(id); + } + hidden.push(snowflake_at(WINDOW_MS, 0)); + messages.push(message_by(HIDDEN_AUTHOR, hidden[0], json!({}))); + for offset in 1..7 { + let id = snowflake_at(WINDOW_MS + offset * 1_000, 0); + messages.push(message_by(HIDDEN_AUTHOR, id, json!({}))); + hidden.push(id); + } + visible.sort_unstable_by_key(|id| std::cmp::Reverse(*id)); + (messages, hidden, visible) + } + + fn ids(responses: &[ApiMessageResponse]) -> Vec { + responses + .iter() + .map(|response| response.id.parse().unwrap()) + .collect() + } + + #[tokio::test] + async fn hidden_window_drops_messages_from_every_list_shape_and_refills_the_page() { + let (messages, hidden, visible) = channel_history(); + let shard = memory_shard(messages); + let users = user_service(&shard.transport, Some(WINDOW_MS)).await; + + let latest = shard + .list_api_responses(CHANNEL, 3, None, None, None, build_options()) + .await + .unwrap(); + assert_eq!(ids(&latest), visible[..3].to_vec()); + + let before = shard + .list_api_responses(CHANNEL, 50, Some(hidden[6]), None, None, build_options()) + .await + .unwrap(); + assert_eq!(ids(&before), visible); + + let after = shard + .list_api_responses(CHANNEL, 50, None, Some(visible[1]), None, build_options()) + .await + .unwrap(); + assert_eq!(ids(&after), vec![visible[0]]); + + let around = shard + .list_api_responses(CHANNEL, 4, None, None, Some(hidden[0]), build_options()) + .await + .unwrap(); + assert!(ids(&around).iter().all(|id| visible.contains(id))); + assert!(!around.is_empty()); + + let mut staff_view = build_options(); + staff_view.include_hidden = true; + let all = shard + .list_api_responses(CHANNEL, 50, None, None, None, staff_view) + .await + .unwrap(); + assert_eq!(all.len(), visible.len() + hidden.len()); + users.abort(); + } + + #[tokio::test] + async fn clearing_the_window_restores_every_message() { + let (messages, hidden, visible) = channel_history(); + let shard = memory_shard(messages); + let users = user_service(&shard.transport, None).await; + + let latest = shard + .list_api_responses(CHANNEL, 50, None, None, None, build_options()) + .await + .unwrap(); + assert_eq!(latest.len(), visible.len() + hidden.len()); + let single = shard + .get_api_response(CHANNEL, hidden[0], build_options()) + .await + .unwrap(); + assert_eq!(single.unwrap().id, hidden[0].to_string()); + users.abort(); + } + + #[tokio::test] + async fn single_fetch_hides_messages_inside_the_window_only() { + let (messages, hidden, visible) = channel_history(); + let shard = memory_shard(messages); + let users = user_service(&shard.transport, Some(WINDOW_MS)).await; + + for id in &hidden { + let response = shard + .get_api_response(CHANNEL, *id, build_options()) + .await + .unwrap(); + assert!(response.is_none(), "{id}"); + } + let earlier = visible.last().copied().unwrap(); + let response = shard + .get_api_response(CHANNEL, earlier, build_options()) + .await + .unwrap(); + assert_eq!(response.unwrap().author.id, HIDDEN_AUTHOR.to_string()); + + let mut staff_view = build_options(); + staff_view.include_hidden = true; + assert!( + shard + .get_api_response(CHANNEL, hidden[0], staff_view) + .await + .unwrap() + .is_some() + ); + users.abort(); + } + + #[tokio::test] + async fn search_pins_and_saved_builds_drop_hidden_messages() { + let (messages, hidden, visible) = channel_history(); + let shard = memory_shard(messages.clone()); + let users = user_service(&shard.transport, Some(WINDOW_MS)).await; + + let built = shard + .build_api_responses_from_messages(messages.clone(), build_options()) + .await + .unwrap(); + let mut built_ids = ids(&built); + built_ids.sort_unstable_by_key(|id| std::cmp::Reverse(*id)); + assert_eq!(built_ids, visible); + assert!(built_ids.iter().all(|id| !hidden.contains(id))); + users.abort(); + + let shard = memory_shard(messages.clone()); + let users = user_service(&shard.transport, None).await; + let restored = shard + .build_api_responses_from_messages(messages, build_options()) + .await + .unwrap(); + assert_eq!(restored.len(), visible.len() + hidden.len()); + users.abort(); + } + + #[tokio::test] + async fn replies_lose_their_preview_and_forwards_vanish_when_the_source_is_hidden() { + let (mut messages, hidden, _) = channel_history(); + let reply_id = snowflake_at(WINDOW_MS + 20_000, 0); + let forward_id = snowflake_at(WINDOW_MS + 21_000, 0); + let reference = |kind: i32| { + json!({"message_reference": { + "channel_id": {"__fluxer_type": "bigint", "value": CHANNEL.to_string()}, + "message_id": {"__fluxer_type": "bigint", "value": hidden[1].to_string()}, + "type": kind, + }}) + }; + messages.push(message_by(OTHER_AUTHOR, reply_id, reference(0))); + let mut forward = reference(1); + forward["message_snapshots"] = json!([{"content": "copied", "type": 0}]); + forward["content"] = json!(""); + messages.push(message_by(OTHER_AUTHOR, forward_id, forward)); + let shard = memory_shard(messages); + let users = user_service(&shard.transport, Some(WINDOW_MS)).await; + + let reply = shard + .get_api_response(CHANNEL, reply_id, build_options()) + .await + .unwrap() + .unwrap(); + let reply = serde_json::to_value(&reply).unwrap(); + assert!(reply["referenced_message"].is_null()); + assert_eq!( + reply["message_reference"]["message_id"], + hidden[1].to_string() + ); + assert!( + shard + .get_api_response(CHANNEL, forward_id, build_options()) + .await + .unwrap() + .is_none() + ); + let latest = shard + .list_api_responses(CHANNEL, 2, None, None, None, build_options()) + .await + .unwrap(); + assert_eq!(ids(&latest)[0], reply_id); + assert!(!ids(&latest).contains(&forward_id)); + users.abort(); + } } diff --git a/fluxer_messages/src/types.rs b/fluxer_messages/src/types.rs index 4db97b41f..2ecbe6051 100644 --- a/fluxer_messages/src/types.rs +++ b/fluxer_messages/src/types.rs @@ -103,6 +103,8 @@ pub enum MessageRequest { include_reactions: Option, nonce: Option, tts: Option, + #[serde(default)] + include_hidden: bool, }, BuildResponse { message: Message, @@ -117,6 +119,8 @@ pub enum MessageRequest { include_reactions: Option, nonce: Option, tts: Option, + #[serde(default)] + include_hidden: bool, }, BuildResponses { messages: Vec, @@ -129,6 +133,8 @@ pub enum MessageRequest { #[serde(default)] attachment_url_secret_base64: Option, include_reactions: Option, + #[serde(default)] + include_hidden: bool, }, ListResponses { channel_id: String, @@ -145,6 +151,8 @@ pub enum MessageRequest { #[serde(default)] attachment_url_secret_base64: Option, include_reactions: Option, + #[serde(default)] + include_hidden: bool, }, ExtractMentions { contents: Vec, diff --git a/fluxer_users/src/shard_impl.rs b/fluxer_users/src/shard_impl.rs index b6a5d949a..a2da555a4 100644 --- a/fluxer_users/src/shard_impl.rs +++ b/fluxer_users/src/shard_impl.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse}; +use crate::types::{ApiUserPartial, User, UserPartial, UserRequest, UserResponse, now_ms}; #[cfg(feature = "scylla")] use chrono::{DateTime, NaiveDate, Utc}; +use fluxer_common::user_flags::AccountStanding; use fluxer_svc::shard::ShardService; use fluxer_svc::{postgres, postgres::KeyPart}; use futures::stream::{self, StreamExt}; @@ -54,13 +55,14 @@ const FULL_USER_COLUMNS: &str = "\ first_refund_at, version, \ premium_grace_ends_at, mention_flags, \ last_voice_activity_sharing_change_at, \ - timezone, timezone_privacy_flags"; + timezone, timezone_privacy_flags, content_hidden_since"; #[cfg(feature = "scylla")] const PARTIAL_USER_COLUMNS: &str = "\ user_id, username, discriminator, global_name, \ avatar_hash, bot, system, flags, \ banner_hash, banner_color, accent_color, avatar_color, \ - mention_flags"; + mention_flags, temp_banned_until, pending_deletion_at, deletion_reason_code, \ + content_hidden_since"; const USER_BATCH_SIZE: usize = 128; const USER_BATCH_CONCURRENCY: usize = 8; const USER_CACHE_MIN_GENERATION_STRIPES: usize = 4096; @@ -167,10 +169,10 @@ struct FullUserDbRow { last_voice_activity_sharing_change_at: OptionalTimestamp, timezone: Option, timezone_privacy_flags: Option, + content_hidden_since: OptionalTimestamp, } #[derive(Debug, Deserialize)] -#[cfg_attr(feature = "scylla", derive(DeserializeRow))] struct PartialUserDbRow { user_id: i64, username: String, @@ -185,6 +187,61 @@ struct PartialUserDbRow { accent_color: Option, avatar_color: Option, mention_flags: Option, + #[serde(default)] + temp_banned_until: Option, + #[serde(default)] + pending_deletion_at: Option, + #[serde(default)] + deletion_reason_code: Option, + #[serde(default)] + content_hidden_since: Option, +} + +#[cfg(feature = "scylla")] +#[derive(Debug, DeserializeRow)] +struct PartialUserScyllaRow { + user_id: i64, + username: String, + discriminator: i32, + global_name: Option, + avatar_hash: Option, + bot: Option, + system: Option, + flags: Option, + banner_hash: Option, + banner_color: Option, + accent_color: Option, + avatar_color: Option, + mention_flags: Option, + temp_banned_until: OptionalTimestamp, + pending_deletion_at: OptionalTimestamp, + deletion_reason_code: Option, + content_hidden_since: OptionalTimestamp, +} + +#[cfg(feature = "scylla")] +impl From for PartialUserDbRow { + fn from(row: PartialUserScyllaRow) -> Self { + Self { + user_id: row.user_id, + username: row.username, + discriminator: row.discriminator, + global_name: row.global_name, + avatar_hash: row.avatar_hash, + bot: row.bot, + system: row.system, + flags: row.flags, + banner_hash: row.banner_hash, + banner_color: row.banner_color, + accent_color: row.accent_color, + avatar_color: row.avatar_color, + mention_flags: row.mention_flags, + temp_banned_until: optional_timestamp_millis(row.temp_banned_until), + pending_deletion_at: optional_timestamp_millis(row.pending_deletion_at), + deletion_reason_code: row.deletion_reason_code, + content_hidden_since: optional_timestamp_millis(row.content_hidden_since), + } + } } #[derive(Debug, Deserialize)] @@ -245,6 +302,7 @@ struct FullUserKvRow { last_voice_activity_sharing_change_at: Option, timezone: Option, timezone_privacy_flags: Option, + content_hidden_since: Option, } fn generation_stripes(max_entries: u64) -> usize { @@ -589,7 +647,9 @@ impl ScyllaUsersStorage { .execute_unpaged(&self.stmt_partial, (user_id,)) .await?; let rows = result.into_rows_result()?; - let partial = rows.maybe_first_row::()?.map(Into::into); + let partial = rows + .maybe_first_row::()? + .map(|row| UserPartial::from(PartialUserDbRow::from(row))); Ok(partial) } @@ -599,9 +659,13 @@ impl ScyllaUsersStorage { .execute_unpaged(&self.stmt_partial_batch, (user_ids,)) .await?; let rows = result.into_rows_result()?; - let rows: Vec = - rows.rows::()?.collect::>()?; - Ok(rows.into_iter().map(UserPartial::from).collect::>()) + let rows: Vec = rows + .rows::()? + .collect::>()?; + Ok(rows + .into_iter() + .map(|row| UserPartial::from(PartialUserDbRow::from(row))) + .collect::>()) } } @@ -675,6 +739,7 @@ fn fluxer_system_user() -> User { last_voice_activity_sharing_change_at: None, timezone: None, timezone_privacy_flags: None, + content_hidden_since: None, } } @@ -759,6 +824,12 @@ fn optional_date_string(value: OptionalDate) -> Option { impl From for UserPartial { fn from(row: PartialUserDbRow) -> Self { + let standing = AccountStanding { + flags: row.flags.unwrap_or_default(), + temp_banned_until_ms: row.temp_banned_until, + pending_deletion_at_ms: row.pending_deletion_at, + deletion_reason_code: row.deletion_reason_code, + }; Self { user_id: row.user_id, username: row.username, @@ -773,7 +844,9 @@ impl From for UserPartial { accent_color: row.accent_color, avatar_color: row.avatar_color, mention_flags: row.mention_flags, + content_hidden_since: row.content_hidden_since, } + .visible_to_others(&standing, now_ms()) } } @@ -856,6 +929,7 @@ impl From for User { ), timezone: row.timezone, timezone_privacy_flags: row.timezone_privacy_flags, + content_hidden_since: optional_timestamp_millis(row.content_hidden_since), } } } @@ -921,6 +995,7 @@ impl From for User { last_voice_activity_sharing_change_at: row.last_voice_activity_sharing_change_at, timezone: row.timezone, timezone_privacy_flags: row.timezone_privacy_flags, + content_hidden_since: row.content_hidden_since, } } } @@ -1087,13 +1162,20 @@ mod tests { .split(',') .map(str::trim) .collect::>(); - let partial = serde_json::to_value(test_user(42).to_partial()).unwrap(); - let fields = partial + let mut user = test_user(42); + user.content_hidden_since = Some(1); + let partial = serde_json::to_value(user.to_partial()).unwrap(); + let mut fields = partial .as_object() .unwrap() .keys() .map(String::as_str) .collect::>(); + fields.extend([ + "temp_banned_until", + "pending_deletion_at", + "deletion_reason_code", + ]); let full_columns = FULL_USER_COLUMNS .split(',') .map(str::trim) @@ -1146,4 +1228,146 @@ mod tests { assert_eq!(user.premium_since, Some(1_781_526_896_789)); assert_eq!(user.version, 3); } + + const SPAMMER: i64 = 1 << 6; + const DISABLED: i64 = 1 << 38; + const PROFILE_HIDDEN: i64 = 1 << 7; + + fn styled(user_id: i64, flags: i64) -> User { + let mut user = test_user(user_id); + user.flags = Some(flags); + user.banner_hash = Some("banner_hash".to_owned()); + user.banner_color = Some(3); + user.accent_color = Some(5); + user.avatar_color = Some(9); + user + } + + fn assert_hidden(partial: &UserPartial) { + assert_eq!(partial.username, "HiddenUser"); + assert_eq!(partial.discriminator, 0); + assert_eq!(partial.global_name, None); + assert_eq!(partial.avatar_hash, None); + assert_eq!(partial.avatar_color, None); + assert_eq!(partial.banner_hash, None); + assert_eq!(partial.banner_color, None); + assert_eq!(partial.accent_color, None); + assert_ne!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0); + let api = partial.to_api_partial(); + assert_eq!( + (api.username.as_str(), api.discriminator.as_str()), + ("HiddenUser", "0000") + ); + assert_ne!(api.flags & PROFILE_HIDDEN as i32, 0); + } + + #[test] + fn hidden_and_enforced_accounts_get_a_neutral_partial_and_keep_their_stored_profile() { + let now = now_ms(); + let mut banned = styled(43, DISABLED); + banned.temp_banned_until = Some(now + 60_000); + let mut deleting = styled(44, 0); + deleting.pending_deletion_at = Some(now + 60_000); + deleting.deletion_reason_code = Some(20); + for user in [ + styled(41, PROFILE_HIDDEN), + styled(42, SPAMMER), + banned, + deleting, + ] { + assert_hidden(&user.to_partial()); + assert_eq!(user.username, "Ada"); + assert_eq!(user.avatar_hash.as_deref(), Some("avatar_hash")); + } + } + + #[test] + fn lifted_bans_self_disables_and_normal_accounts_keep_their_partial() { + let now = now_ms(); + let mut expired = styled(45, DISABLED); + expired.temp_banned_until = Some(now - 60_000); + let mut self_deleting = styled(46, 0); + self_deleting.pending_deletion_at = Some(now + 60_000); + self_deleting.deletion_reason_code = Some(1); + for user in [styled(47, 0), styled(48, DISABLED), expired, self_deleting] { + let partial = user.to_partial(); + assert_eq!(partial.username, "Ada", "{}", user.user_id); + assert_eq!(partial.global_name.as_deref(), Some("Ada Lovelace")); + assert_eq!(partial.banner_hash.as_deref(), Some("banner_hash")); + assert_eq!(partial.flags.unwrap_or_default() & PROFILE_HIDDEN, 0); + } + } + + #[test] + fn postgres_partial_rows_apply_the_mask_from_ban_state() { + let until = |ms: i64| { + let at = chrono::DateTime::from_timestamp_millis(ms).unwrap(); + json!({"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)}) + }; + let row = |ms: i64| { + json!({ + "user_id": {"__fluxer_type": "bigint", "value": "42"}, + "username": "ada", + "discriminator": 7, + "global_name": "Ada", + "avatar_hash": "avatar_hash", + "flags": {"__fluxer_type": "bigint", "value": DISABLED.to_string()}, + "temp_banned_until": until(ms), + }) + }; + let now = now_ms(); + assert_hidden(&decode_postgres_user_partial(row(now + 60_000)).unwrap()); + let lifted = decode_postgres_user_partial(row(now - 60_000)).unwrap(); + assert_eq!(lifted.username, "ada"); + assert_eq!(lifted.avatar_hash.as_deref(), Some("avatar_hash")); + } + + #[test] + fn the_message_hide_window_reaches_the_partial_independently_of_the_profile_mask() { + let since = 1_790_000_000_000; + let mut hidden = styled(49, 0); + hidden.content_hidden_since = Some(since); + let partial = hidden.to_partial(); + assert_eq!(partial.content_hidden_since, Some(since)); + assert_eq!(partial.username, "Ada"); + let mut masked = styled(50, PROFILE_HIDDEN); + masked.content_hidden_since = Some(since); + let partial = masked.to_partial(); + assert_hidden(&partial); + assert_eq!(partial.content_hidden_since, Some(since)); + assert_eq!( + styled(51, PROFILE_HIDDEN).to_partial().content_hidden_since, + None + ); + let wire = serde_json::to_value(styled(52, 0).to_partial()).unwrap(); + assert!(wire.get("content_hidden_since").is_none()); + assert!( + serde_json::to_value(hidden.to_partial().to_api_partial()) + .unwrap() + .get("content_hidden_since") + .is_none() + ); + } + + #[test] + fn postgres_partial_rows_read_the_message_hide_window() { + let since = 1_790_000_000_000_i64; + let at = chrono::DateTime::from_timestamp_millis(since).unwrap(); + let row = json!({ + "user_id": {"__fluxer_type": "bigint", "value": "42"}, + "username": "ada", + "discriminator": 7, + "content_hidden_since": {"__fluxer_type": "date", "value": at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)}, + }); + let partial = decode_postgres_user_partial(row).unwrap(); + assert_eq!(partial.content_hidden_since, Some(since)); + assert_eq!(partial.username, "ada"); + let cleared = decode_postgres_user_partial(json!({ + "user_id": {"__fluxer_type": "bigint", "value": "42"}, + "username": "ada", + "discriminator": 7, + })) + .unwrap(); + assert_eq!(cleared.content_hidden_since, None); + } } diff --git a/fluxer_users/src/types.rs b/fluxer_users/src/types.rs index 0686293b1..60a0e4466 100644 --- a/fluxer_users/src/types.rs +++ b/fluxer_users/src/types.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use fluxer_common::user_flags::{ + AccountStanding, USER_FLAG_PROFILE_HIDDEN, USER_FLAG_STAFF, visible_user_flags, +}; #[cfg(test)] use fluxer_common::user_flags::{USER_FLAG_PARTNER, USER_FLAG_STAFF_HIDDEN}; -use fluxer_common::user_flags::{USER_FLAG_STAFF, visible_user_flags}; use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +101,8 @@ pub struct User { pub last_voice_activity_sharing_change_at: Option, pub timezone: Option, pub timezone_privacy_flags: Option, + #[serde(default)] + pub content_hidden_since: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -116,6 +120,8 @@ pub struct UserPartial { pub accent_color: Option, pub avatar_color: Option, pub mention_flags: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_hidden_since: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -139,8 +145,30 @@ const FLUXER_SYSTEM_USER_ID: i64 = 0; const FLUXER_SYSTEM_USERNAME: &str = "Fluxer"; const FLUXER_SYSTEM_DISCRIMINATOR: &str = "0000"; +const HIDDEN_USER_USERNAME: &str = "HiddenUser"; + +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} + impl User { + pub fn standing(&self) -> AccountStanding { + AccountStanding { + flags: self.flags.unwrap_or_default(), + temp_banned_until_ms: self.temp_banned_until, + pending_deletion_at_ms: self.pending_deletion_at, + deletion_reason_code: self.deletion_reason_code, + } + } + pub fn to_partial(&self) -> UserPartial { + self.to_own_partial() + .visible_to_others(&self.standing(), now_ms()) + } + + fn to_own_partial(&self) -> UserPartial { UserPartial { user_id: self.user_id, username: self.username.clone(), @@ -155,11 +183,30 @@ impl User { accent_color: self.accent_color, avatar_color: self.avatar_color, mention_flags: self.mention_flags, + content_hidden_since: self.content_hidden_since, } } } impl UserPartial { + pub fn visible_to_others(self, standing: &AccountStanding, now_ms: i64) -> UserPartial { + if self.user_id == FLUXER_SYSTEM_USER_ID || !standing.profile_hidden(now_ms) { + return self; + } + UserPartial { + username: HIDDEN_USER_USERNAME.to_owned(), + discriminator: 0, + global_name: None, + avatar_hash: None, + flags: Some(self.flags.unwrap_or_default() | USER_FLAG_PROFILE_HIDDEN), + banner_hash: None, + banner_color: None, + accent_color: None, + avatar_color: None, + ..self + } + } + pub fn to_api_partial(&self) -> ApiUserPartial { if self.user_id == FLUXER_SYSTEM_USER_ID { return fluxer_system_user(); @@ -233,6 +280,7 @@ mod tests { accent_color: None, avatar_color: Some(0x336699), mention_flags: Some(0), + content_hidden_since: None, } } @@ -294,6 +342,7 @@ mod tests { last_voice_activity_sharing_change_at: None, timezone: Some("Europe/London".to_owned()), timezone_privacy_flags: Some(1), + content_hidden_since: None, } } diff --git a/packages/constants/src/UserConstants.ts b/packages/constants/src/UserConstants.ts index efe579e87..39a7ebaf8 100644 --- a/packages/constants/src/UserConstants.ts +++ b/packages/constants/src/UserConstants.ts @@ -57,6 +57,7 @@ export const UserFlags = { FRIENDLY_BOT: 1n << 4n, FRIENDLY_BOT_MANUAL_APPROVAL: 1n << 5n, SPAMMER: 1n << 6n, + PROFILE_HIDDEN: 1n << 7n, DELETED: 1n << 34n, SELF_DELETED: 1n << 36n, DISABLED: 1n << 38n, @@ -79,6 +80,7 @@ export const UserFlagsDescriptions: Record = { FRIENDLY_BOT: 'Bot accepts friend requests from users', FRIENDLY_BOT_MANUAL_APPROVAL: 'Bot requires manual approval for friend requests', SPAMMER: 'User is flagged as a spammer', + PROFILE_HIDDEN: 'User profile details are hidden from other users', DELETED: 'User account has been deleted', SELF_DELETED: 'User account was self-deleted', DISABLED: 'User account is disabled', @@ -153,6 +155,8 @@ export const DELETED_USER_USERNAME = 'DeletedUser'; export const DELETED_USER_GLOBAL_NAME = 'Deleted User'; export const DELETED_USER_DISCRIMINATOR = 0; export const DELETED_USER_ID = 1n; +export const HIDDEN_USER_USERNAME = 'HiddenUser'; +export const HIDDEN_USER_DISCRIMINATOR = 0; export const PublicUserFlags = { STAFF: Number(UserFlags.STAFF), PARTNER: Number(UserFlags.PARTNER), @@ -160,6 +164,7 @@ export const PublicUserFlags = { FRIENDLY_BOT: Number(UserFlags.FRIENDLY_BOT), FRIENDLY_BOT_MANUAL_APPROVAL: Number(UserFlags.FRIENDLY_BOT_MANUAL_APPROVAL), SPAMMER: Number(UserFlags.SPAMMER), + PROFILE_HIDDEN: Number(UserFlags.PROFILE_HIDDEN), } as const; export const PublicUserFlagsDescriptions: Record = { STAFF: 'User is a staff member', @@ -168,6 +173,7 @@ export const PublicUserFlagsDescriptions: Record; +const ReportResolutionEnum = createNamedStringLiteralUnion( + [ + ['actioned', 'actioned', 'The report was valid and action was taken'], + ['no_violation', 'no_violation', 'The report was reviewed and no violation was found'], + ['duplicate', 'duplicate', 'The report repeats one that was already handled'], + ], + 'How the report was resolved', +); + export const UpdateReportRequest = z.object({ status: z.literal('resolved').describe('The status to move the report to'), public_comment: createStringType(0, 512).optional().describe('Public comment to include with the resolution'), notify_reporter: z.boolean().default(true).describe('Whether to notify the reporter by system DM and email'), + resolution: ReportResolutionEnum.optional().describe('How the report was resolved'), }); export type UpdateReportRequest = z.infer; diff --git a/tools/dev/cassandra_target_schema.json b/tools/dev/cassandra_target_schema.json index c16daae93..720e1df08 100644 --- a/tools/dev/cassandra_target_schema.json +++ b/tools/dev/cassandra_target_schema.json @@ -8218,6 +8218,10 @@ { "name": "timezone_privacy_flags", "type": "int" + }, + { + "name": "content_hidden_since", + "type": "timestamp" } ], "primary_key": "((user_id))",