diff --git a/packages/ozone/src/api/admin/emitModerationEvent.ts b/packages/ozone/src/api/admin/emitModerationEvent.ts index 7aed324608b..eb1bc71a180 100644 --- a/packages/ozone/src/api/admin/emitModerationEvent.ts +++ b/packages/ozone/src/api/admin/emitModerationEvent.ts @@ -92,7 +92,6 @@ export default function (server: Server, ctx: AppContext) { if (isLabelEvent) { await moderationTxn.formatAndCreateLabels( - ctx.cfg.service.did, result.subjectUri ?? result.subjectDid, result.subjectCid, { diff --git a/packages/ozone/src/api/temp/fetchLabels.ts b/packages/ozone/src/api/temp/fetchLabels.ts index 69ead2cb28e..f11cb2028bb 100644 --- a/packages/ozone/src/api/temp/fetchLabels.ts +++ b/packages/ozone/src/api/temp/fetchLabels.ts @@ -1,29 +1,44 @@ import { Server } from '../../lexicon' import AppContext from '../../context' +import { + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + UNSPECCED_TAKEDOWN_LABEL, +} from '../../mod-service/types' export default function (server: Server, ctx: AppContext) { - server.com.atproto.temp.fetchLabels(async ({ params }) => { - const { limit } = params - const since = - params.since !== undefined ? new Date(params.since).toISOString() : '' - const labelRes = await ctx.db.db - .selectFrom('label') - .selectAll() - .orderBy('label.cts', 'asc') - .where('cts', '>', since) - .limit(limit) - .execute() + server.com.atproto.temp.fetchLabels({ + auth: ctx.authOptionalAccessOrRoleVerifier, + handler: async ({ auth, params }) => { + const { limit } = params + const since = + params.since !== undefined ? new Date(params.since).toISOString() : '' + const includeUnspeccedTakedowns = + auth.credentials.type === 'role' && auth.credentials.admin + const labelRes = await ctx.db.db + .selectFrom('label') + .selectAll() + .orderBy('label.cts', 'asc') + .where('cts', '>', since) + .if(!includeUnspeccedTakedowns, (q) => + q.where('label.val', 'not in', [ + UNSPECCED_TAKEDOWN_LABEL, + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + ]), + ) + .limit(limit) + .execute() - const labels = labelRes.map((l) => ({ - ...l, - cid: l.cid === '' ? undefined : l.cid, - })) + const labels = labelRes.map((l) => ({ + ...l, + cid: l.cid === '' ? undefined : l.cid, + })) - return { - encoding: 'application/json', - body: { - labels, - }, - } + return { + encoding: 'application/json', + body: { + labels, + }, + } + }, }) } diff --git a/packages/ozone/src/context.ts b/packages/ozone/src/context.ts index b346bfe74ba..1cb0ec1bd83 100644 --- a/packages/ozone/src/context.ts +++ b/packages/ozone/src/context.ts @@ -65,6 +65,7 @@ export class AppContext { eventPusher, appviewAgent, appviewAuth, + cfg.service.did, ) const communicationTemplateService = CommunicationTemplateService.creator() diff --git a/packages/ozone/src/daemon/context.ts b/packages/ozone/src/daemon/context.ts index 42c2a54cea5..5af19d89bc4 100644 --- a/packages/ozone/src/daemon/context.ts +++ b/packages/ozone/src/daemon/context.ts @@ -52,6 +52,7 @@ export class DaemonContext { eventPusher, appviewAgent, appviewAuth, + cfg.service.did, ) const eventReverser = new EventReverser(db, modService) diff --git a/packages/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 4c1c84e55dc..0ce130bf8d2 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -24,6 +24,8 @@ import { ModerationEventRow, ModerationSubjectStatusRow, ReversibleModerationEvent, + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + UNSPECCED_TAKEDOWN_LABEL, } from './types' import { ModerationEvent } from '../db/schema/moderation_event' import { StatusKeyset, TimeIdKeyset, paginate } from '../db/pagination' @@ -49,6 +51,7 @@ export class ModerationService { public eventPusher: EventPusher, public appviewAgent: AtpAgent, private appviewAuth: AppviewAuth, + public serverDid: string, ) {} static creator( @@ -56,6 +59,7 @@ export class ModerationService { eventPusher: EventPusher, appviewAgent: AtpAgent, appviewAuth: AppviewAuth, + serverDid: string, ) { return (db: Database) => new ModerationService( @@ -64,6 +68,7 @@ export class ModerationService { eventPusher, appviewAgent, appviewAuth, + serverDid, ) } @@ -359,19 +364,25 @@ export class ModerationService { 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 [repoEvts] = await Promise.all([ + 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.formatAndCreateLabels(subject.did, null, { + create: [UNSPECCED_TAKEDOWN_LABEL], + }), + ]) this.db.onCommit(() => { this.backgroundQueue.add(async () => { @@ -383,18 +394,23 @@ export class ModerationService { } 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 [repoEvts] = await Promise.all([ + 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(), + this.formatAndCreateLabels(subject.did, null, { + negate: [UNSPECCED_TAKEDOWN_LABEL], + }), + ]) this.db.onCommit(() => { this.backgroundQueue.add(async () => { @@ -415,19 +431,27 @@ export class ModerationService { subjectCid: subject.cid, takedownRef, })) - const recordEvts = await this.db.db - .insertInto('record_push_event') - .values(values) - .onConflict((oc) => - oc.columns(['subjectUri', 'eventType']).doUpdateSet({ - takedownRef, - confirmedAt: null, - attempts: 0, - lastAttempted: null, - }), - ) - .returning('id') - .execute() + const blobCids = subject.blobCids + const labels: string[] = [UNSPECCED_TAKEDOWN_LABEL] + if (blobCids && blobCids.length > 0) { + labels.push(UNSPECCED_TAKEDOWN_BLOBS_LABEL) + } + const [recordEvts] = await Promise.all([ + this.db.db + .insertInto('record_push_event') + .values(values) + .onConflict((oc) => + oc.columns(['subjectUri', 'eventType']).doUpdateSet({ + takedownRef, + confirmedAt: null, + attempts: 0, + lastAttempted: null, + }), + ) + .returning('id') + .execute(), + this.formatAndCreateLabels(subject.uri, subject.cid, { create: labels }), + ]) this.db.onCommit(() => { this.backgroundQueue.add(async () => { @@ -437,7 +461,6 @@ export class ModerationService { }) }) - const blobCids = subject.blobCids if (blobCids && blobCids.length > 0) { const blobValues: Insertable[] = [] for (const eventType of TAKEDOWNS) { @@ -478,19 +501,27 @@ export class ModerationService { async reverseTakedownRecord(subject: RecordSubject) { this.db.assertTransaction() - const recordEvts = await this.db.db - .updateTable('record_push_event') - .where('eventType', 'in', TAKEDOWNS) - .where('subjectDid', '=', subject.did) - .where('subjectUri', '=', subject.uri) - .set({ - takedownRef: null, - confirmedAt: null, - attempts: 0, - lastAttempted: null, - }) - .returning('id') - .execute() + const labels: string[] = [UNSPECCED_TAKEDOWN_LABEL] + const blobCids = subject.blobCids + if (blobCids && blobCids.length > 0) { + labels.push(UNSPECCED_TAKEDOWN_BLOBS_LABEL) + } + const [recordEvts] = await Promise.all([ + this.db.db + .updateTable('record_push_event') + .where('eventType', 'in', TAKEDOWNS) + .where('subjectDid', '=', subject.did) + .where('subjectUri', '=', subject.uri) + .set({ + takedownRef: null, + confirmedAt: null, + attempts: 0, + lastAttempted: null, + }) + .returning('id') + .execute(), + this.formatAndCreateLabels(subject.uri, subject.cid, { negate: labels }), + ]) this.db.onCommit(() => { this.backgroundQueue.add(async () => { await Promise.all( @@ -499,7 +530,6 @@ export class ModerationService { }) }) - const blobCids = subject.blobCids if (blobCids && blobCids.length > 0) { const blobEvts = await this.db.db .updateTable('blob_push_event') @@ -695,14 +725,13 @@ export class ModerationService { } async formatAndCreateLabels( - src: string, uri: string, cid: string | null, labels: { create?: string[]; negate?: string[] }, ): Promise { const { create = [], negate = [] } = labels const toCreate = create.map((val) => ({ - src, + src: this.serverDid, uri, cid: cid ?? undefined, val, @@ -710,7 +739,7 @@ export class ModerationService { cts: new Date().toISOString(), })) const toNegate = negate.map((val) => ({ - src, + src: this.serverDid, uri, cid: cid ?? undefined, val, diff --git a/packages/ozone/src/mod-service/types.ts b/packages/ozone/src/mod-service/types.ts index 94fc58a8d33..aca5d99c4ed 100644 --- a/packages/ozone/src/mod-service/types.ts +++ b/packages/ozone/src/mod-service/types.ts @@ -30,3 +30,7 @@ export type ModEventType = | ComAtprotoAdminDefs.ModEventReport | ComAtprotoAdminDefs.ModEventMute | ComAtprotoAdminDefs.ModEventReverseTakedown + +export const UNSPECCED_TAKEDOWN_LABEL = '!unspecced-takedown' + +export const UNSPECCED_TAKEDOWN_BLOBS_LABEL = '!unspecced-takedown-blobs' diff --git a/packages/ozone/tests/__snapshots__/get-record.test.ts.snap b/packages/ozone/tests/__snapshots__/get-record.test.ts.snap index 14a83f9dfda..1ccaed3b85d 100644 --- a/packages/ozone/tests/__snapshots__/get-record.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/get-record.test.ts.snap @@ -7,6 +7,14 @@ Object { "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ + Object { + "cid": "cids(0)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(1)", + "uri": "record(0)", + "val": "!unspecced-takedown", + }, Object { "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", @@ -93,6 +101,14 @@ Object { "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ + Object { + "cid": "cids(0)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(1)", + "uri": "record(0)", + "val": "!unspecced-takedown", + }, Object { "cid": "cids(0)", "cts": "1970-01-01T00:00:00.000Z", diff --git a/packages/ozone/tests/__snapshots__/get-repo.test.ts.snap b/packages/ozone/tests/__snapshots__/get-repo.test.ts.snap index 4ffd7e3564a..1e51ee541ed 100644 --- a/packages/ozone/tests/__snapshots__/get-repo.test.ts.snap +++ b/packages/ozone/tests/__snapshots__/get-repo.test.ts.snap @@ -8,7 +8,15 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", "invites": Array [], "invitesDisabled": false, - "labels": Array [], + "labels": Array [ + Object { + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(1)", + "uri": "user(0)", + "val": "!unspecced-takedown", + }, + ], "moderation": Object { "subjectStatus": Object { "createdAt": "1970-01-01T00:00:00.000Z", diff --git a/packages/ozone/tests/moderation-appeals.test.ts b/packages/ozone/tests/moderation-appeals.test.ts index 81f230bef82..f21098811a7 100644 --- a/packages/ozone/tests/moderation-appeals.test.ts +++ b/packages/ozone/tests/moderation-appeals.test.ts @@ -39,7 +39,7 @@ describe('moderation-appeals', () => { beforeAll(async () => { network = await TestNetwork.create({ - dbPostgresSchema: 'ozone_moderation_statuses', + dbPostgresSchema: 'ozone_moderation_appeals', }) agent = network.ozone.getClient() pdsAgent = network.pds.getClient() diff --git a/packages/ozone/tests/moderation.test.ts b/packages/ozone/tests/moderation.test.ts index 20cab2f2b49..b9899bdcdab 100644 --- a/packages/ozone/tests/moderation.test.ts +++ b/packages/ozone/tests/moderation.test.ts @@ -25,6 +25,10 @@ import { } from '../src/lexicon/types/com/atproto/admin/defs' import { EventReverser } from '../src' import { TestOzone } from '@atproto/dev-env/src/ozone' +import { + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + UNSPECCED_TAKEDOWN_LABEL, +} from '../src/mod-service/types' type BaseCreateReportParams = | { account: string } @@ -40,6 +44,7 @@ describe('moderation', () => { let network: TestNetwork let ozone: TestOzone let agent: AtpAgent + let bskyAgent: AtpAgent let pdsAgent: AtpAgent let sc: SeedClient @@ -139,12 +144,23 @@ describe('moderation', () => { return data } + const getLabel = async (uri: string, val: string, neg = false) => { + return ozone.ctx.db.db + .selectFrom('label') + .selectAll() + .where('uri', '=', uri) + .where('val', '=', val) + .where('neg', '=', neg) + .executeTakeFirst() + } + beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'ozone_moderation', }) ozone = network.ozone agent = network.ozone.getClient() + bskyAgent = network.bsky.getClient() pdsAgent = network.pds.getClient() sc = network.getSeedClient() await basicSeed(sc) @@ -444,12 +460,9 @@ describe('moderation', () => { cid: post.cidStr, } const modService = ctx.modService(ctx.db) - await modService.formatAndCreateLabels( - ctx.cfg.service.did, - post.uriStr, - post.cidStr, - { create: ['kittens'] }, - ) + await modService.formatAndCreateLabels(post.uriStr, post.cidStr, { + create: ['kittens'], + }) await emitLabelEvent({ negateLabelVals: ['kittens'], createLabelVals: [], @@ -464,12 +477,9 @@ describe('moderation', () => { }) await expect(getRecordLabels(post.uriStr)).resolves.toEqual(['kittens']) // Cleanup - await modService.formatAndCreateLabels( - ctx.cfg.service.did, - post.uriStr, - post.cidStr, - { negate: ['kittens'] }, - ) + await modService.formatAndCreateLabels(post.uriStr, post.cidStr, { + negate: ['kittens'], + }) }) it('no-ops when negating an already-negated label and reverses.', async () => { @@ -497,12 +507,9 @@ describe('moderation', () => { }) await expect(getRecordLabels(post.uriStr)).resolves.toEqual(['bears']) // Cleanup - await modService.formatAndCreateLabels( - ctx.cfg.service.did, - post.uriStr, - post.cidStr, - { negate: ['bears'] }, - ) + await modService.formatAndCreateLabels(post.uriStr, post.cidStr, { + negate: ['bears'], + }) }) it('creates non-existing labels and reverses.', async () => { @@ -559,12 +566,9 @@ describe('moderation', () => { it('creates and negates labels on a repo and reverses.', async () => { const { ctx } = ozone const modService = ctx.modService(ctx.db) - await modService.formatAndCreateLabels( - ctx.cfg.service.did, - sc.dids.bob, - null, - { create: ['kittens'] }, - ) + await modService.formatAndCreateLabels(sc.dids.bob, null, { + create: ['kittens'], + }) await emitLabelEvent({ createLabelVals: ['puppies'], negateLabelVals: ['kittens'], @@ -632,34 +636,62 @@ describe('moderation', () => { ).rejects.toThrow('Subject is not taken down') }) - it('fans out repo takedowns to pds', async () => { + it('fans out repo takedowns', async () => { await performTakedown({ account: sc.dids.bob, }) await ozone.processAll() - const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + const pdsRes1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( { did: sc.dids.bob, }, { headers: network.pds.adminAuthHeaders() }, ) - expect(res1.data.takedown?.applied).toBe(true) + expect(pdsRes1.data.takedown?.applied).toBe(true) + + const bskyRes1 = await bskyAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.bob, + }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(bskyRes1.data.takedown?.applied).toBe(true) + + const takedownLabel1 = await getLabel( + sc.dids.bob, + UNSPECCED_TAKEDOWN_LABEL, + ) + expect(takedownLabel1).toBeDefined() // cleanup await performReverseTakedown({ account: sc.dids.bob }) await ozone.processAll() - const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + const pdsRes2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( { did: sc.dids.bob, }, { headers: network.pds.adminAuthHeaders() }, ) - expect(res2.data.takedown?.applied).toBe(false) + expect(pdsRes2.data.takedown?.applied).toBe(false) + + const bskyRes2 = await bskyAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.bob, + }, + { headers: network.bsky.adminAuthHeaders() }, + ) + expect(bskyRes2.data.takedown?.applied).toBe(false) + + const takedownLabel2 = await getLabel( + sc.dids.bob, + UNSPECCED_TAKEDOWN_LABEL, + ) + expect(takedownLabel2).toBeUndefined() }) - it('fans out record takedowns to pds', async () => { + it('fans out record takedowns', async () => { const post = sc.posts[sc.dids.bob][0] const uri = post.ref.uriStr const cid = post.ref.cidStr @@ -667,21 +699,39 @@ describe('moderation', () => { content: { uri, cid }, }) await ozone.processAll() - const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + + const pdsRes1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( { uri }, { headers: network.pds.adminAuthHeaders() }, ) - expect(res1.data.takedown?.applied).toBe(true) + expect(pdsRes1.data.takedown?.applied).toBe(true) + + const bskyRes1 = await bskyAgent.api.com.atproto.admin.getSubjectStatus( + { uri }, + { headers: network.bsky.adminAuthHeaders() }, + ) + expect(bskyRes1.data.takedown?.applied).toBe(true) + + const takedownLabel1 = await getLabel(uri, UNSPECCED_TAKEDOWN_LABEL) + expect(takedownLabel1).toBeDefined() // cleanup await performReverseTakedown({ content: { uri, cid } }) await ozone.processAll() - const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + const pdsRes2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( { uri }, { headers: network.pds.adminAuthHeaders() }, ) - expect(res2.data.takedown?.applied).toBe(false) + expect(pdsRes2.data.takedown?.applied).toBe(false) + const bskyRes2 = await bskyAgent.api.com.atproto.admin.getSubjectStatus( + { uri }, + { headers: network.bsky.adminAuthHeaders() }, + ) + expect(bskyRes2.data.takedown?.applied).toBe(false) + + const takedownLabel2 = await getLabel(uri, UNSPECCED_TAKEDOWN_LABEL) + expect(takedownLabel2).toBeUndefined() }) it('allows full moderators to takedown.', async () => { @@ -732,6 +782,7 @@ describe('moderation', () => { 'Must be a full moderator to perform an account takedown', ) }) + it('automatically reverses actions marked with duration', async () => { await createReport({ reasonType: REASONSPAM, @@ -791,6 +842,22 @@ describe('moderation', () => { }) }) + it('serves label when authed', async () => { + const { data: unauthed } = await agent.api.com.atproto.temp.fetchLabels( + {}, + ) + expect(unauthed.labels.map((l) => l.val)).not.toContain( + UNSPECCED_TAKEDOWN_LABEL, + ) + const { data: authed } = await agent.api.com.atproto.temp.fetchLabels( + {}, + { headers: network.bsky.adminAuthHeaders() }, + ) + expect(authed.labels.map((l) => l.val)).toContain( + UNSPECCED_TAKEDOWN_LABEL, + ) + }) + async function emitLabelEvent( opts: Partial & { subject: ComAtprotoAdminEmitModerationEvent.InputSchema['subject'] @@ -924,6 +991,14 @@ describe('moderation', () => { expect(res.data.takedown?.applied).toBe(true) }) + it('creates a takedown blobs label', async () => { + const label = await getLabel( + post.ref.uriStr, + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + ) + expect(label).toBeDefined() + }) + it('restores blob when action is reversed.', async () => { await performReverseTakedown({ content: { @@ -957,5 +1032,29 @@ describe('moderation', () => { ) expect(res.data.takedown?.applied).toBe(false) }) + + it('serves label when authed', async () => { + const { data: unauthed } = await agent.api.com.atproto.temp.fetchLabels( + {}, + ) + expect(unauthed.labels.map((l) => l.val)).not.toContain( + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + ) + const { data: authed } = await agent.api.com.atproto.temp.fetchLabels( + {}, + { headers: network.bsky.adminAuthHeaders() }, + ) + expect(authed.labels.map((l) => l.val)).toContain( + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + ) + }) + + it('negates takedown blobs label on reversal', async () => { + const label = await getLabel( + post.ref.uriStr, + UNSPECCED_TAKEDOWN_BLOBS_LABEL, + ) + expect(label).toBeUndefined() + }) }) })