UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

1,113 lines (1,112 loc) 51.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SUSPEND_LABEL = exports.TAKEDOWN_LABEL = exports.ModerationService = void 0; const kysely_1 = require("kysely"); const common_1 = require("@atproto/common"); const syntax_1 = require("@atproto/syntax"); const xrpc_server_1 = require("@atproto/xrpc-server"); const util_1 = require("../api/util"); const pagination_1 = require("../db/pagination"); const label_1 = require("../db/schema/label"); const types_1 = require("../db/types"); const lexicons_1 = require("../lexicon/lexicons"); const defs_1 = require("../lexicon/types/tools/ozone/moderation/defs"); const logger_1 = require("../logger"); const util_2 = require("../util"); const status_1 = require("./status"); const subject_1 = require("./subject"); const util_3 = require("./util"); const views_1 = require("./views"); class ModerationService { constructor(db, signingKey, signingKeyId, cfg, backgroundQueue, idResolver, eventPusher, appviewAgent, createAuthHeaders, strikeService, imgInvalidator) { Object.defineProperty(this, "db", { enumerable: true, configurable: true, writable: true, value: db }); Object.defineProperty(this, "signingKey", { enumerable: true, configurable: true, writable: true, value: signingKey }); Object.defineProperty(this, "signingKeyId", { enumerable: true, configurable: true, writable: true, value: signingKeyId }); Object.defineProperty(this, "cfg", { enumerable: true, configurable: true, writable: true, value: cfg }); Object.defineProperty(this, "backgroundQueue", { enumerable: true, configurable: true, writable: true, value: backgroundQueue }); Object.defineProperty(this, "idResolver", { enumerable: true, configurable: true, writable: true, value: idResolver }); Object.defineProperty(this, "eventPusher", { enumerable: true, configurable: true, writable: true, value: eventPusher }); Object.defineProperty(this, "appviewAgent", { enumerable: true, configurable: true, writable: true, value: appviewAgent }); Object.defineProperty(this, "createAuthHeaders", { enumerable: true, configurable: true, writable: true, value: createAuthHeaders }); Object.defineProperty(this, "strikeService", { enumerable: true, configurable: true, writable: true, value: strikeService }); Object.defineProperty(this, "imgInvalidator", { enumerable: true, configurable: true, writable: true, value: imgInvalidator }); Object.defineProperty(this, "views", { enumerable: true, configurable: true, writable: true, value: new views_1.ModerationViews(this.db, this.signingKey, this.signingKeyId, this.appviewAgent, async (method, labelers) => { const authHeaders = await this.createAuthHeaders(this.cfg.appview.did, method); if (labelers?.dids?.length) { authHeaders.headers[util_2.LABELER_HEADER_NAME] = labelers.dids.join(', '); } return authHeaders; }, this.idResolver, this.cfg.service.devMode) }); } static creator(signingKey, signingKeyId, cfg, backgroundQueue, idResolver, eventPusher, appviewAgent, createAuthHeaders, strikeServiceCreator, imgInvalidator) { return (db) => { const strikeService = strikeServiceCreator(db); return new ModerationService(db, signingKey, signingKeyId, cfg, backgroundQueue, idResolver, eventPusher, appviewAgent, createAuthHeaders, strikeService, imgInvalidator); }; } async getEvent(id) { return await this.db.db .selectFrom('moderation_event') .selectAll() .where('id', '=', id) .executeTakeFirst(); } async getEventOrThrow(id) { const event = await this.getEvent(id); if (!event) throw new xrpc_server_1.InvalidRequestError('Moderation event not found'); return event; } async getEventByExternalId(eventType, externalId, subject) { const result = await this.db.db .selectFrom('moderation_event') .where('action', '=', eventType) .where('externalId', '=', externalId) .where('subjectDid', '=', subject.did) .select((0, kysely_1.sql) `1`.as('exists')) .limit(1) .executeTakeFirst(); return !!result; } async getEvents(opts) { const { subject, createdBy, limit, cursor, includeAllUserRecords, sortDirection = 'desc', types, hasComment, comment, createdAfter, createdBefore, addedLabels, removedLabels, addedTags, removedTags, reportTypes, collections, subjectType, policies, modTool, ageAssuranceState, batchId, withStrike, } = opts; const { ref } = this.db.db.dynamic; let builder = this.db.db.selectFrom('moderation_event').selectAll(); if (subject) { const isSubjectAtUri = subject.startsWith('at://'); const subjectDid = isSubjectAtUri ? new syntax_1.AtUri(subject).hostname : subject; const subjectUri = isSubjectAtUri ? subject : null; // regardless of subjectUri check, we always want to query against subjectDid column since that's indexed builder = builder.where('subjectDid', '=', subjectDid); // if requester wants to include all user records, let's ignore matching on subjectUri if (!includeAllUserRecords) { builder = builder .if(!subjectUri, (q) => q.where('subjectUri', 'is', null)) .if(!!subjectUri, (q) => q.where('subjectUri', '=', subjectUri)); } } else if (subjectType === 'account') { builder = builder.where('subjectUri', 'is', null); } else if (subjectType === 'record') { builder = builder.where('subjectUri', 'is not', null); } // If subjectType is set to 'account' let that take priority and ignore collections filter if (collections.length && subjectType !== 'account') { builder = builder.where('subjectUri', 'is not', null).where((qb) => { collections.forEach((collection) => { qb = qb.orWhere('subjectUri', 'like', `%/${collection}/%`); }); return qb; }); } if (types.length) { builder = builder.where((qb) => { if (types.length === 1) { return qb.where('action', '=', types[0]); } return qb.where('action', 'in', types); }); } if (createdBy) { builder = builder.where('createdBy', '=', createdBy); } if (createdAfter) { builder = builder.where('createdAt', '>=', createdAfter); } if (createdBefore) { builder = builder.where('createdAt', '<=', createdBefore); } if (comment) { // the input may end in || in which case, there may be item in the array which is just '' and we want to ignore those const keywords = comment.split('||').filter((keyword) => !!keyword.trim()); if (keywords.length > 1) { builder = builder.where((qb) => { keywords.forEach((keyword) => { qb = qb.orWhere('comment', 'ilike', `%${keyword}%`); }); return qb; }); } else if (keywords.length === 1) { builder = builder.where('comment', 'ilike', `%${keywords[0]}%`); } } if (hasComment) { builder = builder.where('comment', 'is not', null); } // If multiple labels are passed, then only retrieve events where all those labels exist if (addedLabels.length) { addedLabels.forEach((label) => { builder = builder.where('createLabelVals', 'ilike', `%${label}%`); }); } if (removedLabels.length) { removedLabels.forEach((label) => { builder = builder.where('negateLabelVals', 'ilike', `%${label}%`); }); } if (addedTags.length) { builder = builder.where((0, kysely_1.sql) `${ref('addedTags')} @> ${(0, types_1.jsonb)(addedTags)}`); } if (removedTags.length) { builder = builder.where((0, kysely_1.sql) `${ref('removedTags')} @> ${(0, types_1.jsonb)(removedTags)}`); } if (reportTypes?.length) { builder = builder.where((0, kysely_1.sql) `meta->>'reportType'`, 'in', reportTypes); } if (policies?.length) { builder = builder.where((qb) => { policies.forEach((policy) => { qb = qb.orWhere((0, kysely_1.sql) `meta->>'policies'`, 'ilike', `%${policy}%`); }); return qb; }); } if (modTool?.length) { builder = builder .where('modTool', 'is not', null) .where((0, kysely_1.sql) `("modTool" ->> 'name')`, 'in', modTool); } if (batchId) { builder = builder .where('modTool', 'is not', null) .where((0, kysely_1.sql) `("modTool" -> 'meta' ->> 'batchId')`, '=', batchId); } if (ageAssuranceState) { builder = builder .where('action', 'in', [ 'tools.ozone.moderation.defs#ageAssuranceEvent', 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent', ]) .where((0, kysely_1.sql) `meta->>'status'`, '=', ageAssuranceState); } if (withStrike !== undefined) { builder = builder.where('strikeCount', 'is not', null); } const keyset = new pagination_1.TimeIdKeyset(ref(`moderation_event.createdAt`), ref('moderation_event.id')); const paginatedBuilder = (0, pagination_1.paginate)(builder, { limit, cursor, keyset, direction: sortDirection, tryIndex: true, }); const result = await paginatedBuilder.execute(); const infos = await this.views.getAccoutInfosByDid([ ...result.map((row) => row.subjectDid), ...result.map((row) => row.createdBy), ]); const resultWithHandles = result.map((r) => ({ ...r, creatorHandle: infos.get(r.createdBy)?.handle, subjectHandle: infos.get(r.subjectDid)?.handle, })); return { cursor: keyset.packFromResult(result), events: resultWithHandles }; } async getReport(id) { return await this.db.db .selectFrom('moderation_event') .where('action', '=', 'tools.ozone.moderation.defs#modEventReport') .selectAll() .where('id', '=', id) .executeTakeFirst(); } async getCurrentStatus(subject) { let builder = this.db.db.selectFrom('moderation_subject_status').selectAll(); if ('did' in subject) { builder = builder.where('did', '=', subject.did); } else if ('uri' in subject) { builder = builder.where('recordPath', '=', subject.uri.toString()); } // TODO: Handle the cid status return await builder.execute(); } async resolveSubjectsForAccount(did, createdBy, accountEvent) { const subjectsToBeResolved = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', did) .where('recordPath', '!=', '') .where('reviewState', 'in', [defs_1.REVIEWESCALATED, defs_1.REVIEWOPEN]) .selectAll() .execute(); if (subjectsToBeResolved.length === 0) { return; } let accountEventInfo = `Account Event ID: ${accountEvent.id}`; if (accountEvent.comment) { accountEventInfo += ` | Account Event Comment: ${accountEvent.comment}`; } // Process subjects in chunks of 100 since each of these will trigger multiple db queries for (const subjects of (0, common_1.chunkArray)(subjectsToBeResolved, 100)) { await Promise.all(subjects.map(async (subject) => { const eventData = { createdBy, subject: (0, subject_1.subjectFromStatusRow)(subject), }; // For consistency's sake, when acknowledging appealed subjects, we should first resolve the appeal if (subject.appealed) { await this.logEvent({ event: { $type: 'tools.ozone.moderation.defs#modEventResolveAppeal', comment: `[AUTO_RESOLVE_ON_ACCOUNT_ACTION]: Automatically resolving all appealed content due to account level action | ${accountEventInfo}`, }, ...eventData, }); } await this.logEvent({ event: { $type: 'tools.ozone.moderation.defs#modEventAcknowledge', comment: `[AUTO_RESOLVE_ON_ACCOUNT_ACTION]: Automatically resolving all reported content due to account level action | ${accountEventInfo}`, }, ...eventData, }); })); } } async logEvent(info) { this.db.assertTransaction(); const { event, subject, createdBy, externalId, createdAt = new Date(), modTool, } = info; const createLabelVals = (0, defs_1.isModEventLabel)(event) && event.createLabelVals.length > 0 ? event.createLabelVals.join(' ') : undefined; const negateLabelVals = (0, defs_1.isModEventLabel)(event) && event.negateLabelVals.length > 0 ? event.negateLabelVals.join(' ') : undefined; const meta = {}; const addedTags = (0, defs_1.isModEventTag)(event) ? (0, types_1.jsonb)(event.add) : null; const removedTags = (0, defs_1.isModEventTag)(event) ? (0, types_1.jsonb)(event.remove) : null; if ((0, defs_1.isModEventReport)(event)) { meta.reportType = event.reportType; } if ((0, defs_1.isModEventComment)(event) && event.sticky) { meta.sticky = event.sticky; } if ((0, defs_1.isModEventEmail)(event)) { meta.subjectLine = event.subjectLine; meta.isDelivered = !!event.isDelivered; if (event.content) { meta.content = event.content; } if (event.policies?.length) { meta.policies = event.policies.join(','); } } if ((0, defs_1.isAccountEvent)(event)) { meta.active = event.active; meta.timestamp = event.timestamp; if (event.status) meta.status = event.status; } if ((0, defs_1.isModEventPriorityScore)(event)) { meta.priorityScore = event.score; } if ((0, defs_1.isIdentityEvent)(event)) { meta.timestamp = event.timestamp; if (event.handle) meta.handle = event.handle; if (event.pdsHost) meta.pdsHost = event.pdsHost; if (event.tombstone) meta.tombstone = event.tombstone; } if ((0, defs_1.isRecordEvent)(event)) { meta.timestamp = event.timestamp; meta.op = event.op; if (event.cid) meta.cid = event.cid; } if ((0, defs_1.isAgeAssuranceEvent)(event)) { meta.status = event.status; meta.createdAt = event.createdAt; if (event.attemptId) { meta.attemptId = event.attemptId; } if (event.access) { meta.access = event.access; } if (event.initIp) { meta.initIp = event.initIp; } if (event.initUa) { meta.initUa = event.initUa; } if (event.completeIp) { meta.completeIp = event.completeIp; } if (event.completeUa) { meta.completeUa = event.completeUa; } } if ((0, defs_1.isAgeAssuranceOverrideEvent)(event)) { meta.status = event.status; if (event.access) { meta.access = event.access; } } if ((0, defs_1.isScheduleTakedownEvent)(event)) { if (event.executeAfter) { meta.executeAfter = event.executeAfter; } if (event.executeAt) { meta.executeAt = event.executeAt; } if (event.executeUntil) { meta.executeUntil = event.executeUntil; } } if (((0, defs_1.isModEventTakedown)(event) || (0, defs_1.isModEventAcknowledge)(event)) && event.acknowledgeAccountSubjects) { meta.acknowledgeAccountSubjects = true; } if ((0, defs_1.isModEventTakedown)(event) && event.policies?.length) { meta.policies = event.policies.join(','); } if ((0, defs_1.isModEventTakedown)(event) && event.targetServices?.length) { meta.targetServices = event.targetServices.join(','); } // Keep trace of reports that came in while the reporter was in muted stated if ((0, defs_1.isModEventReport)(event)) { const isReportingMuted = await this.isReportingMutedForSubject(createdBy); if (isReportingMuted) { meta.isReporterMuted = true; } } const subjectInfo = subject.info(); // Store severityLevel, strikeCount, and strikeExpiresAt if provided // These values should be calculated by the client based on configuration // processNewEvent will update the account_strike table with the new strike count let severityLevel = null; let strikeCount = null; let strikeExpiresAt = null; if ((0, defs_1.isModEventTakedown)(event) || (0, defs_1.isModEventEmail)(event) || (0, defs_1.isModEventReverseTakedown)(event)) { // Store severityLevel if provided (for display/tracking) if (event.severityLevel) { severityLevel = event.severityLevel; } // Store explicit strikeCount if provided if (event.strikeCount !== undefined) { strikeCount = event.strikeCount; } // Store strikeExpiresAt if provided by client if ('strikeExpiresAt' in event && event.strikeExpiresAt) { strikeExpiresAt = event.strikeExpiresAt; } } const modEvent = await this.db.db .insertInto('moderation_event') .values({ comment: ('comment' in event && typeof event.comment === 'string' && event.comment) || null, action: event.$type, createdAt: createdAt.toISOString(), createdBy, createLabelVals, negateLabelVals, addedTags, removedTags, durationInHours: 'durationInHours' in event && event.durationInHours ? Number(event.durationInHours) : null, meta: Object.assign(meta, subjectInfo.meta), expiresAt: ((0, defs_1.isModEventTakedown)(event) || (0, defs_1.isModEventMute)(event)) && event.durationInHours ? (0, common_1.addHoursToDate)(event.durationInHours, createdAt).toISOString() : undefined, subjectType: subjectInfo.subjectType, subjectDid: subjectInfo.subjectDid, subjectUri: subjectInfo.subjectUri, subjectCid: subjectInfo.subjectCid, subjectBlobCids: (0, types_1.jsonb)(subjectInfo.subjectBlobCids), subjectMessageId: subjectInfo.subjectMessageId, modTool: modTool ? (0, types_1.jsonb)(modTool) : null, externalId: externalId ?? null, severityLevel, strikeCount, strikeExpiresAt, }) .returningAll() .executeTakeFirstOrThrow(); const subjectStatus = await (0, status_1.adjustModerationSubjectStatus)(this.db, modEvent, subject.blobCids); // Updates are only needed if strikeCount is numeric (in some cases even 0) if (modEvent.strikeCount !== null) { try { await this.strikeService.updateSubjectStrikeCount(modEvent.subjectDid); } catch (error) { // Log error but don't fail the entire operation to ensure that events are logged even if updating strike count fails logger_1.httpLogger.error({ err: error, modEventId: modEvent.id }, 'Error processing strikes for moderation event'); } } return { event: modEvent, subjectStatus }; } async getLastReversibleEventForSubject(subject) { // If the subject is neither suspended nor muted don't bother finding the last reversible event // Ideally, this should never happen because the caller of this method should only call this // after ensuring that the suspended or muted subjects are being reversed if (!subject.reverseMute && !subject.reverseSuspend) { return null; } let builder = this.db.db .selectFrom('moderation_event') .where('subjectDid', '=', subject.subject.did); if (subject.subject.recordPath) { builder = builder.where('subjectUri', 'like', `%${subject.subject.recordPath}%`); } // Means the subject was suspended and needs to be unsuspended if (subject.reverseSuspend) { builder = builder .where('action', '=', 'tools.ozone.moderation.defs#modEventTakedown') .where('durationInHours', 'is not', null); } if (subject.reverseMute) { builder = builder .where('action', '=', 'tools.ozone.moderation.defs#modEventMute') .where('durationInHours', 'is not', null); } return await builder .orderBy('id', 'desc') .selectAll() .limit(1) .executeTakeFirst(); } async getSubjectsDueForReversal() { const now = new Date().toISOString(); const subjects = await this.db.db .selectFrom('moderation_subject_status') .where('suspendUntil', '<', now) .orWhere('muteUntil', '<', now) .selectAll() .execute(); return subjects.map((row) => ({ subject: (0, subject_1.subjectFromStatusRow)(row), reverseSuspend: !!row.suspendUntil && row.suspendUntil < now, reverseMute: !!row.muteUntil && row.muteUntil < now, })); } async isSubjectSuspended(did) { const res = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', did) .where('recordPath', '=', '') .where('suspendUntil', '>', new Date().toISOString()) .select('did') .limit(1) .executeTakeFirst(); return !!res; } async revertState({ createdBy, createdAt, comment, action, subject, }) { const isRevertingTakedown = action === 'tools.ozone.moderation.defs#modEventTakedown'; this.db.assertTransaction(); const { event } = await this.logEvent({ event: { $type: isRevertingTakedown ? 'tools.ozone.moderation.defs#modEventReverseTakedown' : 'tools.ozone.moderation.defs#modEventUnmute', comment: comment ?? undefined, }, createdAt, createdBy, subject, }); if (isRevertingTakedown) { if (subject.isRepo()) { await this.reverseTakedownRepo(subject); } else if (subject.isRecord()) { await this.reverseTakedownRecord(subject); } } return event; } async takedownRepo(subject, takedownId, targetServices, isSuspend = false) { const takedownRef = `BSKY-${isSuspend ? 'SUSPEND' : 'TAKEDOWN'}-${takedownId}`; const values = this.eventPusher .getTakedownServices(targetServices) .map((eventType) => ({ eventType, subjectDid: subject.did, takedownRef, })); // The label is consumed by appview if we opt for appview only takedown, this is needed // if we opt for pds level takedown, adding the label doesn't hurt const takedownLabel = isSuspend ? exports.SUSPEND_LABEL : exports.TAKEDOWN_LABEL; await this.formatAndCreateLabels(subject.did, null, { create: [takedownLabel], }); // If we dont have to push any events, return early if (!values.length) { return; } const repoEvts = await this.db.db .insertInto('repo_push_event') .values(values) .onConflict((oc) => oc.columns(['subjectDid', 'eventType']).doUpdateSet({ takedownRef, confirmedAt: null, attempts: 0, lastAttempted: null, })) .returning('id') .execute(); this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.all(repoEvts.map((evt) => this.eventPusher.attemptRepoEvent(evt.id))); }); }); } async reverseTakedownRepo(subject) { const repoEvts = await this.db.db .updateTable('repo_push_event') .where('eventType', 'in', TAKEDOWNS) .where('subjectDid', '=', subject.did) .set({ takedownRef: null, confirmedAt: null, attempts: 0, lastAttempted: null, }) .returning('id') .execute(); const existingTakedownLabels = await this.db.db .selectFrom('label') .where('label.uri', '=', subject.did) .where('label.val', 'in', [exports.TAKEDOWN_LABEL, exports.SUSPEND_LABEL]) .where('neg', '=', false) .selectAll() .execute(); const takedownVals = existingTakedownLabels.map((row) => row.val); await this.formatAndCreateLabels(subject.did, null, { negate: takedownVals, }); this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.all(repoEvts.map((evt) => this.eventPusher.attemptRepoEvent(evt.id))); }); }); } async takedownRecord(subject, takedownId, targetServices) { this.db.assertTransaction(); await this.formatAndCreateLabels(subject.uri, subject.cid, { create: [exports.TAKEDOWN_LABEL], }); const takedownRef = `BSKY-TAKEDOWN-${takedownId}`; const blobCids = subject.blobCids; if (blobCids && blobCids.length > 0) { const blobValues = []; for (const eventType of this.eventPusher.getTakedownServices(targetServices)) { for (const cid of blobCids) { blobValues.push({ eventType, takedownRef, subjectDid: subject.did, subjectUri: subject.uri || null, subjectBlobCid: cid.toString(), }); } } const blobEvts = await this.eventPusher.logBlobPushEvent(blobValues, takedownRef); this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.allSettled(blobEvts.map((evt) => this.eventPusher .attemptBlobEvent(evt.id) .catch((err) => logger_1.httpLogger.error({ ...evt, err }, 'failed to push blob event')))); if (this.imgInvalidator) { await Promise.allSettled((subject.blobCids ?? []).map((cid) => { const paths = (this.cfg.cdn.paths ?? []).map((path) => path.replace('%s', subject.did).replace('%s', cid)); return this.imgInvalidator ?.invalidate(cid, paths) .catch((err) => logger_1.httpLogger.error({ err, paths, cid }, 'failed to invalidate blob on cdn')); })); } }); }); } } async reverseTakedownRecord(subject) { this.db.assertTransaction(); await this.formatAndCreateLabels(subject.uri, subject.cid, { negate: [exports.TAKEDOWN_LABEL], }); const blobCids = subject.blobCids; if (blobCids && blobCids.length > 0) { const blobEvts = await this.db.db .updateTable('blob_push_event') .where('eventType', 'in', TAKEDOWNS) .where('subjectDid', '=', subject.did) .where('subjectBlobCid', 'in', blobCids.map((c) => c.toString())) .set({ takedownRef: null, confirmedAt: null, attempts: 0, lastAttempted: null, }) .returning('id') .execute(); this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.all(blobEvts.map((evt) => this.eventPusher.attemptBlobEvent(evt.id))); }); }); } } async report(info) { const { reasonType, reason, reportedBy, createdAt = new Date(), subject, modTool, } = info; const result = await this.logEvent({ event: { $type: 'tools.ozone.moderation.defs#modEventReport', reportType: reasonType, comment: reason, }, createdBy: reportedBy, subject, createdAt, modTool, }); return result; } async getSubjectStatuses({ queueCount, queueIndex, queueSeed = '', includeAllUserRecords, cursor, limit = 50, takendown, appealed, reviewState, reviewedAfter, reviewedBefore, reportedAfter, reportedBefore, includeMuted = false, hostingDeletedBefore, hostingDeletedAfter, hostingUpdatedBefore, hostingUpdatedAfter, hostingStatuses, onlyMuted = false, ignoreSubjects, sortDirection = 'desc', lastReviewedBy, sortField = 'lastReportedAt', subject, tags, excludeTags, collections, subjectType, minAccountSuspendCount, minReportedRecordsCount, minTakendownRecordsCount, minPriorityScore, minStrikeCount, ageAssuranceState, }) { let builder = (0, status_1.moderationSubjectStatusQueryBuilder)(this.db.db); const { ref } = this.db.db.dynamic; if (subject) { const subjectInfo = (0, status_1.getStatusIdentifierFromSubject)(subject); builder = builder.where('moderation_subject_status.did', '=', subjectInfo.did); if (!includeAllUserRecords) { builder = builder.where((qb) => subjectInfo.recordPath ? qb.where('moderation_subject_status.recordPath', '=', subjectInfo.recordPath) : qb.where('moderation_subject_status.recordPath', '=', '')); } } else if (subjectType === 'account') { builder = builder.where('moderation_subject_status.recordPath', '=', ''); } else if (subjectType === 'record') { builder = builder.where('moderation_subject_status.recordPath', '!=', ''); } // Only fetch items that belongs to the specified queue when specified if (!subject && queueCount && queueCount > 0 && queueIndex !== undefined && queueIndex >= 0 && queueIndex < queueCount) { builder = builder.where(queueSeed ? (0, kysely_1.sql) `ABS(HASHTEXT(${queueSeed} || moderation_subject_status.did)) % ${queueCount}` : (0, kysely_1.sql) `ABS(HASHTEXT(moderation_subject_status.did)) % ${queueCount}`, '=', queueIndex); } // If subjectType is set to 'account' let that take priority and ignore collections filter if (subjectType !== 'account' && collections?.length) { builder = builder .where('moderation_subject_status.recordPath', '!=', '') .where((qb) => { for (const collection of collections) { qb = qb.orWhere('moderation_subject_status.recordPath', 'like', `${collection}/%`); } return qb; }); } if (ignoreSubjects?.length) { builder = builder .where('moderation_subject_status.did', 'not in', ignoreSubjects) .where('moderation_subject_status.recordPath', 'not in', ignoreSubjects); } const reviewStateNormalized = (0, util_1.getReviewState)(reviewState); if (reviewStateNormalized) { builder = builder.where('moderation_subject_status.reviewState', '=', reviewStateNormalized); } if (lastReviewedBy) { builder = builder.where('moderation_subject_status.lastReviewedBy', '=', lastReviewedBy); } if (reviewedAfter) { builder = builder.where('moderation_subject_status.lastReviewedAt', '>', reviewedAfter); } if (reviewedBefore) { builder = builder.where('moderation_subject_status.lastReviewedAt', '<', reviewedBefore); } if (hostingUpdatedAfter) { builder = builder.where('moderation_subject_status.hostingUpdatedAt', '>', hostingUpdatedAfter); } if (hostingUpdatedBefore) { builder = builder.where('moderation_subject_status.hostingUpdatedAt', '<', hostingUpdatedBefore); } if (hostingDeletedAfter) { builder = builder.where('moderation_subject_status.hostingDeletedAt', '>', hostingDeletedAfter); } if (hostingDeletedBefore) { builder = builder.where('moderation_subject_status.hostingDeletedAt', '<', hostingDeletedBefore); } if (hostingStatuses?.length) { builder = builder.where('moderation_subject_status.hostingStatus', 'in', hostingStatuses); } if (reportedAfter) { builder = builder.where('moderation_subject_status.lastReviewedAt', '>', reportedAfter); } if (reportedBefore) { builder = builder.where('moderation_subject_status.lastReportedAt', '<', reportedBefore); } if (takendown) { builder = builder.where('moderation_subject_status.takendown', '=', true); } if (appealed !== undefined) { builder = appealed === false ? builder.where('moderation_subject_status.appealed', 'is', null) : builder.where('moderation_subject_status.appealed', '=', appealed); } if (!includeMuted) { builder = builder.where((qb) => qb .where('moderation_subject_status.muteUntil', '<', new Date().toISOString()) .orWhere('moderation_subject_status.muteUntil', 'is', null)); } if (onlyMuted) { builder = builder.where((qb) => qb .where('moderation_subject_status.muteUntil', '>', new Date().toISOString()) .orWhere('moderation_subject_status.muteReportingUntil', '>', new Date().toISOString())); } // ["tag1", "tag2 && tag3", "tag4"] => [["tag1"], ["tag2", "tag3"], ["tag4"]] const conditions = parseTags(tags); if (conditions?.length) { // [["tag1"], ["tag2", "tag3"], ["tag4"]] => (tags ? 'tag1') OR (tags ? 'tag2' AND tags ? 'tag3') OR (tags ? 'tag4') builder = builder.where((qb) => { for (const subTags of conditions) { // OR between every conditions items (subTags) qb = qb.orWhere((qb) => { // AND between every subTags items (subTag) for (const subTag of subTags) { qb = qb.where((0, kysely_1.sql) `${ref('moderation_subject_status.tags')} ? ${subTag}`); } return qb; }); } return qb; }); } if (excludeTags?.length) { builder = builder.where((qb) => qb .where((0, kysely_1.sql) `NOT(${ref('moderation_subject_status.tags')} ?| array[${kysely_1.sql.join(excludeTags)}]::TEXT[])`) .orWhere('tags', 'is', null)); } if (minAccountSuspendCount != null && minAccountSuspendCount > 0) { builder = builder.where('account_events_stats.suspendCount', '>=', minAccountSuspendCount); } if (minTakendownRecordsCount != null && minTakendownRecordsCount > 0) { builder = builder.where('account_record_status_stats.takendownCount', '>=', minTakendownRecordsCount); } if (minReportedRecordsCount != null && minReportedRecordsCount > 0) { builder = builder.where('account_record_events_stats.reportedCount', '>=', minReportedRecordsCount); } if (minPriorityScore != null && minPriorityScore >= 0) { builder = builder.where('moderation_subject_status.priorityScore', '>=', minPriorityScore); } if (minStrikeCount != null && minStrikeCount >= 0) { builder = builder.where('account_strike.activeStrikeCount', '>=', minStrikeCount); } if (ageAssuranceState) { builder = builder.where('moderation_subject_status.ageAssuranceState', '=', ageAssuranceState); } const keyset = new pagination_1.StatusKeyset(sortField === 'reportedRecordsCount' ? ref(`account_record_events_stats.reportedCount`) : sortField === 'takendownRecordsCount' ? ref(`account_record_status_stats.takendownCount`) : sortField === 'priorityScore' ? ref(`moderation_subject_status.priorityScore`) : ref(`moderation_subject_status.${sortField}`), ref('moderation_subject_status.id')); const paginatedBuilder = (0, pagination_1.paginate)(builder, { limit, cursor, keyset, direction: sortDirection, tryIndex: true, nullsLast: true, }); const results = await paginatedBuilder.execute(); const infos = await this.views.getAccoutInfosByDid(results.map((r) => r.did)); return { statuses: results.map((r) => ({ ...r, handle: infos.get(r.did)?.handle ?? syntax_1.INVALID_HANDLE, })), cursor: keyset.packFromResult(results), }; } async getStatus(subject) { const result = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', subject.did) .where('recordPath', '=', subject.recordPath ?? '') .selectAll() .executeTakeFirst(); return result ?? null; } // This is used to check if the reporter of an incoming report is muted from reporting // so we want to make sure this look up is as fast as possible async isReportingMutedForSubject(did) { const result = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', did) .where('recordPath', '=', '') .where('muteReportingUntil', '>', new Date().toISOString()) .select((0, kysely_1.sql) `true`.as('status')) .executeTakeFirst(); return !!result; } async formatAndCreateLabels(uri, cid, labels, durationInHours) { const exp = durationInHours !== undefined ? (0, common_1.addHoursToDate)(durationInHours).toISOString() : undefined; const { create = [], negate = [] } = labels; const toCreate = create.map((val) => ({ src: this.cfg.service.did, uri, cid: cid ?? undefined, val, exp, cts: new Date().toISOString(), })); const toNegate = negate.map((val) => ({ src: this.cfg.service.did, uri, cid: cid ?? undefined, val, neg: true, cts: new Date().toISOString(), })); const formatted = [...toCreate, ...toNegate]; return this.createLabels(formatted); } async createLabels(labels) { if (labels.length < 1) return []; const signedLabels = await Promise.all(labels.map((l) => (0, util_3.signLabel)(l, this.signingKey))); const dbVals = signedLabels.map((l) => (0, util_3.formatLabelRow)(l, this.signingKeyId)); const { ref } = this.db.db.dynamic; const excluded = (col) => ref(`excluded.${col}`); const res = await this.db.db .insertInto('label') .values(dbVals) .onConflict((oc) => oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({ id: (0, kysely_1.sql) `${excluded('id')}`, neg: (0, kysely_1.sql) `${excluded('neg')}`, cts: (0, kysely_1.sql) `${excluded('cts')}`, exp: (0, kysely_1.sql) `${excluded('exp')}`, sig: (0, kysely_1.sql) `${excluded('sig')}`, signingKeyId: (0, kysely_1.sql) `${excluded('signingKeyId')}`, })) .returningAll() .execute(); await (0, kysely_1.sql) `notify ${ref(label_1.LabelChannel)}`.execute(this.db.db); return res.map((row) => (0, util_3.formatLabel)(row)); } async sendEmail(opts) { const { subject, content, recipientDid } = opts; const { agent: pdsAgent, url } = await (0, util_3.getPdsAgentForRepo)(this.idResolver, recipientDid, this.cfg.service.devMode); if (!pdsAgent) { throw new xrpc_server_1.InvalidRequestError('Invalid pds service in DID doc'); } const { data: serverInfo } = await pdsAgent.com.atproto.server.describeServer(); if (serverInfo.did !== `did:web:${url.hostname}`) { // @TODO do bidirectional check once implemented. in the meantime, // matching did to hostname we're talking to is pretty good. throw new xrpc_server_1.InvalidRequestError('Invalid pds service in DID doc'); } const { data: delivery } = await pdsAgent.com.atproto.admin.sendEmail({ subject, content, recipientDid, senderDid: this.cfg.service.did, }, { encoding: 'application/json', ...(await this.createAuthHeaders(serverInfo.did, lexicons_1.ids.ComAtprotoAdminSendEmail)), }); if (!delivery.sent) { throw new xrpc_server_1.InvalidRequestError('Email was accepted but not sent'); } } async buildModerationQuery(subjectType, createdByDids, isActionQuery) { if (!createdByDids.length) return []; const actionTypes = [ 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventLabel', ]; const countAll = () => { return (0, kysely_1.sql) `COUNT(*)`; }; const countAllDistinctBy = (ref) => { return (0, kysely_1.sql) `COUNT(DISTINCT ${ref})`; }; const countTakedownsDistinctBy = (ref) => { return (0, kysely_1.sql) `COUNT(DISTINCT ${ref}) FILTER ( WHERE actions."action" = 'tools.ozone.moderation.defs#modEventTakedown' )`; }; const countLabelsDistinctBy = (ref) => { return (0, kysely_1.sql) `COUNT(DISTINCT ${ref}) FILTER ( WHERE actions."action" = 'tools.ozone.moderation.defs#modEventLabel' )`; }; const query = this.db.db .selectFrom('moderation_event as reports') .where('reports.action', '=', 'tools.ozone.moderation.defs#modEventReport') .where('reports.subjectUri', subjectType === 'account' ? 'is' : 'is not', null) .where('reports.createdBy', 'in', createdByDids) .select(['reports.createdBy as did']); if (!isActionQuery) { if (subjectType === 'account') { return query .select([ () => countAll().as('accountReportCount'), (eb) => countAllDistinctBy(eb.ref('reports.subjectDid')).as('reportedAccountCount'), ]) .groupBy('reports.createdBy') .execute(); } else { return query .select([ () => countAll().as('recordReportCount'), (eb) => countAllDistinctBy(eb.ref('reports.subjectUri')).as('reportedRecordCount'), ]) .groupBy('reports.createdBy') .execute(); } } if (subjectType === 'account') { return query .leftJoin('moderation_event as actions', (join) => join .onRef('actions.subjectDid', '=', 'reports.subjectDid') .on('actions.subjectUri', 'is', null) .onRef('actions.createdAt', '>', 'reports.createdAt') .on('actions.action', 'in', actionTypes)) .select([ (eb) => countTakedownsDistinctBy(eb.ref('actions.subjectDid')).as('takendownAccountCount'), (eb) => countLabelsDistinctBy(eb.ref('actions.subjectDid')).as('labeledAccountCount'), ]) .groupBy('reports.createdBy') .execute(); } else { return query .leftJoin('moderation_event as actions', (join) => join .onRef('actions.subjectDid', '=', 'reports.subjectDid') .onRef('actions.subjectUri', '=', 'reports.subjectUri') .onRef('actions.createdAt', '>', 'reports.createdAt') .on('actions.action', 'in', actionTypes)) .select([ (eb) => countTakedownsDistinctBy(eb.ref('actions.subjectUri')).as('takendownRecordCount'), (eb) => countLabelsDistinctBy(eb.ref('actions.subjectUri')).as('labeledRecordCount'), ]) .groupBy('reports.createdBy') .execute(); } } async getReporterStats(dids) { const [accountReports, recordReports, accountActions, recordActions] = await Promise.all([ this.buildModerationQuery('account', dids, false), this.buildModerationQuery('record', dids, false), this.buildModerationQuery('account', dids, true), this.buildModerationQuery('record', dids, true), ]); // Create a map to hold the aggregated stats for each `did` const statsMap = new Map(); // Helper function to ensure a `did` entry exists in the map const ensureDidEntry = (did) => { if (!statsMap.has(did)) { statsMap.set(did, { did, accountReportCount: 0, recordReportCount: 0, reportedAccountCount: 0, reportedRecordCount: 0, takendownAccountCount: 0, takendownRecordCount: 0, labeledAccountCount: 0, labeledRecordCount: 0, }); } return statsMap.get(did); }; // Merge accountReports for (const report of accountReports) { const entry = ensureDidEntry(report.did); entry.accountReportCount = report.accountReportCount ?? 0; entry.reportedAccountCount = report.reportedAccountCount ?? 0; } // Merge recordReports for (const report of recordReports) { const entry = ensureDidEntry(report.did); entry.recordReportCount = report.recordReportCount ?? 0; entry.reportedRecordCount = report.reportedRecordCount ?? 0; } // Merge accountActions for (const action of accountActions) { const entry = ensureDidEntry(action.did); entry.takendownAccountCount = action.takendownAccountCount ?? 0; entry.labeledAccountCount = action.labeledAccountCount ?? 0; } // Merge recordActions