diff --git a/packages/ozone/src/api/com/atproto/admin/emitModerationEvent.ts b/packages/ozone/src/api/com/atproto/admin/emitModerationEvent.ts index cb149d1e5ba..74c6488f03b 100644 --- a/packages/ozone/src/api/com/atproto/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/com/atproto/admin/emitModerationEvent.ts @@ -1,20 +1,12 @@ -import { CID } from 'multiformats/cid' -import { AtUri } from '@atproto/syntax' -import { - AuthRequiredError, - InvalidRequestError, - UpstreamFailureError, -} from '@atproto/xrpc-server' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { getSubject } from '../moderation/util' import { isModEventLabel, isModEventReverseTakedown, isModEventTakedown, } from '../../../../lexicon/types/com/atproto/admin/defs' -import { TakedownSubjects } from '../../../../services/moderation' -import { retryHttp } from '../../../../util/retry' +import { subjectFromInput } from '../../../../services/moderation/subject' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ @@ -23,15 +15,19 @@ export default function (server: Server, ctx: AppContext) { const access = auth.credentials const db = ctx.db const moderationService = ctx.services.moderation(db) - const { subject, createdBy, subjectBlobCids, event } = input.body + const { createdBy, event } = input.body const isTakedownEvent = isModEventTakedown(event) const isReverseTakedownEvent = isModEventReverseTakedown(event) const isLabelEvent = isModEventLabel(event) + const subject = subjectFromInput( + input.body.subject, + input.body.subjectBlobCids, + ) // apply access rules // if less than moderator access then can not takedown an account - if (!access.moderator && isTakedownEvent && 'did' in subject) { + if (!access.moderator && isTakedownEvent && subject.isRepo()) { throw new AuthRequiredError( 'Must be a full moderator to perform an account takedown', ) @@ -54,11 +50,9 @@ export default function (server: Server, ctx: AppContext) { ]) } - const subjectInfo = getSubject(subject) - if (isTakedownEvent || isReverseTakedownEvent) { const isSubjectTakendown = await moderationService.isSubjectTakendown( - subjectInfo, + subject, ) if (isSubjectTakendown && isTakedownEvent) { @@ -70,145 +64,51 @@ export default function (server: Server, ctx: AppContext) { } } - const { result: moderationEvent, takenDown } = await db.transaction( - async (dbTxn) => { - const moderationTxn = ctx.services.moderation(dbTxn) - - const result = await moderationTxn.logEvent({ - event, - subject: subjectInfo, - subjectBlobCids: - subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], - createdBy, - }) - - let takenDown: TakedownSubjects | undefined - - if ( - result.subjectType === 'com.atproto.admin.defs#repoRef' && - result.subjectDid - ) { - // No credentials to revoke on appview - if (isTakedownEvent) { - takenDown = await moderationTxn.takedownRepo({ - takedownId: result.id, - did: result.subjectDid, - }) - } - - if (isReverseTakedownEvent) { - await moderationTxn.reverseTakedownRepo({ - did: result.subjectDid, - }) - takenDown = { - subjects: [ - { - $type: 'com.atproto.admin.defs#repoRef', - did: result.subjectDid, - }, - ], - did: result.subjectDid, - } - } + const moderationEvent = await db.transaction(async (dbTxn) => { + const moderationTxn = ctx.services.moderation(dbTxn) + + const result = await moderationTxn.logEvent({ + event, + subject, + createdBy, + }) + + if (subject.isRepo()) { + if (isTakedownEvent) { + await moderationTxn.takedownRepo(subject, result.id) + } else if (isReverseTakedownEvent) { + await moderationTxn.reverseTakedownRepo(subject) } + } - if ( - result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri - ) { - const subjectUri = new AtUri(result.subjectUri) - const blobCids = subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [] - if (isTakedownEvent) { - await moderationTxn.takedownRecord({ - takedownId: result.id, - uri: subjectUri, - // TODO: I think this will always be available for strongRefs? - cid: CID.parse(result.subjectCid as string), - }) - if (blobCids && blobCids.length > 0) { - await moderationTxn.takedownBlobs({ - takedownId: result.id, - did: subjectUri.hostname, - blobCids, - }) - } - } - - if (isReverseTakedownEvent) { - await moderationTxn.reverseTakedownRecord({ - uri: new AtUri(result.subjectUri), - }) - await moderationTxn.reverseTakedownBlobs({ - did: subjectUri.hostname, - blobCids, - }) - // takenDown = { - // did: result.subjectDid, - // subjects: [ - // { - // $type: 'com.atproto.repo.strongRef', - // uri: result.subjectUri, - // cid: result.subjectCid ?? '', - // }, - // ...blobCids.map((cid) => ({ - // $type: 'com.atproto.admin.defs#repoBlobRef', - // did: result.subjectDid, - // cid: cid.toString(), - // recordUri: result.subjectUri, - // })), - // ], - // } - } + if (subject.isRecord()) { + if (isTakedownEvent) { + await moderationTxn.takedownRecord(subject, result.id) } - if (isLabelEvent) { - await moderationTxn.formatAndCreateLabels( - ctx.cfg.labelerDid, - result.subjectUri ?? result.subjectDid, - result.subjectCid, - { - create: result.createLabelVals?.length - ? result.createLabelVals.split(' ') - : undefined, - negate: result.negateLabelVals?.length - ? result.negateLabelVals.split(' ') - : undefined, - }, - ) + if (isReverseTakedownEvent) { + await moderationTxn.reverseTakedownRecord(subject) } + } - return { result, takenDown } - }, - ) + if (isLabelEvent) { + await moderationTxn.formatAndCreateLabels( + ctx.cfg.labelerDid, + result.subjectUri ?? result.subjectDid, + result.subjectCid, + { + create: result.createLabelVals?.length + ? result.createLabelVals.split(' ') + : undefined, + negate: result.negateLabelVals?.length + ? result.negateLabelVals.split(' ') + : undefined, + }, + ) + } - // @TODO move to commit hook on takedown method - // if (takenDown && ctx.moderationPushAgent) { - // const { did, subjects } = takenDown - // if (did && subjects.length > 0) { - // const agent = ctx.moderationPushAgent - // const results = await Promise.allSettled( - // subjects.map((subject) => - // retryHttp(() => - // agent.api.com.atproto.admin.updateSubjectStatus({ - // subject, - // takedown: isTakedownEvent - // ? { - // applied: true, - // ref: moderationEvent.id.toString(), - // } - // : { - // applied: false, - // }, - // }), - // ), - // ), - // ) - // const hadFailure = results.some((r) => r.status === 'rejected') - // if (hadFailure) { - // throw new UpstreamFailureError('failed to apply action on PDS') - // } - // } - // } + return result + }) return { encoding: 'application/json', diff --git a/packages/ozone/src/api/com/atproto/moderation/createReport.ts b/packages/ozone/src/api/com/atproto/moderation/createReport.ts index 7c22cd40367..2cd6e30a7ce 100644 --- a/packages/ozone/src/api/com/atproto/moderation/createReport.ts +++ b/packages/ozone/src/api/com/atproto/moderation/createReport.ts @@ -1,16 +1,18 @@ import { AuthRequiredError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { getReasonType, getSubject } from './util' +import { getReasonType } from './util' import { softDeleted } from '../../../../db/util' +import { subjectFromInput } from '../../../../services/moderation/subject' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ // @TODO anonymous reports w/ optional auth are a temporary measure auth: ctx.authOptionalVerifier, handler: async ({ input, auth }) => { - const { reasonType, reason, subject } = input.body const requester = auth.credentials.did + const { reasonType, reason } = input.body + const subject = subjectFromInput(input.body.subject) const db = ctx.db @@ -28,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { return moderationTxn.report({ reasonType: getReasonType(reasonType), reason, - subject: getSubject(subject), + subject, reportedBy: requester || ctx.cfg.serverDid, }) }) diff --git a/packages/ozone/src/api/com/atproto/moderation/util.ts b/packages/ozone/src/api/com/atproto/moderation/util.ts index d757b359787..cf1392a5611 100644 --- a/packages/ozone/src/api/com/atproto/moderation/util.ts +++ b/packages/ozone/src/api/com/atproto/moderation/util.ts @@ -1,8 +1,5 @@ -import { CID } from 'multiformats/cid' import { InvalidRequestError } from '@atproto/xrpc-server' -import { AtUri } from '@atproto/syntax' import { InputSchema as ReportInput } from '../../../../lexicon/types/com/atproto/moderation/createReport' -import { InputSchema as ActionInput } from '../../../../lexicon/types/com/atproto/admin/emitModerationEvent' import { REASONOTHER, REASONSPAM, @@ -19,29 +16,6 @@ import { import { ModerationEvent } from '../../../../db/schema/moderation_event' import { ModerationSubjectStatusRow } from '../../../../services/moderation/types' -type SubjectInput = ReportInput['subject'] | ActionInput['subject'] - -export const getSubject = (subject: SubjectInput) => { - if ( - subject.$type === 'com.atproto.admin.defs#repoRef' && - typeof subject.did === 'string' - ) { - return { did: subject.did } - } - if ( - subject.$type === 'com.atproto.repo.strongRef' && - typeof subject.uri === 'string' && - typeof subject.cid === 'string' - ) { - const uri = new AtUri(subject.uri) - return { - uri, - cid: CID.parse(subject.cid), - } - } - throw new InvalidRequestError('Invalid subject') -} - export const getReasonType = (reasonType: ReportInput['reasonType']) => { if (reasonTypes.has(reasonType)) { return reasonType as NonNullable['reportType'] diff --git a/packages/ozone/src/db/periodic-moderation-event-reversal.ts b/packages/ozone/src/db/periodic-moderation-event-reversal.ts index 1402d49a039..fbea0eb0452 100644 --- a/packages/ozone/src/db/periodic-moderation-event-reversal.ts +++ b/packages/ozone/src/db/periodic-moderation-event-reversal.ts @@ -2,11 +2,9 @@ import { wait } from '@atproto/common' import { Leader } from './leader' import { dbLogger } from '../logger' import AppContext from '../context' -import { AtUri } from '@atproto/api' -import { ModerationSubjectStatusRow } from '../services/moderation/types' -import { CID } from 'multiformats/cid' import AtpAgent from '@atproto/api' import { retryHttp } from '../util/retry' +import { ReversalSubject } from '../services/moderation' export const MODERATION_ACTION_REVERSAL_ID = 1011 @@ -19,27 +17,18 @@ export class PeriodicModerationEventReversal { this.pushAgent = appContext.moderationPushAgent } - async revertState(eventRow: ModerationSubjectStatusRow) { + async revertState(subject: ReversalSubject) { await this.appContext.db.transaction(async (dbTxn) => { const moderationTxn = this.appContext.services.moderation(dbTxn) const originalEvent = - await moderationTxn.getLastReversibleEventForSubject(eventRow) + await moderationTxn.getLastReversibleEventForSubject(subject) if (originalEvent) { const { restored } = await moderationTxn.revertState({ action: originalEvent.action, createdBy: originalEvent.createdBy, comment: '[SCHEDULED_REVERSAL] Reverting action as originally scheduled', - subject: - eventRow.recordPath && eventRow.recordCid - ? { - uri: AtUri.make( - eventRow.did, - ...eventRow.recordPath.split('/'), - ), - cid: CID.parse(eventRow.recordCid), - } - : { did: eventRow.did }, + subject: subject.subject, createdAt: new Date(), }) diff --git a/packages/ozone/src/services/moderation/index.ts b/packages/ozone/src/services/moderation/index.ts index 4cc5b802213..2e1b331b5e5 100644 --- a/packages/ozone/src/services/moderation/index.ts +++ b/packages/ozone/src/services/moderation/index.ts @@ -24,7 +24,6 @@ import { ModerationEventRow, ModerationSubjectStatusRow, ReversibleModerationEvent, - SubjectInfo, } from './types' import { ModerationEvent } from '../../db/schema/moderation_event' import { paginate } from '../../db/pagination' @@ -33,6 +32,7 @@ import AtpAgent from '@atproto/api' import { Label } from '../../lexicon/types/com/atproto/label/defs' import { sql } from 'kysely' import { dedupeStrs } from '@atproto/common' +import { ModSubject, RecordSubject, RepoSubject } from './subject' export class ModerationService { constructor(public db: Database, public appviewAgent: AtpAgent) {} @@ -171,50 +171,14 @@ export class ModerationService { return await builder.execute() } - buildSubjectInfo( - subject: { did: string } | { uri: AtUri; cid: CID }, - subjectBlobCids?: CID[], - ): SubjectInfo { - if ('did' in subject) { - if (subjectBlobCids?.length) { - throw new InvalidRequestError('Blobs do not apply to repo subjects') - } - // Allowing dids that may not exist: may have been deleted but needs to remain actionable. - return { - subjectType: 'com.atproto.admin.defs#repoRef', - subjectDid: subject.did, - subjectUri: null, - subjectCid: null, - } - } - - // Allowing records/blobs that may not exist: may have been deleted but needs to remain actionable. - return { - subjectType: 'com.atproto.repo.strongRef', - subjectDid: subject.uri.host, - subjectUri: subject.uri.toString(), - subjectCid: subject.cid.toString(), - } - } - async logEvent(info: { event: ModEventType - subject: { did: string } | { uri: AtUri; cid: CID } - subjectBlobCids?: CID[] + subject: ModSubject createdBy: string createdAt?: Date }): Promise { this.db.assertTransaction() - const { - event, - createdBy, - subject, - subjectBlobCids, - createdAt = new Date(), - } = info - - // Resolve subject info - const subjectInfo = this.buildSubjectInfo(subject, subjectBlobCids) + const { event, subject, createdBy, createdAt = new Date() } = info const createLabelVals = isModEventLabel(event) && event.createLabelVals.length > 0 @@ -257,47 +221,43 @@ export class ModerationService { event.durationInHours ? addHoursToDate(event.durationInHours, createdAt).toISOString() : undefined, - ...subjectInfo, + ...subject.info(), }) .returningAll() .executeTakeFirstOrThrow() - await adjustModerationSubjectStatus(this.db, modEvent, subjectBlobCids) + await adjustModerationSubjectStatus(this.db, modEvent, subject.blobCids) return modEvent } - async getLastReversibleEventForSubject({ - did, - muteUntil, - recordPath, - suspendUntil, - }: ModerationSubjectStatusRow) { - const isSuspended = suspendUntil && new Date(suspendUntil) < new Date() - const isMuted = muteUntil && new Date(muteUntil) < new Date() - + 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 (!isSuspended && !isMuted) { + if (!subject.reverseMute && !subject.reverseSuspend) { return null } let builder = this.db.db .selectFrom('moderation_event') - .where('subjectDid', '=', did) + .where('subjectDid', '=', subject.subject.did) - if (recordPath) { - builder = builder.where('subjectUri', 'like', `%${recordPath}%`) + if (subject.subject.recordPath) { + builder = builder.where( + 'subjectUri', + 'like', + `%${subject.subject.recordPath}%`, + ) } // Means the subject was suspended and needs to be unsuspended - if (isSuspended) { + if (subject.reverseSuspend) { builder = builder .where('action', '=', 'com.atproto.admin.defs#modEventTakedown') .where('durationInHours', 'is not', null) } - if (isMuted) { + if (subject.reverseMute) { builder = builder .where('action', '=', 'com.atproto.admin.defs#modEventMute') .where('durationInHours', 'is not', null) @@ -310,15 +270,33 @@ export class ModerationService { .executeTakeFirst() } - async getSubjectsDueForReversal(): Promise { - const subjectsDueForReversal = await this.db.db + async getSubjectsDueForReversal(): Promise { + const now = new Date().toISOString() + const subjects = await this.db.db .selectFrom('moderation_subject_status') - .where('suspendUntil', '<', new Date().toISOString()) - .orWhere('muteUntil', '<', new Date().toISOString()) + .where('suspendUntil', '<', now) + .orWhere('muteUntil', '<', now) .selectAll() .execute() - return subjectsDueForReversal + return subjects.map((row) => { + let subject: ModSubject + if (row.recordPath && row.recordCid) { + const uri = AtUri.make(row.did, ...row.recordPath.split('/')).toString() + subject = new RecordSubject( + uri, + row.recordCid, + row.blobCids ?? undefined, + ) + } else { + subject = new RepoSubject(row.did) + } + return { + subject, + reverseSuspend: !!row.suspendUntil && row.suspendUntil < now, + reverseMute: !!row.muteUntil && row.muteUntil < now, + } + }) } async isSubjectSuspended(did: string): Promise { @@ -364,13 +342,8 @@ export class ModerationService { return { result, restored } } - if ( - result.subjectType === 'com.atproto.admin.defs#repoRef' && - result.subjectDid - ) { - await this.reverseTakedownRepo({ - did: result.subjectDid, - }) + if (subject.isRepo()) { + await this.reverseTakedownRepo(subject) restored = { did: result.subjectDid, subjects: [ @@ -382,20 +355,14 @@ export class ModerationService { } } - if ( - result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri - ) { - const uri = new AtUri(result.subjectUri) - await this.reverseTakedownRecord({ - uri, - }) - const did = uri.hostname + if (subject.isRecord()) { + await this.reverseTakedownRecord(subject) // TODO: MOD_EVENT This bit needs testing + const did = subject.did const subjectStatus = await this.db.db .selectFrom('moderation_subject_status') - .where('did', '=', uri.host) - .where('recordPath', '=', `${uri.collection}/${uri.rkey}`) + .where('did', '=', did) + .where('recordPath', '=', subject.recordPath) .select('blobCids') .executeTakeFirst() const blobCids = subjectStatus?.blobCids || [] @@ -404,8 +371,8 @@ export class ModerationService { subjects: [ { $type: 'com.atproto.repo.strongRef', - uri: result.subjectUri, - cid: result.subjectCid ?? '', + uri: subject.uri, + cid: subject.cid, }, ...blobCids.map((cid) => ({ $type: 'com.atproto.admin.defs#repoBlobRef', @@ -420,16 +387,12 @@ export class ModerationService { return { result, restored } } - async takedownRepo(info: { - takedownId: number - did: string - }): Promise { - const { takedownId, did } = info + async takedownRepo(subject: RepoSubject, takedownId: number) { await this.db.db .insertInto('repo_push_event') .values({ eventType: 'takedown', - subjectDid: did, + subjectDid: subject.did, takedownId, }) .onConflict((oc) => @@ -438,42 +401,26 @@ export class ModerationService { .doUpdateSet({ confirmedAt: null, takedownId }), ) .execute() - - return { - did, - subjects: [ - { - $type: 'com.atproto.admin.defs#repoRef', - did, - }, - ], - } } - async reverseTakedownRepo(info: { did: string }) { + async reverseTakedownRepo(subject: RepoSubject) { await this.db.db .updateTable('repo_push_event') .where('eventType', '=', 'takedown') - .where('subjectDid', '=', info.did) + .where('subjectDid', '=', subject.did) .set({ takedownId: null, confirmedAt: null }) .execute() } - async takedownRecord(info: { - takedownId: number - uri: AtUri - cid: CID - }): Promise { - const { takedownId, uri, cid } = info - const did = uri.hostname + async takedownRecord(subject: RecordSubject, takedownId: number) { this.db.assertTransaction() await this.db.db .insertInto('record_push_event') .values({ eventType: 'takedown', - subjectDid: uri.hostname, - subjectUri: uri.toString(), - subjectCid: cid.toString(), + subjectDid: subject.did, + subjectUri: subject.uri, + subjectCid: subject.cid, takedownId, }) .onConflict((oc) => @@ -482,44 +429,15 @@ export class ModerationService { .doUpdateSet({ confirmedAt: null, takedownId }), ) .execute() - return { - did, - subjects: [ - { - $type: 'com.atproto.repo.strongRef', - uri: uri.toString(), - cid: cid.toString(), - }, - ], - } - } - async reverseTakedownRecord(info: { uri: AtUri }) { - this.db.assertTransaction() - await this.db.db - .updateTable('record_push_event') - .where('eventType', '=', 'takedown') - .where('subjectDid', '=', info.uri.hostname) - .where('subjectUri', '=', info.uri.toString()) - .set({ takedownId: null, confirmedAt: null }) - .execute() - } - - async takedownBlobs(info: { - takedownId: number - did: string - blobCids: CID[] - }): Promise { - const { takedownId, did, blobCids } = info - this.db.assertTransaction() - - if (blobCids.length > 0) { + const blobCids = subject.blobCids + if (blobCids && blobCids.length > 0) { await this.db.db .insertInto('blob_push_event') .values( blobCids.map((cid) => ({ eventType: 'takedown' as const, - subjectDid: did, + subjectDid: subject.did, subjectBlobCid: cid.toString(), takedownId, })), @@ -531,37 +449,37 @@ export class ModerationService { ) .execute() } - return { - did, - subjects: blobCids.map((cid) => ({ - $type: 'com.atproto.admin.defs#repoBlobRef', - did, - cid: cid.toString(), - })), - } } - async reverseTakedownBlobs(info: { did: string; blobCids: CID[] }) { + async reverseTakedownRecord(subject: RecordSubject) { this.db.assertTransaction() - const { did, blobCids } = info - if (blobCids.length < 1) return await this.db.db - .updateTable('blob_push_event') + .updateTable('record_push_event') .where('eventType', '=', 'takedown') - .where('subjectDid', '=', did) - .where( - 'subjectBlobCid', - 'in', - blobCids.map((c) => c.toString()), - ) + .where('subjectDid', '=', subject.did) + .where('subjectUri', '=', subject.uri) .set({ takedownId: null, confirmedAt: null }) .execute() + const blobCids = subject.blobCids + if (blobCids && blobCids.length > 0) { + await this.db.db + .updateTable('blob_push_event') + .where('eventType', '=', 'takedown') + .where('subjectDid', '=', subject.did) + .where( + 'subjectBlobCid', + 'in', + blobCids.map((c) => c.toString()), + ) + .set({ takedownId: null, confirmedAt: null }) + .execute() + } } async report(info: { reasonType: NonNullable['reportType'] reason?: string - subject: { did: string } | { uri: AtUri; cid: CID } + subject: ModSubject reportedBy: string createdAt?: Date }): Promise { @@ -702,16 +620,11 @@ export class ModerationService { } } - async isSubjectTakendown( - subject: { did: string } | { uri: AtUri }, - ): Promise { - const { did, recordPath } = getStatusIdentifierFromSubject( - 'did' in subject ? subject.did : subject.uri, - ) + async isSubjectTakendown(subject: ModSubject): Promise { const builder = this.db.db .selectFrom('moderation_subject_status') - .where('did', '=', did) - .where('recordPath', '=', recordPath || '') + .where('did', '=', subject.did) + .where('recordPath', '=', subject.recordPath || '') const result = await builder.select('takendown').executeTakeFirst() @@ -772,3 +685,9 @@ export type TakedownSubjects = { did: string subjects: (RepoRef | RepoBlobRef | StrongRef)[] } + +export type ReversalSubject = { + subject: ModSubject + reverseSuspend: boolean + reverseMute: boolean +} diff --git a/packages/ozone/src/services/moderation/status.ts b/packages/ozone/src/services/moderation/status.ts index 2ecb640e484..782dde662e5 100644 --- a/packages/ozone/src/services/moderation/status.ts +++ b/packages/ozone/src/services/moderation/status.ts @@ -10,7 +10,6 @@ import { } from '../../lexicon/types/com/atproto/admin/defs' import { ModerationEventRow, ModerationSubjectStatusRow } from './types' import { HOUR } from '@atproto/common' -import { CID } from 'multiformats/cid' import { sql } from 'kysely' const getSubjectStatusForModerationEvent = ({ @@ -93,7 +92,7 @@ const getSubjectStatusForModerationEvent = ({ export const adjustModerationSubjectStatus = async ( db: Database, moderationEvent: ModerationEventRow, - blobCids?: CID[], + blobCids?: string[], ) => { const { action, @@ -169,7 +168,7 @@ export const adjustModerationSubjectStatus = async ( if (blobCids?.length) { const newBlobCids = sql`${JSON.stringify( - blobCids.map((c) => c.toString()), + blobCids, )}` as unknown as ModerationSubjectStatusRow['blobCids'] newStatus.blobCids = newBlobCids subjectStatus.blobCids = newBlobCids diff --git a/packages/ozone/src/services/moderation/subject.ts b/packages/ozone/src/services/moderation/subject.ts new file mode 100644 index 00000000000..3ceaedcb363 --- /dev/null +++ b/packages/ozone/src/services/moderation/subject.ts @@ -0,0 +1,94 @@ +import { AtUri } from '@atproto/syntax' +import { InputSchema as ReportInput } from '../../lexicon/types/com/atproto/moderation/createReport' +import { InputSchema as ActionInput } from '../../lexicon/types/com/atproto/admin/emitModerationEvent' +import { InvalidRequestError } from '@atproto/xrpc-server' + +type SubjectInput = ReportInput['subject'] | ActionInput['subject'] + +export const subjectFromInput = ( + subject: SubjectInput, + blobs?: string[], +): ModSubject => { + if ( + subject.$type === 'com.atproto.admin.defs#repoRef' && + typeof subject.did === 'string' + ) { + if (blobs && blobs.length > 0) { + throw new InvalidRequestError('Blobs do not apply to repo subjects') + } + return new RepoSubject(subject.did) + } + if ( + subject.$type === 'com.atproto.repo.strongRef' && + typeof subject.uri === 'string' && + typeof subject.cid === 'string' + ) { + return new RecordSubject(subject.uri, subject.cid, blobs) + } + throw new InvalidRequestError('Invalid subject') +} + +type SubjectInfo = { + subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef' + subjectDid: string + subjectUri: string | null + subjectCid: string | null +} + +export interface ModSubject { + did: string + recordPath: string | undefined + blobCids?: string[] + isRepo(): this is RepoSubject + isRecord(): this is RecordSubject + info(): SubjectInfo +} + +export class RepoSubject implements ModSubject { + blobCids = undefined + recordPath = undefined + constructor(public did: string) {} + isRepo() { + return true + } + isRecord() { + return false + } + info() { + return { + subjectType: 'com.atproto.admin.defs#repoRef' as const, + subjectDid: this.did, + subjectUri: null, + subjectCid: null, + } + } +} + +export class RecordSubject implements ModSubject { + parsedUri: AtUri + did: string + recordPath: string + constructor( + public uri: string, + public cid: string, + public blobCids?: string[], + ) { + this.parsedUri = new AtUri(uri) + this.did = this.parsedUri.hostname + this.recordPath = `${this.parsedUri.collection}/${this.parsedUri.rkey}` + } + isRepo() { + return false + } + isRecord() { + return true + } + info() { + return { + subjectType: 'com.atproto.repo.strongRef' as const, + subjectDid: this.did, + subjectUri: this.uri, + subjectCid: this.cid, + } + } +} diff --git a/packages/ozone/src/services/moderation/types.ts b/packages/ozone/src/services/moderation/types.ts index fded00ce172..c1b0cf3cdd0 100644 --- a/packages/ozone/src/services/moderation/types.ts +++ b/packages/ozone/src/services/moderation/types.ts @@ -1,23 +1,8 @@ import { Selectable } from 'kysely' import { ModerationEvent } from '../../db/schema/moderation_event' import { ModerationSubjectStatus } from '../../db/schema/moderation_subject_status' -import { AtUri } from '@atproto/syntax' -import { CID } from 'multiformats/cid' import { ComAtprotoAdminDefs } from '@atproto/api' - -export type SubjectInfo = - | { - subjectType: 'com.atproto.admin.defs#repoRef' - subjectDid: string - subjectUri: null - subjectCid: null - } - | { - subjectType: 'com.atproto.repo.strongRef' - subjectDid: string - subjectUri: string - subjectCid: string - } +import { ModSubject } from './subject' export type ModerationEventRow = Selectable export type ReversibleModerationEvent = Pick< @@ -25,7 +10,7 @@ export type ReversibleModerationEvent = Pick< 'createdBy' | 'comment' | 'action' > & { createdAt?: Date - subject: { did: string } | { uri: AtUri; cid: CID } + subject: ModSubject } export type ModerationEventRowWithHandle = ModerationEventRow & {