From a2a07f1e6350a11d009b83551381b1994aef897c Mon Sep 17 00:00:00 2001 From: devin ivy Date: Thu, 28 Dec 2023 00:15:15 -0500 Subject: [PATCH 1/5] Experimental node clustering for bsky frontends (#1985) * experimental node clustering for bsky frontends * build --- .../workflows/build-and-push-bsky-aws.yaml | 2 +- services/bsky/api.js | 36 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index beaf10eb655..34bba3070cd 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 - - timeline-limit-1-opt + - bsky-node-clustering env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} diff --git a/services/bsky/api.js b/services/bsky/api.js index c4882335761..42737d72b56 100644 --- a/services/bsky/api.js +++ b/services/bsky/api.js @@ -13,6 +13,7 @@ require('dd-trace') // Only works with commonjs // Tracer code above must come before anything else const path = require('path') const assert = require('assert') +const cluster = require('cluster') const { BunnyInvalidator, CloudfrontInvalidator, @@ -140,12 +141,14 @@ const main = async () => { await bsky.start() // Graceful shutdown (see also https://aws.amazon.com/blogs/containers/graceful-shutdowns-with-ecs/) - process.on('SIGTERM', async () => { + const shutdown = async () => { // Gracefully shutdown periodic-moderation-event-reversal before destroying bsky instance periodicModerationEventReversal.destroy() await periodicModerationEventReversalRunning await bsky.destroy() - }) + } + process.on('SIGTERM', shutdown) + process.on('disconnect', shutdown) // when clustering } const getEnv = () => ({ @@ -223,4 +226,31 @@ const maintainXrpcResource = (span, req) => { } } -main() +const workerCount = maybeParseInt(process.env.CLUSTER_WORKER_COUNT) + +if (workerCount) { + if (cluster.isPrimary) { + console.log(`primary ${process.pid} is running`) + const workers = new Set() + for (let i = 0; i < workerCount; ++i) { + workers.add(cluster.fork()) + } + let teardown = false + cluster.on('exit', (worker) => { + workers.delete(worker) + if (!teardown) { + workers.add(cluster.fork()) // restart on crash + } + }) + process.on('SIGTERM', () => { + teardown = true + console.log('disconnecting workers') + workers.forEach((w) => w.disconnect()) + }) + } else { + console.log(`worker ${process.pid} is running`) + main() + } +} else { + main() // non-clustering +} From 28609519a8ef7c3ef5b988698225b9b68c9c86fd Mon Sep 17 00:00:00 2001 From: devin ivy Date: Fri, 29 Dec 2023 11:35:59 -0500 Subject: [PATCH 2/5] Pin alpine version temporarily for pds docker build (#1976) * pin alpine version temporarily for pds docker build * fix * add note --- services/pds/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/pds/Dockerfile b/services/pds/Dockerfile index c108df56ddd..6d092bb5229 100644 --- a/services/pds/Dockerfile +++ b/services/pds/Dockerfile @@ -1,4 +1,6 @@ -FROM node:18-alpine as build +# @NOTE just a temp fix: alpine3.19 breaks sharp install, see nodejs/docker-node#2009 +# see additional reference to this image further down. +FROM node:18-alpine3.18 as build RUN npm install -g pnpm @@ -35,7 +37,7 @@ RUN pnpm install --prod --shamefully-hoist --frozen-lockfile --prefer-offline > WORKDIR services/pds # Uses assets from build stage to reduce build size -FROM node:18-alpine +FROM node:18-alpine3.18 RUN apk add --update dumb-init From ad0d976188d1f07401b9675b5c6045c91e82a84e Mon Sep 17 00:00:00 2001 From: bnewbold Date: Wed, 3 Jan 2024 00:24:32 +0100 Subject: [PATCH 3/5] lexicons: more string limits (#1994) * limit external embed strings sizes * set a (very large) alt text length limit The motivation is to have *some* size limit on every string in post records, to maximize interoperation. For example, we currently have a CBOR library rejecting some records because of too-long strings. We don't want to limit the ability of folks to be very descriptive in alt text, specifically, so chose what seems to be a very large limit. If this is not large enough, based on feedback, we can bump it even higher. As context this is the largest string length limit in all of our lexicons. * make thumbnail URL fields format=uri This mostly results in checks against the string being empty, or unlimited size. * codegen: string limits * add changeset for string format lex updates --- .changeset/odd-kids-warn.md | 5 +++++ lexicons/app/bsky/embed/external.json | 26 +++++++++++++++++++++----- lexicons/app/bsky/embed/images.json | 16 ++++++++++++---- packages/api/src/client/lexicons.ts | 15 +++++++++++++++ packages/bsky/src/lexicon/lexicons.ts | 15 +++++++++++++++ packages/pds/src/lexicon/lexicons.ts | 15 +++++++++++++++ 6 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 .changeset/odd-kids-warn.md diff --git a/.changeset/odd-kids-warn.md b/.changeset/odd-kids-warn.md new file mode 100644 index 00000000000..021bda81456 --- /dev/null +++ b/.changeset/odd-kids-warn.md @@ -0,0 +1,5 @@ +--- +'@atproto/api': patch +--- + +additional app.bsky.feed.post Lexicon string format limits diff --git a/lexicons/app/bsky/embed/external.json b/lexicons/app/bsky/embed/external.json index 8946382835f..85ef69ea4a2 100644 --- a/lexicons/app/bsky/embed/external.json +++ b/lexicons/app/bsky/embed/external.json @@ -18,8 +18,16 @@ "required": ["uri", "title", "description"], "properties": { "uri": { "type": "string", "format": "uri" }, - "title": { "type": "string" }, - "description": { "type": "string" }, + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000 + }, + "description": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000 + }, "thumb": { "type": "blob", "accept": ["image/*"], @@ -42,9 +50,17 @@ "required": ["uri", "title", "description"], "properties": { "uri": { "type": "string", "format": "uri" }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "thumb": { "type": "string" } + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000 + }, + "description": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "thumb": { "type": "string", "format": "uri" } } } } diff --git a/lexicons/app/bsky/embed/images.json b/lexicons/app/bsky/embed/images.json index 5baa7ab3f74..48106f62aa1 100644 --- a/lexicons/app/bsky/embed/images.json +++ b/lexicons/app/bsky/embed/images.json @@ -23,7 +23,11 @@ "accept": ["image/*"], "maxSize": 1000000 }, - "alt": { "type": "string" }, + "alt": { + "type": "string", + "maxGraphemes": 5000, + "maxLength": 50000 + }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } }, @@ -51,9 +55,13 @@ "type": "object", "required": ["thumb", "fullsize", "alt"], "properties": { - "thumb": { "type": "string" }, - "fullsize": { "type": "string" }, - "alt": { "type": "string" }, + "thumb": { "type": "string", "format": "uri" }, + "fullsize": { "type": "string", "format": "uri" }, + "alt": { + "type": "string", + "maxGraphemes": 5000, + "maxLength": 50000 + }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 55de861d600..3d6c725ed18 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4744,9 +4744,13 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'blob', @@ -4775,12 +4779,17 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'string', + format: 'uri', }, }, }, @@ -4816,6 +4825,8 @@ export const schemaDict = { }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4859,12 +4870,16 @@ export const schemaDict = { properties: { thumb: { type: 'string', + format: 'uri', }, fullsize: { type: 'string', + format: 'uri', }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 55de861d600..3d6c725ed18 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -4744,9 +4744,13 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'blob', @@ -4775,12 +4779,17 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'string', + format: 'uri', }, }, }, @@ -4816,6 +4825,8 @@ export const schemaDict = { }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4859,12 +4870,16 @@ export const schemaDict = { properties: { thumb: { type: 'string', + format: 'uri', }, fullsize: { type: 'string', + format: 'uri', }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 55de861d600..3d6c725ed18 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -4744,9 +4744,13 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'blob', @@ -4775,12 +4779,17 @@ export const schemaDict = { }, title: { type: 'string', + maxGraphemes: 300, + maxLength: 3000, }, description: { type: 'string', + maxGraphemes: 1000, + maxLength: 10000, }, thumb: { type: 'string', + format: 'uri', }, }, }, @@ -4816,6 +4825,8 @@ export const schemaDict = { }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4859,12 +4870,16 @@ export const schemaDict = { properties: { thumb: { type: 'string', + format: 'uri', }, fullsize: { type: 'string', + format: 'uri', }, alt: { type: 'string', + maxGraphemes: 5000, + maxLength: 50000, }, aspectRatio: { type: 'ref', From 5e7b0136da6f17922379156ed7d00ca28ed8e3d8 Mon Sep 17 00:00:00 2001 From: Foysal Ahamed Date: Wed, 3 Jan 2024 01:17:42 +0100 Subject: [PATCH 4/5] :sparkles: Allow appealing a moderator decision through special report type (#1969) * :sparkles: Allow appealing a moderator decision through special report type * :sparkles: Allow querying subjects by appealed status * :sparkles: Move to appealed boolean state column * :sparkles: Remove leftover * :sparkles: Move appealed status to new boolean column * :sparkles: Throw when non-author attempts to appeal a subject * :rotating_light: Appease the linter gods * build --------- Co-authored-by: Devin Ivy --- .../workflows/build-and-push-bsky-aws.yaml | 2 +- lexicons/com/atproto/admin/defs.json | 22 +- .../admin/queryModerationStatuses.json | 4 + lexicons/com/atproto/moderation/defs.json | 7 +- packages/api/src/client/index.ts | 1 + packages/api/src/client/lexicons.ts | 31 ++ .../client/types/com/atproto/admin/defs.ts | 26 ++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + .../atproto/admin/queryModerationStatuses.ts | 2 + .../com/atproto/moderation/createReport.ts | 17 +- .../src/api/com/atproto/moderation/util.ts | 2 + ...13T181744386Z-moderation-subject-appeal.ts | 23 ++ packages/bsky/src/db/migrations/index.ts | 1 + packages/bsky/src/db/tables/moderation.ts | 3 + packages/bsky/src/lexicon/index.ts | 1 + packages/bsky/src/lexicon/lexicons.ts | 31 ++ .../lexicon/types/com/atproto/admin/defs.ts | 26 ++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + .../bsky/src/services/moderation/index.ts | 9 + .../bsky/src/services/moderation/status.ts | 24 ++ .../bsky/src/services/moderation/views.ts | 2 + .../tests/admin/moderation-appeals.test.ts | 269 ++++++++++++++++++ packages/dev-env/src/seed-client.ts | 2 +- packages/lex-cli/src/codegen/client.ts | 8 +- .../src/api/com/atproto/moderation/util.ts | 2 + packages/pds/src/lexicon/index.ts | 1 + packages/pds/src/lexicon/lexicons.ts | 31 ++ .../lexicon/types/com/atproto/admin/defs.ts | 26 ++ .../atproto/admin/queryModerationStatuses.ts | 2 + .../types/com/atproto/moderation/defs.ts | 3 + 32 files changed, 574 insertions(+), 14 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/.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 }} diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index fae60e45cf7..23448b7ac8d 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,9 +168,18 @@ "type": "string", "format": "datetime" }, + "lastAppealedAt": { + "type": "string", + "format": "datetime", + "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." + }, "suspendUntil": { "type": "string", "format": "datetime" @@ -469,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/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..df55181aef0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -297,6 +297,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 3d6c725ed18..dbbac6b7a2f 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,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + 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.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -717,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', @@ -1361,6 +1383,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, @@ -1946,6 +1972,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: { @@ -1973,6 +2000,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 42a587bcc62..d4b35ae8056 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 @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** 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. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -538,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/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/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/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/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/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts new file mode 100644 index 00000000000..95662737a63 --- /dev/null +++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts @@ -0,0 +1,23 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('moderation_subject_status') + .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('lastAppealedAt') + .execute() + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('appealed') + .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..99f5e73310d 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/db/tables/moderation.ts @@ -20,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 @@ -47,9 +48,11 @@ export interface ModerationSubjectStatus { lastReviewedBy: string | null lastReviewedAt: string | null lastReportedAt: 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 c51998a66e6..40c50cd1687 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -135,6 +135,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/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 3d6c725ed18..dbbac6b7a2f 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,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + 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.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -717,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', @@ -1361,6 +1383,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, @@ -1946,6 +1972,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: { @@ -1973,6 +2000,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 33a4ccd1b9a..4be9efb21a9 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 @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** 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. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -538,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/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/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 2362da5d556..151f6137a05 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -12,6 +12,7 @@ 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, @@ -82,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({ lastReviewedBy: createdBy, lastReviewedAt: createdAt, } + case 'com.atproto.admin.defs#modEventResolveAppeal': + return { + appealed: false, + } default: return null } @@ -106,6 +111,10 @@ export const adjustModerationSubjectStatus = async ( createdAt, } = moderationEvent + const isAppealEvent = + action === 'com.atproto.admin.defs#modEventReport' && + meta?.reportType === REASONAPPEAL + const subjectStatus = getSubjectStatusForModerationEvent({ action, createdBy, @@ -162,6 +171,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 2dc9c5ec7e4..654a6e54291 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -485,9 +485,11 @@ export class ModerationViews { lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined, lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined, lastReportedAt: subjectStatus.lastReportedAt ?? 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 new file mode 100644 index 00000000000..8b2af9a5a42 --- /dev/null +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -0,0 +1,269 @@ +import { TestNetwork, SeedClient } from '@atproto/dev-env' +import AtpAgent, { + ComAtprotoAdminDefs, + ComAtprotoAdminEmitModerationEvent, + ComAtprotoAdminQueryModerationStatuses, +} from '@atproto/api' +import basicSeed from '../seeds/basic' +import { + REASONMISLEADING, + REASONSPAM, +} from '../../src/lexicon/types/com/atproto/moderation/defs' +import { + 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 + 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, + 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', () => { + 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 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 or a moderator 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, undefined) + + // Create a report as normal user with appeal type + 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) + + // Emit report event as moderator + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventReport', + reportType: REASONAPPEAL, + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.alice, + }) + + // 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 + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventResolveAppeal', + }, + subject: getBobsPostSubject(), + createdBy: sc.dids.carol, + }) + + const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false) + + 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 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()) + }) + }) + + 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('appeal status is maintained while review state changes based on incoming events', 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, REVIEWOPEN, true) + + // 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 REVIEWOPEN, as report events are meant to do + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) + + // Emit an escalation event + await emitModerationEvent({ + event: { + $type: 'com.atproto.admin.defs#modEventEscalate', + }, + subject: getAlicesPostSubject(), + createdBy: sc.dids.carol, + }) + + await assertSubjectStatus( + getAlicesPostSubject().uri, + REVIEWESCALATED, + true, + ) + + // 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 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/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' 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/index.ts b/packages/pds/src/lexicon/index.ts index c51998a66e6..40c50cd1687 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -135,6 +135,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/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 3d6c725ed18..dbbac6b7a2f 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,9 +238,20 @@ export const schemaDict = { type: 'string', format: 'datetime', }, + lastAppealedAt: { + type: 'string', + format: 'datetime', + 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.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -717,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', @@ -1361,6 +1383,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, @@ -1946,6 +1972,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: { @@ -1973,6 +2000,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 33a4ccd1b9a..4be9efb21a9 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 @@ -147,7 +148,11 @@ export interface SubjectStatusView { lastReviewedBy?: string lastReviewedAt?: string lastReportedAt?: string + /** 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. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -538,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 } 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 5560b7a25a5fe6d7c191bd6bbc8db2d5d1e3639e Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 3 Jan 2024 16:41:07 -0500 Subject: [PATCH 5/5] Revert "lexicons: more string limits (#1994)" This reverts commit ad0d976188d1f07401b9675b5c6045c91e82a84e. --- .changeset/odd-kids-warn.md | 5 ----- lexicons/app/bsky/embed/external.json | 26 +++++--------------------- lexicons/app/bsky/embed/images.json | 16 ++++------------ packages/api/src/client/lexicons.ts | 15 --------------- packages/bsky/src/lexicon/lexicons.ts | 15 --------------- packages/pds/src/lexicon/lexicons.ts | 15 --------------- 6 files changed, 9 insertions(+), 83 deletions(-) delete mode 100644 .changeset/odd-kids-warn.md diff --git a/.changeset/odd-kids-warn.md b/.changeset/odd-kids-warn.md deleted file mode 100644 index 021bda81456..00000000000 --- a/.changeset/odd-kids-warn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@atproto/api': patch ---- - -additional app.bsky.feed.post Lexicon string format limits diff --git a/lexicons/app/bsky/embed/external.json b/lexicons/app/bsky/embed/external.json index 85ef69ea4a2..8946382835f 100644 --- a/lexicons/app/bsky/embed/external.json +++ b/lexicons/app/bsky/embed/external.json @@ -18,16 +18,8 @@ "required": ["uri", "title", "description"], "properties": { "uri": { "type": "string", "format": "uri" }, - "title": { - "type": "string", - "maxGraphemes": 300, - "maxLength": 3000 - }, - "description": { - "type": "string", - "maxGraphemes": 1000, - "maxLength": 10000 - }, + "title": { "type": "string" }, + "description": { "type": "string" }, "thumb": { "type": "blob", "accept": ["image/*"], @@ -50,17 +42,9 @@ "required": ["uri", "title", "description"], "properties": { "uri": { "type": "string", "format": "uri" }, - "title": { - "type": "string", - "maxGraphemes": 300, - "maxLength": 3000 - }, - "description": { - "type": "string", - "maxGraphemes": 1000, - "maxLength": 10000 - }, - "thumb": { "type": "string", "format": "uri" } + "title": { "type": "string" }, + "description": { "type": "string" }, + "thumb": { "type": "string" } } } } diff --git a/lexicons/app/bsky/embed/images.json b/lexicons/app/bsky/embed/images.json index 48106f62aa1..5baa7ab3f74 100644 --- a/lexicons/app/bsky/embed/images.json +++ b/lexicons/app/bsky/embed/images.json @@ -23,11 +23,7 @@ "accept": ["image/*"], "maxSize": 1000000 }, - "alt": { - "type": "string", - "maxGraphemes": 5000, - "maxLength": 50000 - }, + "alt": { "type": "string" }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } }, @@ -55,13 +51,9 @@ "type": "object", "required": ["thumb", "fullsize", "alt"], "properties": { - "thumb": { "type": "string", "format": "uri" }, - "fullsize": { "type": "string", "format": "uri" }, - "alt": { - "type": "string", - "maxGraphemes": 5000, - "maxLength": 50000 - }, + "thumb": { "type": "string" }, + "fullsize": { "type": "string" }, + "alt": { "type": "string" }, "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index dbbac6b7a2f..c0e7e51fddc 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4775,13 +4775,9 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'blob', @@ -4810,17 +4806,12 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'string', - format: 'uri', }, }, }, @@ -4856,8 +4847,6 @@ export const schemaDict = { }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4901,16 +4890,12 @@ export const schemaDict = { properties: { thumb: { type: 'string', - format: 'uri', }, fullsize: { type: 'string', - format: 'uri', }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref', diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index dbbac6b7a2f..c0e7e51fddc 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -4775,13 +4775,9 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'blob', @@ -4810,17 +4806,12 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'string', - format: 'uri', }, }, }, @@ -4856,8 +4847,6 @@ export const schemaDict = { }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4901,16 +4890,12 @@ export const schemaDict = { properties: { thumb: { type: 'string', - format: 'uri', }, fullsize: { type: 'string', - format: 'uri', }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref', diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index dbbac6b7a2f..c0e7e51fddc 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -4775,13 +4775,9 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'blob', @@ -4810,17 +4806,12 @@ export const schemaDict = { }, title: { type: 'string', - maxGraphemes: 300, - maxLength: 3000, }, description: { type: 'string', - maxGraphemes: 1000, - maxLength: 10000, }, thumb: { type: 'string', - format: 'uri', }, }, }, @@ -4856,8 +4847,6 @@ export const schemaDict = { }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref', @@ -4901,16 +4890,12 @@ export const schemaDict = { properties: { thumb: { type: 'string', - format: 'uri', }, fullsize: { type: 'string', - format: 'uri', }, alt: { type: 'string', - maxGraphemes: 5000, - maxLength: 50000, }, aspectRatio: { type: 'ref',