From f0522240539125b1d07a1377a9ded9ef7f39e01e Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 14 Dec 2023 15:58:53 +0100 Subject: [PATCH 1/8] :sparkles: Allow appealing a moderator decision through special report type --- lexicons/com/atproto/admin/defs.json | 16 +- lexicons/com/atproto/moderation/defs.json | 7 +- packages/api/src/client/index.ts | 2 + packages/api/src/client/lexicons.ts | 17 ++ .../client/types/com/atproto/admin/defs.ts | 5 + .../types/com/atproto/moderation/defs.ts | 3 + ...13T181744386Z-moderation-subject-appeal.ts | 15 ++ packages/bsky/src/db/migrations/index.ts | 1 + packages/bsky/src/db/tables/moderation.ts | 8 +- packages/bsky/src/lexicon/index.ts | 4 +- packages/bsky/src/lexicon/lexicons.ts | 17 ++ .../lexicon/types/com/atproto/admin/defs.ts | 5 + .../types/com/atproto/moderation/defs.ts | 3 + .../bsky/src/services/moderation/status.ts | 31 ++- .../bsky/src/services/moderation/views.ts | 1 + .../tests/admin/moderation-appeals.test.ts | 215 ++++++++++++++++++ packages/pds/src/lexicon/index.ts | 4 +- packages/pds/src/lexicon/lexicons.ts | 17 ++ .../lexicon/types/com/atproto/admin/defs.ts | 5 + .../types/com/atproto/moderation/defs.ts | 3 + 20 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts create mode 100644 packages/bsky/tests/admin/moderation-appeals.test.ts diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index dcded1387d3..949f021aa8e 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -167,6 +167,11 @@ "type": "string", "format": "datetime" }, + "appealedAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp referencing when the owner of the subject appealed a moderation action" + }, "takendown": { "type": "boolean" }, @@ -432,7 +437,12 @@ }, "subjectReviewState": { "type": "string", - "knownValues": ["#reviewOpen", "#reviewEscalated", "#reviewClosed"] + "knownValues": [ + "#reviewOpen", + "#reviewEscalated", + "#reviewClosed", + "#reviewAppealed" + ] }, "reviewOpen": { "type": "token", @@ -446,6 +456,10 @@ "type": "token", "description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator" }, + "reviewAppealed": { + "type": "token", + "description": "Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content" + }, "modEventTakedown": { "type": "object", "description": "Take down a subject permanently or temporarily", diff --git a/lexicons/com/atproto/moderation/defs.json b/lexicons/com/atproto/moderation/defs.json index a06579a502e..b9e980df779 100644 --- a/lexicons/com/atproto/moderation/defs.json +++ b/lexicons/com/atproto/moderation/defs.json @@ -10,7 +10,8 @@ "com.atproto.moderation.defs#reasonMisleading", "com.atproto.moderation.defs#reasonSexual", "com.atproto.moderation.defs#reasonRude", - "com.atproto.moderation.defs#reasonOther" + "com.atproto.moderation.defs#reasonOther", + "com.atproto.moderation.defs#reasonAppeal" ] }, "reasonSpam": { @@ -36,6 +37,10 @@ "reasonOther": { "type": "token", "description": "Other: reports not falling under another report category" + }, + "reasonAppeal": { + "type": "token", + "description": "Appeal: appeal a previously taken moderation action" } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index a42dbd9320a..375a9a9c07f 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -289,6 +289,7 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', + DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -297,6 +298,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 51f3a9f5456..dc1cce6ac8c 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -237,6 +237,12 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + appealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the owner of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, @@ -676,6 +682,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', + 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -693,6 +700,11 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, + reviewAppealed: { + type: 'token', + description: + 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', + }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -1937,6 +1949,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +1977,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index cd55a41b97c..8201f3ba208 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -147,6 +147,8 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the owner of the subject appealed a moderation action */ + appealedAt?: string takendown?: boolean suspendUntil?: string [k: string]: unknown @@ -488,6 +490,7 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' + | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -496,6 +499,8 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' +/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ +export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { diff --git a/packages/api/src/client/types/com/atproto/moderation/defs.ts b/packages/api/src/client/types/com/atproto/moderation/defs.ts index b6463993614..802cd2bc996 100644 --- a/packages/api/src/client/types/com/atproto/moderation/defs.ts +++ b/packages/api/src/client/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' diff --git a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts new file mode 100644 index 00000000000..cce1f1546da --- /dev/null +++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('moderation_subject_status') + .addColumn('appealedAt', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('appealedAt') + .execute() +} diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/db/migrations/index.ts index f3ed5bc4dbd..ea14e775383 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/db/migrations/index.ts @@ -32,3 +32,4 @@ export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post' export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes' export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status' export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache' +export * as _20231213T181744386Z from './20231213T181744386Z-moderation-subject-appeal' diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/db/tables/moderation.ts index f1ac3572785..8632c908bb4 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/db/tables/moderation.ts @@ -3,6 +3,7 @@ import { REVIEWCLOSED, REVIEWOPEN, REVIEWESCALATED, + REVIEWAPPEALED, } from '../../lexicon/types/com/atproto/admin/defs' export const eventTableName = 'moderation_event' @@ -41,12 +42,17 @@ export interface ModerationSubjectStatus { recordPath: string recordCid: string | null blobCids: string[] | null - reviewState: typeof REVIEWCLOSED | typeof REVIEWOPEN | typeof REVIEWESCALATED + reviewState: + | typeof REVIEWCLOSED + | typeof REVIEWOPEN + | typeof REVIEWESCALATED + | typeof REVIEWAPPEALED createdAt: string updatedAt: string lastReviewedBy: string | null lastReviewedAt: string | null lastReportedAt: string | null + appealedAt: string | null muteUntil: string | null suspendUntil: string | null takendown: boolean diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index c51998a66e6..7b2cb30be3d 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -127,6 +127,7 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', + DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -135,6 +136,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', @@ -1597,13 +1599,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 51f3a9f5456..dc1cce6ac8c 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -237,6 +237,12 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + appealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the owner of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, @@ -676,6 +682,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', + 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -693,6 +700,11 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, + reviewAppealed: { + type: 'token', + description: + 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', + }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -1937,6 +1949,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +1977,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 27f080cbe31..850ac620f8d 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -147,6 +147,8 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the owner of the subject appealed a moderation action */ + appealedAt?: string takendown?: boolean suspendUntil?: string [k: string]: unknown @@ -488,6 +490,7 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' + | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -496,6 +499,8 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' +/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ +export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { diff --git a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' diff --git a/packages/bsky/src/services/moderation/status.ts b/packages/bsky/src/services/moderation/status.ts index 2362da5d556..6c8e22b67dc 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -7,11 +7,13 @@ import { REVIEWOPEN, REVIEWCLOSED, REVIEWESCALATED, + REVIEWAPPEALED, } 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' +import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs' const getSubjectStatusForModerationEvent = ({ action, @@ -106,6 +108,10 @@ export const adjustModerationSubjectStatus = async ( createdAt, } = moderationEvent + const isAppealEvent = + action === 'com.atproto.admin.defs#modEventReport' && + meta?.reportType === REASONAPPEAL + const subjectStatus = getSubjectStatusForModerationEvent({ action, createdBy, @@ -118,6 +124,11 @@ export const adjustModerationSubjectStatus = async ( return null } + // Exit early if someone is trying to game the system by appealing someone else's content + if (isAppealEvent && createdBy !== subjectDid) { + return null + } + const now = new Date().toISOString() // If subjectUri exists, it's not a repoRef so pass along the uri to get identifier back const identifier = getStatusIdentifierFromSubject(subjectUri || subjectDid) @@ -131,13 +142,31 @@ export const adjustModerationSubjectStatus = async ( .selectAll() .executeTakeFirst() - if ( + // If the incoming event is an appeal and the subject has never been appealed before, allow setting the state to appealed + if (isAppealEvent) { + if (!currentStatus?.appealedAt) { + subjectStatus.reviewState = REVIEWAPPEALED + subjectStatus.appealedAt = createdAt + } else { + // If the subject has been appealed before, we don't want to change the state to reviewOpen caused by the report event + // so we set the reviewState to whatever is the current state in DB + subjectStatus.reviewState = currentStatus.reviewState + } + } else if ( currentStatus?.reviewState === REVIEWESCALATED && subjectStatus.reviewState === REVIEWOPEN ) { // If the current status is escalated and the incoming event is to open the review // We want to keep the status as escalated subjectStatus.reviewState = REVIEWESCALATED + } else if ( + currentStatus?.reviewState === REVIEWAPPEALED && + subjectStatus.reviewState !== REVIEWCLOSED + ) { + // Keep the status as appealed if the incoming event is not to close the review + // But don't exit here because there may be other properties that + // require updating such lastReportedAt or lastReviewedAt + subjectStatus.reviewState = REVIEWAPPEALED } // Set these because we don't want to override them if they're already set diff --git a/packages/bsky/src/services/moderation/views.ts b/packages/bsky/src/services/moderation/views.ts index 2dc9c5ec7e4..f133780d416 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -485,6 +485,7 @@ export class ModerationViews { lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined, lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined, lastReportedAt: subjectStatus.lastReportedAt ?? undefined, + appealedAt: subjectStatus.appealedAt ?? undefined, muteUntil: subjectStatus.muteUntil ?? undefined, suspendUntil: subjectStatus.suspendUntil ?? undefined, takendown: subjectStatus.takendown ?? undefined, diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts new file mode 100644 index 00000000000..8d1701f73fe --- /dev/null +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -0,0 +1,215 @@ +import { TestNetwork, SeedClient } from '@atproto/dev-env' +import AtpAgent, { + ComAtprotoAdminDefs, + ComAtprotoAdminEmitModerationEvent, + ComAtprotoAdminQueryModerationStatuses, +} from '@atproto/api' +import { forSnapshot } from '../_util' +import basicSeed from '../seeds/basic' +import { + REASONMISLEADING, + REASONSPAM, +} from '../../src/lexicon/types/com/atproto/moderation/defs' +import { + REVIEWAPPEALED, + REVIEWCLOSED, + REVIEWOPEN, +} from '@atproto/api/src/client/types/com/atproto/admin/defs' +import { REASONAPPEAL } from '@atproto/api/src/client/types/com/atproto/moderation/defs' + +describe('moderation-appeals', () => { + let network: TestNetwork + let agent: AtpAgent + let pdsAgent: AtpAgent + let sc: SeedClient + + const emitModerationEvent = async ( + eventData: ComAtprotoAdminEmitModerationEvent.InputSchema, + ) => { + return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, { + encoding: 'application/json', + headers: network.bsky.adminAuthHeaders('moderator'), + }) + } + + const queryModerationStatuses = ( + statusQuery: ComAtprotoAdminQueryModerationStatuses.QueryParams, + ) => + agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, { + headers: network.bsky.adminAuthHeaders('moderator'), + }) + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_moderation_statuses', + }) + agent = network.bsky.getClient() + pdsAgent = network.pds.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + const assertSubjectStatus = async ( + subject: string, + status: string, + ): Promise => { + const { data } = await queryModerationStatuses({ + subject, + }) + expect(data.subjectStatuses[0]?.reviewState).toEqual(status) + return data.subjectStatuses[0] + } + describe('appeals from users', () => { + const getBobsPostSubject = () => ({ + $type: 'com.atproto.repo.strongRef', + uri: sc.posts[sc.dids.bob][1].ref.uriStr, + cid: sc.posts[sc.dids.bob][1].ref.cidStr, + }) + const assertBobsPostStatus = async (status: string) => + assertSubjectStatus(getBobsPostSubject().uri, status) + + it('only changes subject status if original author of the content is appealing', async () => { + // Create a report by alice + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONMISLEADING, + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.alice, + }) + + await assertBobsPostStatus(REVIEWOPEN) + + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONAPPEAL, + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.alice, + }) + + // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN + await assertBobsPostStatus(REVIEWOPEN) + + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONAPPEAL, + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.bob, + }) + + // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN + const status = await assertBobsPostStatus(REVIEWAPPEALED) + expect(status?.appealedAt).not.toBeNull() + }) + it('does not change status to appealed if an appeal was already received', async () => { + // Resolve appeal with acknowledge + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventAcknowledge', + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.carol, + }) + + await assertBobsPostStatus(REVIEWCLOSED) + + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONAPPEAL, + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.bob, + }) + + // Verify that even after the appeal event by bob for his post, the status is still REVIEWCLOSED + await assertBobsPostStatus(REVIEWCLOSED) + }) + }) + + describe('appeal resolution', () => { + const getAlicesPostSubject = () => ({ + $type: 'com.atproto.repo.strongRef', + uri: sc.posts[sc.dids.alice][1].ref.uriStr, + cid: sc.posts[sc.dids.alice][1].ref.cidStr, + }) + it('only allows changing appealed status to closed', async () => { + // Bob reports alice's post + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONMISLEADING, + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.bob, + }) + + // Moderator acknowledges the report, assume a label was applied too + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventAcknowledge', + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.carol, + }) + + // Alice appeals the report + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONAPPEAL, + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.alice, + }) + + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + + // Another bob reports it again + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONSPAM, + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.bob, + }) + + // Assert that the status is still REVIEWAPPEALED and not REVIEWOPEN, as report events are meant to do + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + + // Emit an escalation event + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventEscalate', + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.carol, + }) + + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + + // Emit an acknowledge event + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventAcknowledge', + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.carol, + }) + + // Assert that status moved on to reviewClosed + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED) + }) + }) +}) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index c51998a66e6..7b2cb30be3d 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -127,6 +127,7 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', + DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -135,6 +136,7 @@ export const COM_ATPROTO_MODERATION = { DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual', DefsReasonRude: 'com.atproto.moderation.defs#reasonRude', DefsReasonOther: 'com.atproto.moderation.defs#reasonOther', + DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal', } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', @@ -1597,13 +1599,11 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } -type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth - opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 51f3a9f5456..dc1cce6ac8c 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -237,6 +237,12 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + appealedAt: { + type: 'string', + format: 'datetime', + description: + 'Timestamp referencing when the owner of the subject appealed a moderation action', + }, takendown: { type: 'boolean', }, @@ -676,6 +682,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', + 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -693,6 +700,11 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, + reviewAppealed: { + type: 'token', + description: + 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', + }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -1937,6 +1949,7 @@ export const schemaDict = { 'com.atproto.moderation.defs#reasonSexual', 'com.atproto.moderation.defs#reasonRude', 'com.atproto.moderation.defs#reasonOther', + 'com.atproto.moderation.defs#reasonAppeal', ], }, reasonSpam: { @@ -1964,6 +1977,10 @@ export const schemaDict = { type: 'token', description: 'Other: reports not falling under another report category', }, + reasonAppeal: { + type: 'token', + description: 'Appeal: appeal a previously taken moderation action', + }, }, }, ComAtprotoRepoApplyWrites: { diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 27f080cbe31..850ac620f8d 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -147,6 +147,8 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** Timestamp referencing when the owner of the subject appealed a moderation action */ + appealedAt?: string takendown?: boolean suspendUntil?: string [k: string]: unknown @@ -488,6 +490,7 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' + | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -496,6 +499,8 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' +/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ +export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { diff --git a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts @@ -13,6 +13,7 @@ export type ReasonType = | 'com.atproto.moderation.defs#reasonSexual' | 'com.atproto.moderation.defs#reasonRude' | 'com.atproto.moderation.defs#reasonOther' + | 'com.atproto.moderation.defs#reasonAppeal' | (string & {}) /** Spam: frequent unwanted promotion, replies, mentions */ @@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual' export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude' /** Other: reports not falling under another report category */ export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther' +/** Appeal: appeal a previously taken moderation action */ +export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal' From cb29500852e14f8213bd8fb156808b98699f4cca Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 14 Dec 2023 16:43:36 +0100 Subject: [PATCH 2/8] :sparkles: Allow querying subjects by appealed status --- packages/bsky/src/api/com/atproto/moderation/util.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/bsky/src/api/com/atproto/moderation/util.ts b/packages/bsky/src/api/com/atproto/moderation/util.ts index bc0ece2ff9f..2d11376cb4c 100644 --- a/packages/bsky/src/api/com/atproto/moderation/util.ts +++ b/packages/bsky/src/api/com/atproto/moderation/util.ts @@ -15,6 +15,7 @@ import { REVIEWCLOSED, REVIEWESCALATED, REVIEWOPEN, + REVIEWAPPEALED, } from '../../../../lexicon/types/com/atproto/admin/defs' import { ModerationEvent } from '../../../../db/tables/moderation' import { ModerationSubjectStatusRow } from '../../../../services/moderation/types' @@ -64,7 +65,12 @@ export const getReviewState = (reviewState?: string) => { throw new InvalidRequestError('Invalid review state') } -const reviewStates = new Set([REVIEWCLOSED, REVIEWESCALATED, REVIEWOPEN]) +const reviewStates = new Set([ + REVIEWCLOSED, + REVIEWESCALATED, + REVIEWOPEN, + REVIEWAPPEALED, +]) const reasonTypes = new Set([ REASONOTHER, From 3dfc2e89a47e07f2089fda517df21209c9733392 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 21 Dec 2023 23:11:53 +0100 Subject: [PATCH 3/8] :sparkles: Move to appealed boolean state column --- lexicons/com/atproto/admin/defs.json | 30 +++++---- .../admin/queryModerationStatuses.json | 4 ++ packages/api/src/client/index.ts | 1 - packages/api/src/client/lexicons.ts | 28 ++++++--- .../client/types/com/atproto/admin/defs.ts | 29 +++++++-- .../atproto/admin/queryModerationStatuses.ts | 2 + .../atproto/admin/queryModerationStatuses.ts | 2 + ...13T181744386Z-moderation-subject-appeal.ts | 12 +++- packages/bsky/src/db/tables/moderation.ts | 11 ++-- packages/bsky/src/lexicon/index.ts | 3 +- packages/bsky/src/lexicon/lexicons.ts | 28 ++++++--- .../lexicon/types/com/atproto/admin/defs.ts | 29 +++++++-- .../atproto/admin/queryModerationStatuses.ts | 2 + .../bsky/src/services/moderation/index.ts | 9 +++ .../bsky/src/services/moderation/status.ts | 40 ++++++------ .../bsky/src/services/moderation/views.ts | 3 +- .../tests/admin/moderation-appeals.test.ts | 62 +++++++++++++------ packages/pds/src/lexicon/index.ts | 3 +- packages/pds/src/lexicon/lexicons.ts | 28 ++++++--- .../lexicon/types/com/atproto/admin/defs.ts | 29 +++++++-- .../atproto/admin/queryModerationStatuses.ts | 2 + 21 files changed, 260 insertions(+), 97 deletions(-) diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 949f021aa8e..220cc687adf 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -69,7 +69,8 @@ "#modEventLabel", "#modEventAcknowledge", "#modEventEscalate", - "#modEventMute" + "#modEventMute", + "#modEventResolveAppeal" ] }, "subject": { @@ -167,7 +168,7 @@ "type": "string", "format": "datetime" }, - "appealedAt": { + "lastAppealedAt": { "type": "string", "format": "datetime", "description": "Timestamp referencing when the owner of the subject appealed a moderation action" @@ -175,6 +176,10 @@ "takendown": { "type": "boolean" }, + "appealed": { + "type": "boolean", + "description": "True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." + }, "suspendUntil": { "type": "string", "format": "datetime" @@ -437,12 +442,7 @@ }, "subjectReviewState": { "type": "string", - "knownValues": [ - "#reviewOpen", - "#reviewEscalated", - "#reviewClosed", - "#reviewAppealed" - ] + "knownValues": ["#reviewOpen", "#reviewEscalated", "#reviewClosed"] }, "reviewOpen": { "type": "token", @@ -456,10 +456,6 @@ "type": "token", "description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator" }, - "reviewAppealed": { - "type": "token", - "description": "Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content" - }, "modEventTakedown": { "type": "object", "description": "Take down a subject permanently or temporarily", @@ -483,6 +479,16 @@ } } }, + "modEventResolveAppeal": { + "type": "object", + "description": "Resolve appeal on a subject", + "properties": { + "comment": { + "type": "string", + "description": "Describe resolution." + } + } + }, "modEventComment": { "type": "object", "description": "Add a comment to a subject", diff --git a/lexicons/com/atproto/admin/queryModerationStatuses.json b/lexicons/com/atproto/admin/queryModerationStatuses.json index 98fec5bd642..e3e2a859bd2 100644 --- a/lexicons/com/atproto/admin/queryModerationStatuses.json +++ b/lexicons/com/atproto/admin/queryModerationStatuses.json @@ -64,6 +64,10 @@ "type": "boolean", "description": "Get subjects that were taken down" }, + "appealed": { + "type": "boolean", + "description": "Get subjects in unresolved appealed status" + }, "limit": { "type": "integer", "minimum": 1, diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 375a9a9c07f..df55181aef0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -289,7 +289,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,6 +729,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -1369,6 +1379,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 8201f3ba208..10adece39ff 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + lastAppealedAt?: string takendown?: boolean + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string diff --git a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts index 80eb17d8cb3..0039016a353 100644 --- a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts @@ -31,6 +31,8 @@ export interface QueryParams { sortDirection?: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit?: number cursor?: string } diff --git a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts index 5a74bfca3ae..e664e90343c 100644 --- a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts @@ -9,6 +9,7 @@ export default function (server: Server, ctx: AppContext) { const { subject, takendown, + appealed, reviewState, reviewedAfter, reviewedBefore, @@ -28,6 +29,7 @@ export default function (server: Server, ctx: AppContext) { reviewState: getReviewState(reviewState), subject, takendown, + appealed, reviewedAfter, reviewedBefore, reportedAfter, diff --git a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts index cce1f1546da..95662737a63 100644 --- a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts +++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts @@ -3,13 +3,21 @@ import { Kysely } from 'kysely' export async function up(db: Kysely): Promise { await db.schema .alterTable('moderation_subject_status') - .addColumn('appealedAt', 'varchar') + .addColumn('lastAppealedAt', 'varchar') + .execute() + await db.schema + .alterTable('moderation_subject_status') + .addColumn('appealed', 'boolean') .execute() } export async function down(db: Kysely): Promise { await db.schema .alterTable('moderation_subject_status') - .dropColumn('appealedAt') + .dropColumn('lastAppealedAt') + .execute() + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('appealed') .execute() } diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/db/tables/moderation.ts index 8632c908bb4..99f5e73310d 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/db/tables/moderation.ts @@ -3,7 +3,6 @@ import { REVIEWCLOSED, REVIEWOPEN, REVIEWESCALATED, - REVIEWAPPEALED, } from '../../lexicon/types/com/atproto/admin/defs' export const eventTableName = 'moderation_event' @@ -21,6 +20,7 @@ export interface ModerationEvent { | 'com.atproto.admin.defs#modEventMute' | 'com.atproto.admin.defs#modEventReverseTakedown' | 'com.atproto.admin.defs#modEventEmail' + | 'com.atproto.admin.defs#modEventResolveAppeal' subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef' subjectDid: string subjectUri: string | null @@ -42,20 +42,17 @@ export interface ModerationSubjectStatus { recordPath: string recordCid: string | null blobCids: string[] | null - reviewState: - | typeof REVIEWCLOSED - | typeof REVIEWOPEN - | typeof REVIEWESCALATED - | typeof REVIEWAPPEALED + reviewState: typeof REVIEWCLOSED | typeof REVIEWOPEN | typeof REVIEWESCALATED createdAt: string updatedAt: string lastReviewedBy: string | null lastReviewedAt: string | null lastReportedAt: string | null - appealedAt: string | null + lastAppealedAt: string | null muteUntil: string | null suspendUntil: string | null takendown: boolean + appealed: boolean | null comment: string | null } diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 7b2cb30be3d..40c50cd1687 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -127,7 +127,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -1599,11 +1598,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,6 +729,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -1369,6 +1379,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 850ac620f8d..1a4643ecff9 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + lastAppealedAt?: string takendown?: boolean + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -32,6 +32,8 @@ export interface QueryParams { sortDirection: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit: number cursor?: string } diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts index 717155d0317..84769100ae9 100644 --- a/packages/bsky/src/services/moderation/index.ts +++ b/packages/bsky/src/services/moderation/index.ts @@ -539,6 +539,7 @@ export class ModerationService { cursor, limit = 50, takendown, + appealed, reviewState, reviewedAfter, reviewedBefore, @@ -554,6 +555,7 @@ export class ModerationService { cursor?: string limit?: number takendown?: boolean + appealed?: boolean | null reviewedBefore?: string reviewState?: ModerationSubjectStatusRow['reviewState'] reviewedAfter?: string @@ -615,6 +617,13 @@ export class ModerationService { builder = builder.where('takendown', '=', true) } + if (appealed !== undefined) { + builder = + appealed === null + ? builder.where('appealed', 'is', null) + : builder.where('appealed', '=', appealed) + } + if (!includeMuted) { builder = builder.where((qb) => qb diff --git a/packages/bsky/src/services/moderation/status.ts b/packages/bsky/src/services/moderation/status.ts index 6c8e22b67dc..9e82eb19827 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -7,7 +7,6 @@ import { REVIEWOPEN, REVIEWCLOSED, REVIEWESCALATED, - REVIEWAPPEALED, } from '../../lexicon/types/com/atproto/admin/defs' import { ModerationEventRow, ModerationSubjectStatusRow } from './types' import { HOUR } from '@atproto/common' @@ -84,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({ lastReviewedBy: createdBy, lastReviewedAt: createdAt, } + case 'com.atproto.admin.defs#modEventResolveAppeal': + return { + appealed: false, + } default: return null } @@ -142,31 +145,13 @@ export const adjustModerationSubjectStatus = async ( .selectAll() .executeTakeFirst() - // If the incoming event is an appeal and the subject has never been appealed before, allow setting the state to appealed - if (isAppealEvent) { - if (!currentStatus?.appealedAt) { - subjectStatus.reviewState = REVIEWAPPEALED - subjectStatus.appealedAt = createdAt - } else { - // If the subject has been appealed before, we don't want to change the state to reviewOpen caused by the report event - // so we set the reviewState to whatever is the current state in DB - subjectStatus.reviewState = currentStatus.reviewState - } - } else if ( + if ( currentStatus?.reviewState === REVIEWESCALATED && subjectStatus.reviewState === REVIEWOPEN ) { // If the current status is escalated and the incoming event is to open the review // We want to keep the status as escalated subjectStatus.reviewState = REVIEWESCALATED - } else if ( - currentStatus?.reviewState === REVIEWAPPEALED && - subjectStatus.reviewState !== REVIEWCLOSED - ) { - // Keep the status as appealed if the incoming event is not to close the review - // But don't exit here because there may be other properties that - // require updating such lastReportedAt or lastReviewedAt - subjectStatus.reviewState = REVIEWAPPEALED } // Set these because we don't want to override them if they're already set @@ -191,6 +176,21 @@ export const adjustModerationSubjectStatus = async ( subjectStatus.takendown = false } + if (isAppealEvent) { + newStatus.appealed = true + subjectStatus.appealed = true + newStatus.lastAppealedAt = createdAt + subjectStatus.lastAppealedAt = createdAt + } + + if ( + action === 'com.atproto.admin.defs#modEventResolveAppeal' && + subjectStatus.appealed + ) { + newStatus.appealed = false + subjectStatus.appealed = false + } + if (action === 'com.atproto.admin.defs#modEventComment' && meta?.sticky) { newStatus.comment = comment subjectStatus.comment = comment diff --git a/packages/bsky/src/services/moderation/views.ts b/packages/bsky/src/services/moderation/views.ts index f133780d416..654a6e54291 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -485,10 +485,11 @@ export class ModerationViews { lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined, lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined, lastReportedAt: subjectStatus.lastReportedAt ?? undefined, - appealedAt: subjectStatus.appealedAt ?? undefined, + lastAppealedAt: subjectStatus.lastAppealedAt ?? undefined, muteUntil: subjectStatus.muteUntil ?? undefined, suspendUntil: subjectStatus.suspendUntil ?? undefined, takendown: subjectStatus.takendown ?? undefined, + appealed: subjectStatus.appealed ?? undefined, subjectRepoHandle: subjectStatus.handle ?? undefined, subjectBlobCids: subjectStatus.blobCids || [], subject: !subjectStatus.recordPath diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts index 8d1701f73fe..4a3eb8b13ee 100644 --- a/packages/bsky/tests/admin/moderation-appeals.test.ts +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -11,11 +11,11 @@ import { REASONSPAM, } from '../../src/lexicon/types/com/atproto/moderation/defs' import { - REVIEWAPPEALED, REVIEWCLOSED, REVIEWOPEN, } from '@atproto/api/src/client/types/com/atproto/admin/defs' import { REASONAPPEAL } from '@atproto/api/src/client/types/com/atproto/moderation/defs' +import { REVIEWESCALATED } from '../../src/lexicon/types/com/atproto/admin/defs' describe('moderation-appeals', () => { let network: TestNetwork @@ -57,11 +57,13 @@ describe('moderation-appeals', () => { const assertSubjectStatus = async ( subject: string, status: string, + appealed: boolean | undefined, ): Promise => { const { data } = await queryModerationStatuses({ subject, }) expect(data.subjectStatuses[0]?.reviewState).toEqual(status) + expect(data.subjectStatuses[0]?.appealed).toEqual(appealed) return data.subjectStatuses[0] } describe('appeals from users', () => { @@ -70,8 +72,10 @@ describe('moderation-appeals', () => { uri: sc.posts[sc.dids.bob][1].ref.uriStr, cid: sc.posts[sc.dids.bob][1].ref.cidStr, }) - const assertBobsPostStatus = async (status: string) => - assertSubjectStatus(getBobsPostSubject().uri, status) + const assertBobsPostStatus = async ( + status: string, + appealed: boolean | undefined, + ) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed) it('only changes subject status if original author of the content is appealing', async () => { // Create a report by alice @@ -84,7 +88,7 @@ describe('moderation-appeals', () => { createdBy: sc.dids.alice, }) - await assertBobsPostStatus(REVIEWOPEN) + await assertBobsPostStatus(REVIEWOPEN, undefined) await emitModerationEvent({ event: { @@ -96,7 +100,7 @@ describe('moderation-appeals', () => { }) // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN - await assertBobsPostStatus(REVIEWOPEN) + await assertBobsPostStatus(REVIEWOPEN, undefined) await emitModerationEvent({ event: { @@ -107,21 +111,21 @@ describe('moderation-appeals', () => { createdBy: sc.dids.bob, }) - // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN - const status = await assertBobsPostStatus(REVIEWAPPEALED) + // Verify that since the appeal was emitted by bob, the appealed state has been set to true + const status = await assertBobsPostStatus(REVIEWOPEN, true) expect(status?.appealedAt).not.toBeNull() }) - it('does not change status to appealed if an appeal was already received', async () => { + it('allows multiple appeals and updates last appealed timestamp', async () => { // Resolve appeal with acknowledge await emitModerationEvent({ event: { - $type: 'com.atproto.admin.defs#modEventAcknowledge', + $type: 'com.atproto.admin.defs#modEventResolveAppeal', }, subject: getBobsPostSubject(), createdBy: sc.dids.carol, }) - await assertBobsPostStatus(REVIEWCLOSED) + const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false) await emitModerationEvent({ event: { @@ -132,8 +136,11 @@ describe('moderation-appeals', () => { createdBy: sc.dids.bob, }) - // Verify that even after the appeal event by bob for his post, the status is still REVIEWCLOSED - await assertBobsPostStatus(REVIEWCLOSED) + // Verify that even after the appeal event by bob for his post, the appeal status is true again with new timestamp + const newStatus = await assertBobsPostStatus(REVIEWOPEN, true) + expect( + new Date(`${previousStatus?.lastAppealedAt}`).getTime(), + ).toBeLessThan(new Date(`${newStatus?.lastAppealedAt}`).getTime()) }) }) @@ -143,7 +150,7 @@ describe('moderation-appeals', () => { uri: sc.posts[sc.dids.alice][1].ref.uriStr, cid: sc.posts[sc.dids.alice][1].ref.cidStr, }) - it('only allows changing appealed status to closed', async () => { + it('appeal status is maintained while review state changes based on incoming events', async () => { // Bob reports alice's post await emitModerationEvent({ event: { @@ -173,9 +180,9 @@ describe('moderation-appeals', () => { createdBy: sc.dids.alice, }) - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) - // Another bob reports it again + // Bob reports it again await emitModerationEvent({ event: { $type: 'com.atproto.admin.defs#modEventReport', @@ -186,7 +193,7 @@ describe('moderation-appeals', () => { }) // Assert that the status is still REVIEWAPPEALED and not REVIEWOPEN, as report events are meant to do - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) // Emit an escalation event await emitModerationEvent({ @@ -197,7 +204,11 @@ describe('moderation-appeals', () => { createdBy: sc.dids.carol, }) - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus( + getAlicesPostSubject().uri, + REVIEWESCALATED, + true, + ) // Emit an acknowledge event await emitModerationEvent({ @@ -208,8 +219,21 @@ describe('moderation-appeals', () => { createdBy: sc.dids.carol, }) - // Assert that status moved on to reviewClosed - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED) + // Assert that status moved on to reviewClosed while appealed status is still true + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, true) + + // Emit a resolveAppeal event + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventResolveAppeal', + comment: 'lgtm', + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.carol, + }) + + // Assert that status stayed the same while appealed status is still true + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, false) }) }) }) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 7b2cb30be3d..40c50cd1687 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -127,7 +127,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -1599,11 +1598,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -102,6 +102,7 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#modEventAcknowledge', 'lex:com.atproto.admin.defs#modEventEscalate', 'lex:com.atproto.admin.defs#modEventMute', + 'lex:com.atproto.admin.defs#modEventResolveAppeal', ], }, subject: { @@ -237,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { takendown: { type: 'boolean', }, + appealed: { + type: 'boolean', + description: + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,6 +729,16 @@ export const schemaDict = { }, }, }, + modEventResolveAppeal: { + type: 'object', + description: 'Resolve appeal on a subject', + properties: { + comment: { + type: 'string', + description: 'Describe resolution.', + }, + }, + }, modEventComment: { type: 'object', description: 'Add a comment to a subject', @@ -1369,6 +1379,10 @@ export const schemaDict = { type: 'boolean', description: 'Get subjects that were taken down', }, + appealed: { + type: 'boolean', + description: 'Get subjects in unresolved appealed status', + }, limit: { type: 'integer', minimum: 1, diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 850ac620f8d..1a4643ecff9 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -76,6 +76,7 @@ export interface ModEventViewDetail { | ModEventAcknowledge | ModEventEscalate | ModEventMute + | ModEventResolveAppeal | { $type: string; [k: string]: unknown } subject: | RepoView @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + lastAppealedAt?: string takendown?: boolean + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v) } +/** Resolve appeal on a subject */ +export interface ModEventResolveAppeal { + /** Describe resolution. */ + comment?: string + [k: string]: unknown +} + +export function isModEventResolveAppeal( + v: unknown, +): v is ModEventResolveAppeal { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#modEventResolveAppeal' + ) +} + +export function validateModEventResolveAppeal(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v) +} + /** Add a comment to a subject */ export interface ModEventComment { comment: string diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -32,6 +32,8 @@ export interface QueryParams { sortDirection: 'asc' | 'desc' /** Get subjects that were taken down */ takendown?: boolean + /** Get subjects in unresolved appealed status */ + appealed?: boolean limit: number cursor?: string } From b16c818f3305054f905780cc89f0ec88baa7d765 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 21 Dec 2023 23:24:07 +0100 Subject: [PATCH 4/8] :sparkles: Remove leftover --- packages/bsky/src/api/com/atproto/moderation/util.ts | 8 +------- packages/bsky/tests/admin/moderation-appeals.test.ts | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/bsky/src/api/com/atproto/moderation/util.ts b/packages/bsky/src/api/com/atproto/moderation/util.ts index 2d11376cb4c..bc0ece2ff9f 100644 --- a/packages/bsky/src/api/com/atproto/moderation/util.ts +++ b/packages/bsky/src/api/com/atproto/moderation/util.ts @@ -15,7 +15,6 @@ import { REVIEWCLOSED, REVIEWESCALATED, REVIEWOPEN, - REVIEWAPPEALED, } from '../../../../lexicon/types/com/atproto/admin/defs' import { ModerationEvent } from '../../../../db/tables/moderation' import { ModerationSubjectStatusRow } from '../../../../services/moderation/types' @@ -65,12 +64,7 @@ export const getReviewState = (reviewState?: string) => { throw new InvalidRequestError('Invalid review state') } -const reviewStates = new Set([ - REVIEWCLOSED, - REVIEWESCALATED, - REVIEWOPEN, - REVIEWAPPEALED, -]) +const reviewStates = new Set([REVIEWCLOSED, REVIEWESCALATED, REVIEWOPEN]) const reasonTypes = new Set([ REASONOTHER, diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts index 4a3eb8b13ee..7b67de381f9 100644 --- a/packages/bsky/tests/admin/moderation-appeals.test.ts +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -192,7 +192,7 @@ describe('moderation-appeals', () => { createdBy: sc.dids.bob, }) - // Assert that the status is still REVIEWAPPEALED and not REVIEWOPEN, as report events are meant to do + // Assert that the status is still REVIEWOPEN, as report events are meant to do await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) // Emit an escalation event From 805f3bcfc50f8e6813c795364be47268eb50fe36 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Fri, 22 Dec 2023 15:04:47 +0100 Subject: [PATCH 5/8] :sparkles: Move appealed status to new boolean column --- packages/api/src/client/lexicons.ts | 2 +- .../client/types/com/atproto/admin/defs.ts | 2 +- .../com/atproto/admin/emitModerationEvent.ts | 17 ++++++- .../src/api/com/atproto/moderation/util.ts | 2 + packages/bsky/src/lexicon/lexicons.ts | 2 +- .../lexicon/types/com/atproto/admin/defs.ts | 2 +- .../tests/admin/moderation-appeals.test.ts | 50 +++++++++++++++---- .../src/api/com/atproto/moderation/util.ts | 2 + packages/pds/src/lexicon/lexicons.ts | 2 +- .../lexicon/types/com/atproto/admin/defs.ts | 2 +- 10 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 2ff45b93e4a..d1dbf807044 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 10adece39ff..200785627d1 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -151,7 +151,7 @@ export interface SubjectStatusView { /** Timestamp referencing when the owner of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown diff --git a/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts b/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts index 8b007f64ca1..450c13b0a8a 100644 --- a/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts +++ b/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts @@ -15,6 +15,7 @@ import { } from '../../../../lexicon/types/com/atproto/admin/defs' import { TakedownSubjects } from '../../../../services/moderation' import { retryHttp } from '../../../../util/retry' +import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ @@ -74,13 +75,27 @@ export default function (server: Server, ctx: AppContext) { async (dbTxn) => { const moderationTxn = ctx.services.moderation(dbTxn) const labelTxn = ctx.services.label(dbTxn) + // If an appeal report is coming in from a moderator, we need to override the createdBy + // so that the appeal is attributed to the author of the content + const isAppealEvent = + event.$type === 'com.atproto.admin.defs#modEventReport' && + event.reportType === REASONAPPEAL + const proxiedCreatedBy = isAppealEvent + ? 'uri' in subjectInfo && subjectInfo.uri + ? subjectInfo.uri.host + : subjectInfo.did + : createdBy + + if (isAppealEvent) { + event.comment = `${event.comment || ''} [MOD DID: ${createdBy}]` + } const result = await moderationTxn.logEvent({ event, subject: subjectInfo, subjectBlobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], - createdBy, + createdBy: proxiedCreatedBy, }) let takenDown: TakedownSubjects | undefined diff --git a/packages/bsky/src/api/com/atproto/moderation/util.ts b/packages/bsky/src/api/com/atproto/moderation/util.ts index bc0ece2ff9f..fbb144b1c0a 100644 --- a/packages/bsky/src/api/com/atproto/moderation/util.ts +++ b/packages/bsky/src/api/com/atproto/moderation/util.ts @@ -10,6 +10,7 @@ import { REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, } from '../../../../lexicon/types/com/atproto/moderation/defs' import { REVIEWCLOSED, @@ -73,6 +74,7 @@ const reasonTypes = new Set([ REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, ]) const eventTypes = new Set([ diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 2ff45b93e4a..d1dbf807044 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 1a4643ecff9..eb8dda1209f 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -151,7 +151,7 @@ export interface SubjectStatusView { /** Timestamp referencing when the owner of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts index 7b67de381f9..ccf39f653eb 100644 --- a/packages/bsky/tests/admin/moderation-appeals.test.ts +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -4,7 +4,6 @@ import AtpAgent, { ComAtprotoAdminEmitModerationEvent, ComAtprotoAdminQueryModerationStatuses, } from '@atproto/api' -import { forSnapshot } from '../_util' import basicSeed from '../seeds/basic' import { REASONMISLEADING, @@ -72,12 +71,17 @@ describe('moderation-appeals', () => { uri: sc.posts[sc.dids.bob][1].ref.uriStr, cid: sc.posts[sc.dids.bob][1].ref.cidStr, }) + const getCarolPostSubject = () => ({ + $type: 'com.atproto.repo.strongRef', + uri: sc.posts[sc.dids.carol][0].ref.uriStr, + cid: sc.posts[sc.dids.carol][0].ref.cidStr, + }) const assertBobsPostStatus = async ( status: string, appealed: boolean | undefined, ) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed) - it('only changes subject status if original author of the content is appealing', async () => { + it('only changes subject status if original author of the content or a moderator is appealing', async () => { // Create a report by alice await emitModerationEvent({ event: { @@ -90,30 +94,54 @@ describe('moderation-appeals', () => { await assertBobsPostStatus(REVIEWOPEN, undefined) - await emitModerationEvent({ - event: { - $type: 'com.atproto.admin.defs#modEventReport', - reportType: REASONAPPEAL, - }, + // Create a report as normal user with appeal type + await sc.createReport({ + reportedBy: sc.dids.carol, + reasonType: REASONAPPEAL, + reason: 'appealing', subject: getBobsPostSubject(), - createdBy: sc.dids.alice, }) - // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN + // Verify that the appeal status did not change await assertBobsPostStatus(REVIEWOPEN, undefined) + // Emit report event as moderator await emitModerationEvent({ event: { $type: 'com.atproto.admin.defs#modEventReport', reportType: REASONAPPEAL, }, subject: getBobsPostSubject(), - createdBy: sc.dids.bob, + createdBy: sc.dids.alice, }) - // Verify that since the appeal was emitted by bob, the appealed state has been set to true + // Verify that appeal status changed when appeal report was emitted by moderator const status = await assertBobsPostStatus(REVIEWOPEN, true) expect(status?.appealedAt).not.toBeNull() + + // Create a report as normal user for carol's post + await sc.createReport({ + reportedBy: sc.dids.alice, + reasonType: REASONMISLEADING, + reason: 'lies!', + subject: getCarolPostSubject(), + }) + + // Verify that the appeal status on carol's post is undefined + await assertSubjectStatus( + getCarolPostSubject().uri, + REVIEWOPEN, + undefined, + ) + + await sc.createReport({ + reportedBy: sc.dids.carol, + reasonType: REASONAPPEAL, + reason: 'appealing', + subject: getCarolPostSubject(), + }) + // Verify that the appeal status on carol's post is true + await assertSubjectStatus(getCarolPostSubject().uri, REVIEWOPEN, true) }) it('allows multiple appeals and updates last appealed timestamp', async () => { // Resolve appeal with acknowledge diff --git a/packages/pds/src/api/com/atproto/moderation/util.ts b/packages/pds/src/api/com/atproto/moderation/util.ts index 4de1e8cd4bc..e7c33629b5a 100644 --- a/packages/pds/src/api/com/atproto/moderation/util.ts +++ b/packages/pds/src/api/com/atproto/moderation/util.ts @@ -10,6 +10,7 @@ import { REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, } from '../../../../lexicon/types/com/atproto/moderation/defs' import { parseCidParam } from '../../../../util/params' @@ -49,4 +50,5 @@ const reasonTypes = new Set([ REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, ]) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 2ff45b93e4a..d1dbf807044 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject.', + 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 1a4643ecff9..eb8dda1209f 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -151,7 +151,7 @@ export interface SubjectStatusView { /** Timestamp referencing when the owner of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. Null indicates no prior appeal on the subject. */ + /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown From cbdb076a7a9aba841b3f86c7a3180e496c8ca7d9 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 28 Dec 2023 13:26:49 +0100 Subject: [PATCH 6/8] :sparkles: Throw when non-author attempts to appeal a subject --- lexicons/com/atproto/admin/defs.json | 4 ++-- packages/api/src/client/lexicons.ts | 4 ++-- .../src/client/types/com/atproto/admin/defs.ts | 4 ++-- .../com/atproto/admin/emitModerationEvent.ts | 17 +---------------- .../api/com/atproto/moderation/createReport.ts | 17 ++++++++++++++--- packages/bsky/src/lexicon/lexicons.ts | 4 ++-- .../src/lexicon/types/com/atproto/admin/defs.ts | 4 ++-- packages/bsky/src/services/moderation/status.ts | 5 ----- .../bsky/tests/admin/moderation-appeals.test.ts | 14 ++++++++------ packages/pds/src/lexicon/lexicons.ts | 4 ++-- .../src/lexicon/types/com/atproto/admin/defs.ts | 4 ++-- 11 files changed, 37 insertions(+), 44 deletions(-) diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 220cc687adf..f72d938e1b1 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -171,14 +171,14 @@ "lastAppealedAt": { "type": "string", "format": "datetime", - "description": "Timestamp referencing when the owner of the subject appealed a moderation action" + "description": "Timestamp referencing when the author of the subject appealed a moderation action" }, "takendown": { "type": "boolean" }, "appealed": { "type": "boolean", - "description": "True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." + "description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators." }, "suspendUntil": { "type": "string", diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index d1dbf807044..446bc485586 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -242,7 +242,7 @@ export const schemaDict = { type: 'string', format: 'datetime', description: - 'Timestamp referencing when the owner of the subject appealed a moderation action', + 'Timestamp referencing when the author of the subject appealed a moderation action', }, takendown: { type: 'boolean', @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 200785627d1..3a65161ff82 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -148,10 +148,10 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string - /** Timestamp referencing when the owner of the subject appealed a moderation action */ + /** Timestamp referencing when the author of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown diff --git a/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts b/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts index 450c13b0a8a..8b007f64ca1 100644 --- a/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts +++ b/packages/bsky/src/api/com/atproto/admin/emitModerationEvent.ts @@ -15,7 +15,6 @@ import { } from '../../../../lexicon/types/com/atproto/admin/defs' import { TakedownSubjects } from '../../../../services/moderation' import { retryHttp } from '../../../../util/retry' -import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.emitModerationEvent({ @@ -75,27 +74,13 @@ export default function (server: Server, ctx: AppContext) { async (dbTxn) => { const moderationTxn = ctx.services.moderation(dbTxn) const labelTxn = ctx.services.label(dbTxn) - // If an appeal report is coming in from a moderator, we need to override the createdBy - // so that the appeal is attributed to the author of the content - const isAppealEvent = - event.$type === 'com.atproto.admin.defs#modEventReport' && - event.reportType === REASONAPPEAL - const proxiedCreatedBy = isAppealEvent - ? 'uri' in subjectInfo && subjectInfo.uri - ? subjectInfo.uri.host - : subjectInfo.did - : createdBy - - if (isAppealEvent) { - event.comment = `${event.comment || ''} [MOD DID: ${createdBy}]` - } const result = await moderationTxn.logEvent({ event, subject: subjectInfo, subjectBlobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], - createdBy: proxiedCreatedBy, + createdBy, }) let takenDown: TakedownSubjects | undefined diff --git a/packages/bsky/src/api/com/atproto/moderation/createReport.ts b/packages/bsky/src/api/com/atproto/moderation/createReport.ts index b247a319527..4a98d0629d4 100644 --- a/packages/bsky/src/api/com/atproto/moderation/createReport.ts +++ b/packages/bsky/src/api/com/atproto/moderation/createReport.ts @@ -1,8 +1,9 @@ -import { AuthRequiredError } from '@atproto/xrpc-server' +import { AuthRequiredError, ForbiddenError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { getReasonType, getSubject } from './util' import { softDeleted } from '../../../../db/util' +import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ @@ -22,12 +23,22 @@ export default function (server: Server, ctx: AppContext) { } } + const reportReasonType = getReasonType(reasonType) + const reportSubject = getSubject(subject) + const subjectDid = + 'did' in reportSubject ? reportSubject.did : reportSubject.uri.host + + // If the report is an appeal, the requester must be the author of the subject + if (reasonType === REASONAPPEAL && requester !== subjectDid) { + throw new ForbiddenError('You cannot appeal this report') + } + const report = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.services.moderation(dbTxn) return moderationTxn.report({ - reasonType: getReasonType(reasonType), + reasonType: reportReasonType, reason, - subject: getSubject(subject), + subject: reportSubject, reportedBy: requester || ctx.cfg.serverDid, }) }) diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index d1dbf807044..446bc485586 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -242,7 +242,7 @@ export const schemaDict = { type: 'string', format: 'datetime', description: - 'Timestamp referencing when the owner of the subject appealed a moderation action', + 'Timestamp referencing when the author of the subject appealed a moderation action', }, takendown: { type: 'boolean', @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index eb8dda1209f..edc725823f7 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -148,10 +148,10 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string - /** Timestamp referencing when the owner of the subject appealed a moderation action */ + /** Timestamp referencing when the author of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown diff --git a/packages/bsky/src/services/moderation/status.ts b/packages/bsky/src/services/moderation/status.ts index 9e82eb19827..151f6137a05 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -127,11 +127,6 @@ export const adjustModerationSubjectStatus = async ( return null } - // Exit early if someone is trying to game the system by appealing someone else's content - if (isAppealEvent && createdBy !== subjectDid) { - return null - } - const now = new Date().toISOString() // If subjectUri exists, it's not a repoRef so pass along the uri to get identifier back const identifier = getStatusIdentifierFromSubject(subjectUri || subjectDid) diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts index ccf39f653eb..8b2af9a5a42 100644 --- a/packages/bsky/tests/admin/moderation-appeals.test.ts +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -95,12 +95,14 @@ describe('moderation-appeals', () => { await assertBobsPostStatus(REVIEWOPEN, undefined) // Create a report as normal user with appeal type - await sc.createReport({ - reportedBy: sc.dids.carol, - reasonType: REASONAPPEAL, - reason: 'appealing', - subject: getBobsPostSubject(), - }) + expect( + sc.createReport({ + reportedBy: sc.dids.carol, + reasonType: REASONAPPEAL, + reason: 'appealing', + subject: getBobsPostSubject(), + }), + ).rejects.toThrow('You cannot appeal this report') // Verify that the appeal status did not change await assertBobsPostStatus(REVIEWOPEN, undefined) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index d1dbf807044..446bc485586 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -242,7 +242,7 @@ export const schemaDict = { type: 'string', format: 'datetime', description: - 'Timestamp referencing when the owner of the subject appealed a moderation action', + 'Timestamp referencing when the author of the subject appealed a moderation action', }, takendown: { type: 'boolean', @@ -250,7 +250,7 @@ export const schemaDict = { appealed: { type: 'boolean', description: - 'True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', + 'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.', }, suspendUntil: { type: 'string', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index eb8dda1209f..edc725823f7 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -148,10 +148,10 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string - /** Timestamp referencing when the owner of the subject appealed a moderation action */ + /** Timestamp referencing when the author of the subject appealed a moderation action */ lastAppealedAt?: string takendown?: boolean - /** True Indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ + /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */ appealed?: boolean suspendUntil?: string [k: string]: unknown From df7c884824dad1888df14838a6c5090cf173b673 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Thu, 28 Dec 2023 13:36:15 +0100 Subject: [PATCH 7/8] :rotating_light: Appease the linter gods --- packages/dev-env/src/seed-client.ts | 2 +- packages/lex-cli/src/codegen/client.ts | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/dev-env/src/seed-client.ts b/packages/dev-env/src/seed-client.ts index 71dfebd53c0..7fc57d52081 100644 --- a/packages/dev-env/src/seed-client.ts +++ b/packages/dev-env/src/seed-client.ts @@ -448,7 +448,7 @@ export class SeedClient { reason?: string createdBy?: string }) { - const { id, subject, reason = 'X', createdBy = 'did:example:admin' } = opts + const { subject, reason = 'X', createdBy = 'did:example:admin' } = opts const result = await this.agent.api.com.atproto.admin.emitModerationEvent( { subject, diff --git a/packages/lex-cli/src/codegen/client.ts b/packages/lex-cli/src/codegen/client.ts index 33b3a53ff9d..bf7c8892819 100644 --- a/packages/lex-cli/src/codegen/client.ts +++ b/packages/lex-cli/src/codegen/client.ts @@ -4,13 +4,7 @@ import { SourceFile, VariableDeclarationKind, } from 'ts-morph' -import { - Lexicons, - LexiconDoc, - LexXrpcProcedure, - LexXrpcQuery, - LexRecord, -} from '@atproto/lexicon' +import { Lexicons, LexiconDoc, LexRecord } from '@atproto/lexicon' import { NSID } from '@atproto/syntax' import { gen, utilTs, lexiconsTs } from './common' import { GeneratedAPI } from '../types' From b67ef1000c65eb07fad8d18b0e541c16fd2b0cd8 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 2 Jan 2024 18:28:53 -0500 Subject: [PATCH 8/8] build --- .github/workflows/build-and-push-bsky-aws.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index 34bba3070cd..9df469c0615 100644 --- a/.github/workflows/build-and-push-bsky-aws.yaml +++ b/.github/workflows/build-and-push-bsky-aws.yaml @@ -3,7 +3,7 @@ on: push: branches: - main - - bsky-node-clustering + - appeal-report env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}