UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

279 lines 13.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = default_1; const defs_1 = require("@atproto/api/dist/client/types/tools/ozone/moderation/defs"); const xrpc_server_1 = require("@atproto/xrpc-server"); const lexicons_1 = require("../../lexicon/lexicons"); const defs_2 = require("../../lexicon/types/tools/ozone/moderation/defs"); const logger_1 = require("../../logger"); const subject_1 = require("../../mod-service/subject"); const tag_service_1 = require("../../tag-service"); const util_1 = require("../../tag-service/util"); const util_2 = require("../../util"); const util_3 = require("../util"); const util_4 = require("./util"); const handleModerationEvent = async ({ ctx, input, auth, }) => { const access = auth.credentials; const createdBy = auth.credentials.type === 'moderator' ? auth.credentials.iss : input.body.createdBy; const db = ctx.db; const moderationService = ctx.modService(db); const settingService = ctx.settingService(db); const { event, externalId } = input.body; const isAcknowledgeEvent = (0, defs_2.isModEventAcknowledge)(event); const isTakedownEvent = (0, defs_2.isModEventTakedown)(event); const isReverseTakedownEvent = (0, defs_2.isModEventReverseTakedown)(event); const isLabelEvent = (0, defs_2.isModEventLabel)(event); const subject = (0, subject_1.subjectFromInput)(input.body.subject, input.body.subjectBlobCids); if ((0, defs_2.isAgeAssuranceEvent)(event) && !subject.isRepo()) { throw new xrpc_server_1.InvalidRequestError('Invalid subject type'); } if ((0, defs_2.isAgeAssuranceOverrideEvent)(event)) { if (!subject.isRepo()) { throw new xrpc_server_1.InvalidRequestError('Invalid subject type'); } if (!auth.credentials.isModerator) { throw new xrpc_server_1.AuthRequiredError('Must be a full moderator to override age assurance'); } } if ((0, defs_2.isRevokeAccountCredentialsEvent)(event)) { if (!subject.isRepo()) { throw new xrpc_server_1.InvalidRequestError('Invalid subject type'); } if (!auth.credentials.isAdmin) { throw new xrpc_server_1.AuthRequiredError('Must be an admin to revoke account credentials'); } if (!ctx.pdsAgent) { throw new xrpc_server_1.InvalidRequestError('PDS not configured'); } await ctx.pdsAgent.com.atproto.temp.revokeAccountCredentials({ account: subject.did }, await ctx.pdsAuth(lexicons_1.ids.ComAtprotoTempRevokeAccountCredentials)); } // if less than moderator access then can only take ack and escalation actions if (isTakedownEvent || isReverseTakedownEvent) { if (!access.isModerator) { throw new xrpc_server_1.AuthRequiredError('Must be a full moderator to take this type of action'); } // Non admins should not be able to take down feed generators if (!access.isAdmin && subject.recordPath?.includes('app.bsky.feed.generator/')) { throw new xrpc_server_1.AuthRequiredError('Must be a full admin to take this type of action on feed generators'); } } // if less than moderator access then can not apply labels if (!access.isModerator && isLabelEvent) { throw new xrpc_server_1.AuthRequiredError('Must be a full moderator to label content'); } if (isLabelEvent) { validateLabels([ ...(event.createLabelVals ?? []), ...(event.negateLabelVals ?? []), ]); } const isTakedownOrReverseTakedownEvent = isTakedownEvent || isReverseTakedownEvent; if (isTakedownOrReverseTakedownEvent || isLabelEvent) { const status = await moderationService.getStatus(subject); if (status?.takendown && isTakedownEvent) { throw new xrpc_server_1.InvalidRequestError(`Subject is already taken down`); } if (!status?.takendown && isReverseTakedownEvent) { throw new xrpc_server_1.InvalidRequestError(`Subject is not taken down`); } if (status?.tags?.length) { const protectedTags = await (0, util_4.getProtectedTags)(settingService, ctx.cfg.service.did); if (protectedTags) { (0, util_4.assertProtectedTagAction)({ protectedTags, subjectTags: status.tags, actionAuthor: createdBy, isAdmin: auth.credentials.isAdmin, isModerator: auth.credentials.isModerator, isTriage: auth.credentials.isTriage, }); } } if (status?.takendown && isReverseTakedownEvent && subject.isRecord()) { // due to the way blob status is modeled, we should reverse takedown on all // blobs for the record being restored, which aren't taken down on another record. subject.blobCids = status.blobCids ?? []; } } if ((0, defs_2.isModEventEmail)(event) && event.content) { // sending email prior to logging the event to avoid a long transaction below if (!subject.isRepo()) { throw new xrpc_server_1.InvalidRequestError('Email can only be sent to a repo subject'); } const { content, subjectLine } = event; // on error, don't fail the whole event. instead, log the event data with isDelivered false try { await (0, util_2.retryHttp)(() => ctx.modService(db).sendEmail({ subject: subjectLine, content, recipientDid: subject.did, })); event.isDelivered = true; } catch (err) { event.isDelivered = false; logger_1.httpLogger.error({ err, event }, 'failed to send mod event email'); } } if ((0, defs_1.isModEventDivert)(event) && subject.isRecord()) { if (!ctx.blobDiverter) { throw new xrpc_server_1.InvalidRequestError('BlobDiverter not configured for this service'); } await ctx.blobDiverter.uploadBlobOnService(subject.info()); } if (((0, defs_2.isModEventMuteReporter)(event) || (0, defs_2.isModEventUnmuteReporter)(event)) && !subject.isRepo()) { throw new xrpc_server_1.InvalidRequestError('Subject must be a repo when muting reporter'); } if ((0, defs_2.isModEventTag)(event)) { await assertTagAuth(settingService, ctx.cfg.service.did, event, auth); } if ((0, defs_2.isModEventReport)(event)) { await ctx.moderationServiceProfile().validateReasonType(event.reportType); } const moderationEvent = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.modService(dbTxn); if (externalId) { const existingEvent = await moderationTxn.getEventByExternalId((0, util_3.getEventType)(event.$type), externalId, subject); if (existingEvent) { throw new xrpc_server_1.InvalidRequestError(`An event with the same external ID already exists for the subject.`, 'DuplicateExternalId'); } } const result = await moderationTxn.logEvent({ event, subject, createdBy, modTool: input.body.modTool, externalId, }); const tagService = new tag_service_1.TagService(subject, result.subjectStatus, ctx.cfg.service.did, moderationTxn); const initialTags = (0, defs_2.isModEventReport)(event) ? [(0, util_1.getTagForReport)(event.reportType)] : undefined; await tagService.evaluateForSubject(initialTags); if (subject.isRepo()) { if (isTakedownEvent) { const isSuspend = !!result.event.durationInHours; await moderationTxn.takedownRepo(subject, result.event.id, new Set(result.event.meta?.targetServices ? `${result.event.meta.targetServices}`.split(',') : undefined), isSuspend); } else if (isReverseTakedownEvent) { await moderationTxn.reverseTakedownRepo(subject); } } if (subject.isRecord()) { if (isTakedownEvent) { await moderationTxn.takedownRecord(subject, result.event.id, new Set(result.event.meta?.targetServices ? `${result.event.meta.targetServices}`.split(',') : undefined)); } else if (isReverseTakedownEvent) { await moderationTxn.reverseTakedownRecord(subject); } } if ((isTakedownEvent || isAcknowledgeEvent) && result.event.meta?.acknowledgeAccountSubjects) { await moderationTxn.resolveSubjectsForAccount(subject.did, createdBy, result.event); } if (isLabelEvent) { await moderationTxn.formatAndCreateLabels(result.event.subjectUri ?? result.event.subjectDid, result.event.subjectCid, { create: result.event.createLabelVals?.length ? result.event.createLabelVals.split(' ') : undefined, negate: result.event.negateLabelVals?.length ? result.event.negateLabelVals.split(' ') : undefined, }, result.event.durationInHours ?? undefined); } return result.event; }); return moderationService.views.formatEvent(moderationEvent); }; function default_1(server, ctx) { server.tools.ozone.moderation.emitEvent({ auth: ctx.authVerifier.modOrAdminToken, handler: async ({ input, auth }) => { try { const moderationEvent = await handleModerationEvent({ input, auth, ctx, }); // On divert events, we need to automatically take down the blobs if ((0, defs_1.isModEventDivert)(input.body.event)) { await handleModerationEvent({ auth, ctx, input: { ...input, body: { ...input.body, event: { ...input.body.event, $type: 'tools.ozone.moderation.defs#modEventTakedown', comment: '[DIVERT_SIDE_EFFECT]: Automatically taking down after divert event', }, modTool: input.body.modTool, }, }, }); } return { encoding: 'application/json', body: moderationEvent, }; } catch (err) { logger_1.httpLogger.error({ err, body: input.body }, 'failed to emit moderation event'); throw err; } }, }); } const assertTagAuth = async (settingService, serviceDid, event, auth) => { // admins can add/remove any tag if (auth.credentials.isAdmin) return; const protectedTags = await (0, util_4.getProtectedTags)(settingService, serviceDid); if (!protectedTags) { return; } for (const tag of Object.keys(protectedTags)) { if (event.add.includes(tag) || event.remove.includes(tag)) { // if specific moderators are configured to manage this tag but the current user // is not one of them, then throw an error const configuredModerators = protectedTags[tag]?.['moderators']; if (configuredModerators && !configuredModerators.includes(auth.credentials.iss)) { throw new xrpc_server_1.InvalidRequestError(`Not allowed to manage tag: ${tag}`); } const configuredRoles = protectedTags[tag]?.['roles']; if (configuredRoles) { // admins can already do everything so we only check for moderator and triage role config if (auth.credentials.isModerator && !configuredRoles.includes('tools.ozone.team.defs#roleModerator')) { throw new xrpc_server_1.InvalidRequestError(`Can not manage tag ${tag} with moderator role`); } else if (auth.credentials.isTriage && !configuredRoles.includes('tools.ozone.team.defs#roleTriage')) { throw new xrpc_server_1.InvalidRequestError(`Can not manage tag ${tag} with triage role`); } } } } }; const validateLabels = (labels) => { for (const label of labels) { for (const char of badChars) { if (label.includes(char)) { throw new xrpc_server_1.InvalidRequestError(`Invalid label: ${label}`); } } } }; const badChars = [' ', ',', ';', `'`, `"`]; //# sourceMappingURL=emitEvent.js.map