@atproto/ozone
Version:
Backend service for moderating the Bluesky network.
1,113 lines (1,112 loc) • 51.7 kB
JavaScript
"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