From f5c30fdf8fc255b903b08c46afa42cd4188ea9df Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 15 Oct 2026 07:12:46 +0200 Subject: [PATCH] feat(enforcement): record decisions and send notices (#3492) --- fluxer_admin/openapi-admin.json | 1711 +++++++++++++++-- fluxer_admin/src/acl.rs | 4 + fluxer_admin/src/admin_flags.rs | 388 +++- fluxer_admin/src/api/bulk.rs | 14 +- fluxer_admin/src/api/enforcement.rs | 264 +++ fluxer_admin/src/api/generated.rs | 8 - fluxer_admin/src/api/guilds.rs | 21 +- fluxer_admin/src/api/messages.rs | 67 +- fluxer_admin/src/api/mod.rs | 1 + fluxer_admin/src/api/reports.rs | 26 +- fluxer_admin/src/api/types/common.rs | 4 + fluxer_admin/src/api/types/messages.rs | 20 - fluxer_admin/src/api/users.rs | 113 +- fluxer_admin/src/routes/bans_actions.rs | 18 +- fluxer_admin/src/routes/enforcement.rs | 196 ++ fluxer_admin/src/routes/guilds.rs | 48 +- fluxer_admin/src/routes/message_actions.rs | 111 +- fluxer_admin/src/routes/messages.rs | 12 +- fluxer_admin/src/routes/mod.rs | 2 + fluxer_admin/src/routes/reports.rs | 41 +- fluxer_admin/src/routes/user_actions.rs | 453 +++-- fluxer_admin/src/routes/user_tabs.rs | 36 +- fluxer_admin/src/routes/users.rs | 6 + .../components/enforcement_fields.rs | 854 ++++++++ .../src/templates/components/message_list.rs | 90 +- fluxer_admin/src/templates/components/mod.rs | 1 + fluxer_admin/src/templates/layout.rs | 5 +- fluxer_admin/src/templates/layout_scripts.rs | 41 + .../src/templates/layout_sidebar_nav.rs | 6 + .../src/templates/pages/audit_logs_table.rs | 22 +- fluxer_admin/src/templates/pages/bans.rs | 29 +- .../src/templates/pages/bulk_actions.rs | 122 +- .../templates/pages/enforcement_decision.rs | 791 ++++++++ .../pages/guild_detail_tabs/moderation.rs | 34 +- .../src/templates/pages/messages_page.rs | 85 +- fluxer_admin/src/templates/pages/mod.rs | 1 + .../src/templates/pages/report_detail.rs | 90 +- .../pages/user_detail_tabs/account.rs | 36 +- .../pages/user_detail_tabs/moderation.rs | 658 ++++--- .../pages/user_detail_tabs/overview.rs | 156 +- fluxer_admin/src/utils/forms.rs | 4 - fluxer_admin/tests/enforcement_panel.rs | 1047 ++++++++++ fluxer_admin/tests/notification_writes.rs | 170 +- .../pkgs/email/src/EmailProviderTypes.ts | 2 - fluxer_api/pkgs/email/src/EmailService.ts | 90 +- fluxer_api/pkgs/email/src/IEmailService.ts | 40 +- fluxer_api/pkgs/email/src/TestEmailService.ts | 78 +- .../pkgs/email/src/email_i18n/EmailI18n.ts | 5 +- .../email_i18n/EmailI18nLocaleFiles.test.ts | 77 +- .../email/src/email_i18n/EmailI18nMessages.ts | 34 +- .../email_i18n/EmailI18nTypes.generated.ts | 8 +- .../email/src/email_i18n/EmailI18nTypes.ts | 76 +- .../src/email_i18n/weblate/locales/ar.json | 32 +- .../src/email_i18n/weblate/locales/bg.json | 32 +- .../src/email_i18n/weblate/locales/cs.json | 32 +- .../src/email_i18n/weblate/locales/da.json | 32 +- .../src/email_i18n/weblate/locales/de.json | 36 +- .../src/email_i18n/weblate/locales/el.json | 32 +- .../src/email_i18n/weblate/locales/en-GB.json | 32 +- .../email_i18n/weblate/locales/es-419.json | 32 +- .../src/email_i18n/weblate/locales/es-ES.json | 32 +- .../src/email_i18n/weblate/locales/fi.json | 32 +- .../src/email_i18n/weblate/locales/fr.json | 32 +- .../src/email_i18n/weblate/locales/he.json | 32 +- .../src/email_i18n/weblate/locales/hi.json | 32 +- .../src/email_i18n/weblate/locales/hr.json | 32 +- .../src/email_i18n/weblate/locales/hu.json | 32 +- .../src/email_i18n/weblate/locales/id.json | 32 +- .../src/email_i18n/weblate/locales/it.json | 32 +- .../src/email_i18n/weblate/locales/ja.json | 32 +- .../src/email_i18n/weblate/locales/ko.json | 32 +- .../src/email_i18n/weblate/locales/lt.json | 32 +- .../src/email_i18n/weblate/locales/nl.json | 32 +- .../src/email_i18n/weblate/locales/no.json | 36 +- .../src/email_i18n/weblate/locales/pl.json | 32 +- .../src/email_i18n/weblate/locales/pt-BR.json | 32 +- .../src/email_i18n/weblate/locales/ro.json | 32 +- .../src/email_i18n/weblate/locales/ru.json | 32 +- .../src/email_i18n/weblate/locales/sv-SE.json | 32 +- .../src/email_i18n/weblate/locales/th.json | 32 +- .../src/email_i18n/weblate/locales/tr.json | 32 +- .../src/email_i18n/weblate/locales/uk.json | 32 +- .../src/email_i18n/weblate/locales/vi.json | 32 +- .../src/email_i18n/weblate/locales/zh-CN.json | 32 +- .../src/email_i18n/weblate/locales/zh-TW.json | 32 +- .../src/email_i18n/weblate/messages.json | 32 +- fluxer_api/src/api/ApiContext.ts | 3 + fluxer_api/src/api/CreateApiContext.ts | 3 + fluxer_api/src/api/Tables.ts | 50 + fluxer_api/src/api/admin/AdminAuditActions.ts | 3 + fluxer_api/src/api/admin/AdminRepository.ts | 120 +- fluxer_api/src/api/admin/AdminService.ts | 136 +- .../admin/controllers/BanAdminController.ts | 12 +- .../admin/controllers/BulkAdminController.ts | 17 +- .../controllers/EnforcementAdminController.ts | 165 ++ .../admin/controllers/GuildAdminController.ts | 20 +- .../controllers/MessageAdminController.ts | 44 +- .../controllers/ReportAdminController.ts | 15 +- .../admin/controllers/UserAdminController.ts | 161 +- fluxer_api/src/api/admin/controllers/index.ts | 2 + fluxer_api/src/api/admin/models/UserTypes.ts | 11 +- .../api/admin/services/AdminAuditService.ts | 4 - .../services/AdminBanManagementService.ts | 69 +- .../api/admin/services/AdminGuildService.ts | 3 + .../services/AdminMessageDeletionService.ts | 114 +- .../api/admin/services/AdminMessageService.ts | 13 +- .../services/AdminMessageShredService.ts | 114 +- .../api/admin/services/AdminReportService.ts | 76 +- .../api/admin/services/AdminUserBanService.ts | 266 +-- .../services/AdminUserDeletionService.ts | 561 +++--- .../admin/services/AdminUserProfileService.ts | 121 +- .../services/AdminUserSecurityService.ts | 81 +- .../api/admin/services/AdminUserService.ts | 22 +- .../src/api/admin/services/KnownUserIps.ts | 68 + .../services/ReporterResolutionNotifier.ts | 91 +- .../guild/AdminGuildManagementService.ts | 40 +- .../src/api/admin/tests/AdminBanNotes.test.ts | 102 - .../admin/tests/AdminDeletionQueue.test.ts | 128 +- .../api/admin/tests/AdminIpBanExpiry.test.ts | 5 +- .../admin/tests/AdminRepositoryIpBans.test.ts | 1 + .../tests/AdminStaffNotifications.test.ts | 233 +-- .../AdminAuditRouteCoverage.test.ts | 2 + .../EnforcementAdminAuditCases.ts | 127 ++ .../EnforcementAdminAuditCoverage.test.ts | 6 + .../audit_coverage/GuildAdminAuditCases.ts | 43 +- .../audit_coverage/MessageAdminAuditCases.ts | 90 +- .../audit_coverage/ReportAdminAuditCases.ts | 76 +- .../UserWriteAdminAuditCases.ts | 124 +- fluxer_api/src/api/auth/AuthEmailRevert.ts | 2 +- fluxer_api/src/api/auth/AuthLogin.ts | 23 +- fluxer_api/src/api/auth/AuthPassword.ts | 2 +- fluxer_api/src/api/auth/AuthRecoveryKit.ts | 2 +- fluxer_api/src/api/auth/AuthSession.ts | 2 +- fluxer_api/src/api/auth/AuthUtility.ts | 67 +- .../src/api/auth/services/SsoService.ts | 2 + .../AuthSessionCacheInvalidation.test.ts | 7 +- .../src/api/auth/tests/UsernameSignIn.test.ts | 2 +- .../AnnouncementChannelConversion.test.ts | 2 +- .../channel/tests/CrosspostModeration.test.ts | 1 + .../tests/CrosspostPropagation.test.ts | 1 + .../tests/EmbedAttachmentPurge.test.ts | 1 + .../tests/MessageCrosspostFanout.test.ts | 1 + .../tests/ThreadMessageLifecycle.test.ts | 1 + .../src/api/content_i18n/ContentI18n.ts | 6 +- .../api/content_i18n/ContentI18nMessages.ts | 135 ++ .../src/api/content_i18n/JoinLocalizedList.ts | 14 + .../locales/auto-i18n-reviewed-unchanged.json | 1 + .../api/content_i18n/weblate/locales/ar.json | 97 + .../api/content_i18n/weblate/locales/bg.json | 97 + .../api/content_i18n/weblate/locales/cs.json | 97 + .../api/content_i18n/weblate/locales/da.json | 97 + .../api/content_i18n/weblate/locales/de.json | 97 + .../api/content_i18n/weblate/locales/el.json | 97 + .../content_i18n/weblate/locales/en-GB.json | 97 + .../content_i18n/weblate/locales/es-419.json | 97 + .../content_i18n/weblate/locales/es-ES.json | 97 + .../api/content_i18n/weblate/locales/fi.json | 97 + .../api/content_i18n/weblate/locales/fr.json | 97 + .../api/content_i18n/weblate/locales/he.json | 97 + .../api/content_i18n/weblate/locales/hi.json | 97 + .../api/content_i18n/weblate/locales/hr.json | 97 + .../api/content_i18n/weblate/locales/hu.json | 97 + .../api/content_i18n/weblate/locales/id.json | 97 + .../api/content_i18n/weblate/locales/it.json | 97 + .../api/content_i18n/weblate/locales/ja.json | 97 + .../api/content_i18n/weblate/locales/ko.json | 97 + .../api/content_i18n/weblate/locales/lt.json | 97 + .../api/content_i18n/weblate/locales/nl.json | 97 + .../api/content_i18n/weblate/locales/no.json | 97 + .../api/content_i18n/weblate/locales/pl.json | 97 + .../content_i18n/weblate/locales/pt-BR.json | 97 + .../api/content_i18n/weblate/locales/ro.json | 97 + .../api/content_i18n/weblate/locales/ru.json | 97 + .../content_i18n/weblate/locales/sv-SE.json | 97 + .../api/content_i18n/weblate/locales/th.json | 97 + .../api/content_i18n/weblate/locales/tr.json | 97 + .../api/content_i18n/weblate/locales/uk.json | 97 + .../api/content_i18n/weblate/locales/vi.json | 97 + .../content_i18n/weblate/locales/zh-CN.json | 97 + .../content_i18n/weblate/locales/zh-TW.json | 97 + .../api/content_i18n/weblate/messages.json | 97 + .../api/database/types/AdminArchiveTypes.ts | 2 + .../api/database/types/EnforcementTypes.ts | 132 ++ .../src/api/database/types/ReportTypes.ts | 2 + .../src/api/database/types/UserTypes.ts | 3 + .../src/api/enforcement/ClosureState.ts | 31 + .../src/api/enforcement/DecisionStatus.ts | 22 + .../enforcement/EnforcementAccountNotice.ts | 91 + .../src/api/enforcement/EnforcementActor.ts | 19 + .../EnforcementDecisionRepository.ts | 217 +++ .../EnforcementDecisionResponses.ts | 122 ++ .../enforcement/EnforcementDecisionService.ts | 938 +++++++++ .../api/enforcement/EnforcementEvidence.ts | 270 +++ .../src/api/enforcement/EnforcementNotice.ts | 416 ++++ .../enforcement/EnforcementNoticeDelivery.ts | 226 +++ .../src/api/enforcement/FeatureLimitState.ts | 78 + .../src/api/enforcement/NetworkBlockError.ts | 37 + .../src/api/enforcement/NetworkBlocks.ts | 111 ++ fluxer_api/src/api/enforcement/NoticeDates.ts | 32 + .../src/api/enforcement/SuspensionState.ts | 121 ++ .../tests/EnforcementClosure.test.ts | 384 ++++ .../tests/EnforcementContent.test.ts | 521 +++++ .../enforcement/tests/EnforcementCopy.test.ts | 82 + .../EnforcementDecisionRepository.test.ts | 228 +++ .../tests/EnforcementEvidence.test.ts | 136 ++ .../tests/EnforcementFeatureLimits.test.ts | 271 +++ .../tests/EnforcementNetworkBlock.test.ts | 451 +++++ .../tests/EnforcementNotice.test.ts | 488 +++++ .../tests/EnforcementPaymentFraud.test.ts | 228 +++ .../tests/EnforcementSignIn.test.ts | 531 +++++ .../tests/EnforcementSuspension.test.ts | 606 ++++++ .../enforcement/tests/EnforcementTestUtils.ts | 177 ++ .../tests/EnforcementValidation.test.ts | 287 +++ .../EnforcementNotice.test.ts.snap | 612 ++++++ .../GuildDeletionAttachmentPurge.test.ts | 2 +- .../api/guild/tests/GuildDeletionJob.test.ts | 2 +- .../activity/tests/ModerationEvents.test.ts | 32 +- fluxer_api/src/api/instance/LegalUrls.ts | 12 +- .../src/api/instance/ProductName.test.ts | 85 + fluxer_api/src/api/instance/ProductName.ts | 15 +- .../src/api/middleware/IpBanMiddleware.ts | 92 +- .../src/api/middleware/ServiceMiddleware.ts | 4 +- .../src/api/middleware/ServiceSingletons.ts | 12 +- fluxer_api/src/api/models/User.ts | 3 + fluxer_api/src/api/openapi/openapi.json | 18 +- fluxer_api/src/api/report/ReportModels.ts | 2 + fluxer_api/src/api/report/ReportRepository.ts | 6 + fluxer_api/src/api/report/ReportService.ts | 40 +- .../api/report/tests/ContentReporting.test.ts | 9 +- .../tests/ReportRepositoryPostgresKv.test.ts | 16 +- .../services/StripeDisputeWebhookHandler.ts | 19 +- .../services/StripePaymentFraudService.ts | 112 +- .../stripe/services/StripeWebhookService.ts | 9 +- .../stripe/tests/StripeWebhookDispute.test.ts | 31 +- .../StripeWebhookEarlyFraudWarning.test.ts | 45 +- .../src/api/test/TestHarnessController.ts | 7 + fluxer_api/src/api/types/HonoEnv.ts | 2 + fluxer_api/src/api/user/UserHelpers.ts | 6 + .../account/UserAccountRepository.ts | 1 + .../src/api/user/services/SystemDmMessage.ts | 41 +- .../UserDeletionEligibilityService.ts | 4 + .../api/user/services/UserDeletionService.ts | 16 + .../user/tests/AccountClosureSessions.test.ts | 11 +- .../user/tests/HiddenProfileRoutes.test.ts | 62 +- .../tests/FollowerWebhookManagement.test.ts | 2 +- .../tasks/ProcessInactivityDeletions.ts | 34 +- .../api/worker/tasks/ProcessStripeWebhook.ts | 2 + .../tasks/admin_bulk/AdminBulkServices.ts | 94 +- .../admin_bulk/BulkDeleteMessagesForUsers.ts | 60 +- .../admin_bulk/BulkScheduleUserDeletion.ts | 50 +- .../BulkDeleteMessagesForUsersAudit.test.ts | 116 +- .../tests/BulkScheduleUserDeletion.test.ts | 162 +- .../tests/BulkUpdateUserFlagsTask.test.ts | 8 +- fluxer_app/package.json | 1 + .../app/components/layout/AuthLayout.tsx | 5 +- .../features/auth/AuthMessageDescriptors.ts | 20 + .../auth/commands/AuthenticationCommands.ts | 14 + .../auth/components/NetworkBlockedHandler.tsx | 52 + .../AccountSwitcherAuthFlow.tsx | 24 +- .../auth/components/pages/SsoCallbackPage.tsx | 5 + .../flow/AccountRestrictionScreen.module.css | 99 + .../flow/AccountRestrictionScreen.test.tsx | 441 +++++ .../auth/flow/AccountRestrictionScreen.tsx | 382 ++++ .../auth/flow/NetworkBlockCopy.test.ts | 23 + .../src/features/auth/hooks/useAuthForm.ts | 4 + .../src/features/auth/hooks/useLoginFlow.ts | 7 + .../usePasskeyBridgeReturn.ts | 4 + .../auth/state/AccountRestriction.test.ts | 98 + .../features/auth/state/AccountRestriction.ts | 39 + .../src/features/auth/state/AuthFlow.ts | 9 +- .../src/features/auth/state/Authentication.ts | 23 +- .../src/features/i18n/locales/ar/messages.po | 100 + .../locales/auto-i18n-reviewed-unchanged.json | 18 + .../src/features/i18n/locales/bg/messages.po | 100 + .../src/features/i18n/locales/cs/messages.po | 100 + .../src/features/i18n/locales/da/messages.po | 100 + .../src/features/i18n/locales/de/messages.po | 100 + .../src/features/i18n/locales/el/messages.po | 100 + .../features/i18n/locales/en-GB/messages.po | 100 + .../features/i18n/locales/en-US/messages.po | 100 + .../features/i18n/locales/es-419/messages.po | 100 + .../features/i18n/locales/es-ES/messages.po | 100 + .../src/features/i18n/locales/fi/messages.po | 100 + .../src/features/i18n/locales/fr/messages.po | 100 + .../src/features/i18n/locales/he/messages.po | 100 + .../src/features/i18n/locales/hi/messages.po | 100 + .../src/features/i18n/locales/hr/messages.po | 100 + .../src/features/i18n/locales/hu/messages.po | 100 + .../src/features/i18n/locales/id/messages.po | 100 + .../src/features/i18n/locales/it/messages.po | 100 + .../src/features/i18n/locales/ja/messages.po | 100 + .../src/features/i18n/locales/ko/messages.po | 100 + .../src/features/i18n/locales/lt/messages.po | 100 + .../src/features/i18n/locales/nl/messages.po | 100 + .../src/features/i18n/locales/no/messages.po | 100 + .../src/features/i18n/locales/pl/messages.po | 100 + .../features/i18n/locales/pt-BR/messages.po | 100 + .../src/features/i18n/locales/ro/messages.po | 100 + .../src/features/i18n/locales/ru/messages.po | 100 + .../features/i18n/locales/sv-SE/messages.po | 100 + .../src/features/i18n/locales/th/messages.po | 100 + .../src/features/i18n/locales/tr/messages.po | 100 + .../src/features/i18n/locales/uk/messages.po | 100 + .../src/features/i18n/locales/vi/messages.po | 100 + .../features/i18n/locales/zh-CN/messages.po | 100 + .../features/i18n/locales/zh-TW/messages.po | 100 + .../platform/transport/RestTransport.ts | 10 + fluxer_docs/astro.config.ts | 1 + .../src/content/docs/admin-api/blocklists.mdx | 20 +- .../src/content/docs/admin-api/bulk-jobs.mdx | 33 +- .../content/docs/admin-api/enforcement.mdx | 385 ++++ .../src/content/docs/admin-api/guilds.mdx | 11 +- .../src/content/docs/admin-api/index.mdx | 17 +- .../src/content/docs/admin-api/messages.mdx | 10 +- .../src/content/docs/admin-api/reports.mdx | 25 +- .../src/content/docs/admin-api/users.mdx | 306 ++- .../content/docs/http-api/authentication.mdx | 10 +- .../src/content/docs/http-api/errors.md | 160 +- .../content/docs/operator/configuration.mdx | 8 +- packages/constants/src/AdminACLs.ts | 2 + packages/constants/src/ApiErrorCodes.ts | 7 + packages/constants/src/Core.ts | 9 + .../constants/src/EnforcementReasons.test.ts | 170 ++ packages/constants/src/EnforcementReasons.ts | 207 ++ .../constants/src/ValidationErrorCodes.ts | 25 +- .../EnforcementAccountClosedError.ts | 10 + .../EnforcementDecisionEndedError.ts | 10 + .../EnforcementDecisionOwnsStateError.ts | 10 + .../EnforcementDecisionReplacedError.ts | 10 + .../EnforcementDeletionStartedError.ts | 10 + .../moderation/IpBanLinkedToDecisionError.ts | 10 + .../src/domains/moderation/IpBannedError.ts | 29 +- .../UnknownEnforcementDecisionError.ts | 10 + .../user/AccountPermanentlySuspendedError.ts | 5 +- .../user/AccountTemporarilySuspendedError.ts | 5 +- packages/errors/src/i18n/ErrorCodeMappings.ts | 34 +- packages/errors/src/i18n/ErrorI18n.test.ts | 32 +- packages/errors/src/i18n/ErrorI18nMessages.ts | 52 +- .../errors/src/i18n/weblate/locales/ar.json | 36 +- .../errors/src/i18n/weblate/locales/bg.json | 36 +- .../errors/src/i18n/weblate/locales/cs.json | 36 +- .../errors/src/i18n/weblate/locales/da.json | 36 +- .../errors/src/i18n/weblate/locales/de.json | 36 +- .../errors/src/i18n/weblate/locales/el.json | 36 +- .../src/i18n/weblate/locales/en-GB.json | 36 +- .../src/i18n/weblate/locales/es-419.json | 36 +- .../src/i18n/weblate/locales/es-ES.json | 36 +- .../errors/src/i18n/weblate/locales/fi.json | 36 +- .../errors/src/i18n/weblate/locales/fr.json | 36 +- .../errors/src/i18n/weblate/locales/he.json | 36 +- .../errors/src/i18n/weblate/locales/hi.json | 36 +- .../errors/src/i18n/weblate/locales/hr.json | 36 +- .../errors/src/i18n/weblate/locales/hu.json | 36 +- .../errors/src/i18n/weblate/locales/id.json | 36 +- .../errors/src/i18n/weblate/locales/it.json | 36 +- .../errors/src/i18n/weblate/locales/ja.json | 36 +- .../errors/src/i18n/weblate/locales/ko.json | 36 +- .../errors/src/i18n/weblate/locales/lt.json | 36 +- .../errors/src/i18n/weblate/locales/nl.json | 36 +- .../errors/src/i18n/weblate/locales/no.json | 36 +- .../errors/src/i18n/weblate/locales/pl.json | 36 +- .../src/i18n/weblate/locales/pt-BR.json | 36 +- .../errors/src/i18n/weblate/locales/ro.json | 36 +- .../errors/src/i18n/weblate/locales/ru.json | 36 +- .../src/i18n/weblate/locales/sv-SE.json | 36 +- .../errors/src/i18n/weblate/locales/th.json | 36 +- .../errors/src/i18n/weblate/locales/tr.json | 36 +- .../errors/src/i18n/weblate/locales/uk.json | 36 +- .../errors/src/i18n/weblate/locales/vi.json | 36 +- .../src/i18n/weblate/locales/zh-CN.json | 36 +- .../src/i18n/weblate/locales/zh-TW.json | 36 +- .../errors/src/i18n/weblate/messages.json | 36 +- .../i18n/src/runtime/MessageCatalogTypes.ts | 9 +- .../runtime/tests/MessageCatalogTypes.test.ts | 9 + .../tests/StaticCatalogCompile.test.ts | 89 + .../src/domains/admin/AdminGuildSchemas.ts | 7 + .../src/domains/admin/AdminMessageSchemas.ts | 40 +- .../schema/src/domains/admin/AdminSchemas.ts | 23 +- .../src/domains/admin/AdminUserSchemas.ts | 140 +- .../src/domains/admin/EnforcementSchemas.ts | 346 ++++ .../domains/auth/EnforcementNoticeSchemas.ts | 63 + pnpm-lock.yaml | 3 + tools/dev/cassandra_target_schema.json | 227 +++ 383 files changed, 29692 insertions(+), 4139 deletions(-) create mode 100644 fluxer_admin/src/api/enforcement.rs create mode 100644 fluxer_admin/src/routes/enforcement.rs create mode 100644 fluxer_admin/src/templates/components/enforcement_fields.rs create mode 100644 fluxer_admin/src/templates/pages/enforcement_decision.rs create mode 100644 fluxer_admin/tests/enforcement_panel.rs create mode 100644 fluxer_api/src/api/admin/controllers/EnforcementAdminController.ts create mode 100644 fluxer_api/src/api/admin/services/KnownUserIps.ts delete mode 100644 fluxer_api/src/api/admin/tests/AdminBanNotes.test.ts create mode 100644 fluxer_api/src/api/admin/tests/audit_coverage/EnforcementAdminAuditCases.ts create mode 100644 fluxer_api/src/api/admin/tests/audit_coverage/EnforcementAdminAuditCoverage.test.ts create mode 100644 fluxer_api/src/api/content_i18n/JoinLocalizedList.ts create mode 100644 fluxer_api/src/api/database/types/EnforcementTypes.ts create mode 100644 fluxer_api/src/api/enforcement/ClosureState.ts create mode 100644 fluxer_api/src/api/enforcement/DecisionStatus.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementAccountNotice.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementActor.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementDecisionRepository.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementDecisionResponses.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementDecisionService.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementEvidence.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementNotice.ts create mode 100644 fluxer_api/src/api/enforcement/EnforcementNoticeDelivery.ts create mode 100644 fluxer_api/src/api/enforcement/FeatureLimitState.ts create mode 100644 fluxer_api/src/api/enforcement/NetworkBlockError.ts create mode 100644 fluxer_api/src/api/enforcement/NetworkBlocks.ts create mode 100644 fluxer_api/src/api/enforcement/NoticeDates.ts create mode 100644 fluxer_api/src/api/enforcement/SuspensionState.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementClosure.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementContent.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementCopy.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementDecisionRepository.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementEvidence.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementFeatureLimits.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementNetworkBlock.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementNotice.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementPaymentFraud.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementSignIn.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementSuspension.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementTestUtils.ts create mode 100644 fluxer_api/src/api/enforcement/tests/EnforcementValidation.test.ts create mode 100644 fluxer_api/src/api/enforcement/tests/__snapshots__/EnforcementNotice.test.ts.snap create mode 100644 fluxer_api/src/api/instance/ProductName.test.ts create mode 100644 fluxer_app/src/features/auth/components/NetworkBlockedHandler.tsx create mode 100644 fluxer_app/src/features/auth/flow/AccountRestrictionScreen.module.css create mode 100644 fluxer_app/src/features/auth/flow/AccountRestrictionScreen.test.tsx create mode 100644 fluxer_app/src/features/auth/flow/AccountRestrictionScreen.tsx create mode 100644 fluxer_app/src/features/auth/flow/NetworkBlockCopy.test.ts create mode 100644 fluxer_app/src/features/auth/state/AccountRestriction.test.ts create mode 100644 fluxer_app/src/features/auth/state/AccountRestriction.ts create mode 100644 fluxer_docs/src/content/docs/admin-api/enforcement.mdx create mode 100644 packages/constants/src/EnforcementReasons.test.ts create mode 100644 packages/constants/src/EnforcementReasons.ts create mode 100644 packages/errors/src/domains/moderation/EnforcementAccountClosedError.ts create mode 100644 packages/errors/src/domains/moderation/EnforcementDecisionEndedError.ts create mode 100644 packages/errors/src/domains/moderation/EnforcementDecisionOwnsStateError.ts create mode 100644 packages/errors/src/domains/moderation/EnforcementDecisionReplacedError.ts create mode 100644 packages/errors/src/domains/moderation/EnforcementDeletionStartedError.ts create mode 100644 packages/errors/src/domains/moderation/IpBanLinkedToDecisionError.ts create mode 100644 packages/errors/src/domains/moderation/UnknownEnforcementDecisionError.ts create mode 100644 packages/i18n/src/runtime/tests/StaticCatalogCompile.test.ts create mode 100644 packages/schema/src/domains/admin/EnforcementSchemas.ts create mode 100644 packages/schema/src/domains/auth/EnforcementNoticeSchemas.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index ae9d42f0e..026a3bb61 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -1251,7 +1251,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.", + "description": "Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log. An IP address blocked by an enforcement decision returns 409 IP_BAN_LINKED_TO_DECISION with the decision ID, because only an account decision creates or changes such a block.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -1600,7 +1600,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Remove an entry from a blocklist. The value is percent-encoded in the path and is canonicalized the same way it was on add, so an IP covered only by a broader CIDR entry cannot be removed through the narrower address. The profile-substring blocklist requires a scope.", + "description": "Remove an entry from a blocklist. The value is percent-encoded in the path and is canonicalized the same way it was on add, so an IP covered only by a broader CIDR entry cannot be removed through the narrower address. An IP block that belongs to an enforcement decision returns 409 IP_BAN_LINKED_TO_DECISION with the decision ID, and is removed by ending that decision. The profile-substring blocklist requires a scope.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -1983,7 +1983,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Deletes a single message permanently and purges its attachments. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.", + "description": "Deletes a single message permanently and purges its attachments. The basis is holder_request or housekeeping, such as cleanup of webhook or system messages. To remove a message for breaking a rule, shred it with an enforcement basis so a decision records it. Logged to audit log with the basis. Requires MESSAGE_DELETE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -2006,7 +2006,11 @@ }, "description": "The ID of the message" } - ] + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminMessageDeleteRequest"}}} + } } }, "/admin/discovery/applications": { @@ -2526,6 +2530,317 @@ } } }, + "/admin/enforcement-decisions": { + "get": { + "operationId": "list_admin_enforcement_decisions", + "summary": "List enforcement decisions", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionListResponse"}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists the enforcement decisions about one user or community, newest first, without notes or evidence. With review_queue=true it lists the automated decisions waiting for a person instead, such as purchase limits after a chargeback. A person takes one out of the queue by ending it or by recording a warning, suspension or closure on the same account. Report IDs are empty without REPORT_VIEW. Requires ENFORCEMENT_VIEW permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "review_queue", + "in": "query", + "required": false, + "schema": { + "description": "List the automated decisions waiting for a person to review them instead of one subject", + "default": "false", + "type": "string" + }, + "description": "List the automated decisions waiting for a person to review them instead of one subject" + }, + { + "name": "subject_type", + "in": "query", + "required": false, + "schema": { + "description": "Kind of subject whose decisions to list", + "type": "string", + "enum": ["user", "guild"] + }, + "description": "Kind of subject whose decisions to list" + }, + { + "name": "subject_id", + "in": "query", + "required": false, + "schema": { + "description": "ID of the user or community whose decisions to list", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "ID of the user or community whose decisions to list" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": {"description": "Maximum number of decisions to return", "default": "50", "type": "string"}, + "description": "Maximum number of decisions to return" + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "description": "Return decisions with IDs lower than this decision ID", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "Return decisions with IDs lower than this decision ID" + } + ] + } + }, + "/admin/enforcement-decisions/{decision_id}": { + "get": { + "operationId": "get_admin_enforcement_decision", + "summary": "Get enforcement decision", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Returns one enforcement decision with its notes, evidence and the notice the holder sees now. Evidence content and profile values are null without MESSAGE_LOOKUP, and report IDs are empty without REPORT_VIEW. Requires ENFORCEMENT_VIEW permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "decision_id", + "in": "path", + "required": true, + "schema": { + "description": "The ID of the enforcement decision", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "The ID of the enforcement decision" + } + ] + } + }, + "/admin/enforcement-decisions/{decision_id}/end": { + "post": { + "operationId": "end_admin_enforcement_decision", + "summary": "End an enforcement decision", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Ends an active decision as lifted or reversed, undoes what can be undone and emails the holder. Ending a suspension lifts it, and returns 409 when a later decision replaced it. Warnings, removals and suspensions that ended as expired can only be reversed. Creates audit log entry. Requires ENFORCEMENT_VIEW permission plus the permission of the route that made the decision, such as USER_TEMP_BAN for a suspension or USER_UPDATE_FLAGS for a warning.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "decision_id", + "in": "path", + "required": true, + "schema": { + "description": "The ID of the enforcement decision", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "The ID of the enforcement decision" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionEndRequest"}}} + } + } + }, + "/admin/enforcement-decisions/{decision_id}/notes": { + "post": { + "operationId": "annotate_admin_enforcement_decision", + "summary": "Add a note to an enforcement decision", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionNoteResponse"}} + } + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Appends an internal note to an enforcement decision. Notes are staff only, never change and expire with the evidence after a year. Creates audit log entry. Requires ENFORCEMENT_ANNOTATE permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "decision_id", + "in": "path", + "required": true, + "schema": { + "description": "The ID of the enforcement decision", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] + }, + "description": "The ID of the enforcement decision" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EnforcementDecisionNoteRequest"}}} + } + } + }, "/admin/gateway/memory-stats": { "get": { "operationId": "get_admin_gateway_memory_stats", @@ -3074,7 +3389,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Permanently deletes a guild. The guild becomes unavailable at once, and a background job deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.", + "description": "Permanently deletes a guild. The guild becomes unavailable at once, and a background job deletes all channels, messages, and settings. Irreversible operation with no recovery window. The basis is enforcement or holder_request. An enforcement basis records a community removal decision and sends its notice to the owner, and is refused for child safety and violent extremism reasons. Logged to audit log. Requires GUILD_DELETE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -3084,7 +3399,11 @@ "schema": {"description": "The ID of the guild", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, "description": "The ID of the guild" } - ] + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminGuildDeleteRequest"}}} + } } }, "/admin/guilds/{guild_id}/archives": { @@ -5399,7 +5718,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Moves a report to the resolved status. The reporter is always told the report was reviewed: an account reporter by system DM and email, a Digital Services Act notifier by email. notify_reporter false leaves the public comment out of that notice. Nothing is sent when the reporter account is deleted or barred from reporting. Creates an audit log entry. Requires REPORT_RESOLVE permission.", + "description": "Moves a report to the resolved status. resolution is required. actioned also needs the outcome for the reported item, and no_violation sets the outcome no_violation. duplicate needs duplicate_of and takes the outcome stored on that report. The reporter is told the outcome: an account reporter by system DM and email, a Digital Services Act notifier by email. A duplicate of a report with no outcome yet is resolved without telling the reporter. notify_reporter false leaves the public comment out of that notice. Nothing is sent when the reporter account is deleted, or barred from reporting when the report was filed. Creates an audit log entry. Requires REPORT_RESOLVE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6273,12 +6592,12 @@ "/admin/users/{user_id}/ban": { "put": { "operationId": "ban_admin_user", - "summary": "Ban user", + "summary": "Suspend user", "tags": ["Admin"], "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRestrictionResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -6319,7 +6638,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission. Emails the user for temporary bans unless notify_user is false. Permanent bans are never emailed.", + "description": "Suspend the account for duration_hours, or permanently with a duration of zero, and record the enforcement decision behind it. The decision is written first, then the suspension, then linked reports are resolved and the notice is sent. The X-Audit-Log-Reason header is required and becomes the first note. remove_evidence queues removal of the evidence messages after they are snapshotted. block_networks blocks addresses the account used in the last 30 days, for 90 days unless days says otherwise, and only with a permanent suspension. Exempt and special-purpose addresses, and addresses already blocked permanently or by another decision, are skipped and listed in skipped_ips. An address on which another account that is not suspended or closed was last active is refused with ENFORCEMENT_NETWORK_SHARED. Returns 409 when the account is closed. Requires USER_TEMP_BAN permission, plus MESSAGE_LOOKUP for evidence, REPORT_VIEW and REPORT_RESOLVE for reports, MESSAGE_SHRED for remove_evidence, and BAN_IP_ADD and USER_VIEW_IP for block_networks.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6337,7 +6656,7 @@ }, "delete": { "operationId": "unban_admin_user", - "summary": "Unban user", + "summary": "Lift a suspension without a decision", "tags": ["Admin"], "responses": { "200": { @@ -6383,7 +6702,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Immediately remove the ban from the user account. Emails the user only when notify_user is true, the ban was still in force and the account is not closed or pending deletion. The email shows public_reason and never the audit log reason. Creates audit log entry. Requires USER_TEMP_BAN permission.", + "description": "Lift a suspension that no enforcement decision owns, such as one from before decisions existed. Returns 409 with the decision ID when a decision owns the suspension, which is then ended through its end route. Emails the user that the restriction ended when the suspension was still in force. Creates audit log entry. Requires USER_TEMP_BAN permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6393,74 +6712,7 @@ "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, "description": "The ID of the user" } - ], - "requestBody": { - "required": false, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserUnbanRequest"}}} - } - } - }, - "/admin/users/{user_id}/ban/notes": { - "post": { - "operationId": "annotate_admin_user_ban", - "summary": "Add a note to a user ban", - "tags": ["Admin"], - "responses": { - "204": {"description": "No Content"}, - "400": { - "description": "Bad Request - The request was malformed or contained invalid data", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "401": { - "description": "Unauthorized - Authentication is required or the token is invalid", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "403": { - "description": "Forbidden - You do not have permission to perform this action", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - }, - "429": { - "description": "Too Many Requests - You are being rate limited", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, - "headers": { - "Retry-After": { - "description": "Number of seconds to wait before retrying (only on 429)", - "schema": {"type": "integer"} - }, - "X-RateLimit-Limit": { - "description": "The number of requests that can be made in the current window", - "schema": {"type": "integer"} - }, - "X-RateLimit-Remaining": { - "description": "The number of remaining requests that can be made", - "schema": {"type": "integer"} - }, - "X-RateLimit-Reset": { - "description": "Unix timestamp when the rate limit resets", - "schema": {"type": "integer"} - } - } - }, - "500": { - "description": "Internal Server Error - An unexpected error occurred", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} - } - }, - "description": "Append a note to the current ban of a user. The note is recorded as the reason of a new annotate_ban audit log entry whose metadata names the ban audit log entry. Earlier entries are never changed. Requires USER_TEMP_BAN permission.", - "security": [{"adminApiKey": []}], - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, - "description": "The ID of the user" - } - ], - "requestBody": { - "required": true, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBanNoteRequest"}}} - } + ] } }, "/admin/users/{user_id}/bot-status": { @@ -6681,7 +6933,7 @@ "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserRestrictionResponse"}}} }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -6722,7 +6974,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission. Emails the user unless notify_user is false. The email depends on reason_code: user requested and inactivity get neutral wording, other codes get enforcement wording with an appeal path.", + "description": "Close the account and schedule its deletion. The basis says why. A broken rule records an enforcement decision kind closure, keeps the account for at least 74 days so an appeal can be reviewed first (14 days on a self-hosted instance, which has no appeals), sends the notice and, when the rule bars new accounts, bans the account email. The X-Audit-Log-Reason header is then required and becomes the first note. A holder request or housekeeping keeps the account for at least 14 days, sends a neutral email, and is cancelled when the holder signs in. Those two return 409 while an enforcement decision owns the account state. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. block_networks works as on the suspend route and is refused for the other bases. Executing the deletion removes the blocks. Creates audit log entry. Requires USER_DELETE permission, plus MESSAGE_LOOKUP for evidence, REPORT_VIEW and REPORT_RESOLVE for reports, MESSAGE_SHRED for remove_evidence, and BAN_IP_ADD and USER_VIEW_IP for block_networks.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -6786,7 +7038,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Cancel the scheduled account deletion named by expected_pending_deletion_at. Returns 409 when a different deletion is pending and 400 when none is. The user is emailed only when notify_user is true, and the email never includes the audit log reason. Creates audit log entry recording the cancelled deletion. Requires USER_DELETE permission.", + "description": "Cancel a scheduled deletion that no enforcement decision owns, named by expected_pending_deletion_at, such as one the holder asked for, an inactivity deletion or a closure from before decisions existed. Returns 409 with the decision ID when a decision owns the deletion, which is then ended through its end route, 409 when a different deletion is pending and 400 when none is. Emails the user that the deletion was cancelled, or that the restriction ended for a closure from before decisions existed. Creates audit log entry recording the cancelled deletion. Requires USER_DELETE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -7031,6 +7283,72 @@ ] } }, + "/admin/users/{user_id}/feature-limits": { + "put": { + "operationId": "limit_admin_user_features", + "summary": "Limit user features", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Record an enforcement decision that limits one feature and send its notice by email and system DM. Direct messages set SPAMMER and are allowed for spam, automation, fraud and malware reasons only. Reports set REPORT_BANNED until ends_at, 90 days by default and at most 365, and expire on their own. Purchases set PURCHASE_DISABLED for payment reasons. The X-Audit-Log-Reason header is required and becomes the first note. Requires USER_UPDATE_FLAGS permission, plus MESSAGE_LOOKUP for evidence and REPORT_VIEW and REPORT_RESOLVE for reports.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserFeatureLimitRequest"}}} + } + } + }, "/admin/users/{user_id}/flags": { "patch": { "operationId": "update_admin_user_flags", @@ -7080,7 +7398,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Add or remove user flags to control account features and restrictions. Flags determine verification status and special properties. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", + "description": "Add or remove user flags. Flags that record a restriction go through their own routes: adding DISABLED, DELETED, SPAMMER or REPORT_BANNED and removing DELETED return 400, removing DISABLED while a suspension is set returns 400, and removing SPAMMER or REPORT_BANNED while an enforcement decision holds it returns 409 with the decision ID. A self-disabled account can still have DISABLED removed. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -7308,7 +7626,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Queues bulk shredding of the given messages of a user, with attachment deletion. Returns a job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.", + "description": "Queues bulk shredding of the given messages of a user, with attachment deletion. Returns a job ID to track progress asynchronously. With an enforcement basis the first 50 entries are snapshotted as the evidence of a content removal decision before the job is queued, every entry for child safety and violent extremism reasons, and the user is sent its notice. Requires MESSAGE_SHRED permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -7374,7 +7692,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Pass dry_run=false to delete; the default counts without deleting. Requires MESSAGE_DELETE_ALL permission.", + "description": "Deletes all messages from a specific user across all channels. Pass dry_run=false to delete, with a basis in the body. The default counts without deleting and records nothing. An enforcement basis records a content removal decision and sends its notice, and is refused for child safety and violent extremism reasons, which must shred explicit entries instead. Requires MESSAGE_DELETE_ALL permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -7395,7 +7713,11 @@ }, "description": "Count the messages that would be deleted without deleting anything (default true)" } - ] + ], + "requestBody": { + "required": false, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserMessageDeleteRequest"}}} + } } }, "/admin/users/{user_id}/mfa": { @@ -7457,6 +7779,68 @@ ] } }, + "/admin/users/{user_id}/network-block-candidates": { + "get": { + "operationId": "list_admin_user_network_block_candidates", + "summary": "List networks a suspension or closure can block", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NetworkBlockCandidatesResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Lists the addresses the account used in the last 30 days, most recent first, with how many other accounts that are not suspended or closed were last active on each. These are the addresses block_networks accepts on the suspend and close routes. Requires USER_VIEW_IP permission.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ] + } + }, "/admin/users/{user_id}/password-reset": { "post": { "operationId": "send_admin_user_password_reset", @@ -7627,7 +8011,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Add or remove premium-related flags on a user account (badge visibility, override, purchase block, etc). Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", + "description": "Add or remove premium-related flags on a user account (badge visibility, override, purchase block, etc). Adding PURCHASE_DISABLED returns 400, because a purchase limit is recorded through the feature limits route, and removing it while an enforcement decision holds it returns 409 with the decision ID. Creates audit log entry. Requires USER_UPDATE_FLAGS permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -7695,7 +8079,7 @@ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} } }, - "description": "Clear or reset user profile fields such as bio, avatar, or status. Creates audit log entry. Requires USER_UPDATE_PROFILE permission.", + "description": "Clears profile fields such as the bio, avatar or display name. An enforcement basis saves the old values as evidence, records a profile reset decision and sends its notice by system DM and email. Creates audit log entry. Requires USER_UPDATE_PROFILE permission.", "security": [{"adminApiKey": []}], "parameters": [ { @@ -8487,6 +8871,72 @@ ] } }, + "/admin/users/{user_id}/warnings": { + "post": { + "operationId": "warn_admin_user", + "summary": "Warn user", + "tags": ["Admin"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserWarningResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}}, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "description": "Record a warning decision and send its notice by email and system DM. Nothing about the account changes. The X-Audit-Log-Reason header is required and becomes the first note. Refused for reasons that only allow a suspension or content removal. Requires USER_UPDATE_FLAGS permission, plus MESSAGE_LOOKUP for evidence and REPORT_VIEW and REPORT_RESOLVE for reports.", + "security": [{"adminApiKey": []}], + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]}, + "description": "The ID of the user" + } + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserWarningRequest"}}} + } + } + }, "/admin/users/{user_id}/webauthn-credentials": { "get": { "operationId": "list_admin_user_webauthn_credentials", @@ -9716,6 +10166,22 @@ "type": "array", "items": {"$ref": "#/components/schemas/WebAuthnCredentialResponse"} }, + "AdminUserWarningRequest": { + "type": "object", + "properties": {"decision": {"allOf": [{"$ref": "#/components/schemas/EnforcementDecisionInput"}]}}, + "required": ["decision"] + }, + "AdminUserWarningResponse": { + "type": "object", + "properties": { + "decision_id": { + "description": "The warning decision", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + } + }, + "required": ["decision_id"], + "additionalProperties": false + }, "AdminUserUsernameUpdateRequest": { "type": "object", "properties": { @@ -9864,6 +10330,7 @@ "type": "object", "properties": { "fields": { + "minItems": 1, "maxItems": 10, "type": "array", "items": { @@ -9880,9 +10347,10 @@ "type": "string" }, "description": "List of profile fields to clear" - } + }, + "basis": {"allOf": [{"$ref": "#/components/schemas/ModerationBasis"}]} }, - "required": ["fields"] + "required": ["fields", "basis"] }, "AdminUserPremiumFlagsUpdateRequest": { "type": "object", @@ -9923,6 +10391,45 @@ "required": ["url", "expires_at"], "additionalProperties": false }, + "NetworkBlockCandidatesResponse": { + "type": "object", + "properties": { + "networks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ip": {"type": "string"}, + "last_seen_at": {"type": "string", "description": "When the account last used this address"}, + "skip_reason": { + "nullable": true, + "description": "Why this address would be skipped. Null when it can be blocked", + "type": "string", + "enum": ["exempt", "special_purpose"] + }, + "other_unrestricted_accounts": { + "description": "Other accounts that are not suspended or closed and were last active on this address. Blocking is refused when this is above zero", + "allOf": [{"$ref": "#/components/schemas/Int32Type"}] + } + }, + "required": ["ip", "last_seen_at", "skip_reason", "other_unrestricted_accounts"], + "additionalProperties": false + }, + "description": "Addresses the account used in the last 30 days, most recent first" + } + }, + "required": ["networks"], + "additionalProperties": false + }, + "AdminUserMessageDeleteRequest": { + "type": "object", + "properties": { + "basis": { + "description": "Why the messages are removed. Required unless dry_run is true. An enforcement basis refuses child safety and violent extremism reasons", + "allOf": [{"$ref": "#/components/schemas/ModerationBasis"}] + } + } + }, "DeleteAllUserMessagesResponse": { "type": "object", "properties": { @@ -9930,7 +10437,11 @@ "dry_run": {"type": "boolean"}, "channel_count": {"$ref": "#/components/schemas/Int32Type"}, "message_count": {"$ref": "#/components/schemas/Int32Type"}, - "job_id": {"type": "string"} + "job_id": {"type": "string"}, + "decision_id": { + "description": "The content removal decision, for an enforcement basis", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + } }, "required": ["success", "dry_run", "channel_count", "message_count"], "additionalProperties": false @@ -9950,16 +10461,21 @@ }, "required": ["channel_id", "message_id"] } - } + }, + "basis": {"allOf": [{"$ref": "#/components/schemas/ModerationBasis"}]} }, - "required": ["entries"] + "required": ["entries", "basis"] }, "MessageShredResponse": { "type": "object", "properties": { "success": {"type": "boolean", "enum": [true]}, "job_id": {"type": "string"}, - "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991} + "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "decision_id": { + "description": "The content removal decision, for an enforcement basis", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + } }, "required": ["success", "job_id"], "additionalProperties": false @@ -9995,6 +10511,24 @@ } } }, + "AdminUserFeatureLimitRequest": { + "type": "object", + "properties": { + "limit": { + "type": "string", + "enum": ["direct_messages", "reports", "purchases"], + "description": "The one feature this decision limits. Direct messages are for spam, automation, fraud and malware reasons only, reports for misuse of reporting, purchases for payment reasons" + }, + "ends_at": { + "description": "When a reports limit ends, at most 365 days away. Defaults to 90 days. Refused for other limits", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "decision": {"allOf": [{"$ref": "#/components/schemas/EnforcementDecisionInput"}]} + }, + "required": ["limit", "decision"] + }, "AdminUserEmailUpdateRequest": { "type": "object", "properties": { @@ -10150,11 +10684,6 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", "description": "pending_deletion_at of the deletion being cancelled, as shown on the account" - }, - "notify_user": { - "default": false, - "description": "Whether to email the user that the deletion was cancelled", - "type": "boolean" } }, "required": ["expected_pending_deletion_at"] @@ -10162,17 +10691,11 @@ "AdminUserDeletionScheduleRequest": { "type": "object", "properties": { - "reason_code": { - "description": "Code indicating the reason for deletion", - "allOf": [{"$ref": "#/components/schemas/DeletionReasonCode"}] - }, - "public_reason": {"description": "Public-facing reason for the deletion", "type": "string"}, "days_until_deletion": { - "default": 60, - "description": "Number of days until the account is deleted", "type": "integer", "minimum": 1, - "maximum": 365 + "maximum": 365, + "description": "Days until the account is deleted. Raised to 14, or to 74 when a rule was broken so an appeal can be reviewed first. Self-hosted instances have no appeals and raise every basis to 14" }, "replace_pending_deletion_at": { "description": "pending_deletion_at of the deletion this request replaces. Required when a deletion is already scheduled for the account", @@ -10180,13 +10703,27 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" }, - "notify_user": { - "default": true, - "description": "Whether to email the user about the scheduled deletion", + "basis": {"allOf": [{"$ref": "#/components/schemas/ModerationBasis"}]}, + "remove_evidence": { + "description": "Remove the evidence messages after they are snapshotted into the decision. Enforcement basis only", "type": "boolean" + }, + "block_networks": {"allOf": [{"$ref": "#/components/schemas/NetworkBlockRequest"}]} + }, + "required": ["days_until_deletion", "basis"] + }, + "UserRestrictionResponse": { + "type": "object", + "properties": { + "user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}, + "skipped_ips": { + "type": "array", + "items": {"$ref": "#/components/schemas/SkippedNetworkBlock"}, + "description": "Requested network blocks that were not written, with the reason" } }, - "required": ["reason_code"] + "required": ["user", "skipped_ips"], + "additionalProperties": false }, "AdminUserDobUpdateRequest": { "type": "object", @@ -10224,34 +10761,6 @@ "properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}}, "required": ["bot"] }, - "AdminUserBanNoteRequest": { - "type": "object", - "properties": { - "ban_audit_log_id": { - "description": "Audit log entry of the current ban that the note refers to", - "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] - }, - "note": { - "description": "Note to append to the ban. Recorded as the reason of a new audit log entry", - "type": "string" - } - }, - "required": ["ban_audit_log_id", "note"] - }, - "AdminUserUnbanRequest": { - "type": "object", - "properties": { - "notify_user": { - "default": true, - "description": "Whether to email the user that the suspension was lifted", - "type": "boolean" - }, - "public_reason": { - "description": "Reason shown to the user in the unban email. The audit log reason is never emailed", - "type": "string" - } - } - }, "AdminUserBanRequest": { "type": "object", "properties": { @@ -10259,16 +10768,16 @@ "type": "integer", "minimum": 0, "maximum": 8760, - "description": "Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned)." + "description": "Duration of the suspension in hours. Use 0 for a permanent suspension" }, - "reason": {"description": "Reason shown to the user in the ban email", "type": "string"}, - "notify_user": { - "default": true, - "description": "Whether to email the user about a temporary ban. Permanent bans (duration_hours 0) are never emailed", + "decision": {"allOf": [{"$ref": "#/components/schemas/EnforcementDecisionInput"}]}, + "remove_evidence": { + "description": "Remove the evidence messages after they are snapshotted into the decision", "type": "boolean" - } + }, + "block_networks": {"allOf": [{"$ref": "#/components/schemas/NetworkBlockRequest"}]} }, - "required": ["duration_hours"] + "required": ["duration_hours", "decision"] }, "BanUserAvatarRequest": { "type": "object", @@ -10338,7 +10847,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions to assign" @@ -10515,9 +11024,25 @@ ], "enum": ["actioned", "no_violation", "duplicate"], "type": "string" + }, + "outcome": { + "description": "What happened to the reported content or account. Required for actioned, where no_violation is refused. Optional for no_violation, where it can only be no_violation. Refused for duplicate. The reporter is told it", + "x-enumNames": ["removed", "restricted", "action_taken", "no_violation"], + "x-enumDescriptions": [ + "The reported content, profile field or community was removed", + "The reported account was restricted and the content was left up", + "Action was taken under the rules, and the reporter is not told which", + "The reported content or account does not break the rules and was left as it is" + ], + "enum": ["removed", "restricted", "action_taken", "no_violation"], + "type": "string" + }, + "duplicate_of": { + "description": "The report this one repeats. Required for duplicate and refused otherwise. The reporter is told the outcome of that report, or nothing while it has none", + "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}] } }, - "required": ["status"] + "required": ["status", "resolution"] }, "ResolveReportResponse": { "type": "object", @@ -12354,6 +12879,11 @@ "required": ["processed", "errors"], "additionalProperties": false }, + "AdminGuildDeleteRequest": { + "type": "object", + "properties": {"basis": {"allOf": [{"$ref": "#/components/schemas/CommunityRemovalBasis"}]}}, + "required": ["basis"] + }, "UpdateGuildRequest": { "type": "object", "properties": { @@ -12827,6 +13357,382 @@ "required": ["guilds"], "additionalProperties": false }, + "EnforcementDecisionNoteRequest": { + "type": "object", + "properties": {"body": {"description": "The internal note. Staff only", "type": "string"}}, + "required": ["body"] + }, + "EnforcementDecisionNoteResponse": { + "type": "object", + "properties": { + "note_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "author_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "body": {"type": "string"}, + "created_at": {"type": "string"} + }, + "required": ["note_id", "author_id", "body", "created_at"], + "additionalProperties": false + }, + "EnforcementDecisionEndRequest": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": ["lifted", "reversed"], + "description": "Lifted ends a restriction that was correct. Reversed says the decision was wrong" + }, + "statement": {"description": "Note to the user, shown in the email", "type": "string"} + }, + "required": ["outcome"] + }, + "EnforcementDecisionResponse": { + "type": "object", + "properties": { + "decision_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "subject_type": {"type": "string", "enum": ["user", "guild"]}, + "subject_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "subject_name": { + "nullable": true, + "description": "Community name or bot username when the decision was made", + "type": "string" + }, + "recipient_user_id": { + "nullable": true, + "description": "Who receives the notice", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "kind": {"allOf": [{"$ref": "#/components/schemas/EnforcementKindType"}]}, + "reason_code": {"$ref": "#/components/schemas/Int32Type"}, + "source": {"type": "string", "enum": ["report", "own_review"]}, + "automation": {"type": "string", "enum": ["none", "flagged", "automated"]}, + "statement": {"nullable": true, "type": "string"}, + "rights_holder": {"nullable": true, "type": "string"}, + "ends_at": {"nullable": true, "type": "string"}, + "deletion_at": {"nullable": true, "type": "string"}, + "limit": {"nullable": true, "type": "string", "enum": ["direct_messages", "reports", "purchases"]}, + "profile_fields": {"type": "array", "items": {"type": "string"}}, + "removed_message_count": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "removed_all_messages": {"type": "boolean"}, + "payment_at": {"nullable": true, "type": "string"}, + "report_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "description": "Empty without the report view permission" + }, + "decided_by": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "created_at": {"type": "string"}, + "status": {"type": "string", "enum": ["active", "ended"]}, + "end_outcome": { + "nullable": true, + "description": "Expired is recorded by Fluxer when a temporary suspension runs out", + "type": "string", + "enum": ["lifted", "reversed", "expired"] + }, + "ended_at": {"nullable": true, "type": "string"}, + "ended_by": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]}, + "end_statement": {"nullable": true, "type": "string"}, + "notice_status": {"type": "string", "enum": ["pending", "sent", "undelivered"]}, + "notice_channels": {"type": "array", "items": {"type": "string", "enum": ["email", "system_dm", "sign_in"]}}, + "notice_locale": {"nullable": true, "type": "string"}, + "notice_text": {"nullable": true, "description": "The plain text that was sent", "type": "string"}, + "notice_sent_at": {"nullable": true, "type": "string"}, + "expired": {"type": "boolean", "description": "An active suspension or feature limit whose end has passed"}, + "replaced": { + "type": "boolean", + "description": "An active suspension or closure that a later decision replaced" + }, + "notes": {"type": "array", "items": {"$ref": "#/components/schemas/EnforcementDecisionNoteResponse"}}, + "evidence": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "channel_id": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]}, + "timestamp": {"type": "string"}, + "content": { + "nullable": true, + "description": "Null without the message lookup permission, and for restricted decisions", + "type": "string" + }, + "attachments": { + "nullable": true, + "description": "Attachment details without the files. Null without the message lookup permission", + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": {"type": "string"}, + "size": {"nullable": true, "type": "string"}, + "content_type": {"nullable": true, "type": "string"} + }, + "required": ["filename", "size", "content_type"], + "additionalProperties": false + } + }, + "attachment_count": {"$ref": "#/components/schemas/Int32Type"} + }, + "required": ["message_id", "channel_id", "timestamp", "content", "attachments", "attachment_count"], + "additionalProperties": false + }, + "description": "Empty once the evidence has expired" + }, + "profile_values": { + "nullable": true, + "description": "Profile values before a reset. Null without the message lookup permission", + "type": "object", + "additionalProperties": {"type": "string"} + }, + "network_blocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ip": {"type": "string", "description": "Masked without the IP view permission"}, + "expires_at": {"nullable": true, "type": "string"} + }, + "required": ["ip", "expires_at"], + "additionalProperties": false + }, + "description": "Network blocks that belong to this decision and have not expired" + }, + "notice": { + "properties": { + "decision_id": { + "nullable": true, + "description": "The decision reference. Null for restrictions from before decisions", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "kind": {"allOf": [{"$ref": "#/components/schemas/EnforcementKindType"}]}, + "permanent": {"type": "boolean", "description": "Whether this is a suspension with no end"}, + "issued_at": { + "nullable": true, + "description": "When the decision was made, as an ISO 8601 timestamp", + "type": "string" + }, + "ends_at": { + "nullable": true, + "description": "When a suspension or feature limit ends, as an ISO 8601 timestamp", + "type": "string" + }, + "deletion_at": { + "nullable": true, + "description": "When a closed account or bot is deleted, as an ISO 8601 timestamp", + "type": "string" + }, + "summary": {"type": "string", "description": "One sentence saying what happened"}, + "effects": { + "type": "array", + "items": {"type": "string"}, + "description": "What the decision means for the holder, one sentence each" + }, + "rule": { + "nullable": true, + "description": "The rule the decision relies on, null when none was recorded", + "type": "object", + "properties": { + "title": {"type": "string", "description": "Title of the rule that was broken"}, + "url": { + "nullable": true, + "description": "Link to the rule, null when the instance publishes none", + "type": "string" + } + }, + "required": ["title", "url"], + "additionalProperties": false + }, + "facts": { + "type": "array", + "items": {"type": "string"}, + "description": "The finding first, then where and when it happened" + }, + "statement": {"nullable": true, "description": "Note to the holder from the team", "type": "string"}, + "how_we_decided": { + "type": "array", + "items": {"type": "string"}, + "description": "How the case reached the team and who decided it" + }, + "appeal": { + "nullable": true, + "description": "How to appeal, null when the instance offers no appeal route", + "type": "object", + "properties": { + "email": {"type": "string", "description": "Address that takes appeals"}, + "deadline": { + "nullable": true, + "description": "Last day to appeal, already formatted for the reader", + "type": "string" + }, + "account_has_email": { + "type": "boolean", + "description": "Whether the account has an email address to write from" + } + }, + "required": ["email", "deadline", "account_has_email"], + "additionalProperties": false + }, + "redress": { + "type": "array", + "items": {"type": "string"}, + "description": "Other ways to challenge the decision" + } + }, + "description": "What the holder sees now, rendered in English", + "allOf": [{"$ref": "#/components/schemas/EnforcementNotice"}] + } + }, + "required": [ + "decision_id", + "subject_type", + "subject_id", + "subject_name", + "recipient_user_id", + "kind", + "reason_code", + "source", + "automation", + "statement", + "rights_holder", + "ends_at", + "deletion_at", + "limit", + "profile_fields", + "removed_message_count", + "removed_all_messages", + "payment_at", + "report_ids", + "decided_by", + "created_at", + "status", + "end_outcome", + "ended_at", + "ended_by", + "end_statement", + "notice_status", + "notice_channels", + "notice_locale", + "notice_text", + "notice_sent_at", + "expired", + "replaced", + "notes", + "evidence", + "profile_values", + "network_blocks", + "notice" + ], + "additionalProperties": false + }, + "EnforcementDecisionListResponse": { + "type": "object", + "properties": { + "decisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "decision_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "subject_type": {"type": "string", "enum": ["user", "guild"]}, + "subject_id": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "subject_name": { + "nullable": true, + "description": "Community name or bot username when the decision was made", + "type": "string" + }, + "recipient_user_id": { + "nullable": true, + "description": "Who receives the notice", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "kind": {"allOf": [{"$ref": "#/components/schemas/EnforcementKindType"}]}, + "reason_code": {"$ref": "#/components/schemas/Int32Type"}, + "source": {"type": "string", "enum": ["report", "own_review"]}, + "automation": {"type": "string", "enum": ["none", "flagged", "automated"]}, + "statement": {"nullable": true, "type": "string"}, + "rights_holder": {"nullable": true, "type": "string"}, + "ends_at": {"nullable": true, "type": "string"}, + "deletion_at": {"nullable": true, "type": "string"}, + "limit": {"nullable": true, "type": "string", "enum": ["direct_messages", "reports", "purchases"]}, + "profile_fields": {"type": "array", "items": {"type": "string"}}, + "removed_message_count": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "removed_all_messages": {"type": "boolean"}, + "payment_at": {"nullable": true, "type": "string"}, + "report_ids": { + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "description": "Empty without the report view permission" + }, + "decided_by": {"$ref": "#/components/schemas/SnowflakeStringType"}, + "created_at": {"type": "string"}, + "status": {"type": "string", "enum": ["active", "ended"]}, + "end_outcome": { + "nullable": true, + "description": "Expired is recorded by Fluxer when a temporary suspension runs out", + "type": "string", + "enum": ["lifted", "reversed", "expired"] + }, + "ended_at": {"nullable": true, "type": "string"}, + "ended_by": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}]}, + "end_statement": {"nullable": true, "type": "string"}, + "notice_status": {"type": "string", "enum": ["pending", "sent", "undelivered"]}, + "notice_channels": { + "type": "array", + "items": {"type": "string", "enum": ["email", "system_dm", "sign_in"]} + }, + "notice_locale": {"nullable": true, "type": "string"}, + "notice_text": {"nullable": true, "description": "The plain text that was sent", "type": "string"}, + "notice_sent_at": {"nullable": true, "type": "string"}, + "expired": { + "type": "boolean", + "description": "An active suspension or feature limit whose end has passed" + }, + "replaced": { + "type": "boolean", + "description": "An active suspension or closure that a later decision replaced" + } + }, + "required": [ + "decision_id", + "subject_type", + "subject_id", + "subject_name", + "recipient_user_id", + "kind", + "reason_code", + "source", + "automation", + "statement", + "rights_holder", + "ends_at", + "deletion_at", + "limit", + "profile_fields", + "removed_message_count", + "removed_all_messages", + "payment_at", + "report_ids", + "decided_by", + "created_at", + "status", + "end_outcome", + "ended_at", + "ended_by", + "end_statement", + "notice_status", + "notice_channels", + "notice_locale", + "notice_text", + "notice_sent_at", + "expired", + "replaced" + ], + "additionalProperties": false + } + } + }, + "required": ["decisions"], + "additionalProperties": false + }, "DiscoveryAdminRemoveRequest": { "type": "object", "properties": {"reason": {"type": "string", "minLength": 1, "maxLength": 500, "description": "Removal reason"}}, @@ -12943,6 +13849,11 @@ "type": "array", "items": {"$ref": "#/components/schemas/DiscoveryAdminPendingApplicationResponse"} }, + "AdminMessageDeleteRequest": { + "type": "object", + "properties": {"basis": {"allOf": [{"$ref": "#/components/schemas/MessageDeletionBasis"}]}}, + "required": ["basis"] + }, "DeleteMessageResponse": { "type": "object", "properties": {"success": {"type": "boolean", "enum": [true]}}, @@ -13071,36 +13982,26 @@ { "type": "object", "properties": { - "reason_code": { - "description": "Code indicating the reason for deletion", - "allOf": [{"$ref": "#/components/schemas/DeletionReasonCode"}] - }, - "public_reason": {"description": "Public-facing reason for the deletion", "type": "string"}, - "days_until_deletion": { - "default": 60, - "description": "Number of days until the accounts are deleted", - "type": "integer", - "minimum": 1, - "maximum": 365 - }, - "notify_user": { - "default": true, - "description": "Whether to email the user about the scheduled deletion", - "type": "boolean" - }, "user_ids": { "maxItems": 1000, "type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, "description": "List of user IDs to schedule deletion for" }, + "days_until_deletion": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "description": "Days until the account is deleted. Raised to 14, or to 74 when a rule was broken so an appeal can be reviewed first. Self-hosted instances have no appeals and raise every basis to 14" + }, + "basis": {"allOf": [{"$ref": "#/components/schemas/BulkModerationBasis"}]}, "task": { "type": "string", "enum": ["schedule_user_deletion"], "description": "Schedules account deletion for every targeted user" } }, - "required": ["reason_code", "user_ids", "task"] + "required": ["user_ids", "days_until_deletion", "basis", "task"] }, { "type": "object", @@ -13111,13 +14012,14 @@ "items": {"$ref": "#/components/schemas/SnowflakeType"}, "description": "List of user IDs whose messages will be deleted" }, + "basis": {"allOf": [{"$ref": "#/components/schemas/BulkModerationBasis"}]}, "task": { "type": "string", "enum": ["delete_user_messages"], "description": "Deletes every message authored by each targeted user, across all channels" } }, - "required": ["user_ids", "task"] + "required": ["user_ids", "basis", "task"] } ] }, @@ -13150,9 +14052,14 @@ "nullable": true, "description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.", "type": "string" + }, + "decision_id": { + "nullable": true, + "description": "Enforcement decision the matching ip block belongs to. Null for a block added through the blocklist, when nothing matches, and on every blocklist other than ip.", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] } }, - "required": ["banned", "expires_at"], + "required": ["banned", "expires_at", "decision_id"], "additionalProperties": false }, "AdminBlocklistBulkDeleteRequest": { @@ -13555,7 +14462,7 @@ }, "acls": { "description": "Replacement list of access control permissions for the key", - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"} } @@ -13573,7 +14480,7 @@ "type": "string" }, "acls": { - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -13603,7 +14510,7 @@ "maximum": 365 }, "acls": { - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"$ref": "#/components/schemas/AdminAclType"}, "description": "List of access control permissions for the key" @@ -13624,7 +14531,7 @@ "type": "string" }, "acls": { - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"type": "string"}, "description": "List of access control permissions for the key" @@ -13637,7 +14544,7 @@ "type": "object", "properties": { "acls": { - "maxItems": 107, + "maxItems": 109, "type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 64}, "description": "Every admin access control permission the admin API recognises" @@ -13694,6 +14601,8 @@ "bulk:update:user_flags", "discovery:remove", "discovery:review", + "enforcement:annotate", + "enforcement:view", "gateway:memory_stats", "gateway:reload_all", "gift_codes:generate", @@ -14103,37 +15012,31 @@ "notes": {"description": "Internal notes for audit trail", "type": "string"} } }, - "DeletionReasonCode": { - "description": "Reason the account was scheduled for deletion", - "type": "integer", - "minimum": 0, - "maximum": 2147483647, - "format": "int32", - "x-enumNames": [ - "USER_REQUESTED", - "OTHER", - "SPAM", - "CHEATING_OR_EXPLOITATION", - "COORDINATED_RAIDING", - "AUTOMATION_OR_SELFBOT", - "NONCONSENSUAL_SEXUAL_CONTENT", - "SCAM_OR_SOCIAL_ENGINEERING", - "CHILD_SEXUAL_CONTENT", - "PRIVACY_VIOLATION_OR_DOXXING", - "HARASSMENT_OR_BULLYING", - "PAYMENT_FRAUD", - "CHILD_SAFETY_VIOLATION", - "BILLING_DISPUTE_OR_ABUSE", - "UNSOLICITED_EXPLICIT_CONTENT", - "GRAPHIC_VIOLENCE", - "BAN_EVASION", - "TOKEN_OR_CREDENTIAL_SCAM", - "INACTIVITY", - "HATE_SPEECH_OR_EXTREMIST_CONTENT", - "MALICIOUS_LINKS_OR_MALWARE", - "IMPERSONATION_OR_FAKE_IDENTITY" + "BulkModerationBasis": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["enforcement"]}, + "decision": {"allOf": [{"$ref": "#/components/schemas/BulkEnforcementDecisionInput"}]} + }, + "required": ["type", "decision"], + "description": "A rule was broken. Records an enforcement decision and sends its notice" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["holder_request"]}}, + "required": ["type"], + "description": "The account holder or community owner asked" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["housekeeping"]}}, + "required": ["type"], + "description": "Nobody's conduct is involved, such as inactivity or cleanup" + } ], - "enum": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] + "description": "Why the action is taken" }, "GuildFeatureSchema": { "description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_EMOJI_ENABLED, CLONE_STICKER_DISABLED, CLONE_STICKER_ENABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD, ANNOUNCEMENT_CHANNELS_DISABLED (other values allowed)", @@ -14268,6 +15171,69 @@ } ] }, + "BulkEnforcementDecisionInput": { + "type": "object", + "properties": { + "reason_code": {"allOf": [{"$ref": "#/components/schemas/EnforcementReasonCodeType"}]}, + "source": { + "type": "string", + "enum": ["own_review"], + "description": "Bulk decisions cannot link reports, so the team always found them in its own review" + }, + "automation": { + "type": "string", + "enum": ["none", "flagged"], + "description": "Whether automated tools flagged this before a person on the team decided" + }, + "statement": { + "description": "Note to the user, shown in every notice. It is the only fact a bulk decision can give", + "type": "string" + } + }, + "required": ["reason_code", "source", "automation", "statement"], + "additionalProperties": false, + "description": "Why every account in the job is restricted. Reports and evidence messages are refused in bulk" + }, + "EnforcementReasonCodeType": { + "description": "Reason code of an enforcement decision", + "type": "integer", + "minimum": 0, + "maximum": 2147483647, + "format": "int32", + "x-enumNames": [ + "SPAM", + "CHEATING_OR_EXPLOITATION", + "COORDINATED_RAIDING", + "AUTOMATION_OR_SELFBOT", + "NONCONSENSUAL_SEXUAL_CONTENT", + "SCAM_OR_SOCIAL_ENGINEERING", + "CHILD_SEXUAL_CONTENT", + "PRIVACY_VIOLATION_OR_DOXXING", + "HARASSMENT_OR_BULLYING", + "PAYMENT_FRAUD", + "CHILD_SAFETY_VIOLATION", + "BILLING_DISPUTE_OR_ABUSE", + "UNSOLICITED_EXPLICIT_CONTENT", + "GRAPHIC_VIOLENCE", + "BAN_EVASION", + "TOKEN_OR_CREDENTIAL_SCAM", + "HATE_SPEECH_OR_EXTREMIST_CONTENT", + "MALICIOUS_LINKS_OR_MALWARE", + "IMPERSONATION_OR_FAKE_IDENTITY", + "THREATS_OR_VIOLENT_SPEECH", + "VIOLENT_EXTREMISM", + "SELF_HARM_PROMOTION", + "SEXUAL_CONTENT_PLACEMENT", + "ILLEGAL_GOODS_OR_ACTIVITY", + "HARMFUL_DECEPTION", + "REPORTING_ABUSE", + "UNDERAGE_ACCOUNT", + "COPYRIGHT" + ], + "enum": [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 + ] + }, "MessageResponseSchema": { "type": "object", "properties": { @@ -15765,6 +16731,23 @@ {"name": "BANNER_UNSET", "value": "2", "description": "Guild member banner is unset"} ] }, + "MessageDeletionBasis": { + "oneOf": [ + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["holder_request"]}}, + "required": ["type"], + "description": "The account holder or community owner asked" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["housekeeping"]}}, + "required": ["type"], + "description": "Nobody's conduct is involved, such as inactivity or cleanup" + } + ], + "description": "Why the message is deleted. To remove a message for breaking a rule, shred it with an enforcement basis instead" + }, "DiscoveryAdminPendingApplicationResponse": { "type": "object", "properties": { @@ -15873,6 +16856,119 @@ ], "additionalProperties": false }, + "EnforcementKindType": { + "type": "string", + "enum": [ + "warning", + "suspension", + "closure", + "feature_limit", + "content_removal", + "profile_reset", + "community_removal" + ], + "description": "What the decision did" + }, + "EnforcementNotice": { + "type": "object", + "properties": { + "decision_id": { + "nullable": true, + "description": "The decision reference. Null for restrictions from before decisions", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "kind": {"allOf": [{"$ref": "#/components/schemas/EnforcementKindType"}]}, + "permanent": {"type": "boolean", "description": "Whether this is a suspension with no end"}, + "issued_at": { + "nullable": true, + "description": "When the decision was made, as an ISO 8601 timestamp", + "type": "string" + }, + "ends_at": { + "nullable": true, + "description": "When a suspension or feature limit ends, as an ISO 8601 timestamp", + "type": "string" + }, + "deletion_at": { + "nullable": true, + "description": "When a closed account or bot is deleted, as an ISO 8601 timestamp", + "type": "string" + }, + "summary": {"type": "string", "description": "One sentence saying what happened"}, + "effects": { + "type": "array", + "items": {"type": "string"}, + "description": "What the decision means for the holder, one sentence each" + }, + "rule": { + "nullable": true, + "description": "The rule the decision relies on, null when none was recorded", + "type": "object", + "properties": { + "title": {"type": "string", "description": "Title of the rule that was broken"}, + "url": { + "nullable": true, + "description": "Link to the rule, null when the instance publishes none", + "type": "string" + } + }, + "required": ["title", "url"], + "additionalProperties": false + }, + "facts": { + "type": "array", + "items": {"type": "string"}, + "description": "The finding first, then where and when it happened" + }, + "statement": {"nullable": true, "description": "Note to the holder from the team", "type": "string"}, + "how_we_decided": { + "type": "array", + "items": {"type": "string"}, + "description": "How the case reached the team and who decided it" + }, + "appeal": { + "nullable": true, + "description": "How to appeal, null when the instance offers no appeal route", + "type": "object", + "properties": { + "email": {"type": "string", "description": "Address that takes appeals"}, + "deadline": { + "nullable": true, + "description": "Last day to appeal, already formatted for the reader", + "type": "string" + }, + "account_has_email": { + "type": "boolean", + "description": "Whether the account has an email address to write from" + } + }, + "required": ["email", "deadline", "account_has_email"], + "additionalProperties": false + }, + "redress": { + "type": "array", + "items": {"type": "string"}, + "description": "Other ways to challenge the decision" + } + }, + "required": [ + "decision_id", + "kind", + "permanent", + "issued_at", + "ends_at", + "deletion_at", + "summary", + "effects", + "rule", + "facts", + "statement", + "how_we_decided", + "appeal", + "redress" + ], + "additionalProperties": false + }, "Int64StringType": {"type": "string", "pattern": "^-?\\d+$", "format": "int64"}, "GiftCodeDurationTypeSchema": { "description": "Gift code duration unit", @@ -16099,6 +17195,77 @@ ], "enum": [0, 1, 2, 3] }, + "CommunityRemovalBasis": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["enforcement"]}, + "decision": {"allOf": [{"$ref": "#/components/schemas/EnforcementDecisionInput"}]} + }, + "required": ["type", "decision"], + "description": "A rule was broken. Records an enforcement decision and sends its notice" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["holder_request"]}}, + "required": ["type"], + "description": "The account holder or community owner asked" + } + ], + "description": "Why the community is removed" + }, + "EnforcementDecisionInput": { + "type": "object", + "properties": { + "reason_code": {"allOf": [{"$ref": "#/components/schemas/EnforcementReasonCodeType"}]}, + "source": { + "type": "string", + "enum": ["report", "own_review"], + "description": "Whether the team reviewed this after a report or found it in its own review" + }, + "automation": { + "type": "string", + "enum": ["none", "flagged"], + "description": "Whether automated tools flagged this before a person on the team decided" + }, + "report_ids": { + "description": "Reports about the subject that this decision resolves. Required when the source is a report", + "maxItems": 20, + "type": "array", + "items": {"$ref": "#/components/schemas/SnowflakeType"} + }, + "evidence": { + "description": "Messages from the subject that the decision relies on. They are snapshotted when the decision is made", + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "properties": { + "channel_id": {"$ref": "#/components/schemas/SnowflakeType"}, + "message_id": {"$ref": "#/components/schemas/SnowflakeType"} + }, + "required": ["channel_id", "message_id"] + } + }, + "statement": { + "description": "Note to the user, shown in the notice. Never name or describe other people", + "type": "string" + }, + "rights_holder": { + "description": "The rights holder or the right concerned. Required for copyright decisions and refused otherwise", + "type": "string" + }, + "confirmations": { + "description": "Required for child safety and violent extremism decisions", + "maxItems": 2, + "type": "array", + "items": {"type": "string", "enum": ["evidence_preserved", "reports_filed_or_not_required"]} + } + }, + "required": ["reason_code", "source", "automation"], + "description": "Why the restriction is applied. The X-Audit-Log-Reason header is required and becomes the first note" + }, "PurgeGuildAssetErrorSchema": { "type": "object", "properties": { @@ -16893,8 +18060,12 @@ "temp_banned_until": {"nullable": true, "type": "string"}, "pending_deletion_at": {"nullable": true, "type": "string"}, "pending_bulk_message_deletion_at": {"nullable": true, "type": "string"}, - "deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, - "deletion_public_reason": {"nullable": true, "type": "string"}, + "deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/DeletionReasonCode"}]}, + "deletion_public_reason": { + "nullable": true, + "description": "Public reason of a deletion scheduled before enforcement decisions existed. No longer written", + "type": "string" + }, "deletion_audit_log_reason": { "nullable": true, "description": "Private reason recorded with the pending deletion, null without the audit log view permission", @@ -16910,7 +18081,12 @@ "description": "ISO 8601 timestamp when the pending deletion was scheduled", "type": "string" }, - "acls": {"maxItems": 107, "type": "array", "items": {"type": "string"}}, + "enforcement_decision_id": { + "nullable": true, + "description": "The enforcement decision behind the current suspension or closure, null when none owns it", + "allOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}] + }, + "acls": {"maxItems": 109, "type": "array", "items": {"type": "string"}}, "traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}}, "has_totp": {"type": "boolean"}, "authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}}, @@ -16951,6 +18127,7 @@ "deletion_audit_log_reason", "deletion_scheduled_by", "deletion_scheduled_at", + "enforcement_decision_id", "acls", "traits", "has_totp", @@ -16962,6 +18139,48 @@ ], "additionalProperties": false }, + "DeletionReasonCode": { + "description": "Reason the account was scheduled for deletion", + "type": "integer", + "enum": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31 + ], + "format": "int32", + "x-enumNames": [ + "USER_REQUESTED", + "OTHER", + "SPAM", + "CHEATING_OR_EXPLOITATION", + "COORDINATED_RAIDING", + "AUTOMATION_OR_SELFBOT", + "NONCONSENSUAL_SEXUAL_CONTENT", + "SCAM_OR_SOCIAL_ENGINEERING", + "CHILD_SEXUAL_CONTENT", + "PRIVACY_VIOLATION_OR_DOXXING", + "HARASSMENT_OR_BULLYING", + "PAYMENT_FRAUD", + "CHILD_SAFETY_VIOLATION", + "BILLING_DISPUTE_OR_ABUSE", + "UNSOLICITED_EXPLICIT_CONTENT", + "GRAPHIC_VIOLENCE", + "BAN_EVASION", + "TOKEN_OR_CREDENTIAL_SCAM", + "INACTIVITY", + "HATE_SPEECH_OR_EXTREMIST_CONTENT", + "MALICIOUS_LINKS_OR_MALWARE", + "IMPERSONATION_OR_FAKE_IDENTITY", + "THREATS_OR_VIOLENT_SPEECH", + "VIOLENT_EXTREMISM", + "SELF_HARM_PROMOTION", + "SEXUAL_CONTENT_PLACEMENT", + "ILLEGAL_GOODS_OR_ACTIVITY", + "HARMFUL_DECEPTION", + "REPORTING_ABUSE", + "UNDERAGE_ACCOUNT", + "COPYRIGHT" + ] + }, "PremiumFlags": { "type": "integer", "minimum": 0, @@ -16988,6 +18207,66 @@ {"name": "PERKS_DISABLED", "value": "256", "description": "User has temporarily disabled premium perks"} ] }, + "NetworkBlockRequest": { + "type": "object", + "properties": { + "ips": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": {"type": "string"}, + "description": "Addresses the account used in the last 30 days. An address on which another account that is not suspended or closed was last active is refused" + }, + "days": { + "default": 90, + "description": "How many days the blocks last. Ending the decision or deleting the account removes them sooner. Defaults to 90", + "type": "integer", + "minimum": 1, + "maximum": 365 + } + }, + "required": ["ips"], + "description": "Block networks the account used. Only with a permanent suspension or a closure for a broken rule. Requires BAN_IP_ADD and USER_VIEW_IP" + }, + "SkippedNetworkBlock": { + "type": "object", + "properties": { + "ip": {"type": "string"}, + "reason": { + "type": "string", + "enum": ["exempt", "special_purpose", "already_blocked"], + "description": "Exempt addresses and special-purpose ranges such as private, shared carrier and documentation ranges are never blocked. An address that is already blocked permanently or by another decision is left as it is" + } + }, + "required": ["ip", "reason"], + "additionalProperties": false + }, + "ModerationBasis": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["enforcement"]}, + "decision": {"allOf": [{"$ref": "#/components/schemas/EnforcementDecisionInput"}]} + }, + "required": ["type", "decision"], + "description": "A rule was broken. Records an enforcement decision and sends its notice" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["holder_request"]}}, + "required": ["type"], + "description": "The account holder or community owner asked" + }, + { + "type": "object", + "properties": {"type": {"type": "string", "enum": ["housekeeping"]}}, + "required": ["type"], + "description": "Nobody's conduct is involved, such as inactivity or cleanup" + } + ], + "description": "Why the action is taken" + }, "EmailType": {"type": "string"}, "AdminRelationshipEntrySchema": { "type": "object", @@ -17394,6 +18673,11 @@ "EMAIL_UNAVAILABLE_ON_INSTANCE", "EMAIL_VERIFICATION_REQUIRED", "DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED", + "ENFORCEMENT_ACCOUNT_CLOSED", + "ENFORCEMENT_DECISION_ENDED", + "ENFORCEMENT_DECISION_OWNS_STATE", + "ENFORCEMENT_DECISION_REPLACED", + "ENFORCEMENT_DELETION_STARTED", "FOLLOW_TARGET_CONTENT_WARNING_REQUIRED", "FOLLOW_TARGET_NOT_AGE_RESTRICTED", "FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED", @@ -17471,6 +18755,7 @@ "GLOBAL_IP_BANNED", "GLOBAL_IP_TEMPORARILY_BANNED", "IP_BAN_DECLINED", + "IP_BAN_LINKED_TO_DECISION", "MAX_APPLICATIONS", "MAX_BOOKMARKS", "MAX_ACTIVE_THREADS", @@ -17585,6 +18870,7 @@ "UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS", "UNKNOWN_CHANNEL", "UNKNOWN_EMOJI", + "UNKNOWN_ENFORCEMENT_DECISION", "UNKNOWN_FAVORITE_MEME", "UNKNOWN_FORUM_TAG", "UNKNOWN_GIFT_CODE", @@ -17690,6 +18976,11 @@ "This instance doesn't use email.", "Email verification is required for this action.", "Email verification is required for this action.", + "This account is closed, so it can't be suspended.", + "This decision has already ended.", + "An enforcement decision owns this restriction. End the decision instead.", + "A later decision replaced this one, so it can no longer be ended.", + "The deletion of this account has already started, so the decision can no longer be ended.", "Updates from a channel with a content warning can only go to a channel with a content warning or an age restriction.", "Updates from an age-restricted channel can only go to an age-restricted channel.", "Email verification is required for this action.", @@ -17764,9 +19055,10 @@ "IP authorization is required.", "IP authorization resend is on cooldown.", "IP authorization resend limit exceeded.", - "Your IP address {ipAddress} has been permanently blocked from the {product_name} API by platform administrators. {appealEmail, select, null {If you believe this is a mistake, contact the administrators of this instance to appeal.} other {If you believe this is a mistake, contact {appealEmail} to appeal.}} Include this IP address in your appeal.", - "Your IP address {ipAddress} has been temporarily blocked from the {product_name} API. The block lifts on its own when it expires. {appealEmail, select, null {If you think this is a mistake, contact the administrators of this instance and include this IP address.} other {If you think this is a mistake, contact {appealEmail} and include this IP address.}}", + "{reason, select, shown {{product_name} blocked access from this network because of activity that broke its rules.} other {{product_name} blocked access from this network.}} If you share this network, for example at school, at work, on public Wi-Fi, through your mobile carrier or a VPN, this block may have nothing to do with you. The quickest fix is to switch networks, for example to mobile data instead of Wi-Fi, or to turn your VPN on or off.{appealEmail, select, null {} other { If you think this block is a mistake, email {appealEmail} and include your IP address, {ipAddress}.}}", + "{reason, select, shown {{product_name} blocked access from this network because of activity that broke its rules.} other {{product_name} blocked access from this network.}} The block ends on {expiresAt}. If you share this network, for example at school, at work, on public Wi-Fi, through your mobile carrier or a VPN, this block may have nothing to do with you. The quickest fix is to switch networks, for example to mobile data instead of Wi-Fi, or to turn your VPN on or off.{appealEmail, select, null {} other { If you think this block is a mistake, email {appealEmail} and include your IP address, {ipAddress}.}}", "This IP address cannot be added to the blocklist.", + "This network block belongs to an enforcement decision. End the decision to remove it.", "You've reached the maximum of {limit, plural, one {# application} other {# applications}}.", "You've reached the maximum of {count, plural, one {# bookmark} other {# bookmarks}}.", "This community has reached its active thread limit of {count}.", @@ -17881,6 +19173,7 @@ "You need to complete your account setup before you can submit reports.", "Channel wasn't found.", "Unknown emoji.", + "Unknown enforcement decision.", "Unknown saved media.", "Tag wasn't found.", "Unknown gift code.", diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index d5978868d..d2735065d 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -45,6 +45,8 @@ pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features"; pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags"; pub const DISCOVERY_REMOVE: &str = "discovery:remove"; pub const DISCOVERY_REVIEW: &str = "discovery:review"; +pub const ENFORCEMENT_ANNOTATE: &str = "enforcement:annotate"; +pub const ENFORCEMENT_VIEW: &str = "enforcement:view"; pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats"; pub const GATEWAY_RELOAD_ALL: &str = "gateway:reload_all"; pub const GIFT_CODES_GENERATE: &str = "gift_codes:generate"; @@ -153,6 +155,8 @@ pub const ALL_ACLS: &[&str] = &[ BULK_UPDATE_USER_FLAGS, DISCOVERY_REMOVE, DISCOVERY_REVIEW, + ENFORCEMENT_ANNOTATE, + ENFORCEMENT_VIEW, GATEWAY_MEMORY_STATS, GATEWAY_RELOAD_ALL, GIFT_CODES_GENERATE, diff --git a/fluxer_admin/src/admin_flags.rs b/fluxer_admin/src/admin_flags.rs index ce52f586c..f17962667 100644 --- a/fluxer_admin/src/admin_flags.rs +++ b/fluxer_admin/src/admin_flags.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::api::generated::types::EnforcementKindType; + pub struct U64Flag { pub name: &'static str, pub value: u64, @@ -121,6 +123,21 @@ pub const USER_FLAGS: &[U64Flag] = &[ }, ]; +const RESTRICTION_USER_FLAGS: u64 = user_flag_bits::DISABLED + | user_flag_bits::DELETED + | user_flag_bits::SPAMMER + | user_flag_bits::REPORT_BANNED; +const PURCHASE_DISABLED: i32 = 1 << 6; + +pub fn user_flag_is_editable(flag: u64, current_flags: u64, suspended: bool) -> bool { + let self_disabled = flag == user_flag_bits::DISABLED && current_flags & flag != 0 && !suspended; + flag & RESTRICTION_USER_FLAGS == 0 || self_disabled +} + +pub fn premium_flag_is_editable(flag: i32) -> bool { + flag & PURCHASE_DISABLED == 0 +} + pub const PREMIUM_FLAGS: &[I32Flag] = &[ I32Flag { name: "DISCRIMINATOR", @@ -148,7 +165,7 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[ }, I32Flag { name: "PURCHASE_DISABLED", - value: 1 << 6, + value: PURCHASE_DISABLED, }, I32Flag { name: "ENABLED_OVERRIDE", @@ -166,7 +183,7 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[ ("3", "Spam"), ("4", "Cheating or exploitation"), ("5", "Coordinated raiding or manipulation"), - ("6", "Automation or self-bot usage"), + ("6", "Disruptive automation"), ("7", "Nonconsensual sexual content"), ("8", "Scam or social engineering"), ("9", "Child sexual content"), @@ -183,8 +200,157 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[ ("20", "Hate speech or extremist content"), ("21", "Malicious links or malware distribution"), ("22", "Impersonation or fake identity"), + ("23", "Threats or violent speech"), + ("24", "Violent extremism"), + ("25", "Self-harm promotion"), + ("26", "Sexual content outside 18+ spaces"), + ("27", "Illegal goods or activity"), + ("28", "Harmful deception"), + ("29", "Reporting abuse"), + ("30", "Under the minimum age"), + ("31", "Copyright"), ]; +pub fn deletion_reason_label(code: i32) -> String { + let value = code.to_string(); + DELETION_REASONS + .iter() + .find(|(candidate, _)| *candidate == value) + .map_or_else( + || format!("Reason {code}"), + |(_, label)| (*label).to_owned(), + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EnforcementHandling { + Standard, + Protected, + Restricted, +} + +pub struct EnforcementReason { + pub code: i32, + pub handling: EnforcementHandling, + pub kinds: Option<&'static [EnforcementKindType]>, +} + +impl EnforcementReason { + pub fn allows(&self, kind: EnforcementKindType) -> bool { + self.kinds.is_none_or(|kinds| kinds.contains(&kind)) + && (kind != EnforcementKindType::FeatureLimit + || ENFORCEMENT_LIMITS + .iter() + .any(|limit| limit.codes.contains(&self.code))) + } +} + +pub struct EnforcementLimitOption { + pub value: &'static str, + pub label: &'static str, + pub codes: &'static [i32], +} + +pub const ENFORCEMENT_LIMITS: &[EnforcementLimitOption] = &[ + EnforcementLimitOption { + value: "direct_messages", + label: "Direct messages", + codes: &[3, 6, 8, 18, 21], + }, + EnforcementLimitOption { + value: "reports", + label: "Reports", + codes: &[29], + }, + EnforcementLimitOption { + value: "purchases", + label: "Purchases", + codes: &[12, 14], + }, +]; + +pub const COPYRIGHT_REASON_CODE: i32 = 31; + +const RESTRICTED_KINDS: &[EnforcementKindType] = &[ + EnforcementKindType::Suspension, + EnforcementKindType::ContentRemoval, +]; +const PAYMENT_KINDS: &[EnforcementKindType] = &[ + EnforcementKindType::Warning, + EnforcementKindType::Suspension, + EnforcementKindType::Closure, + EnforcementKindType::FeatureLimit, +]; + +const fn reason( + code: i32, + handling: EnforcementHandling, + kinds: Option<&'static [EnforcementKindType]>, +) -> EnforcementReason { + EnforcementReason { + code, + handling, + kinds, + } +} + +pub const ENFORCEMENT_REASONS: &[EnforcementReason] = { + use EnforcementHandling::{Protected, Restricted, Standard}; + use EnforcementKindType::{ + Closure, CommunityRemoval, ContentRemoval, FeatureLimit, ProfileReset, Suspension, Warning, + }; + &[ + reason(3, Standard, None), + reason(4, Standard, None), + reason(5, Standard, None), + reason(6, Standard, None), + reason(7, Protected, None), + reason(8, Standard, None), + reason(9, Restricted, Some(RESTRICTED_KINDS)), + reason(10, Protected, None), + reason(11, Protected, None), + reason(12, Standard, Some(PAYMENT_KINDS)), + reason(13, Restricted, Some(RESTRICTED_KINDS)), + reason(14, Standard, Some(PAYMENT_KINDS)), + reason(15, Protected, None), + reason(16, Standard, None), + reason(17, Standard, Some(&[Warning, Suspension, Closure])), + reason(18, Standard, None), + reason(20, Standard, None), + reason(21, Standard, None), + reason(22, Standard, None), + reason(23, Protected, None), + reason(24, Restricted, Some(RESTRICTED_KINDS)), + reason( + 25, + Standard, + Some(&[ + Warning, + Suspension, + ContentRemoval, + ProfileReset, + CommunityRemoval, + ]), + ), + reason(26, Standard, None), + reason(27, Standard, None), + reason(28, Standard, None), + reason(29, Standard, Some(&[Warning, FeatureLimit, Suspension])), + reason(30, Standard, Some(&[Closure])), + reason( + 31, + Standard, + Some(&[Warning, ContentRemoval, ProfileReset, CommunityRemoval]), + ), + ] +}; + +pub fn enforcement_reason(code: i32) -> Option<&'static EnforcementReason> { + ENFORCEMENT_REASONS + .iter() + .find(|reason| reason.code == code) +} + pub const GUILD_FEATURES: &[&str] = &[ "ANIMATED_ICON", "ANIMATED_BANNER", @@ -226,7 +392,196 @@ pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_ #[cfg(test)] mod tests { - use super::USER_FLAGS; + use super::{ + DELETION_REASONS, ENFORCEMENT_LIMITS, ENFORCEMENT_REASONS, EnforcementHandling, USER_FLAGS, + }; + use crate::api::generated::types::DeletionReasonCode; + use std::collections::HashMap; + + const CORE_TS: &str = include_str!("../../packages/constants/src/Core.ts"); + const ENFORCEMENT_REASONS_TS: &str = + include_str!("../../packages/constants/src/EnforcementReasons.ts"); + + fn quoted_values(list: &str) -> Vec { + list.split('\'') + .skip(1) + .step_by(2) + .map(ToOwned::to_owned) + .collect() + } + + fn deletion_reason_codes() -> HashMap { + let block = CORE_TS + .split("export const DeletionReasons = {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("DeletionReasons block"); + block + .lines() + .filter_map(|line| { + let (name, code) = line.trim().trim_end_matches(',').split_once(": ")?; + Some((name.to_owned(), code.parse().ok()?)) + }) + .collect() + } + + fn kind_lists() -> HashMap> { + ENFORCEMENT_REASONS_TS + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("const ")?; + let (name, value) = rest.split_once(": ReadonlyArray = ")?; + Some((name.to_owned(), quoted_values(value))) + }) + .collect() + } + + fn typescript_reasons() -> Vec<(i32, String, Option>)> { + let codes = deletion_reason_codes(); + let lists = kind_lists(); + let block = ENFORCEMENT_REASONS_TS + .split("export const ENFORCEMENT_REASONS") + .nth(1) + .and_then(|rest| rest.split("\n};").next()) + .expect("ENFORCEMENT_REASONS block"); + block + .split("[DeletionReasons.") + .skip(1) + .map(|entry| { + let name = entry.split(']').next().expect("reason name"); + let handling = entry + .split("handling: '") + .nth(1) + .and_then(|rest| rest.split('\'').next()) + .expect("handling"); + let kinds = entry.split("kinds: ").nth(1).expect("kinds").trim_start(); + let kinds = if kinds.starts_with("'any'") { + None + } else if kinds.starts_with('[') { + Some(quoted_values(kinds.split(']').next().expect("kind list"))) + } else { + let list = kinds + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .next() + .expect("kind list name"); + Some(lists.get(list).expect("named kind list").clone()) + }; + ( + *codes.get(name).expect("reason code"), + handling.to_owned(), + kinds, + ) + }) + .collect() + } + + fn typescript_reason_rules() -> HashMap> { + let codes = deletion_reason_codes(); + let block = ENFORCEMENT_REASONS_TS + .split("export const ENFORCEMENT_REASONS") + .nth(1) + .and_then(|rest| rest.split("\n};").next()) + .expect("ENFORCEMENT_REASONS block"); + let mut rules: HashMap> = HashMap::new(); + for entry in block.split("[DeletionReasons.").skip(1) { + let name = entry.split(']').next().expect("reason name"); + let rule = entry + .split("rule: '") + .nth(1) + .and_then(|rest| rest.split('\'').next()) + .expect("rule"); + rules + .entry(rule.to_owned()) + .or_default() + .push(*codes.get(name).expect("reason code")); + } + rules + } + + fn typescript_limit_rules(limit: &str) -> Vec { + let block = ENFORCEMENT_REASONS_TS + .split("const ENFORCEMENT_LIMIT_RULES") + .nth(1) + .and_then(|rest| rest.split("\n};").next()) + .expect("ENFORCEMENT_LIMIT_RULES block"); + let value = block + .split(&format!("{limit}: ")) + .nth(1) + .and_then(|rest| rest.split(",\n").next()) + .expect("limit rules"); + if value.starts_with("ENFORCEMENT_SPAMMER_LIMIT_RULES") { + let spammer = ENFORCEMENT_REASONS_TS + .split("const ENFORCEMENT_SPAMMER_LIMIT_RULES: ") + .nth(1) + .and_then(|rest| rest.split("]);").next()) + .expect("spammer rules"); + return quoted_values(spammer); + } + quoted_values(value) + } + + #[test] + fn enforcement_limits_match_the_shared_constants() { + let rules = typescript_reason_rules(); + assert_eq!(ENFORCEMENT_LIMITS.len(), 3); + for limit in ENFORCEMENT_LIMITS { + let mut expected: Vec = typescript_limit_rules(limit.value) + .iter() + .flat_map(|rule| rules.get(rule).cloned().unwrap_or_default()) + .collect(); + expected.sort_unstable(); + let mut actual = limit.codes.to_vec(); + actual.sort_unstable(); + assert_eq!(actual, expected, "codes of the {} limit", limit.value); + } + } + + #[test] + fn feature_limits_are_offered_only_for_reasons_with_a_limit() { + use crate::api::generated::types::EnforcementKindType::FeatureLimit; + let offered: Vec = ENFORCEMENT_REASONS + .iter() + .filter(|reason| reason.allows(FeatureLimit)) + .map(|reason| reason.code) + .collect(); + assert_eq!(offered, vec![3, 6, 8, 12, 14, 18, 21, 29]); + } + + #[test] + fn restriction_flags_are_read_only_except_a_self_disabled_account() { + use super::{premium_flag_is_editable, user_flag_bits as bits, user_flag_is_editable}; + for flag in [bits::DELETED, bits::SPAMMER, bits::REPORT_BANNED] { + assert!(!user_flag_is_editable(flag, 0, false)); + assert!(!user_flag_is_editable(flag, flag, false)); + } + assert!(!user_flag_is_editable(bits::DISABLED, 0, false)); + assert!(!user_flag_is_editable(bits::DISABLED, bits::DISABLED, true)); + assert!(user_flag_is_editable(bits::DISABLED, bits::DISABLED, false)); + assert!(user_flag_is_editable(bits::ACCOUNT_LIMITED, 0, true)); + assert!(user_flag_is_editable(bits::STAFF, bits::STAFF, false)); + assert!(!premium_flag_is_editable(1 << 6)); + assert!(premium_flag_is_editable(1 << 0)); + } + + #[test] + fn enforcement_reasons_match_the_shared_constants() { + let expected = typescript_reasons(); + assert_eq!(expected.len(), ENFORCEMENT_REASONS.len()); + for (code, handling, kinds) in expected { + let reason = super::enforcement_reason(code) + .unwrap_or_else(|| panic!("{code} is missing from ENFORCEMENT_REASONS")); + let rust_handling = match reason.handling { + EnforcementHandling::Standard => "standard", + EnforcementHandling::Protected => "protected", + EnforcementHandling::Restricted => "restricted", + }; + assert_eq!(rust_handling, handling, "handling of {code}"); + let rust_kinds = reason + .kinds + .map(|kinds| kinds.iter().map(ToString::to_string).collect::>()); + assert_eq!(rust_kinds, kinds, "kinds of {code}"); + } + } #[test] fn user_flags_cover_every_flag_in_the_admin_spec() { @@ -251,4 +606,31 @@ mod tests { ); } } + + #[test] + fn deletion_reasons_match_the_generated_reason_codes() { + let spec: serde_json::Value = + serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec"); + let mut spec_codes: Vec = spec["components"]["schemas"]["DeletionReasonCode"]["enum"] + .as_array() + .expect("DeletionReasonCode values") + .iter() + .map(|value| { + i32::try_from(value.as_i64().expect("numeric reason code")).expect("i32 code") + }) + .collect(); + spec_codes.sort_unstable(); + let mut label_codes: Vec = DELETION_REASONS + .iter() + .map(|(code, _)| code.parse().expect("numeric reason code")) + .collect(); + label_codes.sort_unstable(); + assert_eq!(label_codes, spec_codes); + for code in label_codes { + assert!( + DeletionReasonCode::try_from(code).is_ok(), + "{code} is not a generated DeletionReasonCode" + ); + } + } } diff --git a/fluxer_admin/src/api/bulk.rs b/fluxer_admin/src/api/bulk.rs index 141280881..767a4f709 100644 --- a/fluxer_admin/src/api/bulk.rs +++ b/fluxer_admin/src/api/bulk.rs @@ -74,9 +74,11 @@ impl AdminApiClient { pub async fn bulk_delete_user_messages( &self, user_ids: &[String], + basis: generated_types::BulkModerationBasis, audit_log_reason: Option<&str>, ) -> ApiResult { let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages { + basis, user_ids: snowflakes(user_ids), }; self.send( @@ -91,26 +93,18 @@ impl AdminApiClient { pub async fn bulk_schedule_user_deletion( &self, user_ids: &[String], - reason_code: u32, days_until_deletion: u32, - public_reason: Option<&str>, - notify_user: bool, + basis: generated_types::BulkModerationBasis, audit_log_reason: Option<&str>, ) -> ApiResult { let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion { + basis, days_until_deletion: crate::api::generated::nonzero_u32( days_until_deletion, "days_until_deletion", ) .map_err(ApiError::Parse)? .into(), - notify_user, - public_reason: public_reason.map(std::borrow::ToOwned::to_owned), - reason_code: crate::api::generated::deletion_reason_code( - i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?, - "reason_code", - ) - .map_err(ApiError::Parse)?, user_ids: snowflakes(user_ids), }; self.send( diff --git a/fluxer_admin/src/api/enforcement.rs b/fluxer_admin/src/api/enforcement.rs new file mode 100644 index 000000000..eb549f405 --- /dev/null +++ b/fluxer_admin/src/api/enforcement.rs @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use reqwest::Method; +use serde::Deserialize; + +use crate::api::generated::types as generated_types; + +use super::client::{AdminApiClient, ApiError, ApiResult}; +use super::types::{AdminUser, UserMutationResponse}; + +#[derive(Debug, Deserialize)] +pub struct RestrictionResponse { + pub user: AdminUser, + pub skipped_ips: Vec, +} + +pub struct DecisionSubmission { + pub decision: generated_types::EnforcementDecisionInput, + pub internal_note: String, +} + +pub struct ClosureRequest { + pub basis: generated_types::ModerationBasis, + pub days_until_deletion: u32, + pub remove_evidence: bool, + pub block_networks: Option, + pub replace_pending_deletion_at: Option>, + pub internal_note: Option, +} + +impl AdminApiClient { + pub async fn suspend_user( + &self, + user_id: &str, + duration_hours: u32, + remove_evidence: bool, + block_networks: Option, + submission: &DecisionSubmission, + ) -> ApiResult { + let body = generated_types::AdminUserBanRequest { + block_networks, + decision: submission.decision.clone(), + duration_hours: i64::from(duration_hours), + remove_evidence: remove_evidence.then_some(true), + }; + self.send( + Method::PUT, + &format!("/admin/users/{}/ban", urlencoding::encode(user_id)), + Some(&body), + Some(&submission.internal_note), + ) + .await + } + + pub async fn list_network_block_candidates( + &self, + user_id: &str, + ) -> ApiResult { + self.get( + &format!( + "/admin/users/{}/network-block-candidates", + urlencoding::encode(user_id) + ), + None, + ) + .await + } + + pub async fn warn_user( + &self, + user_id: &str, + submission: &DecisionSubmission, + ) -> ApiResult { + let body = generated_types::AdminUserWarningRequest { + decision: submission.decision.clone(), + }; + self.send( + Method::POST, + &format!("/admin/users/{}/warnings", urlencoding::encode(user_id)), + Some(&body), + Some(&submission.internal_note), + ) + .await + } + + pub async fn close_account( + &self, + user_id: &str, + request: &ClosureRequest, + ) -> ApiResult { + let body = generated_types::AdminUserDeletionScheduleRequest { + basis: request.basis.clone(), + block_networks: request.block_networks.clone(), + days_until_deletion: crate::api::generated::nonzero_u32( + request.days_until_deletion, + "days_until_deletion", + ) + .map_err(ApiError::Parse)? + .into(), + remove_evidence: request.remove_evidence.then_some(true), + replace_pending_deletion_at: request.replace_pending_deletion_at, + }; + self.send( + Method::PUT, + &format!("/admin/users/{}/deletion", urlencoding::encode(user_id)), + Some(&body), + request.internal_note.as_deref(), + ) + .await + } + + pub async fn limit_features( + &self, + user_id: &str, + limit: generated_types::AdminUserFeatureLimitRequestLimit, + ends_at: Option>, + submission: &DecisionSubmission, + ) -> ApiResult { + let body = generated_types::AdminUserFeatureLimitRequest { + decision: submission.decision.clone(), + ends_at, + limit, + }; + let response: UserMutationResponse = self + .send( + Method::PUT, + &format!( + "/admin/users/{}/feature-limits", + urlencoding::encode(user_id) + ), + Some(&body), + Some(&submission.internal_note), + ) + .await?; + Ok(response.user) + } + + pub async fn list_review_queue( + &self, + ) -> ApiResult { + self.get( + "/admin/enforcement-decisions", + Some(&[("review_queue", "true")]), + ) + .await + } + + pub async fn list_user_decisions( + &self, + user_id: &str, + ) -> ApiResult { + self.get( + "/admin/enforcement-decisions", + Some(&[("subject_type", "user"), ("subject_id", user_id)]), + ) + .await + } + + pub async fn get_decision( + &self, + decision_id: &str, + ) -> ApiResult { + self.get( + &format!( + "/admin/enforcement-decisions/{}", + urlencoding::encode(decision_id) + ), + None, + ) + .await + } + + pub async fn end_decision( + &self, + decision_id: &str, + outcome: generated_types::EnforcementDecisionEndRequestOutcome, + statement: Option, + internal_note: &str, + ) -> ApiResult { + let body = generated_types::EnforcementDecisionEndRequest { outcome, statement }; + self.send( + Method::POST, + &format!( + "/admin/enforcement-decisions/{}/end", + urlencoding::encode(decision_id) + ), + Some(&body), + Some(internal_note), + ) + .await + } + + pub async fn annotate_decision( + &self, + decision_id: &str, + body: String, + ) -> ApiResult { + let body = generated_types::EnforcementDecisionNoteRequest { body }; + self.send( + Method::POST, + &format!( + "/admin/enforcement-decisions/{}/notes", + urlencoding::encode(decision_id) + ), + Some(&body), + None, + ) + .await + } +} + +pub fn api_error_message(error: &ApiError) -> Option { + let ApiError::Http { status, message } = error else { + return None; + }; + if !matches!(status, 400 | 403 | 404 | 409) { + return None; + } + let body: serde_json::Value = serde_json::from_str(message).ok()?; + let detail = body["errors"] + .as_array() + .and_then(|errors| errors.first()) + .and_then(|error| error["message"].as_str()); + detail + .or_else(|| body["message"].as_str()) + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::api_error_message; + use crate::api::client::ApiError; + + fn http(status: u16, body: &str) -> ApiError { + ApiError::Http { + status, + message: body.to_owned(), + } + } + + #[test] + fn api_error_message_prefers_the_first_field_error() { + let body = r#"{"code":"INVALID_FORM_BODY","message":"Invalid form body","errors":[{"path":"decision.statement","code":"ENFORCEMENT_STATEMENT_NOT_ALLOWED","message":"A note to the user is not allowed for this reason"}]}"#; + assert_eq!( + api_error_message(&http(400, body)).as_deref(), + Some("A note to the user is not allowed for this reason") + ); + } + + #[test] + fn api_error_message_reads_conflicts_and_ignores_server_errors() { + let body = r#"{"code":"ENFORCEMENT_DECISION_REPLACED","message":"A later decision replaced this one"}"#; + assert_eq!( + api_error_message(&http(409, body)).as_deref(), + Some("A later decision replaced this one") + ); + assert_eq!(api_error_message(&http(500, body)), None); + assert_eq!(api_error_message(&http(409, "not json")), None); + assert_eq!( + api_error_message(&ApiError::Network("down".to_owned())), + None + ); + } +} diff --git a/fluxer_admin/src/api/generated.rs b/fluxer_admin/src/api/generated.rs index aad0482cf..01af9dd7b 100644 --- a/fluxer_admin/src/api/generated.rs +++ b/fluxer_admin/src/api/generated.rs @@ -56,14 +56,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result Result { - types::DeletionReasonCode::try_from(value) - .map_err(|_| format!("{field} is not a deletion reason code: {value}")) -} - pub(crate) fn user_flags(values: &[String]) -> ApiResult> { values .iter() diff --git a/fluxer_admin/src/api/guilds.rs b/fluxer_admin/src/api/guilds.rs index 51b964bcb..43f7b0f70 100644 --- a/fluxer_admin/src/api/guilds.rs +++ b/fluxer_admin/src/api/guilds.rs @@ -69,13 +69,20 @@ impl AdminApiClient { Ok(()) } - pub async fn delete_guild(&self, guild_id: &str) -> ApiResult { - let response = self - .generated() - .delete_admin_guild(&snowflake(guild_id)) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + pub async fn delete_guild( + &self, + guild_id: &str, + basis: generated_types::CommunityRemovalBasis, + internal_note: Option<&str>, + ) -> ApiResult { + let body = generated_types::AdminGuildDeleteRequest { basis }; + self.send( + Method::DELETE, + &format!("/admin/guilds/{}", urlencoding::encode(guild_id)), + Some(&body), + internal_note, + ) + .await } pub async fn transfer_guild_ownership( diff --git a/fluxer_admin/src/api/messages.rs b/fluxer_admin/src/api/messages.rs index 0bcab9188..641efdac5 100644 --- a/fluxer_admin/src/api/messages.rs +++ b/fluxer_admin/src/api/messages.rs @@ -4,10 +4,12 @@ use reqwest::Method; use crate::api::generated::{snowflake, types as generated_types}; +use crate::templates::components::enforcement_fields::BasisSubmission; + use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ - BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse, - MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse, + BrowseChannelResponse, LookupMessageResponse, MessageShredStatusResponse, + SearchChannelMessagesResponse, }; impl AdminApiClient { @@ -15,8 +17,10 @@ impl AdminApiClient { &self, channel_id: &str, message_id: &str, + basis: generated_types::MessageDeletionBasis, audit_log_reason: Option<&str>, ) -> ApiResult<()> { + let body = generated_types::AdminMessageDeleteRequest { basis }; let _: serde_json::Value = self .send( Method::DELETE, @@ -25,7 +29,7 @@ impl AdminApiClient { urlencoding::encode(channel_id), urlencoding::encode(message_id) ), - None::<&serde_json::Value>, + Some(&body), audit_log_reason, ) .await?; @@ -54,35 +58,44 @@ impl AdminApiClient { pub async fn queue_message_shred( &self, user_id: &str, - entries: &[serde_json::Value], - ) -> ApiResult { - let entries = entries - .iter() - .cloned() - .map(serde_json::from_value::) - .collect::, _>>() - .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::AdminUserMessageShredRequest { entries }; - let response = self - .generated() - .shred_admin_user_messages(&snowflake(user_id), &body) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + entries: Vec, + basis: &BasisSubmission, + ) -> ApiResult { + let body = generated_types::AdminUserMessageShredRequest { + basis: basis.moderation_basis(), + entries, + }; + self.send( + Method::POST, + &format!( + "/admin/users/{}/message-shreds", + urlencoding::encode(user_id) + ), + Some(&body), + basis.internal_note(), + ) + .await } pub async fn delete_all_user_messages( &self, user_id: &str, - dry_run: bool, - ) -> ApiResult { - let dry_run = if dry_run { "true" } else { "false" }; - let response = self - .generated() - .delete_admin_user_messages(&snowflake(user_id), Some(dry_run)) - .await - .map_err(|e| self.generated_error(e))?; - self.generated_value(response.into_inner()) + basis: Option<&BasisSubmission>, + ) -> ApiResult { + let body = generated_types::AdminUserMessageDeleteRequest { + basis: basis.map(BasisSubmission::moderation_basis), + }; + let dry_run = if basis.is_some() { "false" } else { "true" }; + self.send( + Method::DELETE, + &format!( + "/admin/users/{}/messages?dry_run={dry_run}", + urlencoding::encode(user_id) + ), + Some(&body), + basis.and_then(BasisSubmission::internal_note), + ) + .await } pub async fn get_message_shred_status( diff --git a/fluxer_admin/src/api/mod.rs b/fluxer_admin/src/api/mod.rs index 9f007718d..887f6b4ca 100644 --- a/fluxer_admin/src/api/mod.rs +++ b/fluxer_admin/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod bulk; pub mod client; pub mod codes; pub mod discovery; +pub mod enforcement; pub mod guild_assets; pub mod guild_threads; pub mod guilds; diff --git a/fluxer_admin/src/api/reports.rs b/fluxer_admin/src/api/reports.rs index 96a212e0d..74348299c 100644 --- a/fluxer_admin/src/api/reports.rs +++ b/fluxer_admin/src/api/reports.rs @@ -2,7 +2,13 @@ use reqwest::Method; -use crate::api::generated::{snowflake, types::UpdateReportRequestResolution}; +use crate::api::generated::{ + snowflake, + types::{ + UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution, + UpdateReportRequestStatus, + }, +}; use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ @@ -44,20 +50,24 @@ impl AdminApiClient { &self, report_id: &str, resolution: UpdateReportRequestResolution, + outcome: Option, + duplicate_of: Option<&str>, public_comment: Option<&str>, notify_reporter: bool, - audit_log_reason: Option<&str>, ) -> ApiResult { - let mut body = serde_json::json!({"status": "resolved", "resolution": resolution}); - if let Some(public_comment) = public_comment { - body["public_comment"] = serde_json::Value::from(public_comment); - } - body["notify_reporter"] = serde_json::Value::from(notify_reporter); + let body = UpdateReportRequest { + duplicate_of: duplicate_of.map(snowflake), + notify_reporter, + outcome, + public_comment: public_comment.map(str::to_owned), + resolution, + status: UpdateReportRequestStatus::Resolved, + }; self.send( Method::PATCH, &format!("/admin/reports/{}", urlencoding::encode(report_id)), Some(&body), - audit_log_reason, + None, ) .await } diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index b9d2a9495..53824e8ba 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -118,6 +118,8 @@ pub struct AdminUser { pub deletion_scheduled_by: Option, #[serde(default)] pub deletion_scheduled_at: Option, + #[serde(default)] + pub enforcement_decision_id: Option, pub last_active_at: Option, pub last_active_ip: Option, pub last_active_ip_reverse: Option, @@ -253,6 +255,8 @@ pub struct BanCheckResult { #[serde(default)] pub expires_at: Option, #[serde(default)] + pub decision_id: Option, + #[serde(default)] pub entries: Vec, } diff --git a/fluxer_admin/src/api/types/messages.rs b/fluxer_admin/src/api/types/messages.rs index 8b2bb5965..80c9cada5 100644 --- a/fluxer_admin/src/api/types/messages.rs +++ b/fluxer_admin/src/api/types/messages.rs @@ -8,32 +8,12 @@ pub struct LookupMessageResponse { pub data: serde_json::Value, } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct MessageShredResponse { - pub success: Option, - pub job_id: Option, - pub requested: Option, - #[serde(flatten)] - pub extra: serde_json::Value, -} - #[derive(Clone, Debug, Deserialize, Serialize)] pub struct MessageShredStatusResponse { #[serde(flatten)] pub data: serde_json::Value, } -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DeleteAllUserMessagesResponse { - pub success: Option, - pub dry_run: bool, - pub channel_count: u64, - pub message_count: u64, - pub job_id: Option, - #[serde(flatten)] - pub extra: serde_json::Value, -} - #[derive(Clone, Debug, Deserialize, Serialize)] pub struct BrowseChannelResponse { #[serde(flatten)] diff --git a/fluxer_admin/src/api/users.rs b/fluxer_admin/src/api/users.rs index 308bd9868..34edba11e 100644 --- a/fluxer_admin/src/api/users.rs +++ b/fluxer_admin/src/api/users.rs @@ -6,6 +6,8 @@ use reqwest::Method; use crate::api::generated::{snowflake, types as generated_types}; +use crate::templates::components::enforcement_fields::BasisSubmission; + use super::client::{AdminApiClient, ApiError, ApiResult}; use super::types::{ AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse, @@ -282,6 +284,7 @@ impl AdminApiClient { &self, user_id: &str, fields: &[String], + basis: &BasisSubmission, ) -> ApiResult { let fields = fields .iter() @@ -290,14 +293,22 @@ impl AdminApiClient { }) .collect::, _>>() .map_err(|e| ApiError::Parse(e.to_string()))?; - let body = generated_types::AdminUserClearFieldsRequest { fields }; - let response = self - .generated() - .clear_admin_user_profile_fields(&snowflake(user_id), &body) - .await - .map_err(|e| self.generated_error(e))?; - let resp: UserMutationResponse = self.generated_value(response.into_inner())?; - Ok(resp.user) + let body = generated_types::AdminUserClearFieldsRequest { + basis: basis.moderation_basis(), + fields, + }; + let response: UserMutationResponse = self + .send( + Method::DELETE, + &format!( + "/admin/users/{}/profile-fields", + urlencoding::encode(user_id) + ), + Some(&body), + basis.internal_note(), + ) + .await?; + Ok(response.user) } pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult { @@ -355,93 +366,28 @@ impl AdminApiClient { Ok(resp.user) } - pub async fn temp_ban_user( - &self, - user_id: &str, - duration_hours: u32, - reason: Option<&str>, - notify_user: bool, - private_reason: Option<&str>, - ) -> ApiResult { - let body = generated_types::AdminUserBanRequest { - duration_hours: i32::try_from(duration_hours) - .map_err(|e| ApiError::Parse(e.to_string()))? - .into(), - notify_user, - reason: reason.map(std::borrow::ToOwned::to_owned), - }; - let resp: UserMutationResponse = self - .send( - Method::PUT, - &format!("/admin/users/{}/ban", urlencoding::encode(user_id)), - Some(&body), - private_reason, - ) - .await?; - Ok(resp.user) - } - pub async fn unban_user( &self, user_id: &str, - public_reason: Option<&str>, - notify_user: bool, private_reason: Option<&str>, ) -> ApiResult { - let body = generated_types::AdminUserUnbanRequest { - notify_user, - public_reason: public_reason.map(std::borrow::ToOwned::to_owned), - }; let response = self .generated_with_reason(private_reason)? - .unban_admin_user(&snowflake(user_id), &body) + .unban_admin_user(&snowflake(user_id)) .await .map_err(|e| self.generated_error(e))?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?; Ok(resp.user) } - pub async fn schedule_deletion( - &self, - user_id: &str, - reason_code: i32, - public_reason: Option<&str>, - days_until_deletion: u32, - notify_user: bool, - audit_log_reason: Option<&str>, - ) -> ApiResult { - let body = generated_types::AdminUserDeletionScheduleRequest { - days_until_deletion: crate::api::generated::nonzero_u32( - days_until_deletion, - "days_until_deletion", - ) - .map_err(ApiError::Parse)? - .into(), - notify_user, - public_reason: public_reason.map(std::borrow::ToOwned::to_owned), - reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code") - .map_err(ApiError::Parse)?, - replace_pending_deletion_at: None, - }; - let response = self - .generated_with_reason(audit_log_reason)? - .schedule_admin_user_deletion(&snowflake(user_id), &body) - .await - .map_err(|error| self.generated_error(error))?; - let resp: UserMutationResponse = self.generated_value(response.into_inner())?; - Ok(resp.user) - } - pub async fn cancel_deletion( &self, user_id: &str, expected_pending_deletion_at: &str, - notify_user: bool, audit_log_reason: Option<&str>, ) -> ApiResult { let body = serde_json::json!({ "expected_pending_deletion_at": expected_pending_deletion_at, - "notify_user": notify_user, }); let resp: UserMutationResponse = self .send( @@ -454,25 +400,6 @@ impl AdminApiClient { Ok(resp.user) } - pub async fn annotate_ban( - &self, - user_id: &str, - ban_audit_log_id: &str, - note: &str, - ) -> ApiResult<()> { - let body = serde_json::json!({ - "ban_audit_log_id": ban_audit_log_id, - "note": note, - }); - self.send_void( - Method::POST, - &format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)), - Some(&body), - None, - ) - .await - } - pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult { let body = generated_types::AdminUserDobUpdateRequest { date_of_birth: dob.to_owned(), diff --git a/fluxer_admin/src/routes/bans_actions.rs b/fluxer_admin/src/routes/bans_actions.rs index 018c1ba79..06c40461b 100644 --- a/fluxer_admin/src/routes/bans_actions.rs +++ b/fluxer_admin/src/routes/bans_actions.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::api::client::{AdminApiClient, ApiResult}; +use crate::api::client::{AdminApiClient, ApiError, ApiResult}; use crate::api::types::{FlashLevel, FlashMessage}; use crate::middleware::auth::AuthContext; use crate::templates; @@ -201,6 +201,16 @@ async fn execute_check( _ => return ("error", "Unknown ban type".into()), }; match result { + Ok(r) if r.banned && r.decision_id.is_some() => ( + "info", + format!( + "{value} is blocked by decision {}{}. End that decision to remove the block.", + r.decision_id.unwrap_or_default(), + r.expires_at + .map(|expires_at| format!(" until {}", format_admin_timestamp(&expires_at))) + .unwrap_or_default() + ), + ), Ok(r) if r.banned => match r.expires_at { Some(expires_at) => ( "info", @@ -226,6 +236,12 @@ fn ban_action_result( ) -> (&'static str, String) { match result { Ok(()) => ("success", success_message), + Err(ApiError::Http { status: 409, .. }) => ( + "error", + format!( + "{error_message}. It belongs to an enforcement decision, so end that decision instead." + ), + ), Err(error) => { tracing::warn!(%error, "admin API request failed: ban action"); ("error", error_message) diff --git a/fluxer_admin/src/routes/enforcement.rs b/fluxer_admin/src/routes/enforcement.rs new file mode 100644 index 000000000..e94ce93d4 --- /dev/null +++ b/fluxer_admin/src/routes/enforcement.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + acl, + api::{ + client::{AdminApiClient, ApiResultExt}, + enforcement::api_error_message, + generated::types::EnforcementDecisionEndRequestOutcome, + }, + middleware::{ + auth::AuthContext, + csrf::CsrfToken, + flash::{self, FlashData}, + htmx, + }, + state::AppState, + templates::pages::enforcement_decision, + utils::forms::MultiValueForm, +}; +use axum::{ + Router, + extract::{Path, Query, Request, State}, + http::HeaderMap, + response::{Html, IntoResponse, Redirect, Response}, + routing::get, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct DecisionsQuery { + id: Option, +} + +#[derive(Deserialize)] +struct DecisionActionQuery { + action: Option, +} + +pub fn router() -> Router { + Router::new().route("/decisions", get(decisions)).route( + "/decisions/{decision_id}", + get(decision_detail).post(decision_post), + ) +} + +fn is_decision_id(value: &str) -> bool { + (1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +async fn decisions( + State(state): State, + auth: axum::Extension, + Query(query): Query, +) -> Response { + let config = state.config(); + let lookup = query + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + if let Some(id) = lookup.filter(|id| is_decision_id(id)) { + return Redirect::to(&format!("{}/decisions/{id}", config.base_path)).into_response(); + } + let error = lookup.map(|_| "A decision ID is a number."); + let admin_acls = auth + .0 + .admin_user + .as_ref() + .map(|user| user.acls.as_slice()) + .unwrap_or_default(); + let review_queue = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) { + let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + let result = client.list_review_queue().await; + if let Err(error) = &result { + tracing::warn!(%error, "admin API request failed: list the review queue"); + } + Some(result) + } else { + None + }; + let markup = + enforcement_decision::decisions_page(config, &auth.0, error, review_queue.as_ref()); + Html(markup.into_string()).into_response() +} + +async fn decision_detail( + State(state): State, + headers: HeaderMap, + auth: axum::Extension, + csrf: axum::Extension, + Path(decision_id): Path, +) -> Response { + let config = state.config(); + let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + let decision = if is_decision_id(&decision_id) { + client + .get_decision(&decision_id) + .await + .log_error("load enforcement decision") + } else { + None + }; + let users = match &decision { + Some(decision) => client + .lookup_users_by_ids(&enforcement_decision::decision_user_ids(decision)) + .await + .log_error("load users named by an enforcement decision") + .unwrap_or_default(), + None => Vec::new(), + }; + let markup = enforcement_decision::enforcement_decision_page( + config, + &auth.0, + &decision_id, + decision.as_ref(), + &users, + &csrf.0.0, + htmx::targets(&headers, "main-content"), + ); + Html(markup.into_string()).into_response() +} + +async fn decision_post( + State(state): State, + headers: HeaderMap, + auth: axum::Extension, + Path(decision_id): Path, + Query(query): Query, + request: Request, +) -> Response { + let config = state.config(); + let redirect = format!("{}/decisions/{decision_id}", config.base_path); + let flash = match MultiValueForm::from_request(request).await { + Some(form) if is_decision_id(&decision_id) => { + let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); + dispatch(&client, &decision_id, query.action.as_deref(), &form).await + } + Some(_) => FlashData::error("A decision ID is a number."), + None => FlashData::error("Invalid form data"), + }; + if htmx::is_htmx_request(&headers) && htmx::targets(&headers, "flash-container") { + return htmx::toast_response(&flash); + } + flash::redirect_with_flash(&redirect, flash, config.secure_cookies()) +} + +fn failure(context: &str, error: &crate::api::client::ApiError) -> FlashData { + tracing::warn!(%error, "admin API request failed: {context}"); + match api_error_message(error) { + Some(message) => FlashData::error(format!("{context}: {message}")), + None => FlashData::error(context.to_owned()), + } +} + +async fn dispatch( + client: &AdminApiClient, + decision_id: &str, + action: Option<&str>, + form: &MultiValueForm, +) -> FlashData { + match action { + Some("end") => { + let Some(outcome) = form + .first("outcome") + .and_then(|value| EnforcementDecisionEndRequestOutcome::try_from(value).ok()) + else { + return FlashData::error("Choose an outcome"); + }; + let Some(internal_note) = form.clean("internal_note") else { + return FlashData::error("Add an internal note"); + }; + match client + .end_decision( + decision_id, + outcome, + form.clean("statement"), + &internal_note, + ) + .await + { + Ok(_) => FlashData::success("Decision ended"), + Err(error) => failure("Failed to end the decision", &error), + } + } + Some("add_note") => { + let Some(body) = form.clean("body") else { + return FlashData::error("Write a note first"); + }; + match client.annotate_decision(decision_id, body).await { + Ok(_) => FlashData::success("Note added"), + Err(error) => failure("Failed to add the note", &error), + } + } + _ => FlashData::error("Unknown decision action"), + } +} diff --git a/fluxer_admin/src/routes/guilds.rs b/fluxer_admin/src/routes/guilds.rs index 9656058d6..dcf000321 100644 --- a/fluxer_admin/src/routes/guilds.rs +++ b/fluxer_admin/src/routes/guilds.rs @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ - api::client::{AdminApiClient, ApiResultExt}, + api::{ + client::{AdminApiClient, ApiResultExt}, + enforcement::api_error_message, + generated::types::CommunityRemovalBasis, + }, middleware::{ auth::AuthContext, csrf, @@ -10,7 +14,10 @@ use crate::{ }, routes::guild_tabs, state::AppState, - templates, + templates::{ + self, + components::enforcement_fields::{BasisSubmission, basis_from_form}, + }, utils::forms::MultiValueForm, }; use axum::{ @@ -308,11 +315,7 @@ async fn dispatch_guild_action( "Guild shutdown requested", "Failed to shut down guild", ), - "delete_guild" => action_result( - client.delete_guild(guild_id).await, - "Guild deleted", - "Failed to delete guild", - ), + "delete_guild" => remove_community(client, guild_id, form).await, "update_settings" => { let mut settings = serde_json::Map::new(); for key in &[ @@ -455,6 +458,37 @@ async fn dispatch_guild_action( } } +async fn remove_community( + client: &AdminApiClient, + guild_id: &str, + form: &MultiValueForm, +) -> FlashData { + let basis = match basis_from_form(form) { + Ok(basis) => basis, + Err(message) => return FlashData::error(message), + }; + let community_basis = match &basis { + BasisSubmission::Enforcement(submission) => { + CommunityRemovalBasis::Enforcement(submission.decision.clone()) + } + BasisSubmission::HolderRequest(_) => CommunityRemovalBasis::HolderRequest, + BasisSubmission::Housekeeping(_) => return FlashData::error("Choose a basis"), + }; + match client + .delete_guild(guild_id, community_basis, basis.internal_note()) + .await + { + Ok(_) => FlashData::success("Community removed"), + Err(error) => { + tracing::warn!(%error, guild_id, "admin API request failed: remove community"); + FlashData::error(api_error_message(&error).map_or_else( + || "Failed to remove the community".to_owned(), + |message| format!("Failed to remove the community: {message}"), + )) + } + } +} + fn action_result( result: Result, success_message: &'static str, diff --git a/fluxer_admin/src/routes/message_actions.rs b/fluxer_admin/src/routes/message_actions.rs index 91f52fcd2..3b051eebb 100644 --- a/fluxer_admin/src/routes/message_actions.rs +++ b/fluxer_admin/src/routes/message_actions.rs @@ -2,16 +2,23 @@ use crate::{ acl, - api::client::AdminApiClient, + api::{ + client::AdminApiClient, + enforcement::api_error_message, + generated::types::{BulkModerationBasis, MessageDeletionBasis}, + }, middleware::{ auth::AuthContext, flash::{self, FlashData}, }, state::AppState, + templates::{ + components::enforcement_fields::{bulk_decision_from_form, can_take_action}, + pages::messages_page::MessageActions, + }, utils::forms::MultiValueForm, }; use axum::{ - Json, extract::{Query, Request, State}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, @@ -93,17 +100,31 @@ pub(crate) async fn messages_post( .into_response() } "delete" => { - let (Some(cid), Some(mid)) = (&channel_id, &message_id) else { - return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id"); + let back = format!("{base}/messages?channel_id={}", encode_opt(&channel_id)); + let basis = form + .first("basis") + .and_then(|value| MessageDeletionBasis::try_from(value).ok()); + let flash = match (&channel_id, &message_id, basis) { + (Some(cid), Some(mid), Some(basis)) => { + let internal_note = form.clean("internal_note"); + match client + .delete_message(cid, mid, basis, internal_note.as_deref()) + .await + { + Ok(()) => FlashData::success("Message deleted"), + Err(error) => { + tracing::warn!(%error, "admin API request failed: delete message"); + FlashData::error(api_error_message(&error).map_or_else( + || "Failed to delete the message".to_owned(), + |message| format!("Failed to delete the message: {message}"), + )) + } + } + } + (Some(_), Some(_), None) => FlashData::error("Choose a basis"), + _ => FlashData::error("Enter the channel ID and message ID"), }; - let audit_log_reason = form.clean("audit_log_reason"); - match client - .delete_message(cid, mid, audit_log_reason.as_deref()) - .await - { - Ok(()) => Json(serde_json::json!({"success": true})).into_response(), - Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")), - } + flash::redirect_with_flash(&back, flash, config.secure_cookies()) } _ => Redirect::to(&format!("{base}/messages")).into_response(), } @@ -150,6 +171,15 @@ pub(crate) async fn system_dms_post( ) } +fn bulk_basis(form: &MultiValueForm) -> Result { + match form.first("basis") { + Some("enforcement") => bulk_decision_from_form(form).map(BulkModerationBasis::Enforcement), + Some("holder_request") => Ok(BulkModerationBasis::HolderRequest), + Some("housekeeping") => Ok(BulkModerationBasis::Housekeeping), + _ => Err("Choose a basis".to_owned()), + } +} + pub(crate) async fn bulk_actions_post( State(state): State, auth: axum::Extension, @@ -197,35 +227,48 @@ pub(crate) async fn bulk_actions_post( .bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref()) .await } - "bulk-schedule-user-deletion" | "bulk_delete_users" => { + "bulk-schedule-user-deletion" => { let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]); - let (Ok(reason_code), Ok(days)) = ( - form.parse_value::("reason_code"), - form.parse_value::("days_until_deletion"), - ) else { + let basis = match bulk_basis(&form) { + Ok(basis) => basis, + Err(message) => { + return flash::redirect_with_flash( + &format!("{base}/bulk-actions"), + FlashData::error(message), + config.secure_cookies(), + ); + } + }; + let Ok(Some(days)) = form.parse_value::("days_until_deletion") else { return flash::redirect_with_flash( &format!("{base}/bulk-actions"), - FlashData::error("Invalid deletion reason code or delay"), + FlashData::error("Enter the days until deletion"), config.secure_cookies(), ); }; - let public_reason = form.clean("public_reason"); - let notify_user = form.opt_out_value("notify_user"); client .bulk_schedule_user_deletion( &user_ids, - reason_code.unwrap_or(2), - days.unwrap_or(14), - public_reason.as_deref(), - notify_user, - audit_log_reason.as_deref(), + days, + basis, + form.clean("internal_note").as_deref(), ) .await } "bulk-delete-user-messages" => { let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]); + let basis = match bulk_basis(&form) { + Ok(basis) => basis, + Err(message) => { + return flash::redirect_with_flash( + &format!("{base}/bulk-actions"), + FlashData::error(message), + config.secure_cookies(), + ); + } + }; client - .bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref()) + .bulk_delete_user_messages(&user_ids, basis, form.clean("internal_note").as_deref()) .await } _ => { @@ -295,7 +338,13 @@ pub(crate) async fn messages_browse_fragment( match result { Ok(resp) => { let markup = crate::templates::pages::messages_page::browse_messages_fragment( - config, &resp.data, can_delete, None, + config, + &resp.data, + MessageActions { + delete: can_delete, + take_action: can_take_action(admin_acls), + }, + None, ); Html(markup.into_string()).into_response() } @@ -325,14 +374,6 @@ fn encode_opt(value: &Option) -> String { urlencoding::encode(value.as_deref().unwrap_or("")).into_owned() } -fn json_error(status: StatusCode, message: &str) -> Response { - ( - status, - Json(serde_json::json!({"success": false, "error": message})), - ) - .into_response() -} - pub(crate) async fn archives_download( State(state): State, auth: axum::Extension, diff --git a/fluxer_admin/src/routes/messages.rs b/fluxer_admin/src/routes/messages.rs index 0e1159e37..372120697 100644 --- a/fluxer_admin/src/routes/messages.rs +++ b/fluxer_admin/src/routes/messages.rs @@ -5,7 +5,7 @@ use crate::{ api::client::{AdminApiClient, ApiResultExt}, middleware::{auth::AuthContext, csrf::CsrfToken}, state::AppState, - templates, + templates::{self, components::enforcement_fields::can_take_action}, }; use axum::{ Router, @@ -137,6 +137,7 @@ async fn messages_page( prefill_channel_id: channel_id, can_lookup, can_delete, + can_take_action: can_take_action(admin_acls), lookup_result: lookup_result.as_ref(), browse_result: browse_result.as_ref(), search_result: search_result.as_ref(), @@ -201,13 +202,6 @@ async fn bulk_actions_page( csrf: axum::Extension, ) -> Response { let config = state.config(); - let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); - let account_identity = state.account_identity(&client).await; - let markup = templates::pages::bulk_actions::bulk_actions_page( - config, - &auth.0, - &csrf.0.0, - account_identity.is_username(), - ); + let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0); Html(markup.into_string()).into_response() } diff --git a/fluxer_admin/src/routes/mod.rs b/fluxer_admin/src/routes/mod.rs index b9d1802de..71a346c2c 100644 --- a/fluxer_admin/src/routes/mod.rs +++ b/fluxer_admin/src/routes/mod.rs @@ -8,6 +8,7 @@ mod bans_actions; mod billing_actions; pub mod codes; pub mod discovery; +pub mod enforcement; mod guild_tabs; pub mod guilds; pub mod jobs; @@ -64,6 +65,7 @@ pub fn build_router(config: AdminConfig) -> Router { .merge(applications::router()) .merge(codes::router()) .merge(discovery::router()) + .merge(enforcement::router()) .merge(registrations::router()) .merge(jobs::router()) .merge(messages::router()) diff --git a/fluxer_admin/src/routes/reports.rs b/fluxer_admin/src/routes/reports.rs index c8afc9edb..4ccbc68c2 100644 --- a/fluxer_admin/src/routes/reports.rs +++ b/fluxer_admin/src/routes/reports.rs @@ -4,6 +4,7 @@ use crate::{ acl, api::{ client::{AdminApiClient, ApiError, ApiResultExt}, + generated::types::{UpdateReportRequestOutcome, UpdateReportRequestResolution}, reports::SearchReportsParams, types::ReportEntry, }, @@ -15,7 +16,7 @@ use crate::{ htmx, }, state::AppState, - templates, + templates::{self, components::enforcement_fields::is_snowflake}, utils::{forms::clean_string, timestamps::format_admin_timestamp}, }; use axum::{ @@ -78,6 +79,10 @@ struct ResolveForm { #[serde(default)] resolution: Option, #[serde(default)] + outcome: Option, + #[serde(default)] + duplicate_of: Option, + #[serde(default)] public_comment: Option, #[serde(default)] notify_reporter: Option, @@ -323,6 +328,35 @@ async fn report_resolve( config.secure_cookies(), ); }; + let outcome = match resolution { + UpdateReportRequestResolution::NoViolation => Some(UpdateReportRequestOutcome::NoViolation), + UpdateReportRequestResolution::Actioned => form + .outcome + .as_deref() + .and_then(|value| UpdateReportRequestOutcome::try_from(value).ok()) + .filter(|outcome| *outcome != UpdateReportRequestOutcome::NoViolation), + UpdateReportRequestResolution::Duplicate => None, + }; + let duplicate_of = form + .duplicate_of + .as_deref() + .map(str::trim) + .filter(|value| is_snowflake(value)) + .map(str::to_owned); + let missing = match resolution { + UpdateReportRequestResolution::Actioned if outcome.is_none() => Some(OUTCOME_REQUIRED), + UpdateReportRequestResolution::Duplicate if duplicate_of.is_none() => { + Some(DUPLICATE_OF_REQUIRED) + } + _ => None, + }; + if let Some(message) = missing { + return flash::redirect_with_flash( + &back, + FlashData::error(message), + config.secure_cookies(), + ); + } let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let public_comment = clean_string(form.public_comment.as_deref().unwrap_or("")); let notify_reporter = @@ -331,9 +365,10 @@ async fn report_resolve( .resolve_report( &report_id, resolution, + outcome, + duplicate_of.as_deref(), public_comment.as_deref(), notify_reporter, - None, ) .await { @@ -347,6 +382,8 @@ async fn report_resolve( } const RESOLUTION_REQUIRED: &str = "Choose a resolution"; +const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account"; +const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats"; async fn report_legal_hold( State(state): State, diff --git a/fluxer_admin/src/routes/user_actions.rs b/fluxer_admin/src/routes/user_actions.rs index 05a4a8298..d205aac51 100644 --- a/fluxer_admin/src/routes/user_actions.rs +++ b/fluxer_admin/src/routes/user_actions.rs @@ -3,8 +3,19 @@ use crate::utils::plural::count_noun; use crate::{ admin_flags, - api::client::{AdminApiClient, ApiError}, + api::{ + client::{AdminApiClient, ApiError}, + enforcement::{ClosureRequest, api_error_message}, + generated::{ + snowflake, + types::{AdminUserFeatureLimitRequestLimit, AdminUserMessageShredRequestEntriesItem}, + }, + }, middleware::flash::FlashData, + templates::components::enforcement_fields::{ + BasisSubmission, basis_from_form, decision_from_form, network_blocks_from_form, + skipped_networks_summary, + }, utils::forms::MultiValueForm, }; use std::collections::HashSet; @@ -74,16 +85,21 @@ pub async fn dispatch( return DispatchOutcome::error("User not found"); } }; + let suspended = user.temp_banned_until.is_some(); + let editable = + |flag: &u64| admin_flags::user_flag_is_editable(*flag, user.flags, suspended); let add = submitted .iter() .copied() - .filter(|flag| user.flags & *flag == 0) + .filter(|flag| user.flags & *flag == 0 && editable(flag)) .map(|flag| flag.to_string()) .collect::>(); let remove = admin_flags::USER_FLAGS .iter() .map(|flag| flag.value) - .filter(|flag| user.flags & *flag != 0 && !selected.contains(flag)) + .filter(|flag| { + user.flags & *flag != 0 && !selected.contains(flag) && editable(flag) + }) .map(|flag| flag.to_string()) .collect::>(); DispatchOutcome::from_result( @@ -122,12 +138,18 @@ pub async fn dispatch( let add = submitted .iter() .copied() - .filter(|flag| current & *flag != *flag) + .filter(|flag| { + current & *flag != *flag && admin_flags::premium_flag_is_editable(*flag) + }) .collect::>(); let remove = admin_flags::PREMIUM_FLAGS .iter() .map(|flag| flag.value) - .filter(|flag| current & *flag == *flag && !selected.contains(flag)) + .filter(|flag| { + current & *flag == *flag + && !selected.contains(flag) + && admin_flags::premium_flag_is_editable(*flag) + }) .collect::>(); DispatchOutcome::from_result( client.update_premium_flags(user_id, &add, &remove).await, @@ -172,12 +194,20 @@ pub async fn dispatch( "Failed to terminate user sessions", ), "clear_fields" => { - let f = form.list_values_any(&["fields[]", "fields"]); - DispatchOutcome::from_result( - client.clear_user_fields(user_id, &f).await, - "User fields cleared successfully", - "Failed to clear user fields", - ) + let fields = form.list_values_any(&["fields[]", "fields"]); + if fields.is_empty() { + return DispatchOutcome::error("Choose at least one field"); + } + let basis = match basis_from_form(form) { + Ok(basis) => basis, + Err(message) => return DispatchOutcome::error(message), + }; + match client.clear_user_fields(user_id, &fields, &basis).await { + Ok(_) => DispatchOutcome::success("Profile fields cleared"), + Err(error) => { + decision_failure("Failed to clear the profile fields", user_id, &error) + } + } } "set_bot_status" => { let val = form.bool_value("bot"); @@ -218,43 +248,61 @@ pub async fn dispatch( "Failed to change email", ) } - "temp_ban" => { - let Ok(duration) = form.parse_value_any::(&["duration_hours", "duration"]) else { - return DispatchOutcome::error("Invalid ban duration"); + "suspend" => { + let Ok(Some(duration_hours)) = form.parse_value::("duration_hours") else { + return DispatchOutcome::error("Choose a duration"); }; - let reason = get("reason"); - let private = get("private_reason"); - let notify_user = form.opt_out_value("notify_user"); - DispatchOutcome::from_result( - client - .temp_ban_user( - user_id, - duration.unwrap_or(24), - reason.as_deref(), - notify_user, - private.as_deref(), - ) - .await, - "User temporarily banned successfully", - "Failed to temporarily ban user", - ) + let submission = match decision_from_form(form) { + Ok(submission) => submission, + Err(message) => return DispatchOutcome::error(message), + }; + let block_networks = match network_blocks_from_form(form) { + Ok(block_networks) => block_networks, + Err(message) => return DispatchOutcome::error(message), + }; + let remove_evidence = form.bool_value("remove_evidence"); + match client + .suspend_user( + user_id, + duration_hours, + remove_evidence, + block_networks, + &submission, + ) + .await + { + Ok(response) => { + let done = if duration_hours == 0 { + "Account banned." + } else { + "Account suspended." + }; + DispatchOutcome::success(with_skipped(done, &response.skipped_ips)) + } + Err(error) => decision_failure("Failed to suspend the account", user_id, &error), + } + } + "warn" => { + let submission = match decision_from_form(form) { + Ok(submission) => submission, + Err(message) => return DispatchOutcome::error(message), + }; + match client.warn_user(user_id, &submission).await { + Ok(response) => DispatchOutcome::success(format!( + "Warning sent as decision {}", + response.decision_id.as_str() + )), + Err(error) => decision_failure("Failed to send the warning", user_id, &error), + } } "unban" => { - let public_reason = get("public_reason"); - let private_reason = get("private_reason"); - let notify_user = form.opt_out_value("notify_user"); - DispatchOutcome::from_result( - client - .unban_user( - user_id, - public_reason.as_deref(), - notify_user, - private_reason.as_deref(), - ) - .await, - "User unbanned successfully", - "Failed to unban user", - ) + let Some(internal_note) = get("internal_note") else { + return DispatchOutcome::error("Add an internal note"); + }; + match client.unban_user(user_id, Some(&internal_note)).await { + Ok(_) => DispatchOutcome::success("Suspension lifted"), + Err(error) => decision_failure("Failed to lift the suspension", user_id, &error), + } } "ban_ip" => { let Some(ip) = get("ip") else { @@ -279,30 +327,33 @@ pub async fn dispatch( "Failed to ban avatar hash", ) } - "schedule_deletion" => { - let Ok(reason_code) = form.parse_value::("reason_code") else { - return DispatchOutcome::error("Invalid deletion reason code"); + "close" => close_account(client, user_id, form).await, + "limit_features" => { + let Some(limit) = form + .first("limit") + .and_then(|value| AdminUserFeatureLimitRequestLimit::try_from(value).ok()) + else { + return DispatchOutcome::error("Choose a limit"); }; - let public_reason = get("public_reason"); - let private_reason = get("private_reason"); - let Ok(days) = form.parse_value_any::(&["days_until_deletion", "days"]) else { - return DispatchOutcome::error("Invalid deletion delay"); + let ends_at = match (limit, form.first("ends_on")) { + (AdminUserFeatureLimitRequestLimit::Reports, Some(value)) => match parse_day(value) + { + Some(ends_at) => Some(ends_at), + None => return DispatchOutcome::error("Enter the end date as a date"), + }, + _ => None, }; - let notify_user = form.opt_out_value("notify_user"); - DispatchOutcome::from_result( - client - .schedule_deletion( - user_id, - reason_code.unwrap_or(0), - public_reason.as_deref(), - days.unwrap_or(60), - notify_user, - private_reason.as_deref(), - ) - .await, - "User deletion scheduled successfully", - "Failed to schedule user deletion", - ) + let submission = match decision_from_form(form) { + Ok(submission) => submission, + Err(message) => return DispatchOutcome::error(message), + }; + match client + .limit_features(user_id, limit, ends_at, &submission) + .await + { + Ok(_) => DispatchOutcome::success("Feature limited"), + Err(error) => decision_failure("Failed to limit the feature", user_id, &error), + } } "cancel_deletion" => { let Some(expected) = get("expected_pending_deletion_at") else { @@ -315,40 +366,15 @@ pub async fn dispatch( "Confirm whose deletion you are canceling before submitting", ); } - let Some(private_reason) = get("private_reason") else { - return DispatchOutcome::error("A private reason is required to cancel a deletion"); + let Some(internal_note) = get("internal_note") else { + return DispatchOutcome::error("Add an internal note"); }; - let notify_user = form.bool_value("notify_user"); match client - .cancel_deletion(user_id, &expected, notify_user, Some(&private_reason)) + .cancel_deletion(user_id, &expected, Some(&internal_note)) .await { - Ok(_) => DispatchOutcome::success("User deletion canceled successfully"), - Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error( - "The pending deletion changed since this page loaded. Reload and review.", - ), - Err(error) => { - tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion"); - DispatchOutcome::error("Failed to cancel user deletion") - } - } - } - "annotate_ban" => { - let Some(ban_audit_log_id) = get("ban_audit_log_id") else { - return DispatchOutcome::error("The ban audit log entry is missing from the form"); - }; - let Some(note) = get("note") else { - return DispatchOutcome::error("Note is required"); - }; - match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await { - Ok(()) => DispatchOutcome::success("Note added to the ban"), - Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error( - "The ban changed since this page loaded. Reload and review.", - ), - Err(error) => { - tracing::warn!(%error, user_id, "admin API request failed: annotate ban"); - DispatchOutcome::error("Failed to add the note to the ban") - } + Ok(_) => DispatchOutcome::success("Deletion cancelled"), + Err(error) => decision_failure("Failed to cancel the deletion", user_id, &error), } } "change_dob" => { @@ -431,79 +457,172 @@ pub async fn dispatch( } } } - "delete_all_messages" => { - let dry_run = parse_dry_run(form.first("dry_run")); - match client.delete_all_user_messages(user_id, dry_run).await { - Ok(response) if dry_run => DispatchOutcome { - flash: FlashData::success(format!( - "Dry run found {} across {}. Confirm to delete them permanently.", - count_noun(response.message_count, "message", "messages"), - count_noun(response.channel_count, "channel", "channels") - )), - redirect_params: vec![ - ("delete_all_messages_dry_run".to_owned(), "true".to_owned()), - ( - "delete_all_messages_channel_count".to_owned(), - response.channel_count.to_string(), - ), - ( - "delete_all_messages_message_count".to_owned(), - response.message_count.to_string(), - ), - ], - }, - Ok(response) => { - if let Some(job_id) = response.job_id { - DispatchOutcome { - flash: FlashData::success( - "Delete job queued. Monitor progress in the status panel.", - ), - redirect_params: vec![("message_shred_job_id".to_owned(), job_id)], - } - } else { - DispatchOutcome::success("No messages found for deletion.") - } - } - Err(error) => { - tracing::warn!(%error, user_id, "admin API request failed: delete all user messages"); - DispatchOutcome::error("Failed to delete all user messages") - } - } - } + "delete_all_messages" => delete_all_messages(client, user_id, form).await, "cancel_bulk_message_deletion" => DispatchOutcome::from_result( client.cancel_bulk_message_deletion(user_id).await, "Bulk message deletion canceled successfully", "Failed to cancel bulk message deletion", ), "message_shred" => { - let csv = form.first("csv_data").unwrap_or_default(); - match parse_message_shred_csv(csv) { - Ok(entries) if entries.is_empty() => DispatchOutcome::error( - "CSV did not contain any valid channel_id,message_id pairs.", - ), - Ok(entries) => match client.queue_message_shred(user_id, &entries).await { - Ok(response) => { - if let Some(job_id) = response.job_id { - DispatchOutcome { - flash: FlashData::success("Message shred job queued"), - redirect_params: vec![("message_shred_job_id".to_owned(), job_id)], - } - } else { - DispatchOutcome::success("Message shred job queued") - } - } - Err(error) => { - tracing::warn!(%error, user_id, "admin API request failed: queue message shred job"); - DispatchOutcome::error("Failed to queue message shred job") - } + let entries = match parse_message_shred_csv(form.first("csv_data").unwrap_or_default()) + { + Ok(entries) if entries.is_empty() => { + return DispatchOutcome::error("The CSV has no channel_id,message_id rows."); + } + Ok(entries) => entries, + Err(error) => return DispatchOutcome::error(error), + }; + let basis = match basis_from_form(form) { + Ok(basis) => basis, + Err(message) => return DispatchOutcome::error(message), + }; + match client.queue_message_shred(user_id, entries, &basis).await { + Ok(response) => DispatchOutcome { + flash: FlashData::success(removal_message( + "Message shred queued", + response.decision_id.as_ref().map(|id| id.as_str()), + )), + redirect_params: vec![("message_shred_job_id".to_owned(), response.job_id)], }, - Err(error) => DispatchOutcome::error(error), + Err(error) => { + decision_failure("Failed to queue the message shred", user_id, &error) + } } } _ => DispatchOutcome::error(format!("Unknown user action: {action}")), } } +fn with_skipped( + done: &str, + skipped: &[crate::api::generated::types::SkippedNetworkBlock], +) -> String { + match skipped_networks_summary(skipped) { + Some(summary) => format!("{done} {summary}"), + None => done.to_owned(), + } +} + +async fn close_account( + client: &AdminApiClient, + user_id: &str, + form: &MultiValueForm, +) -> DispatchOutcome { + let Ok(Some(days_until_deletion)) = form.parse_value::("days_until_deletion") else { + return DispatchOutcome::error("Enter the days until deletion"); + }; + let replace_pending_deletion_at = match form.clean("replace_pending_deletion_at") { + Some(value) => match chrono::DateTime::parse_from_rfc3339(&value) { + Ok(at) => Some(at.with_timezone(&chrono::Utc)), + Err(_) => { + return DispatchOutcome::error( + "The pending deletion is missing from the form. Reload and review.", + ); + } + }, + None => None, + }; + let basis = match basis_from_form(form) { + Ok(basis) => basis, + Err(message) => return DispatchOutcome::error(message), + }; + let enforcement = matches!(basis, BasisSubmission::Enforcement(_)); + let block_networks = match network_blocks_from_form(form) { + Ok(block_networks) => block_networks.filter(|_| enforcement), + Err(message) => return DispatchOutcome::error(message), + }; + let request = ClosureRequest { + basis: basis.moderation_basis(), + days_until_deletion, + remove_evidence: enforcement && form.bool_value("remove_evidence"), + block_networks, + replace_pending_deletion_at, + internal_note: basis.internal_note().map(str::to_owned), + }; + match client.close_account(user_id, &request).await { + Ok(response) => { + DispatchOutcome::success(with_skipped("Account closed.", &response.skipped_ips)) + } + Err(error) => decision_failure("Failed to close the account", user_id, &error), + } +} + +async fn delete_all_messages( + client: &AdminApiClient, + user_id: &str, + form: &MultiValueForm, +) -> DispatchOutcome { + let basis = if parse_dry_run(form.first("dry_run")) { + None + } else { + match basis_from_form(form) { + Ok(basis) => Some(basis), + Err(message) => return DispatchOutcome::error(message), + } + }; + let response = match client + .delete_all_user_messages(user_id, basis.as_ref()) + .await + { + Ok(response) => response, + Err(error) => return decision_failure("Failed to delete the messages", user_id, &error), + }; + let message_count = u64::try_from(*response.message_count).unwrap_or_default(); + let channel_count = u64::try_from(*response.channel_count).unwrap_or_default(); + if basis.is_none() { + return DispatchOutcome { + flash: FlashData::success(format!( + "Found {} in {}. Choose a basis to delete them.", + count_noun(message_count, "message", "messages"), + count_noun(channel_count, "channel", "channels") + )), + redirect_params: vec![ + ("delete_all_messages_dry_run".to_owned(), "true".to_owned()), + ( + "delete_all_messages_channel_count".to_owned(), + channel_count.to_string(), + ), + ( + "delete_all_messages_message_count".to_owned(), + message_count.to_string(), + ), + ], + }; + } + match response.job_id { + Some(job_id) => DispatchOutcome { + flash: FlashData::success(removal_message( + "Delete job queued", + response.decision_id.as_ref().map(|id| id.as_str()), + )), + redirect_params: vec![("message_shred_job_id".to_owned(), job_id)], + }, + None => DispatchOutcome::success("No messages to delete"), + } +} + +fn removal_message(queued: &str, decision_id: Option<&str>) -> String { + match decision_id { + Some(decision_id) => format!("{queued} as decision {decision_id}"), + None => queued.to_owned(), + } +} + +fn parse_day(value: &str) -> Option> { + chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .ok()? + .and_hms_opt(0, 0, 0) + .map(|at| at.and_utc()) +} + +fn decision_failure(context: &str, user_id: &str, error: &ApiError) -> DispatchOutcome { + tracing::warn!(%error, user_id, "admin API request failed: {context}"); + match api_error_message(error) { + Some(message) => DispatchOutcome::error(format!("{context}: {message}")), + None => DispatchOutcome::error(context.to_owned()), + } +} + fn has_legacy_flag_delta_fields(form: &MultiValueForm) -> bool { ["add_flags[]", "add_flags", "remove_flags[]", "remove_flags"] .iter() @@ -521,7 +640,9 @@ fn parse_dry_run(value: Option<&str>) -> bool { !matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0") } -fn parse_message_shred_csv(csv: &str) -> Result, String> { +fn parse_message_shred_csv( + csv: &str, +) -> Result, String> { let mut entries = Vec::new(); for line in csv.trim().lines() { let trimmed = line.trim(); @@ -534,12 +655,10 @@ fn parse_message_shred_csv(csv: &str) -> Result, String> "Each row must contain channel_id and message_id separated by a comma".to_owned(), ); } - let channel_id = parse_csv_snowflake(parts[0], "channel_id", trimmed)?; - let message_id = parse_csv_snowflake(parts[1], "message_id", trimmed)?; - entries.push(serde_json::json!({ - "channel_id": channel_id, - "message_id": message_id, - })); + entries.push(AdminUserMessageShredRequestEntriesItem { + channel_id: snowflake(&parse_csv_snowflake(parts[0], "channel_id", trimmed)?), + message_id: snowflake(&parse_csv_snowflake(parts[1], "message_id", trimmed)?), + }); } Ok(entries) } @@ -572,8 +691,10 @@ mod tests { let entries = parse_message_shred_csv("channel_id,message_id\n123,456\n789,101112") .expect("valid csv"); assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["channel_id"], "123"); - assert_eq!(entries[0]["message_id"], "456"); + assert_eq!( + serde_json::to_value(&entries[0]).expect("serializable entry"), + serde_json::json!({"channel_id": "123", "message_id": "456"}) + ); } #[test] diff --git a/fluxer_admin/src/routes/user_tabs.rs b/fluxer_admin/src/routes/user_tabs.rs index 1278c4bb3..5f6721563 100644 --- a/fluxer_admin/src/routes/user_tabs.rs +++ b/fluxer_admin/src/routes/user_tabs.rs @@ -25,6 +25,8 @@ pub struct TabQuery { pub delete_all_messages_dry_run: Option, pub delete_all_messages_channel_count: Option, pub delete_all_messages_message_count: Option, + pub report_id: Option, + pub message: Option, } #[allow(clippy::too_many_arguments)] @@ -114,7 +116,8 @@ pub async fn render( } _ => None, }; - let ban_logs = if u.temp_banned_until.is_some() + let legacy_logs = if u.temp_banned_until.is_some() + && u.enforcement_decision_id.is_none() && acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW) { client @@ -130,16 +133,41 @@ pub async fn render( offset: 0, }) .await - .log_error("load ban audit logs") + .log_error("load suspension audit logs") .map(|response| response.logs) .unwrap_or_default() } else { Vec::new() }; + let decisions = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) { + Some(client.list_user_decisions(user_id).await) + } else { + None + }; + if let Some(Err(error)) = &decisions { + tracing::warn!(%error, user_id, "admin API request failed: list user decisions"); + } + let can_block_networks = acl::has_permission(admin_acls, acl::USER_VIEW_IP) + && acl::has_permission(admin_acls, acl::BAN_IP_ADD) + && (acl::has_permission(admin_acls, acl::USER_TEMP_BAN) + || acl::has_permission(admin_acls, acl::USER_DELETE)); + let network_candidates = if can_block_networks { + Some(client.list_network_block_candidates(user_id).await) + } else { + None + }; + if let Some(Err(error)) = &network_candidates { + tracing::warn!(%error, user_id, "admin API request failed: list network block candidates"); + } let context = tabs::moderation::ModerationContext { deletion_scheduler: deletion_scheduler.as_ref(), - current_ban: tabs::moderation::find_current_ban(&u, &ban_logs), - username_sign_in: account_identity.is_username(), + legacy_suspension: tabs::moderation::find_legacy_suspension(&u, &legacy_logs), + decisions: decisions.as_ref(), + network_candidates: network_candidates.as_ref(), + prefill: tabs::moderation::DecisionPrefill { + report_ids: query.report_id.iter().cloned().collect(), + messages: query.message.iter().cloned().collect(), + }, }; Some(tabs::moderation::moderation_tab( config, diff --git a/fluxer_admin/src/routes/users.rs b/fluxer_admin/src/routes/users.rs index 245a7ccf8..c31a89c9a 100644 --- a/fluxer_admin/src/routes/users.rs +++ b/fluxer_admin/src/routes/users.rs @@ -53,6 +53,8 @@ struct UserDetailQuery { delete_all_messages_dry_run: Option, delete_all_messages_channel_count: Option, delete_all_messages_message_count: Option, + report_id: Option, + message: Option, } #[derive(Deserialize)] @@ -380,6 +382,8 @@ async fn user_tab( delete_all_messages_dry_run: None, delete_all_messages_channel_count: None, delete_all_messages_message_count: None, + report_id: None, + message: None, }; let admin_acls = auth .0 @@ -458,6 +462,8 @@ fn to_tab_query(q: &UserDetailQuery) -> user_tabs::TabQuery { delete_all_messages_dry_run: q.delete_all_messages_dry_run.clone(), delete_all_messages_channel_count: q.delete_all_messages_channel_count, delete_all_messages_message_count: q.delete_all_messages_message_count, + report_id: q.report_id.clone(), + message: q.message.clone(), } } diff --git a/fluxer_admin/src/templates/components/enforcement_fields.rs b/fluxer_admin/src/templates/components/enforcement_fields.rs new file mode 100644 index 000000000..abaa9c61f --- /dev/null +++ b/fluxer_admin/src/templates/components/enforcement_fields.rs @@ -0,0 +1,854 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + acl, + admin_flags::{ + self, COPYRIGHT_REASON_CODE, ENFORCEMENT_REASONS, EnforcementHandling, user_flag_bits, + }, + api::{ + client::ApiResult, + enforcement::DecisionSubmission, + generated::{snowflake, types as generated_types}, + }, + templates::components::form::checkbox, + utils::{forms::MultiValueForm, plural::count_noun, timestamps::format_admin_timestamp}, +}; +use maud::{Markup, html}; + +pub const FIELD_LABEL_CLASS: &str = "block text-sm font-medium text-neutral-700"; +pub const FIELD_HELP_CLASS: &str = "text-xs text-neutral-500"; +pub const FIELD_INPUT_CLASS: &str = "block w-full rounded-md border border-neutral-300 \ + px-3 py-2 text-sm shadow-sm focus:border-brand-primary focus:outline-none \ + focus:ring-1 focus:ring-brand-primary"; +pub const INTERNAL_NOTE_MAX_CHARS: usize = 512; +const STATEMENT_MAX_CHARS: usize = 500; +const ACCOUNT_DELETION_MIN_DAYS: u32 = 14; +const ENFORCEMENT_CLOSURE_MIN_DAYS: u32 = 74; +const RIGHTS_HOLDER_MAX_CHARS: usize = 200; + +const AUTOMATED_LIMIT_FLAGS: &[(u64, &str)] = &[ + (user_flag_bits::ACCOUNT_LIMITED, "Account limited"), + (user_flag_bits::PROFILE_HIDDEN, "Profile hidden"), +]; + +const HANDLING_GROUPS: &[(EnforcementHandling, &str)] = &[ + (EnforcementHandling::Standard, "Standard"), + ( + EnforcementHandling::Protected, + "Protected: fewer details in the notice", + ), + ( + EnforcementHandling::Restricted, + "Restricted: no details in the notice", + ), +]; + +pub struct DecisionFields<'a> { + pub id_prefix: &'a str, + pub kind: generated_types::EnforcementKindType, + pub report_ids: &'a [String], + pub messages: Option<&'a [String]>, + pub user_flags: Option, + pub allow_restricted: bool, +} + +pub struct BasisFields<'a> { + pub decision: DecisionFields<'a>, + pub choices: &'a [(&'a str, &'a str)], + pub plain_note_help: &'a str, + pub enforcement_extra: Markup, +} + +pub enum BasisSubmission { + Enforcement(DecisionSubmission), + HolderRequest(Option), + Housekeeping(Option), +} + +impl BasisSubmission { + pub fn internal_note(&self) -> Option<&str> { + match self { + Self::Enforcement(submission) => Some(&submission.internal_note), + Self::HolderRequest(note) | Self::Housekeeping(note) => note.as_deref(), + } + } + + pub fn moderation_basis(&self) -> generated_types::ModerationBasis { + match self { + Self::Enforcement(submission) => { + generated_types::ModerationBasis::Enforcement(submission.decision.clone()) + } + Self::HolderRequest(_) => generated_types::ModerationBasis::HolderRequest, + Self::Housekeeping(_) => generated_types::ModerationBasis::Housekeeping, + } + } +} + +pub fn automated_limits(flags: u64) -> Vec<&'static str> { + AUTOMATED_LIMIT_FLAGS + .iter() + .filter(|(bit, _)| flags & bit != 0) + .map(|(_, label)| *label) + .collect() +} + +pub fn field_label(for_id: &str, text: &str) -> Markup { + html! { + label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) } + } +} + +pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup { + let id = format!("{id_prefix}-internal-note"); + html! { + div class="space-y-1" { + (field_label(&id, "Internal note")) + input type="text" id=(id) name="internal_note" required + maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS); + p class=(FIELD_HELP_CLASS) { (help) } + } + } +} + +pub fn deletion_days_field(id: &str, self_hosted: bool) -> Markup { + let (days, help) = if self_hosted { + ( + ACCOUNT_DELETION_MIN_DAYS, + format!("At least {ACCOUNT_DELETION_MIN_DAYS} days."), + ) + } else { + ( + ENFORCEMENT_CLOSURE_MIN_DAYS, + format!( + "At least {ENFORCEMENT_CLOSURE_MIN_DAYS} days when a rule was broken. At least {ACCOUNT_DELETION_MIN_DAYS} days otherwise." + ), + ) + }; + html! { + div class="space-y-1" { + (field_label(id, "Days until deletion")) + input type="number" id=(id) name="days_until_deletion" + value=(days) min="1" max="365" required class=(FIELD_INPUT_CLASS); + p class=(FIELD_HELP_CLASS) { (help) } + } + } +} + +pub fn statement_field(id_prefix: &str, help: &str) -> Markup { + let id = format!("{id_prefix}-statement"); + html! { + div class="space-y-1" { + (field_label(&id, "Note to the user")) + textarea id=(id) name="statement" rows="2" maxlength=(STATEMENT_MAX_CHARS) + class=(FIELD_INPUT_CLASS) {} + p class=(FIELD_HELP_CLASS) { (help) } + } + } +} + +pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup { + html! { + fieldset class="space-y-1" { + legend class=(FIELD_LABEL_CLASS) { (legend) } + div class="flex flex-wrap gap-x-4 gap-y-1" { + @for (value, label) in options { + @let id = format!("{id_prefix}-{name}-{value}"); + div class="flex items-center gap-2" { + input type="radio" id=(id) name=(name) value=(value) required + class="h-4 w-4 accent-neutral-900"; + label for=(id) class="text-sm text-neutral-900" { (label) } + } + } + } + } + } +} + +fn codes_attribute(handling: EnforcementHandling) -> String { + ENFORCEMENT_REASONS + .iter() + .filter(|reason| reason.handling == handling) + .map(|reason| reason.code.to_string()) + .collect::>() + .join(" ") +} + +pub fn reason_select( + id_prefix: &str, + kind: generated_types::EnforcementKindType, + allow_restricted: bool, +) -> Markup { + let id = format!("{id_prefix}-reason"); + html! { + div class="space-y-1" { + (field_label(&id, "Reason")) + select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) { + option value="" disabled selected { "Choose a reason" } + @for (handling, group) in HANDLING_GROUPS + .iter() + .filter(|(handling, _)| allow_restricted || *handling != EnforcementHandling::Restricted) + { + @let reasons = ENFORCEMENT_REASONS + .iter() + .filter(|reason| reason.handling == *handling && reason.allows(kind)) + .collect::>(); + @if !reasons.is_empty() { + optgroup label=(group) { + @for reason in reasons { + option value=(reason.code) { + (admin_flags::deletion_reason_label(reason.code)) + } + } + } + } + } + } + } + } +} + +fn automated_limits_hint(flags: u64) -> Markup { + let limits = automated_limits(flags); + html! { + p class=(FIELD_HELP_CLASS) data-automated-limits="" { + @if limits.is_empty() { + "No automated limits on this account now." + } @else { + "Automated limits on this account now: " (limits.join(", ")) "." + } + } + } +} + +pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup { + let prefix = fields.id_prefix; + let restricted_codes = codes_attribute(EnforcementHandling::Restricted); + let reports_id = format!("{prefix}-report-ids"); + let messages_id = format!("{prefix}-evidence"); + let rights_holder_id = format!("{prefix}-rights-holder"); + html! { + div class="space-y-4" data-enforcement-fields="" { + (reason_select(prefix, fields.kind, fields.allow_restricted)) + (radio_group("How did we find this?", "source", prefix, &[ + ("report", "A report"), + ("own_review", "Our own review"), + ])) + div class="space-y-1" { + (radio_group("Automated tools", "automation", prefix, &[ + ("none", "Not involved"), + ("flagged", "Flagged this for review"), + ])) + @if let Some(flags) = fields.user_flags { + (automated_limits_hint(flags)) + } + } + div class="space-y-1" { + (field_label(&reports_id, "Reports")) + textarea id=(reports_id) name="report_ids" rows="2" + placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) { + (fields.report_ids.join("\n")) + } + } + @if let Some(messages) = fields.messages { + div class="space-y-1" { + (field_label(&messages_id, "Messages")) + textarea id=(messages_id) name="evidence" rows="3" + placeholder="One message link or channel_id/message_id per line" + class=(FIELD_INPUT_CLASS) { + (messages.join("\n")) + } + } + } + div data-hide-for-codes=(restricted_codes) { + (statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions.")) + } + div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden { + (field_label(&rights_holder_id, "Rights holder or right concerned")) + input type="text" id=(rights_holder_id) name="rights_holder" required disabled + maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS); + p class=(FIELD_HELP_CLASS) { + "For example Example Records, or trademark. This is the one place a name is shown to the user." + } + } + div class="space-y-2" data-show-for-codes=(restricted_codes) hidden { + (checkbox("confirmations", "evidence_preserved", "Evidence is preserved", false, true)) + (checkbox("confirmations", "reports_filed_or_not_required", "Any required report is filed, or none is required", false, true)) + } + (internal_note_field(prefix, "Staff only. Saved as the first note on the decision.")) + } + } +} + +const NETWORK_BLOCK_DEFAULT_DAYS: u64 = 90; + +fn network_note(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> String { + use generated_types::NetworkBlockCandidatesResponseNetworksItemSkipReason as SkipReason; + match network.skip_reason { + Some(SkipReason::Exempt) => "Exempt, never blocked".to_owned(), + Some(SkipReason::SpecialPurpose) => { + "Private or special-purpose range, never blocked".to_owned() + } + None => { + let others = u64::try_from(*network.other_unrestricted_accounts).unwrap_or(0); + format!( + "{} last active here", + count_noun( + others, + "other unrestricted account", + "other unrestricted accounts" + ) + ) + } + } +} + +fn is_blockable(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> bool { + network.skip_reason.is_none() && *network.other_unrestricted_accounts == 0 +} + +pub fn network_block_fields( + id_prefix: &str, + candidates: &ApiResult, +) -> Markup { + let days_id = format!("{id_prefix}-network-block-days"); + html! { + fieldset class="space-y-2" data-network-blocks="" { + legend class=(FIELD_LABEL_CLASS) { "Block networks this account used" } + @match candidates { + Err(_) => { + p class="text-sm text-red-700" { "Failed to load the networks this account used." } + } + Ok(response) if response.networks.is_empty() => { + p class=(FIELD_HELP_CLASS) { "This account used no networks in the last 30 days." } + } + Ok(response) => { + p class=(FIELD_HELP_CLASS) { + "Networks from the last 30 days. A network other unrestricted accounts use can't be blocked." + } + @for network in &response.networks { + div { + (checkbox("block_networks", &network.ip, &network.ip, false, is_blockable(network))) + p class={(FIELD_HELP_CLASS) " pl-7"} { + "Last used " (format_admin_timestamp(&network.last_seen_at)) ". " (network_note(network)) "." + } + } + } + div class="space-y-1" { + (field_label(&days_id, "Block length in days")) + input type="number" id=(days_id) name="network_block_days" + value=(NETWORK_BLOCK_DEFAULT_DAYS) min="1" max="365" class=(FIELD_INPUT_CLASS); + p class=(FIELD_HELP_CLASS) { "Ending the decision or deleting the account removes the blocks sooner." } + } + } + } + } + } +} + +pub fn network_blocks_from_form( + form: &MultiValueForm, +) -> Result, String> { + let ips = form + .values("block_networks") + .iter() + .map(|ip| ip.trim().to_owned()) + .filter(|ip| !ip.is_empty()) + .collect::>(); + if ips.is_empty() { + return Ok(None); + } + let days = match form.parse_value::("network_block_days") { + Ok(Some(days)) => days, + Ok(None) => NETWORK_BLOCK_DEFAULT_DAYS, + Err(_) => return Err("Enter the block length as a number of days".to_owned()), + }; + let days = + std::num::NonZeroU64::new(days).ok_or("The block length must be at least one day")?; + Ok(Some(generated_types::NetworkBlockRequest { days, ips })) +} + +pub fn skipped_networks_summary( + skipped: &[generated_types::SkippedNetworkBlock], +) -> Option { + use generated_types::SkippedNetworkBlockReason as Reason; + if skipped.is_empty() { + return None; + } + let entries = skipped + .iter() + .map(|entry| { + let reason = match entry.reason { + Reason::Exempt => "exempt", + Reason::SpecialPurpose => "special-purpose range", + Reason::AlreadyBlocked => "already blocked", + }; + format!("{} ({reason})", entry.ip) + }) + .collect::>(); + Some(format!("Not blocked: {}.", entries.join(", "))) +} + +pub fn basis_fields(fields: &BasisFields<'_>) -> Markup { + let prefix = fields.decision.id_prefix; + let plain = fields + .choices + .iter() + .map(|(value, _)| *value) + .filter(|value| *value != "enforcement") + .collect::>() + .join(" "); + html! { + (radio_group("Basis", "basis", prefix, fields.choices)) + fieldset class="space-y-4" data-show-for-basis="enforcement" hidden disabled { + (enforcement_fields(&fields.decision)) + (fields.enforcement_extra) + } + fieldset data-show-for-basis=(plain) hidden disabled { + (internal_note_field(&format!("{prefix}-plain"), fields.plain_note_help)) + } + } +} + +pub fn basis_from_form(form: &MultiValueForm) -> Result { + match form.first("basis") { + Some("enforcement") => decision_from_form(form).map(BasisSubmission::Enforcement), + Some("holder_request") => Ok(BasisSubmission::HolderRequest(form.clean("internal_note"))), + Some("housekeeping") => Ok(BasisSubmission::Housekeeping(form.clean("internal_note"))), + _ => Err("Choose a basis".to_owned()), + } +} + +pub fn decision_from_form(form: &MultiValueForm) -> Result { + let reason_code = form + .first("reason_code") + .and_then(|value| value.trim().parse::().ok()) + .and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok()) + .ok_or("Choose a reason")?; + let source = form + .first("source") + .and_then(|value| generated_types::EnforcementDecisionInputSource::try_from(value).ok()) + .ok_or("Choose how we found this")?; + let automation = form + .first("automation") + .and_then(|value| generated_types::EnforcementDecisionInputAutomation::try_from(value).ok()) + .ok_or("Choose whether automated tools were involved")?; + let report_ids = lines(form.first("report_ids")) + .map(|line| { + is_snowflake(line) + .then(|| snowflake(line)) + .ok_or_else(|| format!("Not a report ID: {line}")) + }) + .collect::, _>>()?; + let evidence = lines(form.first("evidence")) + .map(|line| { + parse_message_reference(line) + .map(|(channel_id, message_id)| { + generated_types::EnforcementDecisionInputEvidenceItem { + channel_id: snowflake(&channel_id), + message_id: snowflake(&message_id), + } + }) + .ok_or_else(|| format!("Not a message link or channel_id/message_id: {line}")) + }) + .collect::, _>>()?; + let confirmations = form + .values("confirmations") + .iter() + .map(|value| { + generated_types::EnforcementDecisionInputConfirmationsItem::try_from(value.as_str()) + .map_err(|_| format!("Unknown confirmation: {value}")) + }) + .collect::, _>>()?; + let internal_note = form.clean("internal_note").ok_or("Add an internal note")?; + Ok(DecisionSubmission { + decision: generated_types::EnforcementDecisionInput { + automation, + confirmations, + evidence, + reason_code, + report_ids, + rights_holder: form.clean("rights_holder"), + source, + statement: form.clean("statement"), + }, + internal_note, + }) +} + +pub fn bulk_decision_from_form( + form: &MultiValueForm, +) -> Result { + let reason_code = form + .first("reason_code") + .and_then(|value| value.trim().parse::().ok()) + .and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok()) + .ok_or("Choose a reason")?; + let automation = form + .first("automation") + .and_then(|value| { + generated_types::BulkEnforcementDecisionInputAutomation::try_from(value).ok() + }) + .ok_or("Choose whether automated tools were involved")?; + let statement = form.clean("statement").ok_or("Add a note to the user")?; + Ok(generated_types::BulkEnforcementDecisionInput { + automation, + reason_code, + source: generated_types::BulkEnforcementDecisionInputSource::OwnReview, + statement, + }) +} + +fn lines(value: Option<&str>) -> impl Iterator { + value + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) +} + +pub(crate) fn is_snowflake(value: &str) -> bool { + (1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn snowflake_pair(channel_id: &str, message_id: &str) -> Option<(String, String)> { + (is_snowflake(channel_id) && is_snowflake(message_id)) + .then(|| (channel_id.to_owned(), message_id.to_owned())) +} + +pub fn parse_message_reference(line: &str) -> Option<(String, String)> { + let line = line.trim(); + if let Some((channel_id, message_id)) = line.split_once(['/', ',']) + && let Some(pair) = snowflake_pair(channel_id.trim(), message_id.trim()) + { + return Some(pair); + } + let url = url::Url::parse(line) + .or_else(|_| url::Url::parse("https://admin.invalid/")?.join(line)) + .ok()?; + let query_value = |key: &str| { + url.query_pairs() + .find(|(name, _)| name == key) + .map(|(_, value)| value.into_owned()) + }; + if let (Some(channel_id), Some(message_id)) = + (query_value("channel_id"), query_value("message_id")) + { + return snowflake_pair(&channel_id, &message_id); + } + let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); + match segments.as_slice() { + [.., "channels", _, channel_id, message_id] => snowflake_pair(channel_id, message_id), + _ => None, + } +} + +pub struct TakeAction<'a> { + pub author_id: Option<&'a str>, + pub report_id: Option<&'a str>, +} + +pub fn can_take_action(admin_acls: &[String]) -> bool { + acl::has_any_permission(admin_acls, &[acl::USER_TEMP_BAN, acl::USER_UPDATE_FLAGS]) +} + +pub fn take_action_href( + base: &str, + user_id: &str, + report_id: Option<&str>, + message: Option<(&str, &str)>, +) -> String { + let mut href = format!("{base}/users/{user_id}?tab=moderation"); + if let Some(report_id) = report_id { + href.push_str("&report_id="); + href.push_str(&urlencoding::encode(report_id)); + } + if let Some((channel_id, message_id)) = message { + href.push_str("&message="); + href.push_str(&urlencoding::encode(&format!("{channel_id}/{message_id}"))); + } + href +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::generated::types::EnforcementKindType; + + fn form(body: &str) -> MultiValueForm { + MultiValueForm::parse(body.as_bytes()) + } + + fn render(kind: EnforcementKindType, flags: u64) -> String { + enforcement_fields(&DecisionFields { + id_prefix: "suspend", + kind, + report_ids: &["1600000000000000001".to_owned()], + messages: Some(&["1700000000000000001/1700000000000000002".to_owned()]), + user_flags: Some(flags), + allow_restricted: true, + }) + .into_string() + } + + #[test] + fn deletion_days_default_to_the_closure_floor_of_each_mode() { + let hosted = deletion_days_field("close-days", false).into_string(); + assert!(hosted.contains(r#"name="days_until_deletion" value="74""#)); + assert!( + hosted.contains("At least 74 days when a rule was broken. At least 14 days otherwise.") + ); + let self_hosted = deletion_days_field("close-days", true).into_string(); + assert!(self_hosted.contains(r#"name="days_until_deletion" value="14""#)); + assert!(self_hosted.contains(">At least 14 days.

")); + assert!(!self_hosted.contains("74")); + } + + #[test] + fn fieldset_renders_every_input_with_prefills() { + let html = render(EnforcementKindType::Suspension, 0); + for marker in [ + r#"name="reason_code""#, + r#"name="source" value="report""#, + r#"name="source" value="own_review""#, + r#"name="automation" value="none""#, + r#"name="automation" value="flagged""#, + r#"name="report_ids""#, + r#"name="evidence""#, + r#"name="statement""#, + r#"name="rights_holder""#, + r#"name="confirmations" value="evidence_preserved""#, + r#"name="confirmations" value="reports_filed_or_not_required""#, + r#"name="internal_note""#, + ">1600000000000000001", + ">1700000000000000001/1700000000000000002", + "No automated limits on this account now.", + ] { + assert!(html.contains(marker), "missing {marker}\n{html}"); + } + assert!(!html.contains(r#"name="automation" value="automated""#)); + assert!(!html.contains(r#"checked"#)); + } + + #[test] + fn rights_holder_and_confirmations_start_hidden_and_follow_their_codes() { + let html = render(EnforcementKindType::Suspension, 0); + assert!(html.contains(r#"data-show-for-codes="31" hidden"#)); + assert!(html.contains(r#"data-show-for-codes="9 13 24" hidden"#)); + assert!(html.contains(r#"data-hide-for-codes="9 13 24""#)); + assert!(html.contains(r#"name="rights_holder" required disabled"#)); + } + + #[test] + fn reasons_are_grouped_by_handling_and_filtered_by_kind() { + let suspend = render(EnforcementKindType::Suspension, 0); + assert!(suspend.contains( + r#""#)); + assert!(warn.contains(r#"