UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

279 lines 12.8 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 defs_2 = require("../../lexicon/types/tools/ozone/moderation/defs"); const subject_1 = require("../../mod-service/subject"); const constants_1 = require("../../setting/constants"); 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 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 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 ?? []), ]); } if (isTakedownEvent || isReverseTakedownEvent) { 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 getProtectedTags(settingService, ctx.cfg.service.did); if (protectedTags) { assertProtectedTagAction(protectedTags, status.tags, createdBy, auth); } } 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; await (0, util_2.retryHttp)(() => ctx.modService(db).sendEmail({ subject: subjectLine, content, recipientDid: subject.did, })); } 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); } 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, isSuspend); } else if (isReverseTakedownEvent) { await moderationTxn.reverseTakedownRepo(subject); } } if (subject.isRecord()) { if (isTakedownEvent) { await moderationTxn.takedownRecord(subject, result.event.id); } 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 }) => { 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, }; }, }); } const assertProtectedTagAction = (protectedTags, subjectTags, actionAuthor, auth) => { subjectTags.forEach((tag) => { if (!Object.hasOwn(protectedTags, tag)) return; if (protectedTags[tag]['moderators'] && !protectedTags[tag]['moderators'].includes(actionAuthor)) { throw new xrpc_server_1.InvalidRequestError(`Not allowed to action on protected tag: ${tag}`); } if (protectedTags[tag]['roles']) { if (auth.credentials.isAdmin) { if (protectedTags[tag]['roles'].includes('tools.ozone.team.defs#roleAdmin')) { return; } throw new xrpc_server_1.InvalidRequestError(`Not allowed to action on protected tag: ${tag}`); } if (auth.credentials.isModerator) { if (protectedTags[tag]['roles'].includes('tools.ozone.team.defs#roleModerator')) { return; } throw new xrpc_server_1.InvalidRequestError(`Not allowed to action on protected tag: ${tag}`); } if (auth.credentials.isTriage) { if (protectedTags[tag]['roles'].includes('tools.ozone.team.defs#roleTriage')) { return; } throw new xrpc_server_1.InvalidRequestError(`Not allowed to action on protected tag: ${tag}`); } } }); }; const assertTagAuth = async (settingService, serviceDid, event, auth) => { // admins can add/remove any tag if (auth.credentials.isAdmin) return; const protectedTags = await 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 getProtectedTags = async (settingService, serviceDid) => { const protectedTagSetting = await settingService.query({ keys: [constants_1.ProtectedTagSettingKey], scope: 'instance', did: serviceDid, limit: 1, }); // if no protected tags are configured, then no need to do further check if (!protectedTagSetting.options.length) { return; } return protectedTagSetting.options[0].value; }; 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