UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

398 lines 17.6 kB
"use strict"; // This may require better organization but for now, just dumping functions here containing DB queries for moderation status Object.defineProperty(exports, "__esModule", { value: true }); exports.getStatusIdentifierFromSubject = exports.adjustModerationSubjectStatus = exports.moderationSubjectStatusQueryBuilder = void 0; const common_1 = require("@atproto/common"); const syntax_1 = require("@atproto/syntax"); const util_1 = require("../api/util"); const types_1 = require("../db/types"); const defs_1 = require("../lexicon/types/tools/ozone/moderation/defs"); const getSubjectStatusForModerationEvent = ({ currentStatus, action, createdBy, createdAt, durationInHours, }) => { const defaultReviewState = currentStatus ? currentStatus.reviewState : defs_1.REVIEWNONE; switch (action) { case 'tools.ozone.moderation.defs#modEventAcknowledge': return { lastReviewedBy: createdBy, reviewState: defs_1.REVIEWCLOSED, lastReviewedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventReport': return { reviewState: defs_1.REVIEWOPEN, lastReportedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventEscalate': return { lastReviewedBy: createdBy, reviewState: defs_1.REVIEWESCALATED, lastReviewedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventReverseTakedown': return { lastReviewedBy: createdBy, reviewState: defs_1.REVIEWCLOSED, takendown: false, suspendUntil: null, lastReviewedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventUnmuteReporter': return { lastReviewedBy: createdBy, muteReportingUntil: null, // It's not likely to receive an unmute event that does not already have a status row // but if it does happen, default to unnecessary reviewState: defaultReviewState, lastReviewedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventUnmute': return { lastReviewedBy: createdBy, muteUntil: null, // It's not likely to receive an unmute event that does not already have a status row // but if it does happen, default to unnecessary reviewState: defaultReviewState, lastReviewedAt: createdAt, }; case 'tools.ozone.moderation.defs#modEventTakedown': return { // If we are doing a takedown, safe to move the item out of appealed state ...(currentStatus?.appealed ? { appealed: false } : {}), takendown: true, lastReviewedBy: createdBy, reviewState: defs_1.REVIEWCLOSED, lastReviewedAt: createdAt, suspendUntil: durationInHours ? new Date(Date.now() + durationInHours * common_1.HOUR).toISOString() : null, }; case 'tools.ozone.moderation.defs#modEventMuteReporter': return { lastReviewedBy: createdBy, lastReviewedAt: createdAt, // By default, mute for 24hrs muteReportingUntil: new Date(Date.now() + (durationInHours || 24) * common_1.HOUR).toISOString(), // It's not likely to receive a mute event on a subject that does not already have a status row // but if it does happen, default to unnecessary reviewState: defaultReviewState, }; case 'tools.ozone.moderation.defs#modEventMute': return { lastReviewedBy: createdBy, lastReviewedAt: createdAt, // By default, mute for 24hrs muteUntil: new Date(Date.now() + (durationInHours || 24) * common_1.HOUR).toISOString(), // It's not likely to receive a mute event on a subject that does not already have a status row // but if it does happen, default to unnecessary reviewState: defaultReviewState, }; case 'tools.ozone.moderation.defs#modEventComment': return { lastReviewedBy: createdBy, lastReviewedAt: createdAt, reviewState: defaultReviewState, }; case 'tools.ozone.moderation.defs#modEventTag': return { tags: [], reviewState: defaultReviewState }; case 'tools.ozone.moderation.defs#modEventResolveAppeal': return { appealed: false, }; case 'tools.ozone.moderation.defs#ageAssuranceEvent': case 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent': return { reviewState: defaultReviewState, }; default: return {}; } }; const hostingEvents = [ 'tools.ozone.moderation.defs#accountEvent', 'tools.ozone.moderation.defs#identityEvent', 'tools.ozone.moderation.defs#recordEvent', ]; const getSubjectStatusForRecordEvent = ({ event, currentStatus, }) => { const timestamp = typeof event.meta?.timestamp === 'string' ? event.meta?.timestamp : event.createdAt; if (event.action === 'tools.ozone.moderation.defs#recordEvent') { if (event.meta?.op === 'delete') { return { hostingStatus: 'deleted', hostingDeletedAt: timestamp, }; } else if (event.meta?.op === 'update') { return { hostingStatus: 'active', hostingUpdatedAt: timestamp, }; } return {}; } if (event.action === 'tools.ozone.moderation.defs#accountEvent') { const status = { hostingUpdatedAt: timestamp, }; if (event.meta?.status) { status.hostingStatus = `${event.meta?.status}`; } if (event.meta?.status === 'deleted') { status.hostingDeletedAt = timestamp; } else if (event.meta?.status === 'deactivated') { status.hostingDeactivatedAt = timestamp; } else { // When deactivated accounts are re-activated, we receive the event with just the active flag set to true // so we want to make sure that the hostingStatus is not set to an outdated value if (currentStatus?.hostingStatus === 'deactivated' && event.meta?.active) { status.hostingStatus = 'active'; status.hostingReactivatedAt = timestamp; } } return status; } if (event.action === 'tools.ozone.moderation.defs#identityEvent') { const status = { hostingUpdatedAt: timestamp, }; if (event.meta?.tombstone) { status.hostingStatus = 'tombstoned'; status.hostingDeletedAt = timestamp; } return status; } return {}; }; const moderationSubjectStatusQueryBuilder = (db) => { // @NOTE: Using select() instead of selectAll() below because the materialized // views might be incomplete, and we don't want the null `did` columns to // interfere with the (never null) `did` column from the // `moderation_subject_status` table in the results return db .selectFrom('moderation_subject_status') .selectAll('moderation_subject_status') .leftJoin('account_events_stats', (join) => join.onRef('moderation_subject_status.did', '=', 'account_events_stats.subjectDid')) .select([ 'account_events_stats.takedownCount', 'account_events_stats.suspendCount', 'account_events_stats.escalateCount', 'account_events_stats.reportCount', 'account_events_stats.appealCount', ]) .leftJoin('account_record_events_stats', (join) => join.onRef('moderation_subject_status.did', '=', 'account_record_events_stats.subjectDid')) .select([ 'account_record_events_stats.totalReports', 'account_record_events_stats.reportedCount', 'account_record_events_stats.escalatedCount', 'account_record_events_stats.appealedCount', ]) .leftJoin('account_record_status_stats', (join) => join.onRef('moderation_subject_status.did', '=', 'account_record_status_stats.did')) .select([ 'account_record_status_stats.subjectCount', 'account_record_status_stats.pendingCount', 'account_record_status_stats.processedCount', 'account_record_status_stats.takendownCount', ]) .leftJoin('account_strike', (join) => join.onRef('moderation_subject_status.did', '=', 'account_strike.did')) .select([ 'account_strike.activeStrikeCount as strikeCount', 'account_strike.totalStrikeCount', 'account_strike.firstStrikeAt', 'account_strike.lastStrikeAt', ]); }; exports.moderationSubjectStatusQueryBuilder = moderationSubjectStatusQueryBuilder; // Based on a given moderation action event, this function will update the moderation status of the subject // If there's no existing status, it will create one // If the action event does not affect the status, it will do nothing const adjustModerationSubjectStatus = async (db, moderationEvent, blobCids) => { const { action, subjectDid, subjectUri, subjectCid, createdBy, meta, addedTags, removedTags, comment, createdAt, } = moderationEvent; // If subjectUri exists, it's not a repoRef so pass along the uri to get identifier back const identifier = (0, exports.getStatusIdentifierFromSubject)(subjectUri || subjectDid); db.assertTransaction(); const now = new Date().toISOString(); const currentStatus = await db.db .selectFrom('moderation_subject_status') .where('did', '=', identifier.did) .where('recordPath', '=', identifier.recordPath) // Make sure we respect other updates that may be happening at the same time .forUpdate() .selectAll() .executeTakeFirst(); if (hostingEvents.includes(action)) { const newStatus = getSubjectStatusForRecordEvent({ event: moderationEvent, currentStatus, }); if (!Object.keys(newStatus).length) { return currentStatus || null; } const status = await db.db .insertInto('moderation_subject_status') .values({ ...identifier, ...newStatus, // newStatus doesn't contain a reviewState or takendown so in case this is a new entry // we need to set a default values so that the insert doesn't fail reviewState: currentStatus ? currentStatus.reviewState : defs_1.REVIEWNONE, // @TODO: should we try to update this based on status property of account event? // For now we're the only one emitting takedowns so i don't think it makes too much of a difference takendown: currentStatus ? currentStatus.takendown : false, ageAssuranceState: currentStatus ? currentStatus.ageAssuranceState : 'unknown', createdAt: now, updatedAt: now, }) .onConflict((oc) => oc.constraint('moderation_status_unique_idx').doUpdateSet({ ...newStatus, updatedAt: now, })) .returningAll() .executeTakeFirst(); return status || null; } // If reporting is muted for this reporter, we don't want to update the subject status if (meta?.isReporterMuted) { return currentStatus || null; } const isAppealEvent = action === 'tools.ozone.moderation.defs#modEventReport' && meta?.reportType && (0, util_1.isAppealReport)(`${meta.reportType}`); const subjectStatus = getSubjectStatusForModerationEvent({ currentStatus, action, createdBy, createdAt, durationInHours: moderationEvent.durationInHours, }); if (currentStatus?.reviewState === defs_1.REVIEWESCALATED && subjectStatus.reviewState !== defs_1.REVIEWCLOSED) { // If the current status is escalated only allow incoming events to move the state to // reviewClosed because escalated subjects should never move to any other state subjectStatus.reviewState = defs_1.REVIEWESCALATED; } if (currentStatus && subjectStatus.reviewState === defs_1.REVIEWNONE) { // reviewNone is ONLY allowed when there is no current status // If there is a current status, it should not be allowed to move back to reviewNone subjectStatus.reviewState = currentStatus.reviewState; } // Set these because we don't want to override them if they're already set const defaultData = { comment: null, // Defaulting reviewState to open for any event may not be the desired behavior. // For instance, if a subject never had any event and we just want to leave a comment to keep an eye on it // that shouldn't mean we want to review the subject reviewState: defs_1.REVIEWNONE, recordCid: subjectCid || null, ageAssuranceState: currentStatus?.ageAssuranceState || 'unknown', }; const newStatus = { ...defaultData, ...subjectStatus, }; if (action === 'tools.ozone.moderation.defs#modEventPriorityScore' && typeof meta?.priorityScore === 'number') { newStatus.priorityScore = meta?.priorityScore; subjectStatus.priorityScore = meta?.priorityScore; } if (action === 'tools.ozone.moderation.defs#modEventReverseTakedown' && !subjectStatus.takendown) { newStatus.takendown = false; subjectStatus.takendown = false; } if (isAppealEvent) { newStatus.appealed = true; subjectStatus.appealed = true; newStatus.lastAppealedAt = createdAt; subjectStatus.lastAppealedAt = createdAt; // Set reviewState to escalated when appeal events are emitted subjectStatus.reviewState = defs_1.REVIEWESCALATED; newStatus.reviewState = defs_1.REVIEWESCALATED; } if (action === 'tools.ozone.moderation.defs#modEventResolveAppeal' && subjectStatus.appealed) { newStatus.appealed = false; subjectStatus.appealed = false; } if (action === 'tools.ozone.moderation.defs#modEventComment' && meta?.sticky) { newStatus.comment = comment; subjectStatus.comment = comment; } if (action === 'tools.ozone.moderation.defs#modEventTag') { let tags = currentStatus?.tags || []; if (addedTags?.length) { tags = tags.concat(addedTags); } if (removedTags?.length) { tags = tags.filter((tag) => !removedTags.includes(tag)); } newStatus.tags = (0, types_1.jsonb)([...new Set(tags)]); subjectStatus.tags = newStatus.tags; } if (action === 'tools.ozone.moderation.defs#ageAssuranceEvent') { // Only when the last update was made by an admin AND state was set to reset user event can override final state if (currentStatus?.ageAssuranceUpdatedBy !== 'admin' || currentStatus?.ageAssuranceState === 'reset') { if (typeof meta?.status === 'string') { newStatus.ageAssuranceState = meta.status; subjectStatus.ageAssuranceState = meta.status; newStatus.ageAssuranceUpdatedBy = 'user'; subjectStatus.ageAssuranceUpdatedBy = 'user'; } } } if (action === 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent') { if (typeof meta?.status === 'string') { newStatus.ageAssuranceState = meta.status; subjectStatus.ageAssuranceState = meta.status; newStatus.ageAssuranceUpdatedBy = 'admin'; subjectStatus.ageAssuranceUpdatedBy = 'admin'; } } if (blobCids?.length) { const newBlobCids = (0, types_1.jsonb)(blobCids); newStatus.blobCids = newBlobCids; subjectStatus.blobCids = newBlobCids; } const insertQuery = db.db .insertInto('moderation_subject_status') .values({ ...identifier, ...newStatus, createdAt: now, updatedAt: now, }) .onConflict((oc) => oc.constraint('moderation_status_unique_idx').doUpdateSet({ ...subjectStatus, updatedAt: now, })); const status = await insertQuery.returningAll().executeTakeFirst(); return status || null; }; exports.adjustModerationSubjectStatus = adjustModerationSubjectStatus; const getStatusIdentifierFromSubject = (subject) => { const isSubjectString = typeof subject === 'string'; if (isSubjectString && subject.startsWith('did:')) { return { did: subject, recordPath: '', }; } if (isSubjectString && !subject.startsWith('at://')) { throw new Error('Subject is neither a did nor an at-uri'); } const uri = isSubjectString ? new syntax_1.AtUri(subject) : subject; return { did: uri.host, recordPath: `${uri.collection}/${uri.rkey}`, }; }; exports.getStatusIdentifierFromSubject = getStatusIdentifierFromSubject; //# sourceMappingURL=status.js.map