UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

1,608 lines (1,472 loc) 47.1 kB
import { Insertable, RawBuilder, sql } from 'kysely' import { CID } from 'multiformats/cid' import { AtpAgent, ToolsOzoneModerationDefs } from '@atproto/api' import { addHoursToDate, chunkArray } from '@atproto/common' import { Keypair } from '@atproto/crypto' import { IdResolver } from '@atproto/identity' import { AtUri, INVALID_HANDLE } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import { getReviewState } from '../api/util' import { BackgroundQueue } from '../background' import { OzoneConfig } from '../config' import { EventPusher } from '../daemon' import { Database } from '../db' import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination' import { BlobPushEvent } from '../db/schema/blob_push_event' import { LabelChannel } from '../db/schema/label' import { ModerationEvent } from '../db/schema/moderation_event' import { jsonb } from '../db/types' import { ImageInvalidator } from '../image-invalidator' import { ids } from '../lexicon/lexicons' import { RepoBlobRef, RepoRef } from '../lexicon/types/com/atproto/admin/defs' import { Label } from '../lexicon/types/com/atproto/label/defs' import { ReasonType } from '../lexicon/types/com/atproto/moderation/defs' import { Main as StrongRef } from '../lexicon/types/com/atproto/repo/strongRef' import { REVIEWESCALATED, REVIEWOPEN, isAccountEvent, isAgeAssuranceEvent, isAgeAssuranceOverrideEvent, isIdentityEvent, isModEventAcknowledge, isModEventComment, isModEventEmail, isModEventLabel, isModEventMute, isModEventPriorityScore, isModEventReport, isModEventTag, isModEventTakedown, isRecordEvent, } from '../lexicon/types/tools/ozone/moderation/defs' import { QueryParams as QueryStatusParams } from '../lexicon/types/tools/ozone/moderation/queryStatuses' import { httpLogger as log } from '../logger' import { LABELER_HEADER_NAME, ParsedLabelers } from '../util' import { adjustModerationSubjectStatus, getStatusIdentifierFromSubject, moderationSubjectStatusQueryBuilder, } from './status' import { ModSubject, RecordSubject, RepoSubject, subjectFromStatusRow, } from './subject' import { ModEventType, ModerationEventRow, ModerationSubjectStatusRow, ModerationSubjectStatusRowWithHandle, ReporterStats, ReporterStatsResult, ReversibleModerationEvent, } from './types' import { dateFromDbDatetime, formatLabel, formatLabelRow, getPdsAgentForRepo, signLabel, } from './util' import { AuthHeaders, ModerationViews } from './views' export type ModerationServiceCreator = (db: Database) => ModerationService export class ModerationService { constructor( public db: Database, public signingKey: Keypair, public signingKeyId: number, public cfg: OzoneConfig, public backgroundQueue: BackgroundQueue, public idResolver: IdResolver, public eventPusher: EventPusher, public appviewAgent: AtpAgent, private createAuthHeaders: ( aud: string, method: string, ) => Promise<AuthHeaders>, public imgInvalidator?: ImageInvalidator, ) {} static creator( signingKey: Keypair, signingKeyId: number, cfg: OzoneConfig, backgroundQueue: BackgroundQueue, idResolver: IdResolver, eventPusher: EventPusher, appviewAgent: AtpAgent, createAuthHeaders: (aud: string, method: string) => Promise<AuthHeaders>, imgInvalidator?: ImageInvalidator, ) { return (db: Database) => new ModerationService( db, signingKey, signingKeyId, cfg, backgroundQueue, idResolver, eventPusher, appviewAgent, createAuthHeaders, imgInvalidator, ) } views = new ModerationViews( this.db, this.signingKey, this.signingKeyId, this.appviewAgent, async (method: string, labelers?: ParsedLabelers) => { const authHeaders = await this.createAuthHeaders( this.cfg.appview.did, method, ) if (labelers?.dids?.length) { authHeaders.headers[LABELER_HEADER_NAME] = labelers.dids.join(', ') } return authHeaders }, this.idResolver, this.cfg.service.devMode, ) async getEvent(id: number): Promise<ModerationEventRow | undefined> { return await this.db.db .selectFrom('moderation_event') .selectAll() .where('id', '=', id) .executeTakeFirst() } async getEventOrThrow(id: number): Promise<ModerationEventRow> { const event = await this.getEvent(id) if (!event) throw new InvalidRequestError('Moderation event not found') return event } async getEventByExternalId( eventType: ModerationEvent['action'], externalId: string, subject: ModSubject, ): Promise<boolean> { const result = await this.db.db .selectFrom('moderation_event') .where('action', '=', eventType) .where('externalId', '=', externalId) .where('subjectDid', '=', subject.did) .select(sql`1`.as('exists')) .limit(1) .executeTakeFirst() return !!result } async getEvents(opts: { subject?: string createdBy?: string limit: number cursor?: string includeAllUserRecords: boolean types: ModerationEvent['action'][] sortDirection?: 'asc' | 'desc' hasComment?: boolean comment?: string createdAfter?: string createdBefore?: string addedLabels: string[] removedLabels: string[] addedTags: string[] removedTags: string[] reportTypes?: string[] collections: string[] subjectType?: string policies?: string[] modTool?: string[] ageAssuranceState?: string batchId?: string }): Promise<{ cursor?: string; events: ModerationEventRow[] }> { const { subject, createdBy, limit, cursor, includeAllUserRecords, sortDirection = 'desc', types, hasComment, comment, createdAfter, createdBefore, addedLabels, removedLabels, addedTags, removedTags, reportTypes, collections, subjectType, policies, modTool, ageAssuranceState, batchId, } = 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 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(sql`${ref('addedTags')} @> ${jsonb(addedTags)}`) } if (removedTags.length) { builder = builder.where( sql`${ref('removedTags')} @> ${jsonb(removedTags)}`, ) } if (reportTypes?.length) { builder = builder.where(sql`meta->>'reportType'`, 'in', reportTypes) } if (policies?.length) { builder = builder.where((qb) => { policies.forEach((policy) => { qb = qb.orWhere(sql`meta->>'policies'`, 'ilike', `%${policy}%`) }) return qb }) } if (modTool?.length) { builder = builder .where('modTool', 'is not', null) .where(sql`("modTool" ->> 'name')`, 'in', modTool) } if (batchId) { builder = builder .where('modTool', 'is not', null) .where(sql`("modTool" -> 'meta' ->> 'batchId')`, '=', batchId) } if (ageAssuranceState) { builder = builder .where('action', 'in', [ 'tools.ozone.moderation.defs#ageAssuranceEvent', 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent', ]) .where(sql`meta->>'status'`, '=', ageAssuranceState) } const keyset = new TimeIdKeyset( ref(`moderation_event.createdAt`), ref('moderation_event.id'), ) const paginatedBuilder = 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: number): Promise<ModerationEventRow | undefined> { return await this.db.db .selectFrom('moderation_event') .where('action', '=', 'tools.ozone.moderation.defs#modEventReport') .selectAll() .where('id', '=', id) .executeTakeFirst() } async getCurrentStatus( subject: { did: string } | { uri: AtUri } | { cids: CID[] }, ) { 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: string, createdBy: string, accountEvent: ModerationEventRow, ) { const subjectsToBeResolved = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', did) .where('recordPath', '!=', '') .where('reviewState', 'in', [REVIEWESCALATED, 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 chunkArray(subjectsToBeResolved, 100)) { await Promise.all( subjects.map(async (subject) => { const eventData = { createdBy, subject: 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: { event: ModEventType subject: ModSubject createdBy: string createdAt?: Date modTool?: ToolsOzoneModerationDefs.ModTool externalId?: string }): Promise<{ event: ModerationEventRow subjectStatus: ModerationSubjectStatusRow | null }> { this.db.assertTransaction() const { event, subject, createdBy, externalId, createdAt = new Date(), modTool, } = info const createLabelVals = isModEventLabel(event) && event.createLabelVals.length > 0 ? event.createLabelVals.join(' ') : undefined const negateLabelVals = isModEventLabel(event) && event.negateLabelVals.length > 0 ? event.negateLabelVals.join(' ') : undefined const meta: Record<string, string | number | boolean> = {} const addedTags = isModEventTag(event) ? jsonb(event.add) : null const removedTags = isModEventTag(event) ? jsonb(event.remove) : null if (isModEventReport(event)) { meta.reportType = event.reportType } if (isModEventComment(event) && event.sticky) { meta.sticky = event.sticky } if (isModEventEmail(event)) { meta.subjectLine = event.subjectLine if (event.content) { meta.content = event.content } } if (isAccountEvent(event)) { meta.active = event.active meta.timestamp = event.timestamp if (event.status) meta.status = event.status } if (isModEventPriorityScore(event)) { meta.priorityScore = event.score } if (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 (isRecordEvent(event)) { meta.timestamp = event.timestamp meta.op = event.op if (event.cid) meta.cid = event.cid } if (isAgeAssuranceEvent(event)) { meta.status = event.status meta.createdAt = event.createdAt if (event.attemptId) { meta.attemptId = event.attemptId } 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 (isAgeAssuranceOverrideEvent(event)) { meta.status = event.status } if ( (isModEventTakedown(event) || isModEventAcknowledge(event)) && event.acknowledgeAccountSubjects ) { meta.acknowledgeAccountSubjects = true } if (isModEventTakedown(event) && event.policies?.length) { meta.policies = event.policies.join(',') } // Keep trace of reports that came in while the reporter was in muted stated if (isModEventReport(event)) { const isReportingMuted = await this.isReportingMutedForSubject(createdBy) if (isReportingMuted) { meta.isReporterMuted = true } } const subjectInfo = subject.info() const modEvent = await this.db.db .insertInto('moderation_event') .values({ comment: ('comment' in event && typeof event.comment === 'string' && event.comment) || null, action: event.$type as ModerationEvent['action'], 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: (isModEventTakedown(event) || isModEventMute(event)) && event.durationInHours ? addHoursToDate(event.durationInHours, createdAt).toISOString() : undefined, subjectType: subjectInfo.subjectType, subjectDid: subjectInfo.subjectDid, subjectUri: subjectInfo.subjectUri, subjectCid: subjectInfo.subjectCid, subjectBlobCids: jsonb(subjectInfo.subjectBlobCids), subjectMessageId: subjectInfo.subjectMessageId, modTool: modTool ? jsonb(modTool) : null, externalId: externalId ?? null, }) .returningAll() .executeTakeFirstOrThrow() const subjectStatus = await adjustModerationSubjectStatus( this.db, modEvent, subject.blobCids, ) return { event: modEvent, subjectStatus } } async getLastReversibleEventForSubject(subject: ReversalSubject) { // 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(): Promise<ReversalSubject[]> { 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: subjectFromStatusRow(row), reverseSuspend: !!row.suspendUntil && row.suspendUntil < now, reverseMute: !!row.muteUntil && row.muteUntil < now, })) } async isSubjectSuspended(did: string): Promise<boolean> { 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, }: ReversibleModerationEvent): Promise<ModerationEventRow> { 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: RepoSubject, takedownId: number, isSuspend = false, ) { const takedownRef = `BSKY-${ isSuspend ? 'SUSPEND' : 'TAKEDOWN' }-${takedownId}` const values = this.eventPusher.takedowns.map((eventType) => ({ eventType, subjectDid: subject.did, takedownRef, })) 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() const takedownLabel = isSuspend ? SUSPEND_LABEL : TAKEDOWN_LABEL await this.formatAndCreateLabels(subject.did, null, { create: [takedownLabel], }) this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.all( repoEvts.map((evt) => this.eventPusher.attemptRepoEvent(evt.id)), ) }) }) } async reverseTakedownRepo(subject: RepoSubject) { 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', [TAKEDOWN_LABEL, 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: RecordSubject, takedownId: number) { this.db.assertTransaction() await this.formatAndCreateLabels(subject.uri, subject.cid, { create: [TAKEDOWN_LABEL], }) const takedownRef = `BSKY-TAKEDOWN-${takedownId}` const blobCids = subject.blobCids if (blobCids && blobCids.length > 0) { const blobValues: Insertable<BlobPushEvent>[] = [] for (const eventType of this.eventPusher.takedowns) { 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) => log.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) => log.error( { err, paths, cid }, 'failed to invalidate blob on cdn', ), ) }), ) } }) }) } } async reverseTakedownRecord(subject: RecordSubject) { this.db.assertTransaction() await this.formatAndCreateLabels(subject.uri, subject.cid, { negate: [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: { reasonType: ReasonType reason?: string subject: ModSubject reportedBy: string createdAt?: Date modTool?: { name: string meta?: { [_ in string]: unknown } } }): Promise<{ event: ModerationEventRow subjectStatus: ModerationSubjectStatusRow | null }> { 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, ageAssuranceState, }: QueryStatusParams): Promise<{ statuses: ModerationSubjectStatusRowWithHandle[] cursor?: string }> { let builder = moderationSubjectStatusQueryBuilder(this.db.db) const { ref } = this.db.db.dynamic if (subject) { const subjectInfo = 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 ? sql`ABS(HASHTEXT(${queueSeed} || moderation_subject_status.did)) % ${queueCount}` : 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 = 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( sql`${ref('moderation_subject_status.tags')} ? ${subTag}`, ) } return qb }) } return qb }) } if (excludeTags?.length) { builder = builder.where((qb) => qb .where( sql`NOT(${ref('moderation_subject_status.tags')} ?| array[${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 (ageAssuranceState) { builder = builder.where( 'moderation_subject_status.ageAssuranceState', '=', ageAssuranceState, ) } const keyset = new 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 = 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 ?? INVALID_HANDLE, })), cursor: keyset.packFromResult(results), } } async getStatus( subject: ModSubject, ): Promise<ModerationSubjectStatusRow | null> { 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: string) { const result = await this.db.db .selectFrom('moderation_subject_status') .where('did', '=', did) .where('recordPath', '=', '') .where('muteReportingUntil', '>', new Date().toISOString()) .select(sql`true`.as('status')) .executeTakeFirst() return !!result } async formatAndCreateLabels( uri: string, cid: string | null, labels: { create?: string[]; negate?: string[] }, durationInHours?: number, ): Promise<Label[]> { const exp = durationInHours !== undefined ? 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: Label[]): Promise<Label[]> { if (labels.length < 1) return [] const signedLabels = await Promise.all( labels.map((l) => signLabel(l, this.signingKey)), ) const dbVals = signedLabels.map((l) => formatLabelRow(l, this.signingKeyId)) const { ref } = this.db.db.dynamic await sql`notify ${ref(LabelChannel)}`.execute(this.db.db) const excluded = (col: string) => ref(`excluded.${col}`) const res = await this.db.db .insertInto('label') .values(dbVals) .onConflict((oc) => oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({ id: sql`${excluded('id')}`, neg: sql`${excluded('neg')}`, cts: sql`${excluded('cts')}`, exp: sql`${excluded('exp')}`, sig: sql`${excluded('sig')}`, signingKeyId: sql`${excluded('signingKeyId')}`, }), ) .returningAll() .execute() return res.map((row) => formatLabel(row)) } async sendEmail(opts: { content: string recipientDid: string subject: string }) { const { subject, content, recipientDid } = opts const { agent: pdsAgent, url } = await getPdsAgentForRepo( this.idResolver, recipientDid, this.cfg.service.devMode, ) if (!pdsAgent) { throw new 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 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, ids.ComAtprotoAdminSendEmail, )), }, ) if (!delivery.sent) { throw new InvalidRequestError('Email was accepted but not sent') } } async buildModerationQuery( subjectType: 'account' | 'record', createdByDids: string[], isActionQuery: boolean, ): Promise<(Partial<ReporterStatsResult> & { did: string })[]> { if (!createdByDids.length) return [] const actionTypes = [ 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventLabel', ] as const const countAll = () => { return sql<number>`COUNT(*)` } const countAllDistinctBy = (ref: RawBuilder) => { return sql<number>`COUNT(DISTINCT ${ref})` } const countTakedownsDistinctBy = (ref: RawBuilder) => { return sql<number>`COUNT(DISTINCT ${ref}) FILTER ( WHERE actions."action" = 'tools.ozone.moderation.defs#modEventTakedown' )` } const countLabelsDistinctBy = (ref: RawBuilder) => { return sql<number>`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: string[]) { 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<string, ReporterStats>() // Helper function to ensure a `did` entry exists in the map const ensureDidEntry = (did: string) => { 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 for (const action of recordActions) { const entry = ensureDidEntry(action.did) entry.takendownRecordCount = action.takendownRecordCount ?? 0 entry.labeledRecordCount = action.labeledRecordCount ?? 0 } // Convert map values to an array and return return Array.from(statsMap.values()) } async getAccountTimeline(did: string) { const { ref } = this.db.db.dynamic // Without the subquery approach, pg tries to do the sort operation first which can be super expensive when a subjectDid has too many entries const result = await this.db.db .selectFrom( this.db.db .selectFrom('moderation_event') .where('subjectDid', '=', did) .select([ dateFromDbDatetime(ref('createdAt')).as('day'), 'subjectUri', 'action', sql<number>`count(*)`.as('count'), ]) .groupBy(['day', 'subjectUri', 'action']) .as('results'), ) .selectAll() .orderBy('day', 'desc') .execute() return result } } const parseTags = (tags?: string[]) => tags ?.map((tag) => tag .split(/\s*&&\s*/g) .map((subTag) => subTag.trim()) // Ignore invalid syntax ("", "tag1 &&", "&& tag2", "tag1 && && tag2", etc.) .filter(Boolean), ) // Ignore invalid items .filter((subTags): subTags is [string, ...string[]] => subTags.length > 0) const TAKEDOWNS = ['pds_takedown' as const, 'appview_takedown' as const] export const TAKEDOWN_LABEL = '!takedown' export const SUSPEND_LABEL = '!suspend' export type TakedownSubjects = { did: string subjects: (RepoRef | RepoBlobRef | StrongRef)[] } export type ReversalSubject = { subject: ModSubject reverseSuspend: boolean reverseMute: boolean }