From ec0dfdc8f5a032e3fa59eea4a0c7586a749a1fca Mon Sep 17 00:00:00 2001 From: bnewbold Date: Mon, 30 Oct 2023 09:28:42 -0700 Subject: [PATCH 1/5] lexicon: maximum report "reason" length of 1000 chars (graphemes) (#1171) * lexicon: maximum report length of 500 chars (graphemes) * lexicon: bump maximum report size to 1000 chars * lexicon: bump max report size again to 2k graphemes * make codegen --- lexicons/com/atproto/moderation/createReport.json | 6 +++++- packages/api/src/client/lexicons.ts | 2 ++ packages/bsky/src/lexicon/lexicons.ts | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lexicons/com/atproto/moderation/createReport.json b/lexicons/com/atproto/moderation/createReport.json index 0f34ed4329b..161d622fcf2 100644 --- a/lexicons/com/atproto/moderation/createReport.json +++ b/lexicons/com/atproto/moderation/createReport.json @@ -43,7 +43,11 @@ "type": "ref", "ref": "com.atproto.moderation.defs#reasonType" }, - "reason": { "type": "string" }, + "reason": { + "type": "string", + "maxGraphemes": 2000, + "maxLength": 20000 + }, "subject": { "type": "union", "refs": [ diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 537350b5f13..3911dc12497 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -1632,6 +1632,8 @@ export const schemaDict = { }, reason: { type: 'string', + maxGraphemes: 2000, + maxLength: 20000, }, subject: { type: 'union', diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 537350b5f13..3911dc12497 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -1632,6 +1632,8 @@ export const schemaDict = { }, reason: { type: 'string', + maxGraphemes: 2000, + maxLength: 20000, }, subject: { type: 'union', From fcb19c9c51daae15e5853e194404493ec22667e9 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Mon, 30 Oct 2023 16:56:17 -0500 Subject: [PATCH 2/5] Simplify PDS moderation (#1723) * spec out new simple pds mod routes * introduce new admin state endpoints * wire up routes * clean up pds * revoke refresh tokens * getUserAccountInfo * pr tidy * fixing some tests * fixing up more tests * fanout takedowns to pds * fanout admin reqs to pds * tidy * more tidy & add more pds moderation tests * getUserAccountInfo -> getAccountInfo * dont hydrate pds info on searchRepos * fix build * port admin tests to bsky package * clean up old snaps * tests on fanout * tweak naming * missed a rename * tidy renames * fix lex name * tidy & move snap * fix build * cleanup repeat process all * skip actor search test * fix bday paradox * tidy up pds service auth * rm skipped test * retry http * tidy * improve fanout error handling * fix test * return signing key in did-web * more tests * tidy serivce auth checks * change takedownId col to takedownRef * build branch * fix bsky test * add service key to indexer * move signing key to api entry * dont build --- lexicons/com/atproto/admin/defs.json | 40 + .../com/atproto/admin/getAccountInfo.json | 24 + .../com/atproto/admin/getSubjectStatus.json | 39 + lexicons/com/atproto/admin/searchRepos.json | 1 - .../atproto/admin/updateSubjectStatus.json | 52 + packages/api/src/client/index.ts | 39 + packages/api/src/client/lexicons.ts | 203 +++- .../client/types/com/atproto/admin/defs.ts | 61 ++ .../types/com/atproto/admin/getAccountInfo.ts | 32 + .../com/atproto/admin/getSubjectStatus.ts | 44 + .../types/com/atproto/admin/searchRepos.ts | 1 - .../com/atproto/admin/updateSubjectStatus.ts | 50 + .../com/atproto/admin/getModerationAction.ts | 30 +- .../com/atproto/admin/getModerationReport.ts | 29 +- .../src/api/com/atproto/admin/getRecord.ts | 16 +- .../bsky/src/api/com/atproto/admin/getRepo.ts | 15 +- .../atproto/admin/reverseModerationAction.ts | 36 +- .../src/api/com/atproto/admin/searchRepos.ts | 7 +- .../com/atproto/admin/takeModerationAction.ts | 48 +- .../bsky/src/api/com/atproto/admin/util.ts | 50 + packages/bsky/src/api/well-known.ts | 8 + packages/bsky/src/auth.ts | 4 +- packages/bsky/src/auto-moderator/index.ts | 1 + packages/bsky/src/context.ts | 26 +- packages/bsky/src/index.ts | 5 +- packages/bsky/src/lexicon/index.ts | 36 + packages/bsky/src/lexicon/lexicons.ts | 203 +++- .../lexicon/types/com/atproto/admin/defs.ts | 61 ++ .../types/com/atproto/admin/getAccountInfo.ts | 41 + .../com/atproto/admin/getSubjectStatus.ts | 54 + .../types/com/atproto/admin/searchRepos.ts | 1 - .../com/atproto/admin/updateSubjectStatus.ts | 61 ++ .../bsky/src/services/moderation/index.ts | 108 +- .../bsky/src/services/moderation/views.ts | 1 + .../get-moderation-action.test.ts.snap | 16 +- .../get-moderation-actions.test.ts.snap | 42 +- .../get-moderation-report.test.ts.snap | 12 +- .../get-moderation-reports.test.ts.snap | 40 +- .../__snapshots__/get-record.test.ts.snap | 36 +- .../admin/__snapshots__/get-repo.test.ts.snap | 9 +- .../__snapshots__/moderation.test.ts.snap | 0 .../tests/admin/get-moderation-action.test.ts | 14 +- .../admin/get-moderation-actions.test.ts | 10 +- .../tests/admin/get-moderation-report.test.ts | 8 +- .../admin/get-moderation-reports.test.ts | 8 +- .../tests/admin/get-record.test.ts | 8 +- .../tests/admin/get-repo.test.ts | 8 +- .../bsky/tests/{ => admin}/moderation.test.ts | 110 +- .../tests/admin/repo-search.test.ts | 21 +- .../tests/auto-moderator/takedowns.test.ts | 8 +- .../bsky/tests/views/actor-search.test.ts | 4 +- packages/bsky/tests/views/posts.test.ts | 2 - packages/dev-env/src/bsky.ts | 8 +- packages/dev-env/src/const.ts | 3 + packages/dev-env/src/pds.ts | 5 +- packages/identity/src/did/atproto-data.ts | 8 + packages/identity/src/did/base-resolver.ts | 4 +- .../api/com/atproto/admin/getAccountInfo.ts | 22 + .../com/atproto/admin/getModerationAction.ts | 51 +- .../com/atproto/admin/getModerationActions.ts | 30 +- .../com/atproto/admin/getModerationReport.ts | 51 +- .../com/atproto/admin/getModerationReports.ts | 46 +- .../src/api/com/atproto/admin/getRecord.ts | 54 +- .../pds/src/api/com/atproto/admin/getRepo.ts | 50 +- .../api/com/atproto/admin/getSubjectStatus.ts | 43 + .../pds/src/api/com/atproto/admin/index.ts | 6 + .../atproto/admin/resolveModerationReports.ts | 29 +- .../atproto/admin/reverseModerationAction.ts | 105 +- .../src/api/com/atproto/admin/searchRepos.ts | 62 +- .../com/atproto/admin/takeModerationAction.ts | 151 +-- .../com/atproto/admin/updateSubjectStatus.ts | 59 ++ .../com/atproto/moderation/createReport.ts | 34 +- .../pds/src/api/com/atproto/repo/getRecord.ts | 2 +- .../api/com/atproto/server/createAccount.ts | 2 +- .../api/com/atproto/server/deleteAccount.ts | 30 +- packages/pds/src/auth-verifier.ts | 78 +- packages/pds/src/context.ts | 3 +- .../20231011T155513453Z-takedown-ref.ts | 41 + packages/pds/src/db/migrations/index.ts | 1 + .../db/periodic-moderation-action-reversal.ts | 88 -- packages/pds/src/db/tables/record.ts | 2 +- packages/pds/src/db/tables/repo-blob.ts | 2 +- packages/pds/src/db/tables/repo-root.ts | 2 +- packages/pds/src/db/util.ts | 6 +- packages/pds/src/index.ts | 1 - packages/pds/src/lexicon/index.ts | 36 + packages/pds/src/lexicon/lexicons.ts | 203 +++- .../lexicon/types/com/atproto/admin/defs.ts | 61 ++ .../types/com/atproto/admin/getAccountInfo.ts | 41 + .../com/atproto/admin/getSubjectStatus.ts | 54 + .../types/com/atproto/admin/searchRepos.ts | 1 - .../com/atproto/admin/updateSubjectStatus.ts | 61 ++ packages/pds/src/services/account/index.ts | 36 +- packages/pds/src/services/moderation/index.ts | 680 ++---------- packages/pds/src/services/moderation/views.ts | 633 ----------- packages/pds/src/services/record/index.ts | 4 +- packages/pds/src/services/repo/blobs.ts | 2 +- packages/pds/tests/account-deletion.test.ts | 9 +- .../__snapshots__/moderation.test.ts.snap | 193 ---- packages/pds/tests/admin/moderation.test.ts | 999 ------------------ packages/pds/tests/auth.test.ts | 13 +- packages/pds/tests/crud.test.ts | 78 +- packages/pds/tests/db.test.ts | 2 +- packages/pds/tests/invite-codes.test.ts | 38 +- .../invites.test.ts => invites-admin.test.ts} | 24 +- packages/pds/tests/moderation.test.ts | 357 +++++++ packages/pds/tests/proxied/admin.test.ts | 5 +- packages/pds/tests/proxied/feedgen.test.ts | 2 +- packages/pds/tests/proxied/notif.test.ts | 2 +- packages/pds/tests/proxied/procedures.test.ts | 2 +- .../tests/proxied/read-after-write.test.ts | 2 +- packages/pds/tests/proxied/views.test.ts | 2 +- packages/pds/tests/seeds/basic.ts | 39 +- packages/pds/tests/seeds/users.ts | 16 +- packages/pds/tests/sync/sync.test.ts | 27 +- packages/xrpc-server/src/auth.ts | 9 +- services/bsky/api.js | 5 + 117 files changed, 3025 insertions(+), 3473 deletions(-) create mode 100644 lexicons/com/atproto/admin/getAccountInfo.json create mode 100644 lexicons/com/atproto/admin/getSubjectStatus.json create mode 100644 lexicons/com/atproto/admin/updateSubjectStatus.json create mode 100644 packages/api/src/client/types/com/atproto/admin/getAccountInfo.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/getSubjectStatus.ts create mode 100644 packages/api/src/client/types/com/atproto/admin/updateSubjectStatus.ts create mode 100644 packages/bsky/src/api/com/atproto/admin/util.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfo.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts create mode 100644 packages/bsky/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts rename packages/{pds => bsky}/tests/admin/__snapshots__/get-moderation-action.test.ts.snap (94%) rename packages/{pds => bsky}/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap (85%) rename packages/{pds => bsky}/tests/admin/__snapshots__/get-moderation-report.test.ts.snap (95%) rename packages/{pds => bsky}/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap (88%) rename packages/{pds => bsky}/tests/admin/__snapshots__/get-record.test.ts.snap (91%) rename packages/{pds => bsky}/tests/admin/__snapshots__/get-repo.test.ts.snap (95%) rename packages/bsky/tests/{ => admin}/__snapshots__/moderation.test.ts.snap (100%) rename packages/{pds => bsky}/tests/admin/get-moderation-action.test.ts (89%) rename packages/{pds => bsky}/tests/admin/get-moderation-actions.test.ts (94%) rename packages/{pds => bsky}/tests/admin/get-moderation-report.test.ts (92%) rename packages/{pds => bsky}/tests/admin/get-moderation-reports.test.ts (98%) rename packages/{pds => bsky}/tests/admin/get-record.test.ts (94%) rename packages/{pds => bsky}/tests/admin/get-repo.test.ts (93%) rename packages/bsky/tests/{ => admin}/moderation.test.ts (91%) rename packages/{pds => bsky}/tests/admin/repo-search.test.ts (84%) create mode 100644 packages/dev-env/src/const.ts create mode 100644 packages/pds/src/api/com/atproto/admin/getAccountInfo.ts create mode 100644 packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts create mode 100644 packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts create mode 100644 packages/pds/src/db/migrations/20231011T155513453Z-takedown-ref.ts delete mode 100644 packages/pds/src/db/periodic-moderation-action-reversal.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfo.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts delete mode 100644 packages/pds/src/services/moderation/views.ts delete mode 100644 packages/pds/tests/admin/__snapshots__/moderation.test.ts.snap delete mode 100644 packages/pds/tests/admin/moderation.test.ts rename packages/pds/tests/{admin/invites.test.ts => invites-admin.test.ts} (91%) create mode 100644 packages/pds/tests/moderation.test.ts diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index a04c77d68f8..318d1c33b5a 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -2,6 +2,14 @@ "lexicon": 1, "id": "com.atproto.admin.defs", "defs": { + "statusAttr": { + "type": "object", + "required": ["applied"], + "properties": { + "applied": { "type": "boolean" }, + "ref": { "type": "string" } + } + }, "actionView": { "type": "object", "required": [ @@ -243,6 +251,29 @@ "inviteNote": { "type": "string" } } }, + "accountView": { + "type": "object", + "required": ["did", "handle", "indexedAt"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "email": { "type": "string" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "invitedBy": { + "type": "ref", + "ref": "com.atproto.server.defs#inviteCode" + }, + "invites": { + "type": "array", + "items": { + "type": "ref", + "ref": "com.atproto.server.defs#inviteCode" + } + }, + "invitesDisabled": { "type": "boolean" }, + "inviteNote": { "type": "string" } + } + }, "repoViewNotFound": { "type": "object", "required": ["did"], @@ -257,6 +288,15 @@ "did": { "type": "string", "format": "did" } } }, + "repoBlobRef": { + "type": "object", + "required": ["did", "cid"], + "properties": { + "did": { "type": "string", "format": "did" }, + "cid": { "type": "string", "format": "cid" }, + "recordUri": { "type": "string", "format": "at-uri" } + } + }, "recordView": { "type": "object", "required": [ diff --git a/lexicons/com/atproto/admin/getAccountInfo.json b/lexicons/com/atproto/admin/getAccountInfo.json new file mode 100644 index 00000000000..da8e839fdfa --- /dev/null +++ b/lexicons/com/atproto/admin/getAccountInfo.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.getAccountInfo", + "defs": { + "main": { + "type": "query", + "description": "View details about an account.", + "parameters": { + "type": "params", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "com.atproto.admin.defs#accountView" + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/getSubjectStatus.json b/lexicons/com/atproto/admin/getSubjectStatus.json new file mode 100644 index 00000000000..a6ce340c009 --- /dev/null +++ b/lexicons/com/atproto/admin/getSubjectStatus.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.getSubjectStatus", + "defs": { + "main": { + "type": "query", + "description": "Fetch the service-specific the admin status of a subject (account, record, or blob)", + "parameters": { + "type": "params", + "properties": { + "did": { "type": "string", "format": "did" }, + "uri": { "type": "string", "format": "at-uri" }, + "blob": { "type": "string", "format": "cid" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subject"], + "properties": { + "subject": { + "type": "union", + "refs": [ + "com.atproto.admin.defs#repoRef", + "com.atproto.repo.strongRef", + "com.atproto.admin.defs#repoBlobRef" + ] + }, + "takedown": { + "type": "ref", + "ref": "com.atproto.admin.defs#statusAttr" + } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/admin/searchRepos.json b/lexicons/com/atproto/admin/searchRepos.json index 85cc6fd482a..acc5a70f942 100644 --- a/lexicons/com/atproto/admin/searchRepos.json +++ b/lexicons/com/atproto/admin/searchRepos.json @@ -13,7 +13,6 @@ "description": "DEPRECATED: use 'q' instead" }, "q": { "type": "string" }, - "invitedBy": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, diff --git a/lexicons/com/atproto/admin/updateSubjectStatus.json b/lexicons/com/atproto/admin/updateSubjectStatus.json new file mode 100644 index 00000000000..5273aea4da6 --- /dev/null +++ b/lexicons/com/atproto/admin/updateSubjectStatus.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.updateSubjectStatus", + "defs": { + "main": { + "type": "procedure", + "description": "Update the service-specific admin status of a subject (account, record, or blob)", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subject"], + "properties": { + "subject": { + "type": "union", + "refs": [ + "com.atproto.admin.defs#repoRef", + "com.atproto.repo.strongRef", + "com.atproto.admin.defs#repoBlobRef" + ] + }, + "takedown": { + "type": "ref", + "ref": "com.atproto.admin.defs#statusAttr" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subject"], + "properties": { + "subject": { + "type": "union", + "refs": [ + "com.atproto.admin.defs#repoRef", + "com.atproto.repo.strongRef", + "com.atproto.admin.defs#repoBlobRef" + ] + }, + "takedown": { + "type": "ref", + "ref": "com.atproto.admin.defs#statusAttr" + } + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 15720ad52f8..3fd82222639 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -11,6 +11,7 @@ import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' +import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -18,6 +19,7 @@ import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/g import * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' +import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports' import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' @@ -25,6 +27,7 @@ import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' @@ -145,6 +148,7 @@ export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' +export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' export * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' export * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -152,6 +156,7 @@ export * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/g export * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports' export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' export * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' +export * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' export * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports' export * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction' export * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' @@ -159,6 +164,7 @@ export * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' export * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction' export * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' export * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +export * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' export * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' export * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' @@ -396,6 +402,17 @@ export class AdminNS { }) } + getAccountInfo( + params?: ComAtprotoAdminGetAccountInfo.QueryParams, + opts?: ComAtprotoAdminGetAccountInfo.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.getAccountInfo', params, undefined, opts) + .catch((e) => { + throw ComAtprotoAdminGetAccountInfo.toKnownErr(e) + }) + } + getInviteCodes( params?: ComAtprotoAdminGetInviteCodes.QueryParams, opts?: ComAtprotoAdminGetInviteCodes.CallOptions, @@ -473,6 +490,17 @@ export class AdminNS { }) } + getSubjectStatus( + params?: ComAtprotoAdminGetSubjectStatus.QueryParams, + opts?: ComAtprotoAdminGetSubjectStatus.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.getSubjectStatus', params, undefined, opts) + .catch((e) => { + throw ComAtprotoAdminGetSubjectStatus.toKnownErr(e) + }) + } + resolveModerationReports( data?: ComAtprotoAdminResolveModerationReports.InputSchema, opts?: ComAtprotoAdminResolveModerationReports.CallOptions, @@ -549,6 +577,17 @@ export class AdminNS { throw ComAtprotoAdminUpdateAccountHandle.toKnownErr(e) }) } + + updateSubjectStatus( + data?: ComAtprotoAdminUpdateSubjectStatus.InputSchema, + opts?: ComAtprotoAdminUpdateSubjectStatus.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.updateSubjectStatus', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoAdminUpdateSubjectStatus.toKnownErr(e) + }) + } } export class IdentityNS { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 3911dc12497..df696e5d06b 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -8,6 +8,18 @@ export const schemaDict = { lexicon: 1, id: 'com.atproto.admin.defs', defs: { + statusAttr: { + type: 'object', + required: ['applied'], + properties: { + applied: { + type: 'boolean', + }, + ref: { + type: 'string', + }, + }, + }, actionView: { type: 'object', required: [ @@ -424,6 +436,44 @@ export const schemaDict = { }, }, }, + accountView: { + type: 'object', + required: ['did', 'handle', 'indexedAt'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + email: { + type: 'string', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + invitesDisabled: { + type: 'boolean', + }, + inviteNote: { + type: 'string', + }, + }, + }, repoViewNotFound: { type: 'object', required: ['did'], @@ -444,6 +494,24 @@ export const schemaDict = { }, }, }, + repoBlobRef: { + type: 'object', + required: ['did', 'cid'], + properties: { + did: { + type: 'string', + format: 'did', + }, + cid: { + type: 'string', + format: 'cid', + }, + recordUri: { + type: 'string', + format: 'at-uri', + }, + }, + }, recordView: { type: 'object', required: [ @@ -730,6 +798,33 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfo: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfo', + defs: { + main: { + type: 'query', + description: 'View details about an account.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1026,6 +1121,55 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.getSubjectStatus', + defs: { + main: { + type: 'query', + description: + 'Fetch the service-specific the admin status of a subject (account, record, or blob)', + parameters: { + type: 'params', + properties: { + did: { + type: 'string', + format: 'did', + }, + uri: { + type: 'string', + format: 'at-uri', + }, + blob: { + type: 'string', + format: 'cid', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminResolveModerationReports: { lexicon: 1, id: 'com.atproto.admin.resolveModerationReports', @@ -1118,9 +1262,6 @@ export const schemaDict = { q: { type: 'string', }, - invitedBy: { - type: 'string', - }, limit: { type: 'integer', minimum: 1, @@ -1326,6 +1467,59 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.updateSubjectStatus', + defs: { + main: { + type: 'procedure', + description: + 'Update the service-specific admin status of a subject (account, record, or blob)', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoIdentityResolveHandle: { lexicon: 1, id: 'com.atproto.identity.resolveHandle', @@ -7369,6 +7563,7 @@ export const ids = { 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', + ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', @@ -7376,6 +7571,7 @@ export const ids = { ComAtprotoAdminGetModerationReports: 'com.atproto.admin.getModerationReports', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', + ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', ComAtprotoAdminResolveModerationReports: 'com.atproto.admin.resolveModerationReports', ComAtprotoAdminReverseModerationAction: @@ -7385,6 +7581,7 @@ export const ids = { ComAtprotoAdminTakeModerationAction: 'com.atproto.admin.takeModerationAction', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', ComAtprotoLabelDefs: 'com.atproto.label.defs', 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 f98814ca8e2..7c48fa87a3c 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -10,6 +10,24 @@ import * as ComAtprotoModerationDefs from '../moderation/defs' import * as ComAtprotoServerDefs from '../server/defs' import * as ComAtprotoLabelDefs from '../label/defs' +export interface StatusAttr { + applied: boolean + ref?: string + [k: string]: unknown +} + +export function isStatusAttr(v: unknown): v is StatusAttr { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#statusAttr' + ) +} + +export function validateStatusAttr(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#statusAttr', v) +} + export interface ActionView { id: number action: ActionType @@ -238,6 +256,30 @@ export function validateRepoViewDetail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoViewDetail', v) } +export interface AccountView { + did: string + handle: string + email?: string + indexedAt: string + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] + invitesDisabled?: boolean + inviteNote?: string + [k: string]: unknown +} + +export function isAccountView(v: unknown): v is AccountView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#accountView' + ) +} + +export function validateAccountView(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#accountView', v) +} + export interface RepoViewNotFound { did: string [k: string]: unknown @@ -272,6 +314,25 @@ export function validateRepoRef(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoRef', v) } +export interface RepoBlobRef { + did: string + cid: string + recordUri?: string + [k: string]: unknown +} + +export function isRepoBlobRef(v: unknown): v is RepoBlobRef { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#repoBlobRef' + ) +} + +export function validateRepoBlobRef(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#repoBlobRef', v) +} + export interface RecordView { uri: string cid: string diff --git a/packages/api/src/client/types/com/atproto/admin/getAccountInfo.ts b/packages/api/src/client/types/com/atproto/admin/getAccountInfo.ts new file mode 100644 index 00000000000..a6d2b97bb63 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getAccountInfo.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + did: string +} + +export type InputSchema = undefined +export type OutputSchema = ComAtprotoAdminDefs.AccountView + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/admin/getSubjectStatus.ts b/packages/api/src/client/types/com/atproto/admin/getSubjectStatus.ts new file mode 100644 index 00000000000..26986e5dde7 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/getSubjectStatus.ts @@ -0,0 +1,44 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams { + did?: string + uri?: string + blob?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts b/packages/api/src/client/types/com/atproto/admin/searchRepos.ts index 372cc98ff13..451077479b9 100644 --- a/packages/api/src/client/types/com/atproto/admin/searchRepos.ts +++ b/packages/api/src/client/types/com/atproto/admin/searchRepos.ts @@ -12,7 +12,6 @@ export interface QueryParams { /** DEPRECATED: use 'q' instead */ term?: string q?: string - invitedBy?: string limit?: number cursor?: string } diff --git a/packages/api/src/client/types/com/atproto/admin/updateSubjectStatus.ts b/packages/api/src/client/types/com/atproto/admin/updateSubjectStatus.ts new file mode 100644 index 00000000000..c7e17b50582 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/updateSubjectStatus.ts @@ -0,0 +1,50 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams {} + +export interface InputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/api/com/atproto/admin/getModerationAction.ts b/packages/bsky/src/api/com/atproto/admin/getModerationAction.ts index 55ff9b9ccf8..51218077bcf 100644 --- a/packages/bsky/src/api/com/atproto/admin/getModerationAction.ts +++ b/packages/bsky/src/api/com/atproto/admin/getModerationAction.ts @@ -1,17 +1,43 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { addAccountInfoToRepoView, getPdsAccountInfo } from './util' +import { + isRecordView, + isRepoView, +} from '../../../../lexicon/types/com/atproto/admin/defs' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationAction({ auth: ctx.roleVerifier, - handler: async ({ params }) => { + handler: async ({ params, auth }) => { const { id } = params const db = ctx.db.getPrimary() const moderationService = ctx.services.moderation(db) const result = await moderationService.getActionOrThrow(id) + + const [action, accountInfo] = await Promise.all([ + moderationService.views.actionDetail(result), + getPdsAccountInfo(ctx, result.subjectDid), + ]) + + // add in pds account info if available + if (isRepoView(action.subject)) { + action.subject = addAccountInfoToRepoView( + action.subject, + accountInfo, + auth.credentials.moderator, + ) + } else if (isRecordView(action.subject)) { + action.subject.repo = addAccountInfoToRepoView( + action.subject.repo, + accountInfo, + auth.credentials.moderator, + ) + } + return { encoding: 'application/json', - body: await moderationService.views.actionDetail(result), + body: action, } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/getModerationReport.ts b/packages/bsky/src/api/com/atproto/admin/getModerationReport.ts index e3faaa04436..814d1069e3f 100644 --- a/packages/bsky/src/api/com/atproto/admin/getModerationReport.ts +++ b/packages/bsky/src/api/com/atproto/admin/getModerationReport.ts @@ -1,17 +1,42 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { + isRecordView, + isRepoView, +} from '../../../../lexicon/types/com/atproto/admin/defs' +import { addAccountInfoToRepoView, getPdsAccountInfo } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReport({ auth: ctx.roleVerifier, - handler: async ({ params }) => { + handler: async ({ params, auth }) => { const { id } = params const db = ctx.db.getPrimary() const moderationService = ctx.services.moderation(db) const result = await moderationService.getReportOrThrow(id) + const [report, accountInfo] = await Promise.all([ + moderationService.views.reportDetail(result), + getPdsAccountInfo(ctx, result.subjectDid), + ]) + + // add in pds account info if available + if (isRepoView(report.subject)) { + report.subject = addAccountInfoToRepoView( + report.subject, + accountInfo, + auth.credentials.moderator, + ) + } else if (isRecordView(report.subject)) { + report.subject.repo = addAccountInfoToRepoView( + report.subject.repo, + accountInfo, + auth.credentials.moderator, + ) + } + return { encoding: 'application/json', - body: await moderationService.views.reportDetail(result), + body: report, } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/getRecord.ts b/packages/bsky/src/api/com/atproto/admin/getRecord.ts index 80e79fd94a2..245ce2b8f26 100644 --- a/packages/bsky/src/api/com/atproto/admin/getRecord.ts +++ b/packages/bsky/src/api/com/atproto/admin/getRecord.ts @@ -1,11 +1,12 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { addAccountInfoToRepoView, getPdsAccountInfo } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ auth: ctx.roleVerifier, - handler: async ({ params }) => { + handler: async ({ params, auth }) => { const { uri, cid } = params const db = ctx.db.getPrimary() const result = await db.db @@ -17,9 +18,20 @@ export default function (server: Server, ctx: AppContext) { if (!result) { throw new InvalidRequestError('Record not found', 'RecordNotFound') } + const [record, accountInfo] = await Promise.all([ + ctx.services.moderation(db).views.recordDetail(result), + getPdsAccountInfo(ctx, result.did), + ]) + + record.repo = addAccountInfoToRepoView( + record.repo, + accountInfo, + auth.credentials.moderator, + ) + return { encoding: 'application/json', - body: await ctx.services.moderation(db).views.recordDetail(result), + body: record, } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/getRepo.ts b/packages/bsky/src/api/com/atproto/admin/getRepo.ts index 5febdfcdd0c..314b345b5e9 100644 --- a/packages/bsky/src/api/com/atproto/admin/getRepo.ts +++ b/packages/bsky/src/api/com/atproto/admin/getRepo.ts @@ -1,20 +1,31 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { addAccountInfoToRepoViewDetail, getPdsAccountInfo } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ auth: ctx.roleVerifier, - handler: async ({ params }) => { + handler: async ({ params, auth }) => { const { did } = params const db = ctx.db.getPrimary() const result = await ctx.services.actor(db).getActor(did, true) if (!result) { throw new InvalidRequestError('Repo not found', 'RepoNotFound') } + const [partialRepo, accountInfo] = await Promise.all([ + ctx.services.moderation(db).views.repoDetail(result), + getPdsAccountInfo(ctx, result.did), + ]) + + const repo = addAccountInfoToRepoViewDetail( + partialRepo, + accountInfo, + auth.credentials.moderator, + ) return { encoding: 'application/json', - body: await ctx.services.moderation(db).views.repoDetail(result), + body: repo, } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/reverseModerationAction.ts b/packages/bsky/src/api/com/atproto/admin/reverseModerationAction.ts index bd478285204..ae76df5b0c7 100644 --- a/packages/bsky/src/api/com/atproto/admin/reverseModerationAction.ts +++ b/packages/bsky/src/api/com/atproto/admin/reverseModerationAction.ts @@ -1,4 +1,8 @@ -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import { ACKNOWLEDGE, ESCALATE, @@ -6,6 +10,7 @@ import { } from '../../../../lexicon/types/com/atproto/admin/defs' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { retryHttp } from '../../../../util/retry' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.reverseModerationAction({ @@ -16,7 +21,7 @@ export default function (server: Server, ctx: AppContext) { const moderationService = ctx.services.moderation(db) const { id, createdBy, reason } = input.body - const moderationAction = await db.transaction(async (dbTxn) => { + const { result, restored } = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.services.moderation(dbTxn) const labelTxn = ctx.services.label(dbTxn) const now = new Date() @@ -53,7 +58,7 @@ export default function (server: Server, ctx: AppContext) { ) } - const result = await moderationTxn.revertAction({ + const { result, restored } = await moderationTxn.revertAction({ id, createdAt: now, createdBy, @@ -77,12 +82,33 @@ export default function (server: Server, ctx: AppContext) { { create, negate }, ) - return result + return { result, restored } }) + if (restored) { + const { did, subjects } = restored + const agent = await ctx.pdsAdminAgent(did) + const results = await Promise.allSettled( + subjects.map((subject) => + retryHttp(() => + agent.api.com.atproto.admin.updateSubjectStatus({ + subject, + takedown: { + applied: false, + }, + }), + ), + ), + ) + const hadFailure = results.some((r) => r.status === 'rejected') + if (hadFailure) { + throw new UpstreamFailureError('failed to revert action on PDS') + } + } + return { encoding: 'application/json', - body: await moderationService.views.action(moderationAction), + body: await moderationService.views.action(result), } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/searchRepos.ts b/packages/bsky/src/api/com/atproto/admin/searchRepos.ts index 8faf041f589..ef580f30d67 100644 --- a/packages/bsky/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/bsky/src/api/com/atproto/admin/searchRepos.ts @@ -1,4 +1,3 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' @@ -8,16 +7,14 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params }) => { const db = ctx.db.getPrimary() const moderationService = ctx.services.moderation(db) - const { invitedBy, limit, cursor } = params - if (invitedBy) { - throw new InvalidRequestError('The invitedBy parameter is unsupported') - } + const { limit, cursor } = params // prefer new 'q' query param over deprecated 'term' const query = params.q ?? params.term const { results, cursor: resCursor } = await ctx.services .actor(db) .getSearchResults({ query, limit, cursor, includeSoftDeleted: true }) + return { encoding: 'application/json', body: { diff --git a/packages/bsky/src/api/com/atproto/admin/takeModerationAction.ts b/packages/bsky/src/api/com/atproto/admin/takeModerationAction.ts index fc49a9c14ff..a8d67fced9f 100644 --- a/packages/bsky/src/api/com/atproto/admin/takeModerationAction.ts +++ b/packages/bsky/src/api/com/atproto/admin/takeModerationAction.ts @@ -1,6 +1,10 @@ import { CID } from 'multiformats/cid' import { AtUri } from '@atproto/syntax' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { @@ -9,6 +13,8 @@ import { TAKEDOWN, } from '../../../../lexicon/types/com/atproto/admin/defs' import { getSubject, getAction } from '../moderation/util' +import { TakedownSubjects } from '../../../../services/moderation' +import { retryHttp } from '../../../../util/retry' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.takeModerationAction({ @@ -52,7 +58,7 @@ export default function (server: Server, ctx: AppContext) { validateLabels([...(createLabelVals ?? []), ...(negateLabelVals ?? [])]) - const moderationAction = await db.transaction(async (dbTxn) => { + const { result, takenDown } = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.services.moderation(dbTxn) const labelTxn = ctx.services.label(dbTxn) @@ -67,13 +73,15 @@ export default function (server: Server, ctx: AppContext) { durationInHours, }) + let takenDown: TakedownSubjects | undefined + if ( result.action === TAKEDOWN && result.subjectType === 'com.atproto.admin.defs#repoRef' && result.subjectDid ) { // No credentials to revoke on appview - await moderationTxn.takedownRepo({ + takenDown = await moderationTxn.takedownRepo({ takedownId: result.id, did: result.subjectDid, }) @@ -82,11 +90,13 @@ export default function (server: Server, ctx: AppContext) { if ( result.action === TAKEDOWN && result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri + result.subjectUri && + result.subjectCid ) { - await moderationTxn.takedownRecord({ + takenDown = await moderationTxn.takedownRecord({ takedownId: result.id, uri: new AtUri(result.subjectUri), + cid: CID.parse(result.subjectCid), blobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], }) } @@ -98,12 +108,36 @@ export default function (server: Server, ctx: AppContext) { { create: createLabelVals, negate: negateLabelVals }, ) - return result + return { result, takenDown } }) + if (takenDown) { + const { did, subjects } = takenDown + if (did && subjects.length > 0) { + const agent = await ctx.pdsAdminAgent(did) + const results = await Promise.allSettled( + subjects.map((subject) => + retryHttp(() => + agent.api.com.atproto.admin.updateSubjectStatus({ + subject, + takedown: { + applied: true, + ref: result.id.toString(), + }, + }), + ), + ), + ) + const hadFailure = results.some((r) => r.status === 'rejected') + if (hadFailure) { + throw new UpstreamFailureError('failed to apply action on PDS') + } + } + } + return { encoding: 'application/json', - body: await moderationService.views.action(moderationAction), + body: await moderationService.views.action(result), } }, }) diff --git a/packages/bsky/src/api/com/atproto/admin/util.ts b/packages/bsky/src/api/com/atproto/admin/util.ts new file mode 100644 index 00000000000..eba3eaa1d1e --- /dev/null +++ b/packages/bsky/src/api/com/atproto/admin/util.ts @@ -0,0 +1,50 @@ +import AppContext from '../../../../context' +import { + RepoView, + RepoViewDetail, + AccountView, +} from '../../../../lexicon/types/com/atproto/admin/defs' + +export const getPdsAccountInfo = async ( + ctx: AppContext, + did: string, +): Promise => { + try { + const agent = await ctx.pdsAdminAgent(did) + const res = await agent.api.com.atproto.admin.getAccountInfo({ did }) + return res.data + } catch (err) { + return null + } +} + +export const addAccountInfoToRepoViewDetail = ( + repoView: RepoViewDetail, + accountInfo: AccountView | null, + includeEmail = false, +): RepoViewDetail => { + if (!accountInfo) return repoView + return { + ...repoView, + email: includeEmail ? accountInfo.email : undefined, + invitedBy: accountInfo.invitedBy, + invitesDisabled: accountInfo.invitesDisabled, + inviteNote: accountInfo.inviteNote, + invites: accountInfo.invites, + } +} + +export const addAccountInfoToRepoView = ( + repoView: RepoView, + accountInfo: AccountView | null, + includeEmail = false, +): RepoView => { + if (!accountInfo) return repoView + return { + ...repoView, + email: includeEmail ? accountInfo.email : undefined, + invitedBy: accountInfo.invitedBy, + invitesDisabled: accountInfo.invitesDisabled, + inviteNote: accountInfo.inviteNote, + } +} diff --git a/packages/bsky/src/api/well-known.ts b/packages/bsky/src/api/well-known.ts index b6813751605..0c0802620e1 100644 --- a/packages/bsky/src/api/well-known.ts +++ b/packages/bsky/src/api/well-known.ts @@ -12,6 +12,14 @@ export const createRouter = (ctx: AppContext): express.Router => { res.json({ '@context': ['https://www.w3.org/ns/did/v1'], id: ctx.cfg.serverDid, + verificationMethod: [ + { + id: `${ctx.cfg.serverDid}#atproto`, + type: 'Multikey', + controller: ctx.cfg.serverDid, + publicKeyMultibase: ctx.signingKey.did().replace('did:key:', ''), + }, + ], service: [ { id: '#bsky_notif', diff --git a/packages/bsky/src/auth.ts b/packages/bsky/src/auth.ts index a92023d55f5..290ef3c7a42 100644 --- a/packages/bsky/src/auth.ts +++ b/packages/bsky/src/auth.ts @@ -14,11 +14,11 @@ export const authVerifier = if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } - const did = await verifyJwt(jwtStr, opts.aud, async (did: string) => { + const payload = await verifyJwt(jwtStr, opts.aud, async (did: string) => { const atprotoData = await idResolver.did.resolveAtprotoData(did) return atprotoData.signingKey }) - return { credentials: { did }, artifacts: { aud: opts.aud } } + return { credentials: { did: payload.iss }, artifacts: { aud: opts.aud } } } export const authOptionalVerifier = diff --git a/packages/bsky/src/auto-moderator/index.ts b/packages/bsky/src/auto-moderator/index.ts index 30befc19110..7118b95ac62 100644 --- a/packages/bsky/src/auto-moderator/index.ts +++ b/packages/bsky/src/auto-moderator/index.ts @@ -271,6 +271,7 @@ export class AutoModerator { await modSrvc.takedownRecord({ takedownId: action.id, uri: uri, + cid: recordCid, blobCids: takedownCids, }) }) diff --git a/packages/bsky/src/context.ts b/packages/bsky/src/context.ts index 42cbfecf218..90e6cf60014 100644 --- a/packages/bsky/src/context.ts +++ b/packages/bsky/src/context.ts @@ -1,5 +1,8 @@ import * as plc from '@did-plc/lib' import { IdResolver } from '@atproto/identity' +import { AtpAgent } from '@atproto/api' +import { Keypair } from '@atproto/crypto' +import { createServiceJwt } from '@atproto/xrpc-server' import { DatabaseCoordinator } from './db' import { ServerConfig } from './config' import { ImageUriBuilder } from './image/uri' @@ -10,7 +13,6 @@ import { BackgroundQueue } from './background' import { MountedAlgos } from './feed-gen/types' import { LabelCache } from './label-cache' import { NotificationServer } from './notifications' -import { AtpAgent } from '@atproto/api' export class AppContext { constructor( @@ -19,6 +21,7 @@ export class AppContext { imgUriBuilder: ImageUriBuilder cfg: ServerConfig services: Services + signingKey: Keypair idResolver: IdResolver didCache: DidSqlCache labelCache: LabelCache @@ -45,6 +48,10 @@ export class AppContext { return this.opts.services } + get signingKey(): Keypair { + return this.opts.signingKey + } + get plcClient(): plc.Client { return new plc.Client(this.cfg.didPlcUrl) } @@ -91,6 +98,23 @@ export class AppContext { return auth.roleVerifier(this.cfg) } + async serviceAuthJwt(aud: string) { + const iss = this.cfg.serverDid + return createServiceJwt({ + iss, + aud, + keypair: this.signingKey, + }) + } + + async pdsAdminAgent(did: string): Promise { + const data = await this.idResolver.did.resolveAtprotoData(did) + const agent = new AtpAgent({ service: data.pds }) + const jwt = await this.serviceAuthJwt(did) + agent.api.setHeader('authorization', `Bearer ${jwt}`) + return agent + } + get backgroundQueue(): BackgroundQueue { return this.opts.backgroundQueue } diff --git a/packages/bsky/src/index.ts b/packages/bsky/src/index.ts index 8ef2109218e..938d634356c 100644 --- a/packages/bsky/src/index.ts +++ b/packages/bsky/src/index.ts @@ -26,6 +26,7 @@ import { MountedAlgos } from './feed-gen/types' import { LabelCache } from './label-cache' import { NotificationServer } from './notifications' import { AtpAgent } from '@atproto/api' +import { Keypair } from '@atproto/crypto' export type { ServerConfigValues } from './config' export type { MountedAlgos } from './feed-gen/types' @@ -54,10 +55,11 @@ export class BskyAppView { static create(opts: { db: DatabaseCoordinator config: ServerConfig + signingKey: Keypair imgInvalidator?: ImageInvalidator algos?: MountedAlgos }): BskyAppView { - const { db, config, algos = {} } = opts + const { db, config, signingKey, algos = {} } = opts let maybeImgInvalidator = opts.imgInvalidator const app = express() app.use(cors()) @@ -116,6 +118,7 @@ export class BskyAppView { cfg: config, services, imgUriBuilder, + signingKey, idResolver, didCache, labelCache, diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 1fd8a1f127c..bf69ebafa68 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -12,6 +12,7 @@ import { schemas } from './lexicons' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' +import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -19,6 +20,7 @@ import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/g import * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' +import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports' import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' @@ -26,6 +28,7 @@ import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' import * as ComAtprotoLabelQueryLabels from './types/com/atproto/label/queryLabels' @@ -225,6 +228,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getAccountInfo( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetAccountInfo.Handler>, + ComAtprotoAdminGetAccountInfo.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getAccountInfo' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getInviteCodes( cfg: ConfigOf< AV, @@ -302,6 +316,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getSubjectStatus( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetSubjectStatus.Handler>, + ComAtprotoAdminGetSubjectStatus.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getSubjectStatus' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + resolveModerationReports( cfg: ConfigOf< AV, @@ -378,6 +403,17 @@ export class AdminNS { const nsid = 'com.atproto.admin.updateAccountHandle' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateSubjectStatus( + cfg: ConfigOf< + AV, + ComAtprotoAdminUpdateSubjectStatus.Handler>, + ComAtprotoAdminUpdateSubjectStatus.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.updateSubjectStatus' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class IdentityNS { diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 3911dc12497..df696e5d06b 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -8,6 +8,18 @@ export const schemaDict = { lexicon: 1, id: 'com.atproto.admin.defs', defs: { + statusAttr: { + type: 'object', + required: ['applied'], + properties: { + applied: { + type: 'boolean', + }, + ref: { + type: 'string', + }, + }, + }, actionView: { type: 'object', required: [ @@ -424,6 +436,44 @@ export const schemaDict = { }, }, }, + accountView: { + type: 'object', + required: ['did', 'handle', 'indexedAt'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + email: { + type: 'string', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + invitesDisabled: { + type: 'boolean', + }, + inviteNote: { + type: 'string', + }, + }, + }, repoViewNotFound: { type: 'object', required: ['did'], @@ -444,6 +494,24 @@ export const schemaDict = { }, }, }, + repoBlobRef: { + type: 'object', + required: ['did', 'cid'], + properties: { + did: { + type: 'string', + format: 'did', + }, + cid: { + type: 'string', + format: 'cid', + }, + recordUri: { + type: 'string', + format: 'at-uri', + }, + }, + }, recordView: { type: 'object', required: [ @@ -730,6 +798,33 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfo: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfo', + defs: { + main: { + type: 'query', + description: 'View details about an account.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1026,6 +1121,55 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.getSubjectStatus', + defs: { + main: { + type: 'query', + description: + 'Fetch the service-specific the admin status of a subject (account, record, or blob)', + parameters: { + type: 'params', + properties: { + did: { + type: 'string', + format: 'did', + }, + uri: { + type: 'string', + format: 'at-uri', + }, + blob: { + type: 'string', + format: 'cid', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminResolveModerationReports: { lexicon: 1, id: 'com.atproto.admin.resolveModerationReports', @@ -1118,9 +1262,6 @@ export const schemaDict = { q: { type: 'string', }, - invitedBy: { - type: 'string', - }, limit: { type: 'integer', minimum: 1, @@ -1326,6 +1467,59 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.updateSubjectStatus', + defs: { + main: { + type: 'procedure', + description: + 'Update the service-specific admin status of a subject (account, record, or blob)', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoIdentityResolveHandle: { lexicon: 1, id: 'com.atproto.identity.resolveHandle', @@ -7369,6 +7563,7 @@ export const ids = { 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', + ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', @@ -7376,6 +7571,7 @@ export const ids = { ComAtprotoAdminGetModerationReports: 'com.atproto.admin.getModerationReports', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', + ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', ComAtprotoAdminResolveModerationReports: 'com.atproto.admin.resolveModerationReports', ComAtprotoAdminReverseModerationAction: @@ -7385,6 +7581,7 @@ export const ids = { ComAtprotoAdminTakeModerationAction: 'com.atproto.admin.takeModerationAction', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', ComAtprotoLabelDefs: 'com.atproto.label.defs', 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 968252a4c2c..ea463368f8e 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -10,6 +10,24 @@ import * as ComAtprotoModerationDefs from '../moderation/defs' import * as ComAtprotoServerDefs from '../server/defs' import * as ComAtprotoLabelDefs from '../label/defs' +export interface StatusAttr { + applied: boolean + ref?: string + [k: string]: unknown +} + +export function isStatusAttr(v: unknown): v is StatusAttr { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#statusAttr' + ) +} + +export function validateStatusAttr(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#statusAttr', v) +} + export interface ActionView { id: number action: ActionType @@ -238,6 +256,30 @@ export function validateRepoViewDetail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoViewDetail', v) } +export interface AccountView { + did: string + handle: string + email?: string + indexedAt: string + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] + invitesDisabled?: boolean + inviteNote?: string + [k: string]: unknown +} + +export function isAccountView(v: unknown): v is AccountView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#accountView' + ) +} + +export function validateAccountView(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#accountView', v) +} + export interface RepoViewNotFound { did: string [k: string]: unknown @@ -272,6 +314,25 @@ export function validateRepoRef(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoRef', v) } +export interface RepoBlobRef { + did: string + cid: string + recordUri?: string + [k: string]: unknown +} + +export function isRepoBlobRef(v: unknown): v is RepoBlobRef { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#repoBlobRef' + ) +} + +export function validateRepoBlobRef(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#repoBlobRef', v) +} + export interface RecordView { uri: string cid: string diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfo.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfo.ts new file mode 100644 index 00000000000..88a2b17a4b8 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/getAccountInfo.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + did: string +} + +export type InputSchema = undefined +export type OutputSchema = ComAtprotoAdminDefs.AccountView +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts new file mode 100644 index 00000000000..7315e51e8c2 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams { + did?: string + uri?: string + blob?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/searchRepos.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/searchRepos.ts index 32266fd66fd..1e7e1a36bb6 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/searchRepos.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/searchRepos.ts @@ -13,7 +13,6 @@ export interface QueryParams { /** DEPRECATED: use 'q' instead */ term?: string q?: string - invitedBy?: string limit: number cursor?: string } diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts new file mode 100644 index 00000000000..559ee948380 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts @@ -0,0 +1,61 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams {} + +export interface InputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts index 0abf8f348eb..e85f1218470 100644 --- a/packages/bsky/src/services/moderation/index.ts +++ b/packages/bsky/src/services/moderation/index.ts @@ -7,7 +7,12 @@ import { ModerationAction, ModerationReport } from '../../db/tables/moderation' import { ModerationViews } from './views' import { ImageUriBuilder } from '../../image/uri' import { ImageInvalidator } from '../../image/invalidator' -import { TAKEDOWN } from '../../lexicon/types/com/atproto/admin/defs' +import { + RepoRef, + RepoBlobRef, + TAKEDOWN, +} from '../../lexicon/types/com/atproto/admin/defs' +import { Main as StrongRef } from '../../lexicon/types/com/atproto/repo/strongRef' import { addHoursToDate } from '../../util/date' export class ModerationService { @@ -355,7 +360,10 @@ export class ModerationService { createdBy, createdAt, reason, - }: ReversibleModerationAction) { + }: ReversibleModerationAction): Promise<{ + result: ModerationActionRow + restored?: TakedownSubjects + }> { this.db.assertTransaction() const result = await this.logReverseAction({ id, @@ -364,6 +372,8 @@ export class ModerationService { reason, }) + let restored: TakedownSubjects | undefined + if ( result.action === TAKEDOWN && result.subjectType === 'com.atproto.admin.defs#repoRef' && @@ -372,6 +382,15 @@ export class ModerationService { await this.reverseTakedownRepo({ did: result.subjectDid, }) + restored = { + did: result.subjectDid, + subjects: [ + { + $type: 'com.atproto.admin.defs#repoRef', + did: result.subjectDid, + }, + ], + } } if ( @@ -379,12 +398,35 @@ export class ModerationService { result.subjectType === 'com.atproto.repo.strongRef' && result.subjectUri ) { + const uri = new AtUri(result.subjectUri) await this.reverseTakedownRecord({ - uri: new AtUri(result.subjectUri), + uri, }) + const did = uri.hostname + const actionBlobs = await this.db.db + .selectFrom('moderation_action_subject_blob') + .where('actionId', '=', id) + .select('cid') + .execute() + restored = { + did, + subjects: [ + { + $type: 'com.atproto.repo.strongRef', + uri: result.subjectUri, + cid: result.subjectCid ?? '', + }, + ...actionBlobs.map((row) => ({ + $type: 'com.atproto.admin.defs#repoBlobRef', + did, + cid: row.cid, + recordUri: result.subjectUri, + })), + ], + } } - return result + return { result, restored } } async logReverseAction( @@ -410,13 +452,27 @@ export class ModerationService { return result } - async takedownRepo(info: { takedownId: number; did: string }) { + async takedownRepo(info: { + takedownId: number + did: string + }): Promise { + const { takedownId, did } = info await this.db.db .updateTable('actor') - .set({ takedownId: info.takedownId }) - .where('did', '=', info.did) + .set({ takedownId }) + .where('did', '=', did) .where('takedownId', 'is', null) .executeTakeFirst() + + return { + did, + subjects: [ + { + $type: 'com.atproto.admin.defs#repoRef', + did, + }, + ], + } } async reverseTakedownRepo(info: { did: string }) { @@ -430,26 +486,45 @@ export class ModerationService { async takedownRecord(info: { takedownId: number uri: AtUri + cid: CID blobCids?: CID[] - }) { + }): Promise { + const { takedownId, uri, cid, blobCids } = info + const did = uri.hostname this.db.assertTransaction() await this.db.db .updateTable('record') - .set({ takedownId: info.takedownId }) - .where('uri', '=', info.uri.toString()) + .set({ takedownId }) + .where('uri', '=', uri.toString()) .where('takedownId', 'is', null) .executeTakeFirst() - if (info.blobCids) { + if (blobCids) { await Promise.all( - info.blobCids.map(async (cid) => { + blobCids.map(async (cid) => { const paths = ImageUriBuilder.presets.map((id) => { - const uri = this.imgUriBuilder.getPresetUri(id, info.uri.host, cid) - return uri.replace(this.imgUriBuilder.endpoint, '') + const imgUri = this.imgUriBuilder.getPresetUri(id, uri.host, cid) + return imgUri.replace(this.imgUriBuilder.endpoint, '') }) await this.imgInvalidator.invalidate(cid.toString(), paths) }), ) } + return { + did, + subjects: [ + { + $type: 'com.atproto.repo.strongRef', + uri: uri.toString(), + cid: cid.toString(), + }, + ...(blobCids || []).map((cid) => ({ + $type: 'com.atproto.admin.defs#repoBlobRef', + did, + cid: cid.toString(), + recordUri: uri.toString(), + })), + ], + } } async reverseTakedownRecord(info: { uri: AtUri }) { @@ -563,6 +638,11 @@ export class ModerationService { } } +export type TakedownSubjects = { + did: string + subjects: (RepoRef | RepoBlobRef | StrongRef)[] +} + export type ModerationActionRow = Selectable export type ReversibleModerationAction = Pick< ModerationActionRow, diff --git a/packages/bsky/src/services/moderation/views.ts b/packages/bsky/src/services/moderation/views.ts index b8d745a594d..06398c3427e 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -211,6 +211,7 @@ export class ModerationViews { .selectFrom('moderation_report') .where('subjectType', '=', 'com.atproto.repo.strongRef') .where('subjectUri', '=', result.uri) + .leftJoin('actor', 'actor.did', 'moderation_report.subjectDid') .orderBy('id', 'desc') .selectAll() .execute(), diff --git a/packages/pds/tests/admin/__snapshots__/get-moderation-action.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-moderation-action.test.ts.snap similarity index 94% rename from packages/pds/tests/admin/__snapshots__/get-moderation-action.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-moderation-action.test.ts.snap index aedd7a5a7ea..fffc5678d9b 100644 --- a/packages/pds/tests/admin/__snapshots__/get-moderation-action.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-moderation-action.test.ts.snap @@ -1,11 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get moderation action view gets moderation action for a record. 1`] = ` +exports[`admin get moderation action view gets moderation action for a record. 1`] = ` Object { "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReports": Array [ Object { @@ -15,8 +15,8 @@ Object { "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", "resolvedByActionIds": Array [ - 3, 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -33,7 +33,7 @@ Object { "moderation": Object { "currentAction": Object { "action": "com.atproto.admin.defs#takedown", - "id": 3, + "id": 2, }, }, "repo": Object { @@ -89,12 +89,12 @@ Object { } `; -exports[`pds admin get moderation action view gets moderation action for a repo. 1`] = ` +exports[`admin get moderation action view gets moderation action for a repo. 1`] = ` Object { "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReports": Array [ Object { @@ -104,8 +104,8 @@ Object { "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", "resolvedByActionIds": Array [ - 3, 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -119,7 +119,7 @@ Object { "reasonType": "com.atproto.moderation.defs#reasonSpam", "reportedBy": "user(2)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.admin.defs#repoRef", diff --git a/packages/pds/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap similarity index 85% rename from packages/pds/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap index 67ef8d45700..625df2076d8 100644 --- a/packages/pds/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-moderation-actions.test.ts.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get moderation actions view gets all moderation actions for a record. 1`] = ` +exports[`admin get moderation actions view gets all moderation actions for a record. 1`] = ` Array [ Object { "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [ 1, @@ -26,13 +26,13 @@ Array [ ] `; -exports[`pds admin get moderation actions view gets all moderation actions for a repo. 1`] = ` +exports[`admin get moderation actions view gets all moderation actions for a repo. 1`] = ` Array [ Object { "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 6, + "id": 5, "reason": "X", "resolvedReportIds": Array [ 3, @@ -47,7 +47,7 @@ Array [ "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -61,7 +61,7 @@ Array [ "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [ 1, @@ -81,13 +81,13 @@ Array [ ] `; -exports[`pds admin get moderation actions view gets all moderation actions. 1`] = ` +exports[`admin get moderation actions view gets all moderation actions. 1`] = ` Array [ Object { "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 7, + "id": 6, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -100,7 +100,7 @@ Array [ "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 6, + "id": 5, "reason": "X", "resolvedReportIds": Array [ 3, @@ -115,7 +115,7 @@ Array [ "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 5, + "id": 4, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -129,7 +129,7 @@ Array [ "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 4, + "id": 3, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -143,7 +143,7 @@ Array [ "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -157,7 +157,7 @@ Array [ "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [ 1, @@ -174,21 +174,5 @@ Array [ }, "subjectBlobCids": Array [], }, - Object { - "action": "com.atproto.admin.defs#flag", - "createLabelVals": Array [ - "repo-action-label", - ], - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "did:example:admin", - "id": 1, - "reason": "test", - "resolvedReportIds": Array [], - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(2)", - }, - "subjectBlobCids": Array [], - }, ] `; diff --git a/packages/pds/tests/admin/__snapshots__/get-moderation-report.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-moderation-report.test.ts.snap similarity index 95% rename from packages/pds/tests/admin/__snapshots__/get-moderation-report.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-moderation-report.test.ts.snap index 70e829d0ab0..44a42b129e7 100644 --- a/packages/pds/tests/admin/__snapshots__/get-moderation-report.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-moderation-report.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get moderation action view gets moderation report for a record. 1`] = ` +exports[`admin get moderation action view gets moderation report for a record. 1`] = ` Object { "createdAt": "1970-01-01T00:00:00.000Z", "id": 2, @@ -12,7 +12,7 @@ Object { "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [ 2, @@ -28,7 +28,7 @@ Object { "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [ 2, @@ -54,7 +54,7 @@ Object { "moderation": Object { "currentAction": Object { "action": "com.atproto.admin.defs#takedown", - "id": 3, + "id": 2, }, }, "repo": Object { @@ -109,7 +109,7 @@ Object { } `; -exports[`pds admin get moderation action view gets moderation report for a repo. 1`] = ` +exports[`admin get moderation action view gets moderation report for a repo. 1`] = ` Object { "createdAt": "1970-01-01T00:00:00.000Z", "id": 1, @@ -120,7 +120,7 @@ Object { "action": "com.atproto.admin.defs#flag", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [ 2, diff --git a/packages/pds/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap similarity index 88% rename from packages/pds/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap index 9cfb5ae3c34..9708df52cc6 100644 --- a/packages/pds/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-moderation-reports.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get moderation reports view gets all moderation reports actioned by a certain moderator. 1`] = ` +exports[`admin get moderation reports view gets all moderation reports actioned by a certain moderator. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -8,7 +8,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -20,7 +20,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all moderation reports actioned by a certain moderator. 2`] = ` +exports[`admin get moderation reports view gets all moderation reports actioned by a certain moderator. 2`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -28,7 +28,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 4, + 3, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -40,7 +40,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all moderation reports by active resolution action type. 1`] = ` +exports[`admin get moderation reports view gets all moderation reports by active resolution action type. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -48,7 +48,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 4, + 3, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -60,7 +60,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all moderation reports for a record. 1`] = ` +exports[`admin get moderation reports view gets all moderation reports for a record. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -68,7 +68,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -80,7 +80,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all moderation reports for a repo. 1`] = ` +exports[`admin get moderation reports view gets all moderation reports for a repo. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -88,7 +88,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 6, + 5, ], "subject": Object { "$type": "com.atproto.admin.defs#repoRef", @@ -115,7 +115,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -127,7 +127,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all moderation reports. 1`] = ` +exports[`admin get moderation reports view gets all moderation reports. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -147,7 +147,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", "resolvedByActionIds": Array [ - 6, + 5, ], "subject": Object { "$type": "com.atproto.admin.defs#repoRef", @@ -174,7 +174,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", "resolvedByActionIds": Array [ - 4, + 3, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -202,7 +202,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(1)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -214,7 +214,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all resolved/unresolved moderation reports. 1`] = ` +exports[`admin get moderation reports view gets all resolved/unresolved moderation reports. 1`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", @@ -222,7 +222,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 6, + 5, ], "subject": Object { "$type": "com.atproto.admin.defs#repoRef", @@ -236,7 +236,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 4, + 3, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -251,7 +251,7 @@ Array [ "reasonType": "com.atproto.moderation.defs#reasonOther", "reportedBy": "user(0)", "resolvedByActionIds": Array [ - 2, + 1, ], "subject": Object { "$type": "com.atproto.repo.strongRef", @@ -263,7 +263,7 @@ Array [ ] `; -exports[`pds admin get moderation reports view gets all resolved/unresolved moderation reports. 2`] = ` +exports[`admin get moderation reports view gets all resolved/unresolved moderation reports. 2`] = ` Array [ Object { "createdAt": "1970-01-01T00:00:00.000Z", diff --git a/packages/pds/tests/admin/__snapshots__/get-record.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-record.test.ts.snap similarity index 91% rename from packages/pds/tests/admin/__snapshots__/get-record.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-record.test.ts.snap index 00fbc5bda1c..cbb922003cb 100644 --- a/packages/pds/tests/admin/__snapshots__/get-record.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-record.test.ts.snap @@ -1,18 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get record view gets a record by uri and cid. 1`] = ` +exports[`admin get record view gets a record by uri and cid. 1`] = ` Object { "blobCids": Array [], "blobs": Array [], "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(0)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(0)", + "val": "self-label", + }, + ], "moderation": Object { "actions": Array [ Object { "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -26,7 +36,7 @@ Object { "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [], "reversal": Object { @@ -44,7 +54,7 @@ Object { ], "currentAction": Object { "action": "com.atproto.admin.defs#takedown", - "id": 3, + "id": 2, }, "reports": Array [ Object { @@ -127,19 +137,29 @@ Object { } `; -exports[`pds admin get record view gets a record by uri, even when taken down. 1`] = ` +exports[`admin get record view gets a record by uri, even when taken down. 1`] = ` Object { "blobCids": Array [], "blobs": Array [], "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(0)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(0)", + "val": "self-label", + }, + ], "moderation": Object { "actions": Array [ Object { "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -153,7 +173,7 @@ Object { "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [], "reversal": Object { @@ -171,7 +191,7 @@ Object { ], "currentAction": Object { "action": "com.atproto.admin.defs#takedown", - "id": 3, + "id": 2, }, "reports": Array [ Object { diff --git a/packages/pds/tests/admin/__snapshots__/get-repo.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/get-repo.test.ts.snap similarity index 95% rename from packages/pds/tests/admin/__snapshots__/get-repo.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/get-repo.test.ts.snap index c90b1a070b2..1a60b27b069 100644 --- a/packages/pds/tests/admin/__snapshots__/get-repo.test.ts.snap +++ b/packages/bsky/tests/admin/__snapshots__/get-repo.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds admin get repo view gets a repo by did, even when taken down. 1`] = ` +exports[`admin get repo view gets a repo by did, even when taken down. 1`] = ` Object { "did": "user(0)", "email": "alice@test.com", @@ -8,13 +8,14 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", "invites": Array [], "invitesDisabled": false, + "labels": Array [], "moderation": Object { "actions": Array [ Object { "action": "com.atproto.admin.defs#takedown", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 3, + "id": 2, "reason": "X", "resolvedReportIds": Array [], "subject": Object { @@ -27,7 +28,7 @@ Object { "action": "com.atproto.admin.defs#acknowledge", "createdAt": "1970-01-01T00:00:00.000Z", "createdBy": "did:example:admin", - "id": 2, + "id": 1, "reason": "X", "resolvedReportIds": Array [], "reversal": Object { @@ -44,7 +45,7 @@ Object { ], "currentAction": Object { "action": "com.atproto.admin.defs#takedown", - "id": 3, + "id": 2, }, "reports": Array [ Object { diff --git a/packages/bsky/tests/__snapshots__/moderation.test.ts.snap b/packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap similarity index 100% rename from packages/bsky/tests/__snapshots__/moderation.test.ts.snap rename to packages/bsky/tests/admin/__snapshots__/moderation.test.ts.snap diff --git a/packages/pds/tests/admin/get-moderation-action.test.ts b/packages/bsky/tests/admin/get-moderation-action.test.ts similarity index 89% rename from packages/pds/tests/admin/get-moderation-action.test.ts rename to packages/bsky/tests/admin/get-moderation-action.test.ts index 11a64799db3..5c7fe3401db 100644 --- a/packages/pds/tests/admin/get-moderation-action.test.ts +++ b/packages/bsky/tests/admin/get-moderation-action.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { TestNetwork, SeedClient } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { FLAG, @@ -11,13 +11,13 @@ import { import { forSnapshot } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get moderation action view', () => { - let network: TestNetworkNoAppView +describe('admin get moderation action view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_moderation_action', }) agent = network.pds.getClient() @@ -75,18 +75,16 @@ describe('pds admin get moderation action view', () => { }) it('gets moderation action for a repo.', async () => { - // id 2 because id 1 is in seed client const result = await agent.api.com.atproto.admin.getModerationAction( - { id: 2 }, + { id: 1 }, { headers: { authorization: network.pds.adminAuth() } }, ) expect(forSnapshot(result.data)).toMatchSnapshot() }) it('gets moderation action for a record.', async () => { - // id 3 because id 1 is in seed client const result = await agent.api.com.atproto.admin.getModerationAction( - { id: 3 }, + { id: 2 }, { headers: { authorization: network.pds.adminAuth() } }, ) expect(forSnapshot(result.data)).toMatchSnapshot() diff --git a/packages/pds/tests/admin/get-moderation-actions.test.ts b/packages/bsky/tests/admin/get-moderation-actions.test.ts similarity index 94% rename from packages/pds/tests/admin/get-moderation-actions.test.ts rename to packages/bsky/tests/admin/get-moderation-actions.test.ts index 01a934c32e0..dfc08aa82b5 100644 --- a/packages/pds/tests/admin/get-moderation-actions.test.ts +++ b/packages/bsky/tests/admin/get-moderation-actions.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { ACKNOWLEDGE, @@ -12,13 +12,13 @@ import { import { forSnapshot, paginateAll } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get moderation actions view', () => { - let network: TestNetworkNoAppView +describe('admin get moderation actions view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_moderation_actions', }) agent = network.pds.getClient() @@ -158,7 +158,7 @@ describe('pds admin get moderation actions view', () => { { headers: network.pds.adminAuthHeaders() }, ) - expect(full.data.actions.length).toEqual(7) // extra one because of seed client + expect(full.data.actions.length).toEqual(6) expect(results(paginatedAll)).toEqual(results([full.data])) }) }) diff --git a/packages/pds/tests/admin/get-moderation-report.test.ts b/packages/bsky/tests/admin/get-moderation-report.test.ts similarity index 92% rename from packages/pds/tests/admin/get-moderation-report.test.ts rename to packages/bsky/tests/admin/get-moderation-report.test.ts index 714596e352f..4a77750aa0a 100644 --- a/packages/pds/tests/admin/get-moderation-report.test.ts +++ b/packages/bsky/tests/admin/get-moderation-report.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { FLAG, @@ -11,13 +11,13 @@ import { import { forSnapshot } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get moderation action view', () => { - let network: TestNetworkNoAppView +describe('admin get moderation action view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_moderation_report', }) agent = network.pds.getClient() diff --git a/packages/pds/tests/admin/get-moderation-reports.test.ts b/packages/bsky/tests/admin/get-moderation-reports.test.ts similarity index 98% rename from packages/pds/tests/admin/get-moderation-reports.test.ts rename to packages/bsky/tests/admin/get-moderation-reports.test.ts index aac3560c048..64313130047 100644 --- a/packages/pds/tests/admin/get-moderation-reports.test.ts +++ b/packages/bsky/tests/admin/get-moderation-reports.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { ACKNOWLEDGE, @@ -12,13 +12,13 @@ import { import { forSnapshot, paginateAll } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get moderation reports view', () => { - let network: TestNetworkNoAppView +describe('admin get moderation reports view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_moderation_reports', }) agent = network.pds.getClient() diff --git a/packages/pds/tests/admin/get-record.test.ts b/packages/bsky/tests/admin/get-record.test.ts similarity index 94% rename from packages/pds/tests/admin/get-record.test.ts rename to packages/bsky/tests/admin/get-record.test.ts index 350709971fc..94ae22b1694 100644 --- a/packages/pds/tests/admin/get-record.test.ts +++ b/packages/bsky/tests/admin/get-record.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { AtUri } from '@atproto/syntax' import { @@ -12,13 +12,13 @@ import { import { forSnapshot } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get record view', () => { - let network: TestNetworkNoAppView +describe('admin get record view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_record', }) agent = network.pds.getClient() diff --git a/packages/pds/tests/admin/get-repo.test.ts b/packages/bsky/tests/admin/get-repo.test.ts similarity index 93% rename from packages/pds/tests/admin/get-repo.test.ts rename to packages/bsky/tests/admin/get-repo.test.ts index 9467643973e..3c1e909a4ab 100644 --- a/packages/pds/tests/admin/get-repo.test.ts +++ b/packages/bsky/tests/admin/get-repo.test.ts @@ -1,4 +1,4 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { ACKNOWLEDGE, @@ -11,13 +11,13 @@ import { import { forSnapshot } from '../_util' import basicSeed from '../seeds/basic' -describe('pds admin get repo view', () => { - let network: TestNetworkNoAppView +describe('admin get repo view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_get_repo', }) agent = network.pds.getClient() diff --git a/packages/bsky/tests/moderation.test.ts b/packages/bsky/tests/admin/moderation.test.ts similarity index 91% rename from packages/bsky/tests/moderation.test.ts rename to packages/bsky/tests/admin/moderation.test.ts index e1af045693b..05200087e3c 100644 --- a/packages/bsky/tests/moderation.test.ts +++ b/packages/bsky/tests/admin/moderation.test.ts @@ -2,23 +2,24 @@ import { TestNetwork, ImageRef, RecordRef, SeedClient } from '@atproto/dev-env' import { TID, cidForCbor } from '@atproto/common' import AtpAgent, { ComAtprotoAdminTakeModerationAction } from '@atproto/api' import { AtUri } from '@atproto/syntax' -import { forSnapshot } from './_util' -import basicSeed from './seeds/basic' +import { forSnapshot } from '../_util' +import basicSeed from '../seeds/basic' import { ACKNOWLEDGE, ESCALATE, FLAG, TAKEDOWN, -} from '../src/lexicon/types/com/atproto/admin/defs' +} from '../../src/lexicon/types/com/atproto/admin/defs' import { REASONOTHER, REASONSPAM, -} from '../src/lexicon/types/com/atproto/moderation/defs' -import { PeriodicModerationActionReversal } from '../src' +} from '../../src/lexicon/types/com/atproto/moderation/defs' +import { PeriodicModerationActionReversal } from '../../src' describe('moderation', () => { let network: TestNetwork let agent: AtpAgent + let pdsAgent: AtpAgent let sc: SeedClient beforeAll(async () => { @@ -26,6 +27,7 @@ describe('moderation', () => { dbPostgresSchema: 'bsky_moderation', }) agent = network.bsky.getClient() + pdsAgent = network.pds.getClient() sc = network.getSeedClient() await basicSeed(sc) await network.processAll() @@ -960,6 +962,82 @@ describe('moderation', () => { ) }) + it('fans out repo takedowns to pds', async () => { + const { data: action } = + await agent.api.com.atproto.admin.takeModerationAction( + { + action: TAKEDOWN, + createdBy: 'did:example:moderator', + reason: 'Y', + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + }, + }, + { + encoding: 'application/json', + headers: network.bsky.adminAuthHeaders(), + }, + ) + + const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.bob, + }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res1.data.takedown?.applied).toBe(true) + + // cleanup + await reverse(action.id) + + const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.bob, + }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res2.data.takedown?.applied).toBe(false) + }) + + it('fans out record takedowns to pds', async () => { + const post = sc.posts[sc.dids.bob][0] + const uri = post.ref.uriStr + const cid = post.ref.cidStr + const { data: action } = + await agent.api.com.atproto.admin.takeModerationAction( + { + action: TAKEDOWN, + createdBy: 'did:example:moderator', + reason: 'Y', + subject: { + $type: 'com.atproto.repo.strongRef', + uri, + cid, + }, + }, + { + encoding: 'application/json', + headers: network.bsky.adminAuthHeaders(), + }, + ) + + const res1 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { uri }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res1.data.takedown?.applied).toBe(true) + + // cleanup + await reverse(action.id) + + const res2 = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { uri }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res2.data.takedown?.applied).toBe(false) + }) + it('allows full moderators to takedown.', async () => { const { data: action } = await agent.api.com.atproto.admin.takeModerationAction( @@ -1159,6 +1237,17 @@ describe('moderation', () => { expect(await fetchImage.json()).toEqual({ message: 'Image not found' }) }) + it('fans takedown out to pds', async () => { + const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.carol, + blob: blob.image.ref.toString(), + }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res.data.takedown?.applied).toBe(true) + }) + it('restores blob when action is reversed.', async () => { await agent.api.com.atproto.admin.reverseModerationAction( { @@ -1183,5 +1272,16 @@ describe('moderation', () => { const size = Number(fetchImage.headers.get('content-length')) expect(size).toBeGreaterThan(9000) }) + + it('fans reversal out to pds', async () => { + const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus( + { + did: sc.dids.carol, + blob: blob.image.ref.toString(), + }, + { headers: network.pds.adminAuthHeaders() }, + ) + expect(res.data.takedown?.applied).toBe(false) + }) }) }) diff --git a/packages/pds/tests/admin/repo-search.test.ts b/packages/bsky/tests/admin/repo-search.test.ts similarity index 84% rename from packages/pds/tests/admin/repo-search.test.ts rename to packages/bsky/tests/admin/repo-search.test.ts index b95dde6063d..fab63257147 100644 --- a/packages/pds/tests/admin/repo-search.test.ts +++ b/packages/bsky/tests/admin/repo-search.test.ts @@ -1,17 +1,17 @@ -import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' +import { SeedClient, TestNetwork } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' import { paginateAll } from '../_util' import usersBulkSeed from '../seeds/users-bulk' -describe('pds admin repo search view', () => { - let network: TestNetworkNoAppView +describe('admin repo search view', () => { + let network: TestNetwork let agent: AtpAgent let sc: SeedClient let headers: { [s: string]: string } beforeAll(async () => { - network = await TestNetworkNoAppView.create({ + network = await TestNetwork.create({ dbPostgresSchema: 'views_admin_repo_search', }) agent = network.pds.getClient() @@ -74,19 +74,6 @@ describe('pds admin repo search view', () => { expect(res.data.repos[0].did).toEqual(term) }) - it('finds repo by email', async () => { - const did = sc.dids['cara-wiegand69.test'] - const { email } = sc.accounts[did] - const res = await agent.api.com.atproto.admin.searchRepos( - { term: email }, - { headers }, - ) - - expect(res.data.repos.length).toEqual(1) - expect(res.data.repos[0].did).toEqual(did) - expect(res.data.repos[0].email).toEqual(email) - }) - it('paginates with term', async () => { const results = (results) => results.flatMap((res) => res.users) const paginator = async (cursor?: string) => { diff --git a/packages/bsky/tests/auto-moderator/takedowns.test.ts b/packages/bsky/tests/auto-moderator/takedowns.test.ts index 6c7b0669b77..d2bc8d4a2a2 100644 --- a/packages/bsky/tests/auto-moderator/takedowns.test.ts +++ b/packages/bsky/tests/auto-moderator/takedowns.test.ts @@ -96,9 +96,9 @@ describe('takedowner', () => { const recordPds = await network.pds.ctx.db.db .selectFrom('record') .where('uri', '=', post.ref.uriStr) - .select('takedownId') + .select('takedownRef') .executeTakeFirst() - expect(recordPds?.takedownId).toEqual(modAction.id) + expect(recordPds?.takedownRef).toEqual(modAction.id.toString()) expect(testInvalidator.invalidated.length).toBe(1) expect(testInvalidator.invalidated[0].subject).toBe( @@ -138,9 +138,9 @@ describe('takedowner', () => { const recordPds = await network.pds.ctx.db.db .selectFrom('record') .where('uri', '=', res.data.uri) - .select('takedownId') + .select('takedownRef') .executeTakeFirst() - expect(recordPds?.takedownId).toEqual(modAction.id) + expect(recordPds?.takedownRef).toEqual(modAction.id.toString()) expect(testInvalidator.invalidated.length).toBe(2) expect(testInvalidator.invalidated[1].subject).toBe( diff --git a/packages/bsky/tests/views/actor-search.test.ts b/packages/bsky/tests/views/actor-search.test.ts index 5562f747700..0f22eff0513 100644 --- a/packages/bsky/tests/views/actor-search.test.ts +++ b/packages/bsky/tests/views/actor-search.test.ts @@ -5,7 +5,9 @@ import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' import { forSnapshot, paginateAll, stripViewer } from '../_util' import usersBulkSeed from '../seeds/users-bulk' -describe('pds actor search views', () => { +// @NOTE skipped to help with CI failures +// The search code is not used in production & we should switch it out for tests on the search proxy interface +describe.skip('pds actor search views', () => { let network: TestNetwork let agent: AtpAgent let sc: SeedClient diff --git a/packages/bsky/tests/views/posts.test.ts b/packages/bsky/tests/views/posts.test.ts index a2710a02cf7..69bade5b91a 100644 --- a/packages/bsky/tests/views/posts.test.ts +++ b/packages/bsky/tests/views/posts.test.ts @@ -18,7 +18,6 @@ describe('pds posts views', () => { sc = network.getSeedClient() await basicSeed(sc) await network.processAll() - await network.bsky.processAll() }) afterAll(async () => { @@ -98,7 +97,6 @@ describe('pds posts views', () => { ) await network.processAll() - await network.bsky.processAll() const { data } = await agent.api.app.bsky.feed.getPosts({ uris: [uri] }) diff --git a/packages/dev-env/src/bsky.ts b/packages/dev-env/src/bsky.ts index ac09e88419d..968bceb9536 100644 --- a/packages/dev-env/src/bsky.ts +++ b/packages/dev-env/src/bsky.ts @@ -9,6 +9,7 @@ import { Client as PlcClient } from '@did-plc/lib' import { BskyConfig } from './types' import { uniqueLockId } from './util' import { TestNetworkNoAppView } from './network-no-appview' +import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' export class TestBsky { constructor( @@ -43,9 +44,9 @@ export class TestBsky { didCacheMaxTTL: DAY, ...cfg, // Each test suite gets its own lock id for the repo subscription - adminPassword: 'admin-pass', - moderatorPassword: 'moderator-pass', - triagePassword: 'triage-pass', + adminPassword: ADMIN_PASSWORD, + moderatorPassword: MOD_PASSWORD, + triagePassword: TRIAGE_PASSWORD, labelerDid: 'did:example:labeler', feedGenDid: 'did:example:feedGen', }) @@ -78,6 +79,7 @@ export class TestBsky { config, algos: cfg.algos, imgInvalidator: cfg.imgInvalidator, + signingKey: serviceKeypair, }) // indexer const ns = cfg.dbPostgresSchema diff --git a/packages/dev-env/src/const.ts b/packages/dev-env/src/const.ts new file mode 100644 index 00000000000..137b8efd2c5 --- /dev/null +++ b/packages/dev-env/src/const.ts @@ -0,0 +1,3 @@ +export const ADMIN_PASSWORD = 'admin-pass' +export const MOD_PASSWORD = 'mod-pass' +export const TRIAGE_PASSWORD = 'triage-pass' diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 501ae390cdb..ada6dbec0a4 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -7,10 +7,7 @@ import { Secp256k1Keypair, randomStr } from '@atproto/crypto' import { AtpAgent } from '@atproto/api' import { PdsConfig } from './types' import { uniqueLockId } from './util' - -const ADMIN_PASSWORD = 'admin-pass' -const MOD_PASSWORD = 'mod-pass' -const TRIAGE_PASSWORD = 'triage-pass' +import { ADMIN_PASSWORD, MOD_PASSWORD, TRIAGE_PASSWORD } from './const' export class TestPds { constructor( diff --git a/packages/identity/src/did/atproto-data.ts b/packages/identity/src/did/atproto-data.ts index 6881bda48dc..c03f76ef598 100644 --- a/packages/identity/src/did/atproto-data.ts +++ b/packages/identity/src/did/atproto-data.ts @@ -62,3 +62,11 @@ export const ensureAtpDocument = (doc: DidDocument): AtprotoData => { } return { did, signingKey, handle, pds } } + +export const ensureAtprotoKey = (doc: DidDocument): string => { + const { signingKey } = parseToAtprotoDocument(doc) + if (!signingKey) { + throw new Error(`Could not parse signingKey from doc: ${doc}`) + } + return signingKey +} diff --git a/packages/identity/src/did/base-resolver.ts b/packages/identity/src/did/base-resolver.ts index 765f354213c..825dddac566 100644 --- a/packages/identity/src/did/base-resolver.ts +++ b/packages/identity/src/did/base-resolver.ts @@ -83,8 +83,8 @@ export abstract class BaseResolver { if (did.startsWith('did:key:')) { return did } else { - const data = await this.resolveAtprotoData(did, forceRefresh) - return data.signingKey + const didDocument = await this.ensureResolve(did, forceRefresh) + return atprotoData.ensureAtprotoKey(didDocument) } } diff --git a/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts new file mode 100644 index 00000000000..cf751d08df4 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/getAccountInfo.ts @@ -0,0 +1,22 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { ensureValidAdminAud } from '../../../../auth-verifier' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getAccountInfo({ + auth: ctx.authVerifier.roleOrAdminService, + handler: async ({ params, auth }) => { + // any admin role auth can get account info, but verify aud on service jwt + ensureValidAdminAud(auth, params.did) + const view = await ctx.services.account(ctx.db).adminView(params.did) + if (!view) { + throw new InvalidRequestError('Account not found', 'NotFound') + } + return { + encoding: 'application/json', + body: view, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts index 02d14a0ce1c..50b9fcde5ad 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationAction.ts @@ -1,54 +1,19 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' -import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationAction({ auth: ctx.authVerifier.role, - handler: async ({ req, params, auth }) => { - const access = auth.credentials - const { db, services } = ctx - const accountService = services.account(db) - const moderationService = services.moderation(db) - - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: resultAppview } = - await ctx.appViewAgent.com.atproto.admin.getModerationAction( - params, - authPassthru(req), - ) - // merge local repo state for subject if available - if (isRepoView(resultAppview.subject)) { - const account = await accountService.getAccount( - resultAppview.subject.did, - true, - ) - const repo = - account && - (await moderationService.views.repo(account, { - includeEmails: access.moderator, - })) - if (repo) { - resultAppview.subject = mergeRepoViewPdsDetails( - resultAppview.subject, - repo, - ) - } - } - return { - encoding: 'application/json', - body: resultAppview, - } - } - - const { id } = params - const result = await moderationService.getActionOrThrow(id) + handler: async ({ req, params }) => { + const { data: resultAppview } = + await ctx.appViewAgent.com.atproto.admin.getModerationAction( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: await moderationService.views.actionDetail(result, { - includeEmails: access.moderator, - }), + body: resultAppview, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts index f36f44c4917..d9cf61ba1ee 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationActions.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationActions.ts @@ -6,32 +6,14 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationActions({ auth: ctx.authVerifier.role, handler: async ({ req, params }) => { - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.getModerationActions( - params, - authPassthru(req), - ) - return { - encoding: 'application/json', - body: result, - } - } - - const { db, services } = ctx - const { subject, limit = 50, cursor } = params - const moderationService = services.moderation(db) - const results = await moderationService.getActions({ - subject, - limit, - cursor, - }) + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.getModerationActions( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: { - cursor: results.at(-1)?.id.toString() ?? undefined, - actions: await moderationService.views.action(results), - }, + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts index 11a7a943542..681679c87db 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReport.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReport.ts @@ -1,54 +1,19 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' -import { isRepoView } from '../../../../lexicon/types/com/atproto/admin/defs' +import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReport({ auth: ctx.authVerifier.role, - handler: async ({ req, params, auth }) => { - const access = auth.credentials - const { db, services } = ctx - const accountService = services.account(db) - const moderationService = services.moderation(db) - - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: resultAppview } = - await ctx.appViewAgent.com.atproto.admin.getModerationReport( - params, - authPassthru(req), - ) - // merge local repo state for subject if available - if (isRepoView(resultAppview.subject)) { - const account = await accountService.getAccount( - resultAppview.subject.did, - true, - ) - const repo = - account && - (await moderationService.views.repo(account, { - includeEmails: access.moderator, - })) - if (repo) { - resultAppview.subject = mergeRepoViewPdsDetails( - resultAppview.subject, - repo, - ) - } - } - return { - encoding: 'application/json', - body: resultAppview, - } - } - - const { id } = params - const result = await moderationService.getReportOrThrow(id) + handler: async ({ req, params }) => { + const { data: resultAppview } = + await ctx.appViewAgent.com.atproto.admin.getModerationReport( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: await moderationService.views.reportDetail(result, { - includeEmails: access.moderator, - }), + body: resultAppview, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts index 20a7bb6c88d..a213504d840 100644 --- a/packages/pds/src/api/com/atproto/admin/getModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/getModerationReports.ts @@ -6,48 +6,14 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getModerationReports({ auth: ctx.authVerifier.role, handler: async ({ req, params }) => { - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.getModerationReports( - params, - authPassthru(req), - ) - return { - encoding: 'application/json', - body: result, - } - } - - const { db, services } = ctx - const { - subject, - resolved, - actionType, - limit = 50, - cursor, - ignoreSubjects = [], - reverse = false, - reporters = [], - actionedBy, - } = params - const moderationService = services.moderation(db) - const results = await moderationService.getReports({ - subject, - resolved, - actionType, - limit, - cursor, - ignoreSubjects, - reverse, - reporters, - actionedBy, - }) + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.getModerationReports( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: { - cursor: results.at(-1)?.id.toString() ?? undefined, - reports: await moderationService.views.report(results), - }, + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getRecord.ts b/packages/pds/src/api/com/atproto/admin/getRecord.ts index 9ec3a0606ae..30075a1d2ab 100644 --- a/packages/pds/src/api/com/atproto/admin/getRecord.ts +++ b/packages/pds/src/api/com/atproto/admin/getRecord.ts @@ -1,57 +1,19 @@ -import { AtUri } from '@atproto/syntax' -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRecord({ auth: ctx.authVerifier.role, - handler: async ({ req, params, auth }) => { - const access = auth.credentials - const { db, services } = ctx - const { uri, cid } = params - const result = await services - .record(db) - .getRecord(new AtUri(uri), cid ?? null, true) - const recordDetail = - result && - (await services.moderation(db).views.recordDetail(result, { - includeEmails: access.moderator, - })) - - if (ctx.cfg.bskyAppView.proxyModeration) { - try { - const { data: recordDetailAppview } = - await ctx.appViewAgent.com.atproto.admin.getRecord( - params, - authPassthru(req), - ) - if (recordDetail) { - recordDetailAppview.repo = mergeRepoViewPdsDetails( - recordDetailAppview.repo, - recordDetail.repo, - ) - } - return { - encoding: 'application/json', - body: recordDetailAppview, - } - } catch (err) { - if (err && err['error'] === 'RecordNotFound') { - throw new InvalidRequestError('Record not found', 'RecordNotFound') - } else { - throw err - } - } - } - - if (!recordDetail) { - throw new InvalidRequestError('Record not found', 'RecordNotFound') - } + handler: async ({ req, params }) => { + const { data: recordDetailAppview } = + await ctx.appViewAgent.com.atproto.admin.getRecord( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: recordDetail, + body: recordDetailAppview, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getRepo.ts b/packages/pds/src/api/com/atproto/admin/getRepo.ts index 9c786772a36..3eb2e7c14c8 100644 --- a/packages/pds/src/api/com/atproto/admin/getRepo.ts +++ b/packages/pds/src/api/com/atproto/admin/getRepo.ts @@ -1,54 +1,18 @@ -import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { authPassthru, mergeRepoViewPdsDetails } from './util' +import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.getRepo({ auth: ctx.authVerifier.role, - handler: async ({ req, params, auth }) => { - const access = auth.credentials - const { db, services } = ctx - const { did } = params - const result = await services.account(db).getAccount(did, true) - const repoDetail = - result && - (await services.moderation(db).views.repoDetail(result, { - includeEmails: access.moderator, - })) - - if (ctx.cfg.bskyAppView.proxyModeration) { - try { - let { data: repoDetailAppview } = - await ctx.appViewAgent.com.atproto.admin.getRepo( - params, - authPassthru(req), - ) - if (repoDetail) { - repoDetailAppview = mergeRepoViewPdsDetails( - repoDetailAppview, - repoDetail, - ) - } - return { - encoding: 'application/json', - body: repoDetailAppview, - } - } catch (err) { - if (err && err['error'] === 'RepoNotFound') { - throw new InvalidRequestError('Repo not found', 'RepoNotFound') - } else { - throw err - } - } - } - - if (!repoDetail) { - throw new InvalidRequestError('Repo not found', 'RepoNotFound') - } + handler: async ({ req, params }) => { + const res = await ctx.appViewAgent.com.atproto.admin.getRepo( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: repoDetail, + body: res.data, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts new file mode 100644 index 00000000000..20ded7bc747 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/getSubjectStatus.ts @@ -0,0 +1,43 @@ +import { CID } from 'multiformats/cid' +import { AtUri } from '@atproto/syntax' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus' +import { ensureValidAdminAud } from '../../../../auth-verifier' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.getSubjectStatus({ + auth: ctx.authVerifier.roleOrAdminService, + handler: async ({ params, auth }) => { + const { did, uri, blob } = params + const modSrvc = ctx.services.moderation(ctx.db) + let body: OutputSchema | null + if (blob) { + if (!did) { + throw new InvalidRequestError( + 'Must provide a did to request blob state', + ) + } + ensureValidAdminAud(auth, did) + body = await modSrvc.getBlobTakedownState(did, CID.parse(blob)) + } else if (uri) { + const parsedUri = new AtUri(uri) + ensureValidAdminAud(auth, parsedUri.hostname) + body = await modSrvc.getRecordTakedownState(parsedUri) + } else if (did) { + ensureValidAdminAud(auth, did) + body = await modSrvc.getRepoTakedownState(did) + } else { + throw new InvalidRequestError('No provided subject') + } + if (body === null) { + throw new InvalidRequestError('Subject not found', 'NotFound') + } + return { + encoding: 'application/json', + body, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts index 84d1fe3218a..29fdec10efe 100644 --- a/packages/pds/src/api/com/atproto/admin/index.ts +++ b/packages/pds/src/api/com/atproto/admin/index.ts @@ -3,6 +3,9 @@ import { Server } from '../../../../lexicon' import resolveModerationReports from './resolveModerationReports' import reverseModerationAction from './reverseModerationAction' import takeModerationAction from './takeModerationAction' +import updateSubjectStatus from './updateSubjectStatus' +import getSubjectStatus from './getSubjectStatus' +import getAccountInfo from './getAccountInfo' import searchRepos from './searchRepos' import getRecord from './getRecord' import getRepo from './getRepo' @@ -22,6 +25,9 @@ export default function (server: Server, ctx: AppContext) { resolveModerationReports(server, ctx) reverseModerationAction(server, ctx) takeModerationAction(server, ctx) + updateSubjectStatus(server, ctx) + getSubjectStatus(server, ctx) + getAccountInfo(server, ctx) searchRepos(server, ctx) getRecord(server, ctx) getRepo(server, ctx) diff --git a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts index 229433fa50c..1246a2364b1 100644 --- a/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts +++ b/packages/pds/src/api/com/atproto/admin/resolveModerationReports.ts @@ -6,31 +6,14 @@ export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.resolveModerationReports({ auth: ctx.authVerifier.role, handler: async ({ req, input }) => { - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.resolveModerationReports( - input.body, - authPassthru(req, true), - ) - return { - encoding: 'application/json', - body: result, - } - } - - const { db, services } = ctx - const moderationService = services.moderation(db) - const { actionId, reportIds, createdBy } = input.body - - const moderationAction = await db.transaction(async (dbTxn) => { - const moderationTxn = services.moderation(dbTxn) - await moderationTxn.resolveReports({ reportIds, actionId, createdBy }) - return await moderationTxn.getActionOrThrow(actionId) - }) - + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.resolveModerationReports( + input.body, + authPassthru(req, true), + ) return { encoding: 'application/json', - body: await moderationService.views.action(moderationAction), + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts index dc5a22e600e..ec52e2c36c6 100644 --- a/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/reverseModerationAction.ts @@ -1,111 +1,20 @@ -import { AtUri } from '@atproto/syntax' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { - isRepoRef, - ACKNOWLEDGE, - ESCALATE, - TAKEDOWN, -} from '../../../../lexicon/types/com/atproto/admin/defs' -import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.reverseModerationAction({ auth: ctx.authVerifier.role, - handler: async ({ req, input, auth }) => { - const access = auth.credentials - const { db, services } = ctx - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.reverseModerationAction( - input.body, - authPassthru(req, true), - ) - - const transact = db.transaction(async (dbTxn) => { - const moderationTxn = services.moderation(dbTxn) - // reverse takedowns - if (result.action === TAKEDOWN && isRepoRef(result.subject)) { - await moderationTxn.reverseTakedownRepo({ - did: result.subject.did, - }) - } - if (result.action === TAKEDOWN && isStrongRef(result.subject)) { - await moderationTxn.reverseTakedownRecord({ - uri: new AtUri(result.subject.uri), - }) - } - }) - - try { - await transact - } catch (err) { - req.log.error( - { err, actionId: input.body.id }, - 'proxied moderation action reversal failed', - ) - } - - return { - encoding: 'application/json', - body: result, - } - } - - const moderationService = services.moderation(db) - const { id, createdBy, reason } = input.body - - const moderationAction = await db.transaction(async (dbTxn) => { - const moderationTxn = services.moderation(dbTxn) - const now = new Date() - - const existing = await moderationTxn.getAction(id) - if (!existing) { - throw new InvalidRequestError('Moderation action does not exist') - } - if (existing.reversedAt !== null) { - throw new InvalidRequestError( - 'Moderation action has already been reversed', - ) - } - - // apply access rules - - // if less than moderator access then can only reverse ack and escalation actions - if ( - !access.moderator && - ![ACKNOWLEDGE, ESCALATE].includes(existing.action) - ) { - throw new AuthRequiredError( - 'Must be a full moderator to reverse this type of action', - ) - } - // if less than moderator access then cannot reverse takedown on an account - if ( - !access.moderator && - existing.action === TAKEDOWN && - existing.subjectType === 'com.atproto.admin.defs#repoRef' - ) { - throw new AuthRequiredError( - 'Must be an admin to reverse an account takedown', - ) - } - - const result = await moderationTxn.revertAction({ - id, - createdAt: now, - createdBy, - reason, - }) - - return result - }) + handler: async ({ req, input }) => { + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.reverseModerationAction( + input.body, + authPassthru(req, true), + ) return { encoding: 'application/json', - body: await moderationService.views.action(moderationAction), + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/searchRepos.ts b/packages/pds/src/api/com/atproto/admin/searchRepos.ts index 8195c8c2d98..bf1ab92e3c3 100644 --- a/packages/pds/src/api/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/api/com/atproto/admin/searchRepos.ts @@ -1,65 +1,21 @@ -import { sql } from 'kysely' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { ListKeyset } from '../../../../services/account' import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.searchRepos({ auth: ctx.authVerifier.role, - handler: async ({ req, params, auth }) => { - if (ctx.cfg.bskyAppView.proxyModeration) { - // @TODO merge invite details to this list view. could also add - // support for invitedBy param, which is not supported by appview. - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.searchRepos( - params, - authPassthru(req), - ) - return { - encoding: 'application/json', - body: result, - } - } - - const access = auth.credentials - const { db, services } = ctx - const moderationService = services.moderation(db) - const { limit, cursor, invitedBy } = params - const query = params.q?.trim() ?? params.term?.trim() ?? '' - - const keyset = new ListKeyset(sql``, sql``) - - if (!query) { - const results = await services - .account(db) - .list({ limit, cursor, includeSoftDeleted: true, invitedBy }) - return { - encoding: 'application/json', - body: { - cursor: keyset.packFromResult(results), - repos: await moderationService.views.repo(results, { - includeEmails: access.moderator, - }), - }, - } - } - - const results = await services - .account(db) - .search({ query, limit, cursor, includeSoftDeleted: true }) - + handler: async ({ req, params }) => { + // @TODO merge invite details to this list view. could also add + // support for invitedBy param, which is not supported by appview. + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.searchRepos( + params, + authPassthru(req), + ) return { encoding: 'application/json', - body: { - // For did search, we can only find 1 or no match, cursors can be ignored entirely - cursor: query.startsWith('did:') - ? undefined - : keyset.packFromResult(results), - repos: await moderationService.views.repo(results, { - includeEmails: access.moderator, - }), - }, + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts index d81bd0233f3..c07e8d04f08 100644 --- a/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts +++ b/packages/pds/src/api/com/atproto/admin/takeModerationAction.ts @@ -1,160 +1,21 @@ -import { CID } from 'multiformats/cid' -import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { - isRepoRef, - ACKNOWLEDGE, - ESCALATE, - TAKEDOWN, -} from '../../../../lexicon/types/com/atproto/admin/defs' -import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' -import { getSubject, getAction } from '../moderation/util' -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import { authPassthru } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.admin.takeModerationAction({ auth: ctx.authVerifier.role, - handler: async ({ req, input, auth }) => { - const access = auth.credentials - const { db, services } = ctx - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.admin.takeModerationAction( - input.body, - authPassthru(req, true), - ) - - const transact = db.transaction(async (dbTxn) => { - const authTxn = services.auth(dbTxn) - const moderationTxn = services.moderation(dbTxn) - // perform takedowns - if (result.action === TAKEDOWN && isRepoRef(result.subject)) { - await authTxn.revokeRefreshTokensByDid(result.subject.did) - await moderationTxn.takedownRepo({ - takedownId: result.id, - did: result.subject.did, - }) - } - if (result.action === TAKEDOWN && isStrongRef(result.subject)) { - await moderationTxn.takedownRecord({ - takedownId: result.id, - uri: new AtUri(result.subject.uri), - blobCids: result.subjectBlobCids.map((cid) => CID.parse(cid)), - }) - } - }) - - try { - await transact - } catch (err) { - req.log.error( - { err, actionId: result.id }, - 'proxied moderation action failed', - ) - } - - return { - encoding: 'application/json', - body: result, - } - } - - const moderationService = services.moderation(db) - const { - action, - subject, - reason, - createdBy, - createLabelVals, - negateLabelVals, - subjectBlobCids, - durationInHours, - } = input.body - - // apply access rules - - // if less than admin access then can not takedown an account - if (!access.moderator && action === TAKEDOWN && 'did' in subject) { - throw new AuthRequiredError( - 'Must be a full moderator to perform an account takedown', - ) - } - // if less than moderator access then can only take ack and escalation actions - if (!access.moderator && ![ACKNOWLEDGE, ESCALATE].includes(action)) { - throw new AuthRequiredError( - 'Must be a full moderator to take this type of action', + handler: async ({ req, input }) => { + const { data: result } = + await ctx.appViewAgent.com.atproto.admin.takeModerationAction( + input.body, + authPassthru(req, true), ) - } - // if less than moderator access then can not apply labels - if ( - !access.moderator && - (createLabelVals?.length || negateLabelVals?.length) - ) { - throw new AuthRequiredError('Must be a full moderator to label content') - } - - validateLabels([...(createLabelVals ?? []), ...(negateLabelVals ?? [])]) - - const moderationAction = await db.transaction(async (dbTxn) => { - const authTxn = services.auth(dbTxn) - const moderationTxn = services.moderation(dbTxn) - - const result = await moderationTxn.logAction({ - action: getAction(action), - subject: getSubject(subject), - subjectBlobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], - createLabelVals, - negateLabelVals, - createdBy, - reason, - durationInHours, - }) - - if ( - result.action === TAKEDOWN && - result.subjectType === 'com.atproto.admin.defs#repoRef' && - result.subjectDid - ) { - await authTxn.revokeRefreshTokensByDid(result.subjectDid) - await moderationTxn.takedownRepo({ - takedownId: result.id, - did: result.subjectDid, - }) - } - - if ( - result.action === TAKEDOWN && - result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri - ) { - await moderationTxn.takedownRecord({ - takedownId: result.id, - uri: new AtUri(result.subjectUri), - blobCids: subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [], - }) - } - - return result - }) return { encoding: 'application/json', - body: await moderationService.views.action(moderationAction), + body: result, } }, }) } - -const validateLabels = (labels: string[]) => { - for (const label of labels) { - for (const char of badChars) { - if (label.includes(char)) { - throw new InvalidRequestError(`Invalid label: ${label}`) - } - } - } -} - -const badChars = [' ', ',', ';', `'`, `"`] diff --git a/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts new file mode 100644 index 00000000000..920debba986 --- /dev/null +++ b/packages/pds/src/api/com/atproto/admin/updateSubjectStatus.ts @@ -0,0 +1,59 @@ +import { CID } from 'multiformats/cid' +import { AtUri } from '@atproto/syntax' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { + isRepoRef, + isRepoBlobRef, +} from '../../../../lexicon/types/com/atproto/admin/defs' +import { isMain as isStrongRef } from '../../../../lexicon/types/com/atproto/repo/strongRef' +import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { ensureValidAdminAud } from '../../../../auth-verifier' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.admin.updateSubjectStatus({ + auth: ctx.authVerifier.roleOrAdminService, + handler: async ({ input, auth }) => { + // if less than moderator access then cannot perform a takedown + if (auth.credentials.type === 'role' && !auth.credentials.moderator) { + throw new AuthRequiredError( + 'Must be a full moderator to update subject state', + ) + } + + const { subject, takedown } = input.body + if (takedown) { + const modSrvc = ctx.services.moderation(ctx.db) + const authSrvc = ctx.services.auth(ctx.db) + if (isRepoRef(subject)) { + ensureValidAdminAud(auth, subject.did) + await Promise.all([ + modSrvc.updateRepoTakedownState(subject.did, takedown), + authSrvc.revokeRefreshTokensByDid(subject.did), + ]) + } else if (isStrongRef(subject)) { + const uri = new AtUri(subject.uri) + ensureValidAdminAud(auth, uri.hostname) + await modSrvc.updateRecordTakedownState(uri, takedown) + } else if (isRepoBlobRef(subject)) { + ensureValidAdminAud(auth, subject.did) + await modSrvc.updateBlobTakedownState( + subject.did, + CID.parse(subject.cid), + takedown, + ) + } else { + throw new InvalidRequestError('Invalid subject') + } + } + + return { + encoding: 'application/json', + body: { + subject, + takedown, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index ecdcc6e87cf..315b72c080a 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -1,43 +1,19 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { getReasonType, getSubject } from './util' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ auth: ctx.authVerifier.accessCheckTakedown, handler: async ({ input, auth }) => { const requester = auth.credentials.did - - if (ctx.cfg.bskyAppView.proxyModeration) { - const { data: result } = - await ctx.appViewAgent.com.atproto.moderation.createReport( - input.body, - { - ...(await ctx.serviceAuthHeaders(requester)), - encoding: 'application/json', - }, - ) - return { + const { data: result } = + await ctx.appViewAgent.com.atproto.moderation.createReport(input.body, { + ...(await ctx.serviceAuthHeaders(requester)), encoding: 'application/json', - body: result, - } - } - - const { db, services } = ctx - const { reasonType, reason, subject } = input.body - - const moderationService = services.moderation(db) - - const report = await moderationService.report({ - reasonType: getReasonType(reasonType), - reason, - subject: getSubject(subject), - reportedBy: requester, - }) - + }) return { encoding: 'application/json', - body: moderationService.views.reportPublic(report), + body: result, } }, }) diff --git a/packages/pds/src/api/com/atproto/repo/getRecord.ts b/packages/pds/src/api/com/atproto/repo/getRecord.ts index 5c99a7226c1..4a333cf0648 100644 --- a/packages/pds/src/api/com/atproto/repo/getRecord.ts +++ b/packages/pds/src/api/com/atproto/repo/getRecord.ts @@ -14,7 +14,7 @@ export default function (server: Server, ctx: AppContext) { const record = await ctx.services .record(ctx.db) .getRecord(uri, cid || null) - if (!record || record.takedownId !== null) { + if (!record || record.takedownRef !== null) { throw new InvalidRequestError(`Could not locate record: ${uri}`) } return { diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 36bdc7b6b86..f1343b3c4e2 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -151,7 +151,7 @@ export const ensureCodeIsAvailable = async ( qb .selectFrom('repo_root') .selectAll() - .where('takedownId', 'is not', null) + .where('takedownRef', 'is not', null) .whereRef('did', '=', ref('invite_code.forUser')), ) .where('code', '=', inviteCode) diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index 4d12edb1b32..2088c387339 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -1,10 +1,9 @@ import { AuthRequiredError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { TAKEDOWN } from '../../../../lexicon/types/com/atproto/admin/defs' import AppContext from '../../../../context' import { MINUTE } from '@atproto/common' -const REASON_ACCT_DELETION = 'ACCOUNT DELETION' +const REASON_ACCT_DELETION = 'account_deletion' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.deleteAccount({ @@ -25,32 +24,17 @@ export default function (server: Server, ctx: AppContext) { .account(ctx.db) .assertValidToken(did, 'delete_account', token) - const now = new Date() await ctx.db.transaction(async (dbTxn) => { const accountService = ctx.services.account(dbTxn) const moderationTxn = ctx.services.moderation(dbTxn) - const [currentAction] = await moderationTxn.getCurrentActions({ did }) - if (currentAction?.action === TAKEDOWN) { - // Do not disturb an existing takedown, continue with account deletion - return await accountService.deleteEmailToken(did, 'delete_account') - } - if (currentAction) { - // Reverse existing action to replace it with a self-takedown - await moderationTxn.logReverseAction({ - id: currentAction.id, - reason: REASON_ACCT_DELETION, - createdBy: did, - createdAt: now, + const currState = await moderationTxn.getRepoTakedownState(did) + // Do not disturb an existing takedown, continue with account deletion + if (currState?.takedown.applied !== true) { + await moderationTxn.updateRepoTakedownState(did, { + applied: true, + ref: REASON_ACCT_DELETION, }) } - const takedown = await moderationTxn.logAction({ - action: TAKEDOWN, - subject: { did }, - reason: REASON_ACCT_DELETION, - createdBy: did, - createdAt: now, - }) - await moderationTxn.takedownRepo({ did, takedownId: takedown.id }) await accountService.deleteEmailToken(did, 'delete_account') }) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 380f94b90b6..dba1550ba0b 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,4 +1,9 @@ -import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' +import { + AuthRequiredError, + InvalidRequestError, + verifyJwt as verifyServiceJwt, +} from '@atproto/xrpc-server' +import { IdResolver } from '@atproto/identity' import * as ui8 from 'uint8arrays' import express from 'express' import * as jwt from 'jsonwebtoken' @@ -34,6 +39,14 @@ type RoleOutput = { } } +type AdminServiceOutput = { + credentials: { + type: 'service' + aud: string + iss: string + } +} + type AccessOutput = { credentials: { type: 'access' @@ -65,6 +78,7 @@ export type AuthVerifierOpts = { adminPass: string moderatorPass: string triagePass: string + adminServiceDid: string } export class AuthVerifier { @@ -72,12 +86,18 @@ export class AuthVerifier { private _adminPass: string private _moderatorPass: string private _triagePass: string + public adminServiceDid: string - constructor(public db: Database, opts: AuthVerifierOpts) { + constructor( + public db: Database, + public idResolver: IdResolver, + opts: AuthVerifierOpts, + ) { this._secret = opts.jwtSecret this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass this._triagePass = opts.triagePass + this.adminServiceDid = opts.adminServiceDid } // verifiers (arrow fns to preserve scope) @@ -98,7 +118,7 @@ export class AuthVerifier { .selectFrom('user_account') .innerJoin('repo_root', 'repo_root.did', 'user_account.did') .where('user_account.did', '=', result.credentials.did) - .where('repo_root.takedownId', 'is', null) + .where('repo_root.takedownRef', 'is', null) .select('user_account.did') .executeTakeFirst() if (!found) { @@ -178,6 +198,43 @@ export class AuthVerifier { } } + adminService = async (reqCtx: ReqCtx): Promise => { + const jwtStr = bearerTokenFromReq(reqCtx.req) + if (!jwtStr) { + throw new AuthRequiredError('missing jwt', 'MissingJwt') + } + const payload = await verifyServiceJwt( + jwtStr, + null, + async (did: string) => { + if (did !== this.adminServiceDid) { + throw new AuthRequiredError( + 'Untrusted issuer for admin actions', + 'UntrustedIss', + ) + } + return this.idResolver.did.resolveAtprotoKey(did) + }, + ) + return { + credentials: { + type: 'service', + aud: payload.aud, + iss: payload.iss, + }, + } + } + + roleOrAdminService = async ( + reqCtx: ReqCtx, + ): Promise => { + if (isBearerToken(reqCtx.req)) { + return this.adminService(reqCtx) + } else { + return this.role(reqCtx) + } + } + validateBearerToken( req: express.Request, scopes: AuthScope[], @@ -300,3 +357,18 @@ export const parseBasicAuth = ( if (!username || !password) return null return { username, password } } + +export const ensureValidAdminAud = ( + auth: RoleOutput | AdminServiceOutput, + subjectDid: string, +) => { + if ( + auth.credentials.type === 'service' && + auth.credentials.aud !== subjectDid + ) { + throw new AuthRequiredError( + 'jwt audience does not match account did', + 'BadJwtAudience', + ) + } +} diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 42277c12bbf..b181ea8b3ad 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -151,11 +151,12 @@ export class AppContext { const appViewAgent = new AtpAgent({ service: cfg.bskyAppView.url }) - const authVerifier = new AuthVerifier(db, { + const authVerifier = new AuthVerifier(db, idResolver, { jwtSecret: secrets.jwtSecret, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, triagePass: secrets.triagePassword, + adminServiceDid: cfg.bskyAppView.did, }) const repoSigningKey = diff --git a/packages/pds/src/db/migrations/20231011T155513453Z-takedown-ref.ts b/packages/pds/src/db/migrations/20231011T155513453Z-takedown-ref.ts new file mode 100644 index 00000000000..e0d4d16e1f1 --- /dev/null +++ b/packages/pds/src/db/migrations/20231011T155513453Z-takedown-ref.ts @@ -0,0 +1,41 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('repo_root') + .addColumn('takedownRef', 'varchar') + .execute() + await db.schema.alterTable('repo_root').dropColumn('takedownId').execute() + + await db.schema + .alterTable('repo_blob') + .addColumn('takedownRef', 'varchar') + .execute() + await db.schema.alterTable('repo_blob').dropColumn('takedownId').execute() + + await db.schema + .alterTable('record') + .addColumn('takedownRef', 'varchar') + .execute() + await db.schema.alterTable('record').dropColumn('takedownId').execute() +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('repo_root') + .addColumn('takedownId', 'integer') + .execute() + await db.schema.alterTable('repo_root').dropColumn('takedownRef').execute() + + await db.schema + .alterTable('repo_blob') + .addColumn('takedownId', 'integer') + .execute() + await db.schema.alterTable('repo_blob').dropColumn('takedownRef').execute() + + await db.schema + .alterTable('record') + .addColumn('takedownId', 'integer') + .execute() + await db.schema.alterTable('record').dropColumn('takedownRef').execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index 9aead0d7012..51979099feb 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -6,3 +6,4 @@ export * as _20230613T164932261Z from './20230613T164932261Z-init' export * as _20230914T014727199Z from './20230914T014727199Z-repo-v3' export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' export * as _20230929T213219699Z from './20230929T213219699Z-takedown-id-as-int' +export * as _20231011T155513453Z from './20231011T155513453Z-takedown-ref' diff --git a/packages/pds/src/db/periodic-moderation-action-reversal.ts b/packages/pds/src/db/periodic-moderation-action-reversal.ts deleted file mode 100644 index b3b631de71d..00000000000 --- a/packages/pds/src/db/periodic-moderation-action-reversal.ts +++ /dev/null @@ -1,88 +0,0 @@ -import assert from 'assert' -import { wait } from '@atproto/common' -import { Leader } from './leader' -import { dbLogger } from '../logger' -import AppContext from '../context' -import { ModerationActionRow } from '../services/moderation' - -export const MODERATION_ACTION_REVERSAL_ID = 1011 - -export class PeriodicModerationActionReversal { - leader = new Leader(MODERATION_ACTION_REVERSAL_ID, this.appContext.db) - destroyed = false - - constructor(private appContext: AppContext) {} - - async revertAction(actionRow: ModerationActionRow) { - return this.appContext.db.transaction(async (dbTxn) => { - const moderationTxn = this.appContext.services.moderation(dbTxn) - await moderationTxn.revertAction({ - id: actionRow.id, - createdBy: actionRow.createdBy, - createdAt: new Date(), - reason: `[SCHEDULED_REVERSAL] Reverting action as originally scheduled`, - }) - }) - } - - async findAndRevertDueActions() { - const moderationService = this.appContext.services.moderation( - this.appContext.db, - ) - const actionsDueForReversal = - await moderationService.getActionsDueForReversal() - - // We shouldn't have too many actions due for reversal at any given time, so running in parallel is probably fine - // Internally, each reversal runs within its own transaction - await Promise.allSettled( - actionsDueForReversal.map(this.revertAction.bind(this)), - ) - } - - async run() { - assert( - this.appContext.db.dialect === 'pg', - 'Moderation action reversal can only be run by postgres', - ) - - while (!this.destroyed) { - try { - const { ran } = await this.leader.run(async ({ signal }) => { - while (!signal.aborted) { - // super basic synchronization by agreeing when the intervals land relative to unix timestamp - const now = Date.now() - const intervalMs = 1000 * 60 - const nextIteration = Math.ceil(now / intervalMs) - const nextInMs = nextIteration * intervalMs - now - await wait(nextInMs) - if (signal.aborted) break - await this.findAndRevertDueActions() - } - }) - if (ran && !this.destroyed) { - throw new Error('View maintainer completed, but should be persistent') - } - } catch (err) { - dbLogger.error( - { - err, - lockId: MODERATION_ACTION_REVERSAL_ID, - }, - 'moderation action reversal errored', - ) - } - if (!this.destroyed) { - await wait(10000 + jitter(2000)) - } - } - } - - destroy() { - this.destroyed = true - this.leader.destroy() - } -} - -function jitter(maxMs) { - return Math.round((Math.random() - 0.5) * maxMs * 2) -} diff --git a/packages/pds/src/db/tables/record.ts b/packages/pds/src/db/tables/record.ts index 03f1008ef0f..04d4dd8524f 100644 --- a/packages/pds/src/db/tables/record.ts +++ b/packages/pds/src/db/tables/record.ts @@ -7,7 +7,7 @@ export interface Record { rkey: string repoRev: string | null indexedAt: string - takedownId: number | null + takedownRef: string | null } export const tableName = 'record' diff --git a/packages/pds/src/db/tables/repo-blob.ts b/packages/pds/src/db/tables/repo-blob.ts index a1fed0877e5..ddeb6c59158 100644 --- a/packages/pds/src/db/tables/repo-blob.ts +++ b/packages/pds/src/db/tables/repo-blob.ts @@ -3,7 +3,7 @@ export interface RepoBlob { recordUri: string repoRev: string | null did: string - takedownId: number | null + takedownRef: string | null } export const tableName = 'repo_blob' diff --git a/packages/pds/src/db/tables/repo-root.ts b/packages/pds/src/db/tables/repo-root.ts index 6b6c921f380..74ca31d3f80 100644 --- a/packages/pds/src/db/tables/repo-root.ts +++ b/packages/pds/src/db/tables/repo-root.ts @@ -4,7 +4,7 @@ export interface RepoRoot { root: string rev: string | null indexedAt: string - takedownId: number | null + takedownRef: string | null } export const tableName = 'repo_root' diff --git a/packages/pds/src/db/util.ts b/packages/pds/src/db/util.ts index 696ac7dee8b..6dd31c67898 100644 --- a/packages/pds/src/db/util.ts +++ b/packages/pds/src/db/util.ts @@ -20,11 +20,11 @@ export const actorWhereClause = (actor: string) => { // Applies to repo_root or record table export const notSoftDeletedClause = (alias: DbRef) => { - return sql`${alias}."takedownId" is null` + return sql`${alias}."takedownRef" is null` } -export const softDeleted = (repoOrRecord: { takedownId: number | null }) => { - return repoOrRecord.takedownId !== null +export const softDeleted = (repoOrRecord: { takedownRef: string | null }) => { + return repoOrRecord.takedownRef !== null } export const countAll = sql`count(*)` diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index cc9e1555895..42544eba492 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -28,7 +28,6 @@ import compression from './util/compression' export * from './config' export { Database } from './db' -export { PeriodicModerationActionReversal } from './db/periodic-moderation-action-reversal' export { DiskBlobStore, MemoryBlobStore } from './storage' export { AppContext } from './context' export { httpLogger } from './logger' diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 1fd8a1f127c..bf69ebafa68 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -12,6 +12,7 @@ import { schemas } from './lexicons' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' +import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo' import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes' import * as ComAtprotoAdminGetModerationAction from './types/com/atproto/admin/getModerationAction' import * as ComAtprotoAdminGetModerationActions from './types/com/atproto/admin/getModerationActions' @@ -19,6 +20,7 @@ import * as ComAtprotoAdminGetModerationReport from './types/com/atproto/admin/g import * as ComAtprotoAdminGetModerationReports from './types/com/atproto/admin/getModerationReports' import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord' import * as ComAtprotoAdminGetRepo from './types/com/atproto/admin/getRepo' +import * as ComAtprotoAdminGetSubjectStatus from './types/com/atproto/admin/getSubjectStatus' import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/admin/resolveModerationReports' import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction' import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos' @@ -26,6 +28,7 @@ import * as ComAtprotoAdminSendEmail from './types/com/atproto/admin/sendEmail' import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction' import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail' import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle' +import * as ComAtprotoAdminUpdateSubjectStatus from './types/com/atproto/admin/updateSubjectStatus' import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle' import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle' import * as ComAtprotoLabelQueryLabels from './types/com/atproto/label/queryLabels' @@ -225,6 +228,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getAccountInfo( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetAccountInfo.Handler>, + ComAtprotoAdminGetAccountInfo.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getAccountInfo' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getInviteCodes( cfg: ConfigOf< AV, @@ -302,6 +316,17 @@ export class AdminNS { return this._server.xrpc.method(nsid, cfg) } + getSubjectStatus( + cfg: ConfigOf< + AV, + ComAtprotoAdminGetSubjectStatus.Handler>, + ComAtprotoAdminGetSubjectStatus.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.getSubjectStatus' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + resolveModerationReports( cfg: ConfigOf< AV, @@ -378,6 +403,17 @@ export class AdminNS { const nsid = 'com.atproto.admin.updateAccountHandle' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateSubjectStatus( + cfg: ConfigOf< + AV, + ComAtprotoAdminUpdateSubjectStatus.Handler>, + ComAtprotoAdminUpdateSubjectStatus.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.updateSubjectStatus' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class IdentityNS { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 537350b5f13..38af4475fb8 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -8,6 +8,18 @@ export const schemaDict = { lexicon: 1, id: 'com.atproto.admin.defs', defs: { + statusAttr: { + type: 'object', + required: ['applied'], + properties: { + applied: { + type: 'boolean', + }, + ref: { + type: 'string', + }, + }, + }, actionView: { type: 'object', required: [ @@ -424,6 +436,44 @@ export const schemaDict = { }, }, }, + accountView: { + type: 'object', + required: ['did', 'handle', 'indexedAt'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + email: { + type: 'string', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + invitedBy: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + invites: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.defs#inviteCode', + }, + }, + invitesDisabled: { + type: 'boolean', + }, + inviteNote: { + type: 'string', + }, + }, + }, repoViewNotFound: { type: 'object', required: ['did'], @@ -444,6 +494,24 @@ export const schemaDict = { }, }, }, + repoBlobRef: { + type: 'object', + required: ['did', 'cid'], + properties: { + did: { + type: 'string', + format: 'did', + }, + cid: { + type: 'string', + format: 'cid', + }, + recordUri: { + type: 'string', + format: 'at-uri', + }, + }, + }, recordView: { type: 'object', required: [ @@ -730,6 +798,33 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetAccountInfo: { + lexicon: 1, + id: 'com.atproto.admin.getAccountInfo', + defs: { + main: { + type: 'query', + description: 'View details about an account.', + parameters: { + type: 'params', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#accountView', + }, + }, + }, + }, + }, ComAtprotoAdminGetInviteCodes: { lexicon: 1, id: 'com.atproto.admin.getInviteCodes', @@ -1026,6 +1121,55 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminGetSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.getSubjectStatus', + defs: { + main: { + type: 'query', + description: + 'Fetch the service-specific the admin status of a subject (account, record, or blob)', + parameters: { + type: 'params', + properties: { + did: { + type: 'string', + format: 'did', + }, + uri: { + type: 'string', + format: 'at-uri', + }, + blob: { + type: 'string', + format: 'cid', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminResolveModerationReports: { lexicon: 1, id: 'com.atproto.admin.resolveModerationReports', @@ -1118,9 +1262,6 @@ export const schemaDict = { q: { type: 'string', }, - invitedBy: { - type: 'string', - }, limit: { type: 'integer', minimum: 1, @@ -1326,6 +1467,59 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminUpdateSubjectStatus: { + lexicon: 1, + id: 'com.atproto.admin.updateSubjectStatus', + defs: { + main: { + type: 'procedure', + description: + 'Update the service-specific admin status of a subject (account, record, or blob)', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['subject'], + properties: { + subject: { + type: 'union', + refs: [ + 'lex:com.atproto.admin.defs#repoRef', + 'lex:com.atproto.repo.strongRef', + 'lex:com.atproto.admin.defs#repoBlobRef', + ], + }, + takedown: { + type: 'ref', + ref: 'lex:com.atproto.admin.defs#statusAttr', + }, + }, + }, + }, + }, + }, + }, ComAtprotoIdentityResolveHandle: { lexicon: 1, id: 'com.atproto.identity.resolveHandle', @@ -7367,6 +7561,7 @@ export const ids = { 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites', + ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo', ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes', ComAtprotoAdminGetModerationAction: 'com.atproto.admin.getModerationAction', ComAtprotoAdminGetModerationActions: 'com.atproto.admin.getModerationActions', @@ -7374,6 +7569,7 @@ export const ids = { ComAtprotoAdminGetModerationReports: 'com.atproto.admin.getModerationReports', ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord', ComAtprotoAdminGetRepo: 'com.atproto.admin.getRepo', + ComAtprotoAdminGetSubjectStatus: 'com.atproto.admin.getSubjectStatus', ComAtprotoAdminResolveModerationReports: 'com.atproto.admin.resolveModerationReports', ComAtprotoAdminReverseModerationAction: @@ -7383,6 +7579,7 @@ export const ids = { ComAtprotoAdminTakeModerationAction: 'com.atproto.admin.takeModerationAction', ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail', ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle', + ComAtprotoAdminUpdateSubjectStatus: 'com.atproto.admin.updateSubjectStatus', ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle', ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle', ComAtprotoLabelDefs: 'com.atproto.label.defs', 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 968252a4c2c..ea463368f8e 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -10,6 +10,24 @@ import * as ComAtprotoModerationDefs from '../moderation/defs' import * as ComAtprotoServerDefs from '../server/defs' import * as ComAtprotoLabelDefs from '../label/defs' +export interface StatusAttr { + applied: boolean + ref?: string + [k: string]: unknown +} + +export function isStatusAttr(v: unknown): v is StatusAttr { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#statusAttr' + ) +} + +export function validateStatusAttr(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#statusAttr', v) +} + export interface ActionView { id: number action: ActionType @@ -238,6 +256,30 @@ export function validateRepoViewDetail(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoViewDetail', v) } +export interface AccountView { + did: string + handle: string + email?: string + indexedAt: string + invitedBy?: ComAtprotoServerDefs.InviteCode + invites?: ComAtprotoServerDefs.InviteCode[] + invitesDisabled?: boolean + inviteNote?: string + [k: string]: unknown +} + +export function isAccountView(v: unknown): v is AccountView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#accountView' + ) +} + +export function validateAccountView(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#accountView', v) +} + export interface RepoViewNotFound { did: string [k: string]: unknown @@ -272,6 +314,25 @@ export function validateRepoRef(v: unknown): ValidationResult { return lexicons.validate('com.atproto.admin.defs#repoRef', v) } +export interface RepoBlobRef { + did: string + cid: string + recordUri?: string + [k: string]: unknown +} + +export function isRepoBlobRef(v: unknown): v is RepoBlobRef { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.admin.defs#repoBlobRef' + ) +} + +export function validateRepoBlobRef(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.admin.defs#repoBlobRef', v) +} + export interface RecordView { uri: string cid: string diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfo.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfo.ts new file mode 100644 index 00000000000..88a2b17a4b8 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getAccountInfo.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' + +export interface QueryParams { + did: string +} + +export type InputSchema = undefined +export type OutputSchema = ComAtprotoAdminDefs.AccountView +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts b/packages/pds/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts new file mode 100644 index 00000000000..7315e51e8c2 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/getSubjectStatus.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams { + did?: string + uri?: string + blob?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts b/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts index 32266fd66fd..1e7e1a36bb6 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/searchRepos.ts @@ -13,7 +13,6 @@ export interface QueryParams { /** DEPRECATED: use 'q' instead */ term?: string q?: string - invitedBy?: string limit: number cursor?: string } diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts b/packages/pds/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts new file mode 100644 index 00000000000..559ee948380 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/updateSubjectStatus.ts @@ -0,0 +1,61 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as ComAtprotoAdminDefs from './defs' +import * as ComAtprotoRepoStrongRef from '../repo/strongRef' + +export interface QueryParams {} + +export interface InputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface OutputSchema { + subject: + | ComAtprotoAdminDefs.RepoRef + | ComAtprotoRepoStrongRef.Main + | ComAtprotoAdminDefs.RepoBlobRef + | { $type: string; [k: string]: unknown } + takedown?: ComAtprotoAdminDefs.StatusAttr + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 9a6910d0e4f..73518199c32 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -14,6 +14,8 @@ import * as sequencer from '../../sequencer' import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' import { EmailTokenPurpose } from '../../db/tables/email-token' import { getRandomToken } from '../../api/com/atproto/server/util' +import { AccountView } from '../../lexicon/types/com/atproto/admin/defs' +import { INVALID_HANDLE } from '@atproto/syntax' export class AccountService { constructor(public db: Database) {} @@ -59,7 +61,7 @@ export class AccountService { const found = await this.db.db .selectFrom('repo_root') .where('did', '=', did) - .where('takedownId', 'is', null) + .where('takedownRef', 'is', null) .select('did') .executeTakeFirst() return found !== undefined @@ -376,6 +378,38 @@ export class AccountService { }) } + async adminView(did: string): Promise { + const accountQb = this.db.db + .selectFrom('did_handle') + .innerJoin('user_account', 'user_account.did', 'did_handle.did') + .where('did_handle.did', '=', did) + .select([ + 'did_handle.did', + 'did_handle.handle', + 'user_account.email', + 'user_account.invitesDisabled', + 'user_account.inviteNote', + 'user_account.createdAt as indexedAt', + ]) + + const [account, invites, invitedBy] = await Promise.all([ + accountQb.executeTakeFirst(), + this.getAccountInviteCodes(did), + this.getInvitedByForAccounts([did]), + ]) + + if (!account) return null + + return { + ...account, + handle: account?.handle ?? INVALID_HANDLE, + invitesDisabled: account.invitesDisabled === 1, + inviteNote: account.inviteNote ?? undefined, + invites, + invitedBy: invitedBy[did], + } + } + selectInviteCodesQb() { const ref = this.db.db.dynamic.ref const builder = this.db.db diff --git a/packages/pds/src/services/moderation/index.ts b/packages/pds/src/services/moderation/index.ts index 9e46332cf33..240a95004d2 100644 --- a/packages/pds/src/services/moderation/index.ts +++ b/packages/pds/src/services/moderation/index.ts @@ -1,15 +1,13 @@ -import { Selectable, sql } from 'kysely' import { CID } from 'multiformats/cid' import { BlobStore } from '@atproto/repo' import { AtUri } from '@atproto/syntax' -import { InvalidRequestError } from '@atproto/xrpc-server' import Database from '../../db' -import { ModerationAction, ModerationReport } from '../../db/tables/moderation' -import { RecordService } from '../record' -import { ModerationViews } from './views' -import SqlRepoStorage from '../../sql-repo-storage' -import { TAKEDOWN } from '../../lexicon/types/com/atproto/admin/defs' -import { addHoursToDate } from '../../util/date' +import { + RepoBlobRef, + RepoRef, + StatusAttr, +} from '../../lexicon/types/com/atproto/admin/defs' +import { Main as StrongRef } from '../../lexicon/types/com/atproto/repo/strongRef' export class ModerationService { constructor(public db: Database, public blobstore: BlobStore) {} @@ -18,614 +16,110 @@ export class ModerationService { return (db: Database) => new ModerationService(db, blobstore) } - views = new ModerationViews(this.db) - - services = { - record: RecordService.creator(), - } - - async getAction(id: number): Promise { - return await this.db.db - .selectFrom('moderation_action') - .selectAll() - .where('id', '=', id) + async getRepoTakedownState( + did: string, + ): Promise | null> { + const res = await this.db.db + .selectFrom('repo_root') + .select('takedownRef') + .where('did', '=', did) .executeTakeFirst() - } - - async getActionOrThrow(id: number): Promise { - const action = await this.getAction(id) - if (!action) throw new InvalidRequestError('Action not found') - return action - } - - async getActions(opts: { - subject?: string - limit: number - cursor?: string - }): Promise { - const { subject, limit, cursor } = opts - let builder = this.db.db.selectFrom('moderation_action') - if (subject) { - builder = builder.where((qb) => { - return qb - .where('subjectDid', '=', subject) - .orWhere('subjectUri', '=', subject) - }) - } - if (cursor) { - const cursorNumeric = parseInt(cursor, 10) - if (isNaN(cursorNumeric)) { - throw new InvalidRequestError('Malformed cursor') - } - builder = builder.where('id', '<', cursorNumeric) - } - return await builder - .selectAll() - .orderBy('id', 'desc') - .limit(limit) - .execute() - } - - async getReport(id: number): Promise { - return await this.db.db - .selectFrom('moderation_report') - .selectAll() - .where('id', '=', id) + if (!res) return null + const state = takedownRefToStatus(res.takedownRef ?? null) + return { + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: did, + }, + takedown: state, + } + } + + async getRecordTakedownState( + uri: AtUri, + ): Promise | null> { + const res = await this.db.db + .selectFrom('record') + .select(['takedownRef', 'cid']) + .where('uri', '=', uri.toString()) .executeTakeFirst() - } - - async getReports(opts: { - subject?: string - resolved?: boolean - actionType?: string - limit: number - cursor?: string - ignoreSubjects?: string[] - reverse?: boolean - reporters?: string[] - actionedBy?: string - }): Promise { - const { - subject, - resolved, - actionType, - limit, - cursor, - ignoreSubjects, - reverse = false, - reporters, - actionedBy, - } = opts - const { ref } = this.db.db.dynamic - let builder = this.db.db.selectFrom('moderation_report') - if (subject) { - builder = builder.where((qb) => { - return qb - .where('subjectDid', '=', subject) - .orWhere('subjectUri', '=', subject) - }) - } - - if (ignoreSubjects?.length) { - const ignoreUris: string[] = [] - const ignoreDids: string[] = [] - - ignoreSubjects.forEach((subject) => { - if (subject.startsWith('at://')) { - ignoreUris.push(subject) - } else if (subject.startsWith('did:')) { - ignoreDids.push(subject) - } - }) - - if (ignoreDids.length) { - builder = builder.where('subjectDid', 'not in', ignoreDids) - } - if (ignoreUris.length) { - builder = builder.where((qb) => { - // Without the null condition, postgres will ignore all reports where `subjectUri` is null - // which will make all the account reports be ignored as well - return qb - .where('subjectUri', 'not in', ignoreUris) - .orWhere('subjectUri', 'is', null) - }) - } - } - - if (reporters?.length) { - builder = builder.where('reportedByDid', 'in', reporters) - } - - if (resolved !== undefined) { - const resolutionsQuery = this.db.db - .selectFrom('moderation_report_resolution') - .selectAll() - .whereRef( - 'moderation_report_resolution.reportId', - '=', - ref('moderation_report.id'), - ) - builder = resolved - ? builder.whereExists(resolutionsQuery) - : builder.whereNotExists(resolutionsQuery) - } - if (actionType !== undefined || actionedBy !== undefined) { - let resolutionActionsQuery = this.db.db - .selectFrom('moderation_report_resolution') - .innerJoin( - 'moderation_action', - 'moderation_action.id', - 'moderation_report_resolution.actionId', - ) - .whereRef( - 'moderation_report_resolution.reportId', - '=', - ref('moderation_report.id'), - ) - - if (actionType) { - resolutionActionsQuery = resolutionActionsQuery - .where('moderation_action.action', '=', sql`${actionType}`) - .where('moderation_action.reversedAt', 'is', null) - } - - if (actionedBy) { - resolutionActionsQuery = resolutionActionsQuery.where( - 'moderation_action.createdBy', - '=', - actionedBy, - ) - } - - builder = builder.whereExists(resolutionActionsQuery.selectAll()) - } - - if (cursor) { - const cursorNumeric = parseInt(cursor, 10) - if (isNaN(cursorNumeric)) { - throw new InvalidRequestError('Malformed cursor') - } - builder = builder.where('id', reverse ? '>' : '<', cursorNumeric) - } - - return await builder - .leftJoin('did_handle', 'did_handle.did', 'moderation_report.subjectDid') - .selectAll(['moderation_report', 'did_handle']) - .orderBy('id', reverse ? 'asc' : 'desc') - .limit(limit) - .execute() - } - - async getReportOrThrow(id: number): Promise { - const report = await this.getReport(id) - if (!report) throw new InvalidRequestError('Report not found') - return report - } - - async getCurrentActions( - subject: { did: string } | { uri: AtUri } | { cids: CID[] }, - ) { - const { ref } = this.db.db.dynamic - let builder = this.db.db - .selectFrom('moderation_action') - .selectAll() - .where('reversedAt', 'is', null) - if ('did' in subject) { - builder = builder - .where('subjectType', '=', 'com.atproto.admin.defs#repoRef') - .where('subjectDid', '=', subject.did) - } else if ('uri' in subject) { - builder = builder - .where('subjectType', '=', 'com.atproto.repo.strongRef') - .where('subjectUri', '=', subject.uri.toString()) - } else { - const blobsForAction = this.db.db - .selectFrom('moderation_action_subject_blob') - .selectAll() - .whereRef('actionId', '=', ref('moderation_action.id')) - .where( - 'cid', - 'in', - subject.cids.map((cid) => cid.toString()), - ) - builder = builder.whereExists(blobsForAction) - } - return await builder.execute() - } - - async logAction(info: { - action: ModerationActionRow['action'] - subject: { did: string } | { uri: AtUri; cid: CID } - subjectBlobCids?: CID[] - reason: string - createLabelVals?: string[] - negateLabelVals?: string[] - createdBy: string - createdAt?: Date - durationInHours?: number - }): Promise { - this.db.assertTransaction() - const { - action, - createdBy, - reason, - subject, - subjectBlobCids, - durationInHours, - createdAt = new Date(), - } = info - const createLabelVals = - info.createLabelVals && info.createLabelVals.length > 0 - ? info.createLabelVals.join(' ') - : undefined - const negateLabelVals = - info.negateLabelVals && info.negateLabelVals.length > 0 - ? info.negateLabelVals.join(' ') - : undefined - - // Resolve subject info - let subjectInfo: SubjectInfo - if ('did' in subject) { - // Allowing dids that may not exist: may have been deleted but needs to remain actionable. - subjectInfo = { - subjectType: 'com.atproto.admin.defs#repoRef', - subjectDid: subject.did, - subjectUri: null, - subjectCid: null, - } - if (subjectBlobCids?.length) { - throw new InvalidRequestError('Blobs do not apply to repo subjects') - } - } else { - // Allowing records/blobs that may not exist: may have been deleted but needs to remain actionable. - subjectInfo = { - subjectType: 'com.atproto.repo.strongRef', - subjectDid: subject.uri.host, - subjectUri: subject.uri.toString(), - subjectCid: subject.cid.toString(), - } - if (subjectBlobCids?.length) { - const cidsFromSubject = await this.db.db - .selectFrom('repo_blob') - .where('recordUri', '=', subject.uri.toString()) - .where( - 'cid', - 'in', - subjectBlobCids.map((c) => c.toString()), - ) - .select('cid') - .execute() - if (cidsFromSubject.length !== subjectBlobCids.length) { - throw new InvalidRequestError('Blobs do not match record subject') - } - } - } - - const subjectActions = await this.getCurrentActions(subject) - if (subjectActions.length) { - throw new InvalidRequestError( - `Subject already has an active action: #${subjectActions[0].id}`, - 'SubjectHasAction', - ) - } - - const actionResult = await this.db.db - .insertInto('moderation_action') - .values({ - action, - reason, - createdAt: createdAt.toISOString(), - createdBy, - createLabelVals, - negateLabelVals, - durationInHours, - expiresAt: - durationInHours !== undefined - ? addHoursToDate(durationInHours, createdAt).toISOString() - : undefined, - ...subjectInfo, - }) - .returningAll() - .executeTakeFirstOrThrow() - - if (subjectBlobCids?.length && !('did' in subject)) { - const blobActions = await this.getCurrentActions({ - cids: subjectBlobCids, - }) - if (blobActions.length) { - throw new InvalidRequestError( - `Blob already has an active action: #${blobActions[0].id}`, - 'SubjectHasAction', - ) - } - - await this.db.db - .insertInto('moderation_action_subject_blob') - .values( - subjectBlobCids.map((cid) => ({ - actionId: actionResult.id, - cid: cid.toString(), - recordUri: subject.uri.toString(), - })), - ) - .execute() - } - - return actionResult - } - - async getActionsDueForReversal(): Promise> { - const actionsDueForReversal = await this.db.db - .selectFrom('moderation_action') - // Get entries that have an durationInHours that has passed and have not been reversed - .where('durationInHours', 'is not', null) - .where('expiresAt', '<', new Date().toISOString()) - .where('reversedAt', 'is', null) - .selectAll() - .execute() - - return actionsDueForReversal - } - - async revertAction({ - id, - createdAt, - createdBy, - reason, - }: { - id: number - createdAt: Date - createdBy: string - reason: string - }) { - const result = await this.logReverseAction({ - id, - createdAt, - createdBy, - reason, - }) - - if ( - result.action === TAKEDOWN && - result.subjectType === 'com.atproto.admin.defs#repoRef' && - result.subjectDid - ) { - await this.reverseTakedownRepo({ - did: result.subjectDid, - }) - } - - if ( - result.action === TAKEDOWN && - result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri - ) { - await this.reverseTakedownRecord({ - uri: new AtUri(result.subjectUri), - }) - } - - return result - } - - async logReverseAction(info: { - id: number - reason: string - createdBy: string - createdAt?: Date - }): Promise { - const { id, createdBy, reason, createdAt = new Date() } = info - - const result = await this.db.db - .updateTable('moderation_action') - .where('id', '=', id) - .set({ - reversedAt: createdAt.toISOString(), - reversedBy: createdBy, - reversedReason: reason, - }) - .returningAll() + if (!res) return null + const state = takedownRefToStatus(res.takedownRef ?? null) + return { + subject: { + $type: 'com.atproto.repo.strongRef', + uri: uri.toString(), + cid: res.cid, + }, + takedown: state, + } + } + + async getBlobTakedownState( + did: string, + cid: CID, + ): Promise | null> { + const res = await this.db.db + .selectFrom('repo_blob') + .select('takedownRef') + .where('did', '=', did) + .where('cid', '=', cid.toString()) .executeTakeFirst() - - if (!result) { - throw new InvalidRequestError('Moderation action not found') + if (!res) return null + const state = takedownRefToStatus(res.takedownRef ?? null) + return { + subject: { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: did, + cid: cid.toString(), + }, + takedown: state, } - - return result - } - - async takedownRepo(info: { takedownId: number; did: string }) { - await this.db.db - .updateTable('repo_root') - .set({ takedownId: info.takedownId }) - .where('did', '=', info.did) - .where('takedownId', 'is', null) - .executeTakeFirst() } - async reverseTakedownRepo(info: { did: string }) { + async updateRepoTakedownState(did: string, takedown: StatusAttr) { + const takedownRef = statusTotakedownRef(takedown) await this.db.db .updateTable('repo_root') - .set({ takedownId: null }) - .where('did', '=', info.did) + .set({ takedownRef }) + .where('did', '=', did) .execute() } - async takedownRecord(info: { - takedownId: number - uri: AtUri - blobCids?: CID[] - }) { - this.db.assertTransaction() - await this.db.db - .updateTable('record') - .set({ takedownId: info.takedownId }) - .where('uri', '=', info.uri.toString()) - .where('takedownId', 'is', null) - .executeTakeFirst() - if (info.blobCids?.length) { - await this.db.db - .updateTable('repo_blob') - .set({ takedownId: info.takedownId }) - .where('recordUri', '=', info.uri.toString()) - .where( - 'cid', - 'in', - info.blobCids.map((c) => c.toString()), - ) - .where('takedownId', 'is', null) - .executeTakeFirst() - await Promise.all( - info.blobCids.map((cid) => this.blobstore.quarantine(cid)), - ) - } - } - - async reverseTakedownRecord(info: { uri: AtUri }) { - this.db.assertTransaction() + async updateRecordTakedownState(uri: AtUri, takedown: StatusAttr) { + const takedownRef = statusTotakedownRef(takedown) await this.db.db .updateTable('record') - .set({ takedownId: null }) - .where('uri', '=', info.uri.toString()) - .execute() - const blobs = await this.db.db - .updateTable('repo_blob') - .set({ takedownId: null }) - .where('takedownId', 'is not', null) - .where('recordUri', '=', info.uri.toString()) - .returning('cid') + .set({ takedownRef }) + .where('uri', '=', uri.toString()) .execute() - await Promise.all( - blobs.map(async (blob) => { - const cid = CID.parse(blob.cid) - await this.blobstore.unquarantine(cid) - }), - ) } - async resolveReports(info: { - reportIds: number[] - actionId: number - createdBy: string - createdAt?: Date - }): Promise { - const { reportIds, actionId, createdBy, createdAt = new Date() } = info - const action = await this.getActionOrThrow(actionId) - - if (!reportIds.length) return - const reports = await this.db.db - .selectFrom('moderation_report') - .where('id', 'in', reportIds) - .select(['id', 'subjectType', 'subjectDid', 'subjectUri']) - .execute() - - reportIds.forEach((reportId) => { - const report = reports.find((r) => r.id === reportId) - if (!report) throw new InvalidRequestError('Report not found') - if (action.subjectDid !== report.subjectDid) { - // Report and action always must target repo or record from the same did - throw new InvalidRequestError( - `Report ${report.id} cannot be resolved by action`, - ) - } - if ( - action.subjectType === 'com.atproto.repo.strongRef' && - report.subjectType === 'com.atproto.repo.strongRef' && - report.subjectUri !== action.subjectUri - ) { - // If report and action are both for a record, they must be for the same record - throw new InvalidRequestError( - `Report ${report.id} cannot be resolved by action`, - ) - } - }) - + async updateBlobTakedownState(did: string, blob: CID, takedown: StatusAttr) { + const takedownRef = statusTotakedownRef(takedown) await this.db.db - .insertInto('moderation_report_resolution') - .values( - reportIds.map((reportId) => ({ - reportId, - actionId, - createdAt: createdAt.toISOString(), - createdBy, - })), - ) - .onConflict((oc) => oc.doNothing()) + .updateTable('repo_blob') + .set({ takedownRef }) + .where('did', '=', did) + .where('cid', '=', blob.toString()) .execute() - } - - async report(info: { - reasonType: ModerationReportRow['reasonType'] - reason?: string - subject: { did: string } | { uri: AtUri; cid?: CID } - reportedBy: string - createdAt?: Date - }): Promise { - const { - reasonType, - reason, - reportedBy, - createdAt = new Date(), - subject, - } = info - - // Resolve subject info - let subjectInfo: SubjectInfo - if ('did' in subject) { - const repo = await new SqlRepoStorage(this.db, subject.did).getRoot() - if (!repo) throw new InvalidRequestError('Repo not found') - subjectInfo = { - subjectType: 'com.atproto.admin.defs#repoRef', - subjectDid: subject.did, - subjectUri: null, - subjectCid: null, - } + if (takedown.applied) { + await this.blobstore.quarantine(blob) } else { - const record = await this.services - .record(this.db) - .getRecord(subject.uri, subject.cid?.toString() ?? null, true) - if (!record) throw new InvalidRequestError('Record not found') - subjectInfo = { - subjectType: 'com.atproto.repo.strongRef', - subjectDid: subject.uri.host, - subjectUri: subject.uri.toString(), - subjectCid: record.cid, - } + await this.blobstore.unquarantine(blob) } - - const report = await this.db.db - .insertInto('moderation_report') - .values({ - reasonType, - reason: reason || null, - createdAt: createdAt.toISOString(), - reportedByDid: reportedBy, - ...subjectInfo, - }) - .returningAll() - .executeTakeFirstOrThrow() - - return report } } -export type ModerationActionRow = Selectable +type StatusResponse = { + subject: T + takedown: StatusAttr +} -export type ModerationReportRow = Selectable -export type ModerationReportRowWithHandle = ModerationReportRow & { - handle?: string | null +const takedownRefToStatus = (id: string | null): StatusAttr => { + return id === null ? { applied: false } : { applied: true, ref: id } } -export type SubjectInfo = - | { - subjectType: 'com.atproto.admin.defs#repoRef' - subjectDid: string - subjectUri: null - subjectCid: null - } - | { - subjectType: 'com.atproto.repo.strongRef' - subjectDid: string - subjectUri: string - subjectCid: string - } +const statusTotakedownRef = (state: StatusAttr): string | null => { + return state.applied ? state.ref ?? new Date().toISOString() : null +} diff --git a/packages/pds/src/services/moderation/views.ts b/packages/pds/src/services/moderation/views.ts deleted file mode 100644 index e8d89620d73..00000000000 --- a/packages/pds/src/services/moderation/views.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { Selectable } from 'kysely' -import { ArrayEl, cborBytesToRecord } from '@atproto/common' -import { AtUri } from '@atproto/syntax' -import Database from '../../db' -import { DidHandle } from '../../db/tables/did-handle' -import { RepoRoot } from '../../db/tables/repo-root' -import { - RepoView, - RepoViewDetail, - RecordView, - RecordViewDetail, - ActionView, - ActionViewDetail, - ReportView, - ReportViewDetail, - BlobView, -} from '../../lexicon/types/com/atproto/admin/defs' -import { OutputSchema as ReportOutput } from '../../lexicon/types/com/atproto/moderation/createReport' -import { ModerationAction } from '../../db/tables/moderation' -import { AccountService } from '../account' -import { RecordService } from '../record' -import { ModerationReportRowWithHandle } from '.' -import { ids } from '../../lexicon/lexicons' - -export class ModerationViews { - constructor(private db: Database) {} - - services = { - account: AccountService.creator(), - record: RecordService.creator(), - } - - repo(result: RepoResult, opts: ModViewOptions): Promise - repo(result: RepoResult[], opts: ModViewOptions): Promise - async repo( - result: RepoResult | RepoResult[], - opts: ModViewOptions, - ): Promise { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const [info, actionResults, invitedBy] = await Promise.all([ - await this.db.db - .selectFrom('did_handle') - .leftJoin('user_account', 'user_account.did', 'did_handle.did') - .leftJoin('record as profile_record', (join) => - join - .onRef('profile_record.did', '=', 'did_handle.did') - .on('profile_record.collection', '=', ids.AppBskyActorProfile) - .on('profile_record.rkey', '=', 'self'), - ) - .leftJoin('ipld_block as profile_block', (join) => - join - .onRef('profile_block.cid', '=', 'profile_record.cid') - .onRef('profile_block.creator', '=', 'did_handle.did'), - ) - .where( - 'did_handle.did', - 'in', - results.map((r) => r.did), - ) - .select([ - 'did_handle.did as did', - 'user_account.email as email', - 'user_account.invitesDisabled as invitesDisabled', - 'user_account.inviteNote as inviteNote', - 'profile_block.content as profileBytes', - ]) - .execute(), - this.db.db - .selectFrom('moderation_action') - .where('reversedAt', 'is', null) - .where('subjectType', '=', 'com.atproto.admin.defs#repoRef') - .where( - 'subjectDid', - 'in', - results.map((r) => r.did), - ) - .select(['id', 'action', 'durationInHours', 'subjectDid']) - .execute(), - this.services - .account(this.db) - .getInvitedByForAccounts(results.map((r) => r.did)), - ]) - - const infoByDid = info.reduce( - (acc, cur) => Object.assign(acc, { [cur.did]: cur }), - {} as Record>, - ) - const actionByDid = actionResults.reduce( - (acc, cur) => Object.assign(acc, { [cur.subjectDid ?? '']: cur }), - {} as Record>, - ) - - const views = results.map((r) => { - const { email, invitesDisabled, profileBytes, inviteNote } = - infoByDid[r.did] ?? {} - const action = actionByDid[r.did] - const relatedRecords: object[] = [] - if (profileBytes) { - relatedRecords.push(cborBytesToRecord(profileBytes)) - } - return { - did: r.did, - handle: r.handle, - email: opts.includeEmails && email ? email : undefined, - relatedRecords, - indexedAt: r.indexedAt, - moderation: { - currentAction: action - ? { - id: action.id, - action: action.action, - durationInHours: action.durationInHours ?? undefined, - } - : undefined, - }, - invitedBy: invitedBy[r.did], - invitesDisabled: invitesDisabled === 1, - inviteNote: inviteNote ?? undefined, - } - }) - - return Array.isArray(result) ? views : views[0] - } - - async repoDetail( - result: RepoResult, - opts: ModViewOptions, - ): Promise { - const repo = await this.repo(result, opts) - const [reportResults, actionResults, inviteCodes] = await Promise.all([ - this.db.db - .selectFrom('moderation_report') - .where('subjectType', '=', 'com.atproto.admin.defs#repoRef') - .where('subjectDid', '=', repo.did) - .orderBy('id', 'desc') - .selectAll() - .execute(), - this.db.db - .selectFrom('moderation_action') - .where('subjectType', '=', 'com.atproto.admin.defs#repoRef') - .where('subjectDid', '=', repo.did) - .orderBy('id', 'desc') - .selectAll() - .execute(), - this.services.account(this.db).getAccountInviteCodes(repo.did), - ]) - const [reports, actions] = await Promise.all([ - this.report(reportResults), - this.action(actionResults), - ]) - return { - ...repo, - moderation: { - ...repo.moderation, - reports, - actions, - }, - invites: inviteCodes, - } - } - - record(result: RecordResult, opts: ModViewOptions): Promise - record(result: RecordResult[], opts: ModViewOptions): Promise - async record( - result: RecordResult | RecordResult[], - opts: ModViewOptions, - ): Promise { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const [repoResults, blobResults, actionResults] = await Promise.all([ - this.db.db - .selectFrom('repo_root') - .innerJoin('did_handle', 'did_handle.did', 'repo_root.did') - .where( - 'repo_root.did', - 'in', - results.map((r) => didFromUri(r.uri)), - ) - .selectAll('repo_root') - .selectAll('did_handle') - .execute(), - this.db.db - .selectFrom('repo_blob') - .where( - 'recordUri', - 'in', - results.map((r) => r.uri), - ) - .select(['cid', 'recordUri']) - .execute(), - this.db.db - .selectFrom('moderation_action') - .where('reversedAt', 'is', null) - .where('subjectType', '=', 'com.atproto.repo.strongRef') - .where( - 'subjectUri', - 'in', - results.map((r) => r.uri), - ) - .select(['id', 'action', 'durationInHours', 'subjectUri']) - .execute(), - ]) - const repos = await this.repo(repoResults, opts) - - const reposByDid = repos.reduce( - (acc, cur) => Object.assign(acc, { [cur.did]: cur }), - {} as Record>, - ) - const blobCidsByUri = blobResults.reduce((acc, cur) => { - acc[cur.recordUri] ??= [] - acc[cur.recordUri].push(cur.cid) - return acc - }, {} as Record) - const actionByUri = actionResults.reduce( - (acc, cur) => Object.assign(acc, { [cur.subjectUri ?? '']: cur }), - {} as Record>, - ) - - const views = results.map((res) => { - const repo = reposByDid[didFromUri(res.uri)] - const action = actionByUri[res.uri] - if (!repo) throw new Error(`Record repo is missing: ${res.uri}`) - return { - uri: res.uri, - cid: res.cid, - value: res.value, - blobCids: blobCidsByUri[res.uri] ?? [], - indexedAt: res.indexedAt, - repo, - moderation: { - currentAction: action - ? { - id: action.id, - action: action.action, - durationInHours: action.durationInHours ?? undefined, - } - : undefined, - }, - } - }) - - return Array.isArray(result) ? views : views[0] - } - - async recordDetail( - result: RecordResult, - opts: ModViewOptions, - ): Promise { - const [record, reportResults, actionResults] = await Promise.all([ - this.record(result, opts), - this.db.db - .selectFrom('moderation_report') - .where('subjectType', '=', 'com.atproto.repo.strongRef') - .where('subjectUri', '=', result.uri) - .leftJoin( - 'did_handle', - 'did_handle.did', - 'moderation_report.subjectDid', - ) - .orderBy('id', 'desc') - .selectAll() - .execute(), - this.db.db - .selectFrom('moderation_action') - .where('subjectType', '=', 'com.atproto.repo.strongRef') - .where('subjectUri', '=', result.uri) - .orderBy('id', 'desc') - .selectAll() - .execute(), - ]) - const [reports, actions, blobs] = await Promise.all([ - this.report(reportResults), - this.action(actionResults), - this.blob(record.blobCids), - ]) - return { - ...record, - blobs, - moderation: { - ...record.moderation, - reports, - actions, - }, - } - } - - action(result: ActionResult): Promise - action(result: ActionResult[]): Promise - async action( - result: ActionResult | ActionResult[], - ): Promise { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const [resolutions, subjectBlobResults] = await Promise.all([ - this.db.db - .selectFrom('moderation_report_resolution') - .select(['reportId as id', 'actionId']) - .where( - 'actionId', - 'in', - results.map((r) => r.id), - ) - .orderBy('id', 'desc') - .execute(), - await this.db.db - .selectFrom('moderation_action_subject_blob') - .selectAll() - .where( - 'actionId', - 'in', - results.map((r) => r.id), - ) - .execute(), - ]) - - const reportIdsByActionId = resolutions.reduce((acc, cur) => { - acc[cur.actionId] ??= [] - acc[cur.actionId].push(cur.id) - return acc - }, {} as Record) - const subjectBlobCidsByActionId = subjectBlobResults.reduce((acc, cur) => { - acc[cur.actionId] ??= [] - acc[cur.actionId].push(cur.cid) - return acc - }, {} as Record) - - const views = results.map((res) => ({ - id: res.id, - action: res.action, - durationInHours: res.durationInHours ?? undefined, - subject: - res.subjectType === 'com.atproto.admin.defs#repoRef' - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: res.subjectDid, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: res.subjectUri, - cid: res.subjectCid, - }, - subjectBlobCids: subjectBlobCidsByActionId[res.id] ?? [], - reason: res.reason, - createdAt: res.createdAt, - createdBy: res.createdBy, - createLabelVals: - res.createLabelVals && res.createLabelVals.length > 0 - ? res.createLabelVals.split(' ') - : undefined, - negateLabelVals: - res.negateLabelVals && res.negateLabelVals.length > 0 - ? res.negateLabelVals.split(' ') - : undefined, - reversal: - res.reversedAt !== null && - res.reversedBy !== null && - res.reversedReason !== null - ? { - createdAt: res.reversedAt, - createdBy: res.reversedBy, - reason: res.reversedReason, - } - : undefined, - resolvedReportIds: reportIdsByActionId[res.id] ?? [], - })) - - return Array.isArray(result) ? views : views[0] - } - - async actionDetail( - result: ActionResult, - opts: ModViewOptions, - ): Promise { - const action = await this.action(result) - const reportResults = action.resolvedReportIds.length - ? await this.db.db - .selectFrom('moderation_report') - .where('id', 'in', action.resolvedReportIds) - .orderBy('id', 'desc') - .selectAll() - .execute() - : [] - const [subject, resolvedReports, subjectBlobs] = await Promise.all([ - this.subject(result, opts), - this.report(reportResults), - this.blob(action.subjectBlobCids), - ]) - return { - id: action.id, - action: action.action, - durationInHours: action.durationInHours, - subject, - subjectBlobs, - createLabelVals: action.createLabelVals, - negateLabelVals: action.negateLabelVals, - reason: action.reason, - createdAt: action.createdAt, - createdBy: action.createdBy, - reversal: action.reversal, - resolvedReports, - } - } - - report(result: ReportResult): Promise - report(result: ReportResult[]): Promise - async report( - result: ReportResult | ReportResult[], - ): Promise { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const resolutions = await this.db.db - .selectFrom('moderation_report_resolution') - .select(['actionId as id', 'reportId']) - .where( - 'reportId', - 'in', - results.map((r) => r.id), - ) - .orderBy('id', 'desc') - .execute() - - const actionIdsByReportId = resolutions.reduce((acc, cur) => { - acc[cur.reportId] ??= [] - acc[cur.reportId].push(cur.id) - return acc - }, {} as Record) - - const views: ReportView[] = results.map((res) => { - const decoratedView: ReportView = { - id: res.id, - createdAt: res.createdAt, - reasonType: res.reasonType, - reason: res.reason ?? undefined, - reportedBy: res.reportedByDid, - subject: - res.subjectType === 'com.atproto.admin.defs#repoRef' - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: res.subjectDid, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: res.subjectUri, - cid: res.subjectCid, - }, - resolvedByActionIds: actionIdsByReportId[res.id] ?? [], - } - - if (res.handle) { - decoratedView.subjectRepoHandle = res.handle - } - - return decoratedView - }) - - return Array.isArray(result) ? views : views[0] - } - - reportPublic(report: ReportResult): ReportOutput { - return { - id: report.id, - createdAt: report.createdAt, - reasonType: report.reasonType, - reason: report.reason ?? undefined, - reportedBy: report.reportedByDid, - subject: - report.subjectType === 'com.atproto.admin.defs#repoRef' - ? { - $type: 'com.atproto.admin.defs#repoRef', - did: report.subjectDid, - } - : { - $type: 'com.atproto.repo.strongRef', - uri: report.subjectUri, - cid: report.subjectCid, - }, - } - } - - async reportDetail( - result: ReportResult, - opts: ModViewOptions, - ): Promise { - const report = await this.report(result) - const actionResults = report.resolvedByActionIds.length - ? await this.db.db - .selectFrom('moderation_action') - .where('id', 'in', report.resolvedByActionIds) - .orderBy('id', 'desc') - .selectAll() - .execute() - : [] - const [subject, resolvedByActions] = await Promise.all([ - this.subject(result, opts), - this.action(actionResults), - ]) - return { - id: report.id, - createdAt: report.createdAt, - reasonType: report.reasonType, - reason: report.reason ?? undefined, - reportedBy: report.reportedBy, - subject, - resolvedByActions, - } - } - - // Partial view for subjects - - async subject( - result: SubjectResult, - opts: ModViewOptions, - ): Promise { - let subject: SubjectView - if (result.subjectType === 'com.atproto.admin.defs#repoRef') { - const repoResult = await this.services - .account(this.db) - .getAccount(result.subjectDid, true) - if (repoResult) { - subject = await this.repo(repoResult, opts) - subject.$type = 'com.atproto.admin.defs#repoView' - } else { - subject = { did: result.subjectDid } - subject.$type = 'com.atproto.admin.defs#repoViewNotFound' - } - } else if ( - result.subjectType === 'com.atproto.repo.strongRef' && - result.subjectUri !== null - ) { - const recordResult = await this.services - .record(this.db) - .getRecord(new AtUri(result.subjectUri), null, true) - if (recordResult) { - subject = await this.record(recordResult, opts) - subject.$type = 'com.atproto.admin.defs#recordView' - } else { - subject = { uri: result.subjectUri } - subject.$type = 'com.atproto.admin.defs#recordViewNotFound' - } - } else { - throw new Error(`Bad subject data: (${result.id}) ${result.subjectType}`) - } - return subject - } - - // Partial view for blobs - - async blob(cids: string[]): Promise { - if (!cids.length) return [] - const [blobResults, actionResults] = await Promise.all([ - this.db.db - .selectFrom('blob') - .where('cid', 'in', cids) - .selectAll() - .execute(), - this.db.db - .selectFrom('moderation_action') - .where('reversedAt', 'is', null) - .innerJoin( - 'moderation_action_subject_blob as subject_blob', - 'subject_blob.actionId', - 'moderation_action.id', - ) - .select(['id', 'action', 'durationInHours', 'cid']) - .execute(), - ]) - const actionByCid = actionResults.reduce( - (acc, cur) => Object.assign(acc, { [cur.cid]: cur }), - {} as Record>, - ) - return blobResults.map((result) => { - const action = actionByCid[result.cid] - return { - cid: result.cid, - mimeType: result.mimeType, - size: result.size, - createdAt: result.createdAt, - // @TODO support #videoDetails here when we start tracking video length - details: - result.mimeType.startsWith('image/') && - result.height !== null && - result.width !== null - ? { - $type: 'com.atproto.admin.blob#imageDetails', - height: result.height, - width: result.width, - } - : undefined, - moderation: { - currentAction: action - ? { - id: action.id, - action: action.action, - durationInHours: action.durationInHours ?? undefined, - } - : undefined, - }, - } - }) - } -} - -type RepoResult = DidHandle & RepoRoot - -type ActionResult = Selectable - -type ReportResult = ModerationReportRowWithHandle - -type RecordResult = { - uri: string - cid: string - value: object - indexedAt: string - takedownId: number | null -} - -type SubjectResult = Pick< - ActionResult & ReportResult, - 'id' | 'subjectType' | 'subjectDid' | 'subjectUri' | 'subjectCid' -> - -type SubjectView = ActionViewDetail['subject'] & ReportViewDetail['subject'] - -function didFromUri(uri: string) { - return new AtUri(uri).host -} - -export type ModViewOptions = { includeEmails: boolean } diff --git a/packages/pds/src/services/record/index.ts b/packages/pds/src/services/record/index.ts index 1914d1b8c61..de2491c97f3 100644 --- a/packages/pds/src/services/record/index.ts +++ b/packages/pds/src/services/record/index.ts @@ -164,7 +164,7 @@ export class RecordService { cid: string value: object indexedAt: string - takedownId: number | null + takedownRef: string | null } | null> { const { ref } = this.db.db.dynamic let builder = this.db.db @@ -189,7 +189,7 @@ export class RecordService { cid: record.cid, value: cborToLexRecord(record.content), indexedAt: record.indexedAt, - takedownId: record.takedownId, + takedownRef: record.takedownRef ? record.takedownRef.toString() : null, } } diff --git a/packages/pds/src/services/repo/blobs.ts b/packages/pds/src/services/repo/blobs.ts index 2bedb88ecfd..318a5a26c4f 100644 --- a/packages/pds/src/services/repo/blobs.ts +++ b/packages/pds/src/services/repo/blobs.ts @@ -162,7 +162,7 @@ export class RepoBlobs { this.db.db .selectFrom('repo_blob') .selectAll() - .where('takedownId', 'is not', null) + .where('takedownRef', 'is not', null) .whereRef('cid', '=', ref('blob.cid')), ) .executeTakeFirst() diff --git a/packages/pds/tests/account-deletion.test.ts b/packages/pds/tests/account-deletion.test.ts index 12bdad8875a..c9adbaf0c5e 100644 --- a/packages/pds/tests/account-deletion.test.ts +++ b/packages/pds/tests/account-deletion.test.ts @@ -14,7 +14,6 @@ import { RepoBlob } from '../src/db/tables/repo-blob' import { Blob } from '../src/db/tables/blob' import { Record } from '../src/db/tables/record' import { RepoSeq } from '../src/db/tables/repo-seq' -import { ACKNOWLEDGE } from '../src/lexicon/types/com/atproto/admin/defs' describe('account deletion', () => { let network: TestNetworkNoAppView @@ -105,16 +104,14 @@ describe('account deletion', () => { }) it('deletes account with a valid token & password', async () => { - // Perform account deletion, including when there's an existing mod action on the account - await agent.api.com.atproto.admin.takeModerationAction( + // Perform account deletion, including when there's an existing takedown on the account + await agent.api.com.atproto.admin.updateSubjectStatus( { - action: ACKNOWLEDGE, subject: { $type: 'com.atproto.admin.defs#repoRef', did: carol.did, }, - createdBy: 'did:example:admin', - reason: 'X', + takedown: { applied: true }, }, { encoding: 'application/json', diff --git a/packages/pds/tests/admin/__snapshots__/moderation.test.ts.snap b/packages/pds/tests/admin/__snapshots__/moderation.test.ts.snap deleted file mode 100644 index 97c189a5ba3..00000000000 --- a/packages/pds/tests/admin/__snapshots__/moderation.test.ts.snap +++ /dev/null @@ -1,193 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`moderation actioning resolves reports on missing repos and records. 1`] = ` -Object { - "recordActionDetail": Object { - "action": "com.atproto.admin.defs#flag", - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "did:example:admin", - "id": 4, - "reason": "Y", - "resolvedReports": Array [ - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 8, - "reason": "defamation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(1)", - "resolvedByActionIds": Array [ - 4, - ], - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(0)", - "uri": "record(0)", - }, - }, - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 7, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(2)", - "resolvedByActionIds": Array [ - 4, - ], - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(0)", - }, - }, - ], - "subject": Object { - "$type": "com.atproto.admin.defs#recordViewNotFound", - "uri": "record(0)", - }, - "subjectBlobs": Array [], - }, - "repoDeletionActionDetail": Object { - "action": "com.atproto.admin.defs#takedown", - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "user(0)", - "id": 3, - "reason": "ACCOUNT DELETION", - "resolvedReports": Array [], - "subject": Object { - "$type": "com.atproto.admin.defs#repoViewNotFound", - "did": "user(0)", - }, - "subjectBlobs": Array [], - }, - "reportADetail": Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 7, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(2)", - "resolvedByActions": Array [ - Object { - "action": "com.atproto.admin.defs#flag", - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "did:example:admin", - "id": 4, - "reason": "Y", - "resolvedReportIds": Array [ - 8, - 7, - ], - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(0)", - "uri": "record(0)", - }, - "subjectBlobCids": Array [], - }, - ], - "subject": Object { - "$type": "com.atproto.admin.defs#repoViewNotFound", - "did": "user(0)", - }, - }, - "reportBDetail": Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 8, - "reason": "defamation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(1)", - "resolvedByActions": Array [ - Object { - "action": "com.atproto.admin.defs#flag", - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "did:example:admin", - "id": 4, - "reason": "Y", - "resolvedReportIds": Array [ - 8, - 7, - ], - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(0)", - "uri": "record(0)", - }, - "subjectBlobCids": Array [], - }, - ], - "subject": Object { - "$type": "com.atproto.admin.defs#recordViewNotFound", - "uri": "record(0)", - }, - }, -} -`; - -exports[`moderation actioning resolves reports on repos and records. 1`] = ` -Object { - "action": "com.atproto.admin.defs#takedown", - "createdAt": "1970-01-01T00:00:00.000Z", - "createdBy": "did:example:admin", - "id": 2, - "reason": "Y", - "resolvedReportIds": Array [ - 6, - 5, - ], - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(0)", - }, - "subjectBlobCids": Array [], -} -`; - -exports[`moderation reporting creates reports of a record. 1`] = ` -Array [ - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 3, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(0)", - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(0)", - "uri": "record(0)", - }, - }, - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 4, - "reason": "defamation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(1)", - "subject": Object { - "$type": "com.atproto.repo.strongRef", - "cid": "cids(1)", - "uri": "record(1)", - }, - }, -] -`; - -exports[`moderation reporting creates reports of a repo. 1`] = ` -Array [ - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 1, - "reasonType": "com.atproto.moderation.defs#reasonSpam", - "reportedBy": "user(0)", - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(1)", - }, - }, - Object { - "createdAt": "1970-01-01T00:00:00.000Z", - "id": 2, - "reason": "impersonation", - "reasonType": "com.atproto.moderation.defs#reasonOther", - "reportedBy": "user(2)", - "subject": Object { - "$type": "com.atproto.admin.defs#repoRef", - "did": "user(1)", - }, - }, -] -`; diff --git a/packages/pds/tests/admin/moderation.test.ts b/packages/pds/tests/admin/moderation.test.ts deleted file mode 100644 index c65812adfed..00000000000 --- a/packages/pds/tests/admin/moderation.test.ts +++ /dev/null @@ -1,999 +0,0 @@ -import { - TestNetworkNoAppView, - ImageRef, - RecordRef, - SeedClient, -} from '@atproto/dev-env' -import AtpAgent from '@atproto/api' -import { AtUri } from '@atproto/syntax' -import { BlobNotFoundError } from '@atproto/repo' -import { forSnapshot } from '../_util' -import { PeriodicModerationActionReversal } from '../../src/db/periodic-moderation-action-reversal' -import basicSeed from '../seeds/basic' -import { - ACKNOWLEDGE, - ESCALATE, - FLAG, - TAKEDOWN, -} from '../../src/lexicon/types/com/atproto/admin/defs' -import { - REASONOTHER, - REASONSPAM, -} from '../../src/lexicon/types/com/atproto/moderation/defs' - -describe('moderation', () => { - let network: TestNetworkNoAppView - let agent: AtpAgent - let sc: SeedClient - - beforeAll(async () => { - network = await TestNetworkNoAppView.create({ - dbPostgresSchema: 'moderation', - }) - agent = network.pds.getClient() - sc = network.getSeedClient() - await basicSeed(sc) - }) - - afterAll(async () => { - await network.close() - }) - - describe('reporting', () => { - it('creates reports of a repo.', async () => { - const { data: reportA } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const { data: reportB } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONOTHER, - reason: 'impersonation', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - headers: sc.getHeaders(sc.dids.carol), - encoding: 'application/json', - }, - ) - expect(forSnapshot([reportA, reportB])).toMatchSnapshot() - }) - - it("fails reporting a repo that doesn't exist.", async () => { - const promise = agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: 'did:plc:unknown', - }, - }, - { headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' }, - ) - await expect(promise).rejects.toThrow('Repo not found') - }) - - it('creates reports of a record.', async () => { - const postA = sc.posts[sc.dids.bob][0].ref - const postB = sc.posts[sc.dids.bob][1].ref - const { data: reportA } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postA.uriStr, - cid: postA.cidStr, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const { data: reportB } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONOTHER, - reason: 'defamation', - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postB.uriStr, - cid: postB.cidStr, - }, - }, - { - headers: sc.getHeaders(sc.dids.carol), - encoding: 'application/json', - }, - ) - expect(forSnapshot([reportA, reportB])).toMatchSnapshot() - }) - - it("fails reporting a record that doesn't exist.", async () => { - const postA = sc.posts[sc.dids.bob][0].ref - const postB = sc.posts[sc.dids.bob][1].ref - const postUriBad = new AtUri(postA.uriStr) - postUriBad.rkey = 'badrkey' - - const promiseA = agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postUriBad.toString(), - cid: postA.cidStr, - }, - }, - { headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' }, - ) - await expect(promiseA).rejects.toThrow('Record not found') - - const promiseB = agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONOTHER, - reason: 'defamation', - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postB.uri.toString(), - cid: postA.cidStr, // bad cid - }, - }, - { headers: sc.getHeaders(sc.dids.carol), encoding: 'application/json' }, - ) - await expect(promiseB).rejects.toThrow('Record not found') - }) - }) - - describe('actioning', () => { - it('resolves reports on repos and records.', async () => { - const { data: reportA } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const post = sc.posts[sc.dids.bob][1].ref - const { data: reportB } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONOTHER, - reason: 'defamation', - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.uri.toString(), - cid: post.cid.toString(), - }, - }, - { - headers: sc.getHeaders(sc.dids.carol), - encoding: 'application/json', - }, - ) - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const { data: actionResolvedReports } = - await agent.api.com.atproto.admin.resolveModerationReports( - { - actionId: action.id, - reportIds: [reportB.id, reportA.id], - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - expect(forSnapshot(actionResolvedReports)).toMatchSnapshot() - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('resolves reports on missing repos and records.', async () => { - // Create fresh user - const deleteme = await sc.createAccount('deleteme', { - email: 'deleteme.test@bsky.app', - handle: 'deleteme.test', - password: 'password', - }) - const post = await sc.post(deleteme.did, 'delete this post') - // Report user and post - const { data: reportA } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: deleteme.did, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const { data: reportB } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONOTHER, - reason: 'defamation', - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.ref.uriStr, - cid: post.ref.cidStr, - }, - }, - { - headers: sc.getHeaders(sc.dids.carol), - encoding: 'application/json', - }, - ) - // Delete full user account - await agent.api.com.atproto.server.requestAccountDelete(undefined, { - headers: sc.getHeaders(deleteme.did), - }) - const { token: deletionToken } = await network.pds.ctx.db.db - .selectFrom('email_token') - .where('purpose', '=', 'delete_account') - .where('did', '=', deleteme.did) - .selectAll() - .executeTakeFirstOrThrow() - await agent.api.com.atproto.server.deleteAccount({ - did: deleteme.did, - password: 'password', - token: deletionToken, - }) - await network.processAll() - // Take action on deleted content - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.ref.uriStr, - cid: post.ref.cidStr, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await agent.api.com.atproto.admin.resolveModerationReports( - { - actionId: action.id, - reportIds: [reportB.id, reportA.id], - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - // Check report and action details - const { data: repoDeletionActionDetail } = - await agent.api.com.atproto.admin.getModerationAction( - { id: action.id - 1 }, - { headers: network.pds.adminAuthHeaders() }, - ) - const { data: recordActionDetail } = - await agent.api.com.atproto.admin.getModerationAction( - { id: action.id }, - { headers: network.pds.adminAuthHeaders() }, - ) - const { data: reportADetail } = - await agent.api.com.atproto.admin.getModerationReport( - { id: reportA.id }, - { headers: network.pds.adminAuthHeaders() }, - ) - const { data: reportBDetail } = - await agent.api.com.atproto.admin.getModerationReport( - { id: reportB.id }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect( - forSnapshot({ - repoDeletionActionDetail, - recordActionDetail, - reportADetail, - reportBDetail, - }), - ).toMatchSnapshot() - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('does not resolve report for mismatching repo.', async () => { - const { data: report } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.carol, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - const promise = agent.api.com.atproto.admin.resolveModerationReports( - { - actionId: action.id, - reportIds: [report.id], - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - await expect(promise).rejects.toThrow( - 'Report 9 cannot be resolved by action', - ) - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('does not resolve report for mismatching record.', async () => { - const postRef1 = sc.posts[sc.dids.alice][0].ref - const postRef2 = sc.posts[sc.dids.bob][0].ref - const { data: report } = - await agent.api.com.atproto.moderation.createReport( - { - reasonType: REASONSPAM, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef1.uriStr, - cid: postRef1.cidStr, - }, - }, - { - headers: sc.getHeaders(sc.dids.alice), - encoding: 'application/json', - }, - ) - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef2.uriStr, - cid: postRef2.cidStr, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - const promise = agent.api.com.atproto.admin.resolveModerationReports( - { - actionId: action.id, - reportIds: [report.id], - createdBy: 'did:example:admin', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - await expect(promise).rejects.toThrow( - 'Report 10 cannot be resolved by action', - ) - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('supports escalating and acknowledging for triage.', async () => { - const postRef1 = sc.posts[sc.dids.alice][0].ref - const postRef2 = sc.posts[sc.dids.bob][0].ref - const { data: action1 } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: ESCALATE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef1.uri.toString(), - cid: postRef1.cid.toString(), - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('triage'), - }, - ) - expect(action1).toEqual( - expect.objectContaining({ - action: ESCALATE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef1.uriStr, - cid: postRef1.cidStr, - }, - }), - ) - const { data: action2 } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: ACKNOWLEDGE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef2.uri.toString(), - cid: postRef2.cid.toString(), - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('triage'), - }, - ) - expect(action2).toEqual( - expect.objectContaining({ - action: ACKNOWLEDGE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef2.uriStr, - cid: postRef2.cidStr, - }, - }), - ) - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action1.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('triage'), - }, - ) - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action2.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('triage'), - }, - ) - }) - - it('only allows record to have one current action.', async () => { - const postRef = sc.posts[sc.dids.alice][0].ref - const { data: acknowledge } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: ACKNOWLEDGE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const flagPromise = agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await expect(flagPromise).rejects.toThrow( - 'Subject already has an active action:', - ) - - // Reverse current then retry - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: acknowledge.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const { data: flag } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postRef.uriStr, - cid: postRef.cidStr, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: flag.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('only allows repo to have one current action.', async () => { - const { data: acknowledge } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: ACKNOWLEDGE, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.alice, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const flagPromise = agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.alice, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await expect(flagPromise).rejects.toThrow( - 'Subject already has an active action:', - ) - - // Reverse current then retry - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: acknowledge.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const { data: flag } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.alice, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: flag.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('only allows blob to have one current action.', async () => { - const img = sc.posts[sc.dids.carol][0].images[0] - const postA = await sc.post(sc.dids.carol, 'image A', undefined, [img]) - const postB = await sc.post(sc.dids.carol, 'image B', undefined, [img]) - const { data: acknowledge } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: ACKNOWLEDGE, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postA.ref.uriStr, - cid: postA.ref.cidStr, - }, - subjectBlobCids: [img.image.ref.toString()], - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const flagPromise = agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postB.ref.uriStr, - cid: postB.ref.cidStr, - }, - subjectBlobCids: [img.image.ref.toString()], - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - await expect(flagPromise).rejects.toThrow( - 'Blob already has an active action:', - ) - // Reverse current then retry - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: acknowledge.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - const { data: flag } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: postB.ref.uriStr, - cid: postB.ref.cidStr, - }, - subjectBlobCids: [img.image.ref.toString()], - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: flag.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('allows full moderators to takedown.', async () => { - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - createdBy: 'did:example:moderator', - reason: 'Y', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('moderator'), - }, - ) - // cleanup - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - }) - - it('automatically reverses actions marked with duration', async () => { - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - createdBy: 'did:example:moderator', - reason: 'Y', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - // Use negative value to set the expiry time in the past so that the action is automatically reversed - // right away without having to wait n number of hours for a successful assertion - durationInHours: -1, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('moderator'), - }, - ) - - // In the actual app, this will be instantiated and run on server startup - const periodicReversal = new PeriodicModerationActionReversal( - network.pds.ctx, - ) - await periodicReversal.findAndRevertDueActions() - - const { data: reversedAction } = - await agent.api.com.atproto.admin.getModerationAction( - { id: action.id }, - { headers: network.pds.adminAuthHeaders() }, - ) - - // Verify that the automatic reversal is attributed to the original moderator of the temporary action - // and that the reason is set to indicate that the action was automatically reversed. - expect(reversedAction.reversal).toMatchObject({ - createdBy: action.createdBy, - reason: '[SCHEDULED_REVERSAL] Reverting action as originally scheduled', - }) - }) - - it('does not allow non-full moderators to takedown.', async () => { - const attemptTakedownTriage = - agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - createdBy: 'did:example:moderator', - reason: 'Y', - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: sc.dids.bob, - }, - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders('triage'), - }, - ) - await expect(attemptTakedownTriage).rejects.toThrow( - 'Must be a full moderator to perform an account takedown', - ) - }) - }) - - describe('blob takedown', () => { - let post: { ref: RecordRef; images: ImageRef[] } - let blob: ImageRef - let actionId: number - - beforeAll(async () => { - post = sc.posts[sc.dids.carol][0] - blob = post.images[1] - const takeAction = await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: post.ref.uriStr, - cid: post.ref.cidStr, - }, - subjectBlobCids: [blob.image.ref.toString()], - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - actionId = takeAction.data.id - }) - - it('removes blob from the store', async () => { - const tryGetBytes = network.pds.ctx.blobstore.getBytes(blob.image.ref) - await expect(tryGetBytes).rejects.toThrow(BlobNotFoundError) - }) - - it('prevents blob from being referenced again.', async () => { - const uploaded = await sc.uploadFile( - sc.dids.alice, - 'tests/sample-img/key-alt.jpg', - 'image/jpeg', - ) - expect(uploaded.image.ref.equals(blob.image.ref)).toBeTruthy() - const referenceBlob = sc.post(sc.dids.alice, 'pic', [], [blob]) - await expect(referenceBlob).rejects.toThrow('Could not find blob:') - }) - - it('prevents image blob from being served, even when cached.', async () => { - const attempt = agent.api.com.atproto.sync.getBlob({ - did: sc.dids.carol, - cid: blob.image.ref.toString(), - }) - await expect(attempt).rejects.toThrow('Blob not found') - }) - - it('restores blob when action is reversed.', async () => { - await agent.api.com.atproto.admin.reverseModerationAction( - { - id: actionId, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) - - // Can post and reference blob - const post = await sc.post(sc.dids.alice, 'pic', [], [blob]) - expect(post.images[0].image.ref.equals(blob.image.ref)).toBeTruthy() - - // Can fetch through image server - const res = await agent.api.com.atproto.sync.getBlob({ - did: sc.dids.carol, - cid: blob.image.ref.toString(), - }) - - expect(res.data.byteLength).toBeGreaterThan(9000) - }) - }) -}) diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index 542001cddce..650b2d1e9a7 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -1,7 +1,6 @@ import * as jwt from 'jsonwebtoken' import AtpAgent from '@atproto/api' import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' -import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' import * as CreateSession from '@atproto/api/src/client/types/com/atproto/server/createSession' import * as RefreshSession from '@atproto/api/src/client/types/com/atproto/server/refreshSession' @@ -243,15 +242,13 @@ describe('auth', () => { email: 'iris@test.com', password: 'password', }) - await agent.api.com.atproto.admin.takeModerationAction( + await agent.api.com.atproto.admin.updateSubjectStatus( { - action: TAKEDOWN, subject: { $type: 'com.atproto.admin.defs#repoRef', did: account.did, }, - createdBy: 'did:example:admin', - reason: 'Y', + takedown: { applied: true }, }, { encoding: 'application/json', @@ -269,15 +266,13 @@ describe('auth', () => { email: 'jared@test.com', password: 'password', }) - await agent.api.com.atproto.admin.takeModerationAction( + await agent.api.com.atproto.admin.updateSubjectStatus( { - action: TAKEDOWN, subject: { $type: 'com.atproto.admin.defs#repoRef', did: account.did, }, - createdBy: 'did:example:admin', - reason: 'Y', + takedown: { applied: true }, }, { encoding: 'application/json', diff --git a/packages/pds/tests/crud.test.ts b/packages/pds/tests/crud.test.ts index c0902e2db29..65544677ff2 100644 --- a/packages/pds/tests/crud.test.ts +++ b/packages/pds/tests/crud.test.ts @@ -13,7 +13,6 @@ import { defaultFetchHandler } from '@atproto/xrpc' import * as Post from '../src/lexicon/types/app/bsky/feed/post' import { paginateAll } from './_util' import AppContext from '../src/context' -import { TAKEDOWN } from '../src/lexicon/types/com/atproto/admin/defs' import { ids } from '../src/lexicon/lexicons' const alice = { @@ -1154,23 +1153,21 @@ describe('crud operations', () => { const posts = await agent.api.app.bsky.feed.post.list({ repo: alice.did }) expect(posts.records.map((r) => r.uri)).toContain(post.uri) - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.repo.strongRef', - uri: created.uri, - cid: created.cid, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: { authorization: network.pds.adminAuth() }, - }, - ) + const subject = { + $type: 'com.atproto.repo.strongRef', + uri: created.uri, + cid: created.cid, + } + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject, + takedown: { applied: true }, + }, + { + encoding: 'application/json', + headers: { authorization: network.pds.adminAuth() }, + }, + ) const postTakedownPromise = agent.api.app.bsky.feed.post.get({ repo: alice.did, @@ -1183,11 +1180,10 @@ describe('crud operations', () => { expect(postsTakedown.records.map((r) => r.uri)).not.toContain(post.uri) // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( + await agent.api.com.atproto.admin.updateSubjectStatus( { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', + subject, + takedown: { applied: false }, }, { encoding: 'application/json', @@ -1200,22 +1196,21 @@ describe('crud operations', () => { const posts = await agent.api.app.bsky.feed.post.list({ repo: alice.did }) expect(posts.records.length).toBeGreaterThan(0) - const { data: action } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: alice.did, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: { authorization: network.pds.adminAuth() }, - }, - ) + const subject = { + $type: 'com.atproto.admin.defs#repoRef', + did: alice.did, + } + + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject, + takedown: { applied: true }, + }, + { + encoding: 'application/json', + headers: { authorization: network.pds.adminAuth() }, + }, + ) const tryListPosts = agent.api.app.bsky.feed.post.list({ repo: alice.did, @@ -1223,11 +1218,10 @@ describe('crud operations', () => { await expect(tryListPosts).rejects.toThrow(/Could not find repo/) // Cleanup - await agent.api.com.atproto.admin.reverseModerationAction( + await agent.api.com.atproto.admin.updateSubjectStatus( { - id: action.id, - createdBy: 'did:example:admin', - reason: 'Y', + subject, + takedown: { applied: false }, }, { encoding: 'application/json', diff --git a/packages/pds/tests/db.test.ts b/packages/pds/tests/db.test.ts index 1a2a42f0930..d5a87595ac8 100644 --- a/packages/pds/tests/db.test.ts +++ b/packages/pds/tests/db.test.ts @@ -52,7 +52,7 @@ describe('db', () => { root: 'x', rev: 'x', indexedAt: 'bad-date', - takedownId: null, + takedownRef: null, }) }) diff --git a/packages/pds/tests/invite-codes.test.ts b/packages/pds/tests/invite-codes.test.ts index f406b77cc3b..e48e1b46fc7 100644 --- a/packages/pds/tests/invite-codes.test.ts +++ b/packages/pds/tests/invite-codes.test.ts @@ -4,7 +4,6 @@ import { TestNetworkNoAppView } from '@atproto/dev-env' import { AppContext } from '../src' import { DAY } from '@atproto/common' import { genInvCodes } from '../src/api/com/atproto/server/util' -import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' describe('account', () => { let network: TestNetworkNoAppView @@ -50,22 +49,20 @@ describe('account', () => { // assign an invite code to the user const code = await createInviteCode(network, agent, 1, account.did) // takedown the user's account - const { data: takedownAction } = - await agent.api.com.atproto.admin.takeModerationAction( - { - action: TAKEDOWN, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: account.did, - }, - createdBy: 'did:example:admin', - reason: 'Y', - }, - { - encoding: 'application/json', - headers: network.pds.adminAuthHeaders(), - }, - ) + const subject = { + $type: 'com.atproto.admin.defs#repoRef', + did: account.did, + } + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject, + takedown: { applied: true }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) // attempt to create account with the previously generated invite code const promise = createAccountWithInvite(agent, code) await expect(promise).rejects.toThrow( @@ -73,11 +70,10 @@ describe('account', () => { ) // double check that reversing the takedown action makes the invite code valid again - await agent.api.com.atproto.admin.reverseModerationAction( + await agent.api.com.atproto.admin.updateSubjectStatus( { - id: takedownAction.id, - createdBy: 'did:example:admin', - reason: 'Y', + subject, + takedown: { applied: false }, }, { encoding: 'application/json', diff --git a/packages/pds/tests/admin/invites.test.ts b/packages/pds/tests/invites-admin.test.ts similarity index 91% rename from packages/pds/tests/admin/invites.test.ts rename to packages/pds/tests/invites-admin.test.ts index 4f52400a314..d971b75285c 100644 --- a/packages/pds/tests/admin/invites.test.ts +++ b/packages/pds/tests/invites-admin.test.ts @@ -167,20 +167,8 @@ describe('pds admin invite views', () => { expect(combined).toEqual(full.data.codes) }) - it('filters admin.searchRepos by invitedBy', async () => { - const searchView = await agent.api.com.atproto.admin.searchRepos( - { invitedBy: alice }, - { headers: network.pds.adminAuthHeaders() }, - ) - expect(searchView.data.repos.length).toBe(2) - expect(searchView.data.repos[0].invitedBy?.available).toBe(1) - expect(searchView.data.repos[0].invitedBy?.uses.length).toBe(1) - expect(searchView.data.repos[1].invitedBy?.available).toBe(1) - expect(searchView.data.repos[1].invitedBy?.uses.length).toBe(1) - }) - - it('hydrates invites into admin.getRepo', async () => { - const aliceView = await agent.api.com.atproto.admin.getRepo( + it('hydrates invites into admin.getAccountInfo', async () => { + const aliceView = await agent.api.com.atproto.admin.getAccountInfo( { did: alice }, { headers: network.pds.adminAuthHeaders() }, ) @@ -221,7 +209,7 @@ describe('pds admin invite views', () => { { encoding: 'application/json', headers: network.pds.adminAuthHeaders() }, ) - const repoRes = await agent.api.com.atproto.admin.getRepo( + const repoRes = await agent.api.com.atproto.admin.getAccountInfo( { did: carol }, { headers: network.pds.adminAuthHeaders() }, ) @@ -243,7 +231,7 @@ describe('pds admin invite views', () => { { encoding: 'application/json', headers: network.pds.adminAuthHeaders() }, ) - const afterEnable = await agent.api.com.atproto.admin.getRepo( + const afterEnable = await agent.api.com.atproto.admin.getAccountInfo( { did: carol }, { headers: network.pds.adminAuthHeaders() }, ) @@ -255,7 +243,7 @@ describe('pds admin invite views', () => { { encoding: 'application/json', headers: network.pds.adminAuthHeaders() }, ) - const afterDisable = await agent.api.com.atproto.admin.getRepo( + const afterDisable = await agent.api.com.atproto.admin.getAccountInfo( { did: carol }, { headers: network.pds.adminAuthHeaders() }, ) @@ -290,7 +278,7 @@ describe('pds admin invite views', () => { { encoding: 'application/json', headers: network.pds.adminAuthHeaders() }, ) - const repoRes = await agent.api.com.atproto.admin.getRepo( + const repoRes = await agent.api.com.atproto.admin.getAccountInfo( { did: carol }, { headers: network.pds.adminAuthHeaders() }, ) diff --git a/packages/pds/tests/moderation.test.ts b/packages/pds/tests/moderation.test.ts new file mode 100644 index 00000000000..ee68bb7aab5 --- /dev/null +++ b/packages/pds/tests/moderation.test.ts @@ -0,0 +1,357 @@ +import { TestNetworkNoAppView, ImageRef, SeedClient } from '@atproto/dev-env' +import AtpAgent from '@atproto/api' +import { BlobNotFoundError } from '@atproto/repo' +import { Secp256k1Keypair } from '@atproto/crypto' +import { createServiceAuthHeaders } from '@atproto/xrpc-server' +import basicSeed from './seeds/basic' +import { + RepoBlobRef, + RepoRef, +} from '../src/lexicon/types/com/atproto/admin/defs' +import { Main as StrongRef } from '../src/lexicon/types/com/atproto/repo/strongRef' + +describe('moderation', () => { + let network: TestNetworkNoAppView + let agent: AtpAgent + let sc: SeedClient + + let repoSubject: RepoRef + let recordSubject: StrongRef + let blobSubject: RepoBlobRef + let blobRef: ImageRef + + const appviewDid = 'did:example:appview' + const altAppviewDid = 'did:example:alt' + let appviewKey: Secp256k1Keypair + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'moderation', + pds: { + bskyAppViewDid: appviewDid, + }, + }) + + appviewKey = await Secp256k1Keypair.create() + const origResolve = network.pds.ctx.idResolver.did.resolveAtprotoKey + network.pds.ctx.idResolver.did.resolveAtprotoKey = async ( + did: string, + forceRefresh?: boolean, + ) => { + if (did === appviewDid || did === altAppviewDid) { + return appviewKey.did() + } + return origResolve(did, forceRefresh) + } + + agent = network.pds.getClient() + sc = network.getSeedClient() + await basicSeed(sc) + await network.processAll() + repoSubject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + } + const post = sc.posts[sc.dids.carol][0] + recordSubject = { + $type: 'com.atproto.repo.strongRef', + uri: post.ref.uriStr, + cid: post.ref.cidStr, + } + blobRef = post.images[1] + blobSubject = { + $type: 'com.atproto.admin.defs#repoBlobRef', + did: sc.dids.carol, + cid: blobRef.image.ref.toString(), + } + }) + + afterAll(async () => { + await network.close() + }) + + it('takes down accounts', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders('moderator'), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.did).toEqual(sc.dids.bob) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-repo') + }) + + it('restores takendown accounts', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: false }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders('moderator'), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.did).toEqual(sc.dids.bob) + expect(res.data.takedown?.applied).toBe(false) + expect(res.data.takedown?.ref).toBeUndefined() + }) + + it('takes down records', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: recordSubject, + takedown: { applied: true, ref: 'test-record' }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders('moderator'), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + uri: recordSubject.uri, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.uri).toEqual(recordSubject.uri) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-record') + }) + + it('restores takendown records', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: recordSubject, + takedown: { applied: false }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders('moderator'), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + uri: recordSubject.uri, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.uri).toEqual(recordSubject.uri) + expect(res.data.takedown?.applied).toBe(false) + expect(res.data.takedown?.ref).toBeUndefined() + }) + + it('does not allow non-full moderators to update subject state', async () => { + const subject = { + $type: 'com.atproto.admin.defs#repoRef', + did: sc.dids.bob, + } + const attemptTakedownTriage = + agent.api.com.atproto.admin.updateSubjectStatus( + { + subject, + takedown: { applied: true }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders('triage'), + }, + ) + await expect(attemptTakedownTriage).rejects.toThrow( + 'Must be a full moderator to update subject state', + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: subject.did, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.takedown?.applied).toBe(false) + }) + + describe('blob takedown', () => { + it('takes down blobs', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: blobSubject, + takedown: { applied: true, ref: 'test-blob' }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: blobSubject.did, + blob: blobSubject.cid, + }, + { headers: network.pds.adminAuthHeaders('moderator') }, + ) + expect(res.data.subject.did).toEqual(blobSubject.did) + expect(res.data.subject.cid).toEqual(blobSubject.cid) + expect(res.data.takedown?.applied).toBe(true) + expect(res.data.takedown?.ref).toBe('test-blob') + }) + + it('removes blob from the store', async () => { + const tryGetBytes = network.pds.ctx.blobstore.getBytes(blobRef.image.ref) + await expect(tryGetBytes).rejects.toThrow(BlobNotFoundError) + }) + + it('prevents blob from being referenced again.', async () => { + const uploaded = await sc.uploadFile( + sc.dids.alice, + 'tests/sample-img/key-alt.jpg', + 'image/jpeg', + ) + expect(uploaded.image.ref.equals(blobRef.image.ref)).toBeTruthy() + const referenceBlob = sc.post(sc.dids.alice, 'pic', [], [blobRef]) + await expect(referenceBlob).rejects.toThrow('Could not find blob:') + }) + + it('prevents image blob from being served, even when cached.', async () => { + const attempt = agent.api.com.atproto.sync.getBlob({ + did: sc.dids.carol, + cid: blobRef.image.ref.toString(), + }) + await expect(attempt).rejects.toThrow('Blob not found') + }) + + it('restores blob when takedown is removed', async () => { + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: blobSubject, + takedown: { applied: false }, + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + + // Can post and reference blob + const post = await sc.post(sc.dids.alice, 'pic', [], [blobRef]) + expect(post.images[0].image.ref.equals(blobRef.image.ref)).toBeTruthy() + + // Can fetch through image server + const res = await agent.api.com.atproto.sync.getBlob({ + did: sc.dids.carol, + cid: blobRef.image.ref.toString(), + }) + + expect(res.data.byteLength).toBeGreaterThan(9000) + }) + }) + + describe('auth', () => { + it('allows service auth requests from the configured appview did', async () => { + const headers = await createServiceAuthHeaders({ + iss: appviewDid, + aud: repoSubject.did, + keypair: appviewKey, + }) + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + + const res = await agent.api.com.atproto.admin.getSubjectStatus( + { + did: repoSubject.did, + }, + headers, + ) + expect(res.data.subject.did).toBe(repoSubject.did) + expect(res.data.takedown?.applied).toBe(true) + }) + + it('does not allow requests from another did', async () => { + const headers = await createServiceAuthHeaders({ + iss: altAppviewDid, + aud: repoSubject.did, + keypair: appviewKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'Untrusted issuer for admin actions', + ) + }) + + it('does not allow requests with a bad signature', async () => { + const badKey = await Secp256k1Keypair.create() + const headers = await createServiceAuthHeaders({ + iss: appviewDid, + aud: repoSubject.did, + keypair: badKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'jwt signature does not match jwt issuer', + ) + }) + + it('does not allow requests with a bad signature', async () => { + // repo subject is bob, so we set alice as the audience + const headers = await createServiceAuthHeaders({ + iss: appviewDid, + aud: sc.dids.alice, + keypair: appviewKey, + }) + const attempt = agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: repoSubject, + takedown: { applied: true, ref: 'test-repo' }, + }, + { + ...headers, + encoding: 'application/json', + }, + ) + await expect(attempt).rejects.toThrow( + 'jwt audience does not match account did', + ) + }) + }) +}) diff --git a/packages/pds/tests/proxied/admin.test.ts b/packages/pds/tests/proxied/admin.test.ts index 23c801cd6b2..8b4fffae9e1 100644 --- a/packages/pds/tests/proxied/admin.test.ts +++ b/packages/pds/tests/proxied/admin.test.ts @@ -37,7 +37,10 @@ describe('proxies admin requests', () => { headers: network.pds.adminAuthHeaders(), }, ) - await basicSeed(sc, invite) + await basicSeed(sc, { + inviteCode: invite.code, + addModLabels: true, + }) await network.processAll() }) diff --git a/packages/pds/tests/proxied/feedgen.test.ts b/packages/pds/tests/proxied/feedgen.test.ts index 142d1235497..6f06ce0d020 100644 --- a/packages/pds/tests/proxied/feedgen.test.ts +++ b/packages/pds/tests/proxied/feedgen.test.ts @@ -23,7 +23,7 @@ describe('feedgen proxy view', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc) + await basicSeed(sc, { addModLabels: true }) // publish feed const feed = await agent.api.app.bsky.feed.generator.create( { repo: sc.dids.alice, rkey: feedUri.rkey }, diff --git a/packages/pds/tests/proxied/notif.test.ts b/packages/pds/tests/proxied/notif.test.ts index fb3de2b8fe7..4fc559ee120 100644 --- a/packages/pds/tests/proxied/notif.test.ts +++ b/packages/pds/tests/proxied/notif.test.ts @@ -70,7 +70,7 @@ describe('notif service proxy', () => { notifDid, async () => network.pds.ctx.repoSigningKey.did(), ) - expect(auth).toEqual(sc.dids.bob) + expect(auth.iss).toEqual(sc.dids.bob) }) }) diff --git a/packages/pds/tests/proxied/procedures.test.ts b/packages/pds/tests/proxied/procedures.test.ts index 00dd02863ce..8c246e38da7 100644 --- a/packages/pds/tests/proxied/procedures.test.ts +++ b/packages/pds/tests/proxied/procedures.test.ts @@ -17,7 +17,7 @@ describe('proxies appview procedures', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc) + await basicSeed(sc, { addModLabels: true }) await network.processAll() alice = sc.dids.alice bob = sc.dids.bob diff --git a/packages/pds/tests/proxied/read-after-write.test.ts b/packages/pds/tests/proxied/read-after-write.test.ts index 1d7d1412c11..1e9e4125084 100644 --- a/packages/pds/tests/proxied/read-after-write.test.ts +++ b/packages/pds/tests/proxied/read-after-write.test.ts @@ -21,7 +21,7 @@ describe('proxy read after write', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc) + await basicSeed(sc, { addModLabels: true }) await network.processAll() alice = sc.dids.alice carol = sc.dids.carol diff --git a/packages/pds/tests/proxied/views.test.ts b/packages/pds/tests/proxied/views.test.ts index 13fa41174b4..94b76719d70 100644 --- a/packages/pds/tests/proxied/views.test.ts +++ b/packages/pds/tests/proxied/views.test.ts @@ -19,7 +19,7 @@ describe('proxies view requests', () => { }) agent = network.pds.getClient() sc = network.getSeedClient() - await basicSeed(sc) + await basicSeed(sc, { addModLabels: true }) alice = sc.dids.alice bob = sc.dids.bob carol = sc.dids.carol diff --git a/packages/pds/tests/seeds/basic.ts b/packages/pds/tests/seeds/basic.ts index 3d045fc9239..1f71b58ff63 100644 --- a/packages/pds/tests/seeds/basic.ts +++ b/packages/pds/tests/seeds/basic.ts @@ -3,8 +3,11 @@ import { ids } from '../../src/lexicon/lexicons' import { FLAG } from '../../src/lexicon/types/com/atproto/admin/defs' import usersSeed from './users' -export default async (sc: SeedClient, invite?: { code: string }) => { - await usersSeed(sc, invite) +export default async ( + sc: SeedClient, + opts?: { inviteCode?: string; addModLabels?: boolean }, +) => { + await usersSeed(sc, opts) const alice = sc.dids.alice const bob = sc.dids.bob @@ -128,22 +131,24 @@ export default async (sc: SeedClient, invite?: { code: string }) => { await sc.repost(dan, sc.posts[alice][1].ref) await sc.repost(dan, alicesReplyToBob.ref) - await sc.agent.com.atproto.admin.takeModerationAction( - { - action: FLAG, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did: dan, + if (opts?.addModLabels) { + await sc.agent.com.atproto.admin.takeModerationAction( + { + action: FLAG, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: dan, + }, + createdBy: 'did:example:admin', + reason: 'test', + createLabelVals: ['repo-action-label'], }, - createdBy: 'did:example:admin', - reason: 'test', - createLabelVals: ['repo-action-label'], - }, - { - encoding: 'application/json', - headers: sc.adminAuthHeaders(), - }, - ) + { + encoding: 'application/json', + headers: sc.adminAuthHeaders(), + }, + ) + } return sc } diff --git a/packages/pds/tests/seeds/users.ts b/packages/pds/tests/seeds/users.ts index bfe6f9abe1c..a142954ac68 100644 --- a/packages/pds/tests/seeds/users.ts +++ b/packages/pds/tests/seeds/users.ts @@ -1,10 +1,16 @@ import { SeedClient } from '@atproto/dev-env' -export default async (sc: SeedClient, invite?: { code: string }) => { - await sc.createAccount('alice', { ...users.alice, inviteCode: invite?.code }) - await sc.createAccount('bob', { ...users.bob, inviteCode: invite?.code }) - await sc.createAccount('carol', { ...users.carol, inviteCode: invite?.code }) - await sc.createAccount('dan', { ...users.dan, inviteCode: invite?.code }) +export default async (sc: SeedClient, opts?: { inviteCode?: string }) => { + await sc.createAccount('alice', { + ...users.alice, + inviteCode: opts?.inviteCode, + }) + await sc.createAccount('bob', { ...users.bob, inviteCode: opts?.inviteCode }) + await sc.createAccount('carol', { + ...users.carol, + inviteCode: opts?.inviteCode, + }) + await sc.createAccount('dan', { ...users.dan, inviteCode: opts?.inviteCode }) await sc.createProfile( sc.dids.alice, diff --git a/packages/pds/tests/sync/sync.test.ts b/packages/pds/tests/sync/sync.test.ts index 424ebc86337..4f99b3bb08c 100644 --- a/packages/pds/tests/sync/sync.test.ts +++ b/packages/pds/tests/sync/sync.test.ts @@ -5,7 +5,6 @@ import { randomStr } from '@atproto/crypto' import * as repo from '@atproto/repo' import { MemoryBlockstore } from '@atproto/repo' import { AtUri } from '@atproto/syntax' -import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' import { CID } from 'multiformats/cid' import { AppContext } from '../../src' @@ -34,7 +33,6 @@ describe('repo sync', () => { password: 'alice-pass', }) did = sc.dids.alice - agent.api.setHeader('authorization', `Bearer ${sc.accounts[did].accessJwt}`) }) afterAll(async () => { @@ -83,11 +81,7 @@ describe('repo sync', () => { // delete two that are already sync & two that have not been for (let i = 0; i < DEL_COUNT; i++) { const uri = uris[i * 5] - await agent.api.app.bsky.feed.post.delete({ - repo: did, - collection: uri.collection, - rkey: uri.rkey, - }) + await sc.deletePost(did, uri) delete repoData[uri.collection][uri.rkey] } @@ -203,14 +197,19 @@ describe('repo sync', () => { describe('repo takedown', () => { beforeAll(async () => { - await sc.takeModerationAction({ - action: TAKEDOWN, - subject: { - $type: 'com.atproto.admin.defs#repoRef', - did, + await agent.api.com.atproto.admin.updateSubjectStatus( + { + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did, + }, + takedown: { applied: true }, }, - }) - agent.api.xrpc.unsetHeader('authorization') + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) }) it('does not sync repo unauthed', async () => { diff --git a/packages/xrpc-server/src/auth.ts b/packages/xrpc-server/src/auth.ts index eb4dfc537c8..9283b13815b 100644 --- a/packages/xrpc-server/src/auth.ts +++ b/packages/xrpc-server/src/auth.ts @@ -4,10 +4,13 @@ import * as crypto from '@atproto/crypto' import * as ui8 from 'uint8arrays' import { AuthRequiredError } from './types' -type ServiceJwtParams = { +type ServiceJwtPayload = { iss: string aud: string exp?: number +} + +type ServiceJwtParams = ServiceJwtPayload & { keypair: crypto.Keypair } @@ -46,7 +49,7 @@ export const verifyJwt = async ( jwtStr: string, ownDid: string | null, // null indicates to skip the audience check getSigningKey: (did: string) => Promise, -): Promise => { +): Promise => { const parts = jwtStr.split('.') if (parts.length !== 3) { throw new AuthRequiredError('poorly formatted jwt', 'BadJwt') @@ -85,7 +88,7 @@ export const verifyJwt = async ( ) } - return payload.iss + return payload } const parseB64UrlToJson = (b64: string) => { diff --git a/services/bsky/api.js b/services/bsky/api.js index 4ae71b17760..fac5b0a7c8b 100644 --- a/services/bsky/api.js +++ b/services/bsky/api.js @@ -18,6 +18,7 @@ const { CloudfrontInvalidator, MultiImageInvalidator, } = require('@atproto/aws') +const { Secp256k1Keypair } = require('@atproto/crypto') const { DatabaseCoordinator, PrimaryDatabase, @@ -64,6 +65,8 @@ const main = async () => { blobCacheLocation: env.blobCacheLocation, }) + const signingKey = await Secp256k1Keypair.import(env.serviceSigningKey) + // configure zero, one, or both image invalidators let imgInvalidator const bunnyInvalidator = env.bunnyAccessKey @@ -93,6 +96,7 @@ const main = async () => { const algos = env.feedPublisherDid ? makeAlgos(env.feedPublisherDid) : {} const bsky = BskyAppView.create({ db, + signingKey, config: cfg, imgInvalidator, algos, @@ -146,6 +150,7 @@ const getEnv = () => ({ dbPoolSize: maybeParseInt(process.env.DB_POOL_SIZE), dbPoolMaxUses: maybeParseInt(process.env.DB_POOL_MAX_USES), dbPoolIdleTimeoutMs: maybeParseInt(process.env.DB_POOL_IDLE_TIMEOUT_MS), + serviceSigningKey: process.env.SERVICE_SIGNING_KEY, publicUrl: process.env.PUBLIC_URL, didPlcUrl: process.env.DID_PLC_URL, imgUriSalt: process.env.IMG_URI_SALT, From b28fdb2ca464ef83c976acb0de04a2e4905dc951 Mon Sep 17 00:00:00 2001 From: YOCKOW Date: Tue, 31 Oct 2023 08:27:36 +0900 Subject: [PATCH 3/5] PDS: Allow configuring non-AWS S3 blob storage. (#1729) * PDS: Allow configuring non-AWS S3 blob storage. See https://github.com/bluesky-social/atproto/issues/1583 * tidy --------- Co-authored-by: devin ivy --- packages/pds/src/config/config.ts | 12 ++++++++++++ packages/pds/src/config/env.ts | 8 ++++++++ packages/pds/src/context.ts | 7 ++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 58040abd781..ab43773dd8e 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -55,6 +55,15 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { } if (env.blobstoreS3Bucket) { blobstoreCfg = { provider: 's3', bucket: env.blobstoreS3Bucket } + if (env.blobstoreS3Region) { + blobstoreCfg.region = env.blobstoreS3Region + } + if (env.blobstoreS3Endpoint) { + blobstoreCfg.endpoint = env.blobstoreS3Endpoint + } + if (env.blobstoreS3ForcePathStyle !== undefined) { + blobstoreCfg.forcePathStyle = env.blobstoreS3ForcePathStyle + } } else if (env.blobstoreDiskLocation) { blobstoreCfg = { provider: 'disk', @@ -238,6 +247,9 @@ export type PostgresConfig = { export type S3BlobstoreConfig = { provider: 's3' bucket: string + region?: string + endpoint?: string + forcePathStyle?: boolean } export type DiskBlobstoreConfig = { diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 2c13124b4c9..3948d633883 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -24,6 +24,9 @@ export const readEnv = (): ServerEnvironment => { // blobstore: one required // s3 blobstoreS3Bucket: envStr('PDS_BLOBSTORE_S3_BUCKET'), + blobstoreS3Region: envStr('PDS_BLOBSTORE_S3_REGION'), + blobstoreS3Endpoint: envStr('PDS_BLOBSTORE_S3_ENDPOINT'), + blobstoreS3ForcePathStyle: envBool('PDS_BLOBSTORE_S3_FORCE_PATH_STYLE'), // disk blobstoreDiskLocation: envStr('PDS_BLOBSTORE_DISK_LOCATION'), blobstoreDiskTmpLocation: envStr('PDS_BLOBSTORE_DISK_TMP_LOCATION'), @@ -118,6 +121,11 @@ export type ServerEnvironment = { blobstoreDiskLocation?: string blobstoreDiskTmpLocation?: string + // -- optional s3 parameters + blobstoreS3Region?: string + blobstoreS3Endpoint?: string + blobstoreS3ForcePathStyle?: boolean + // identity didPlcUrl?: string didCacheStaleTTL?: number diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index b181ea8b3ad..dfa969e84f7 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -103,7 +103,12 @@ export class AppContext { }) const blobstore = cfg.blobstore.provider === 's3' - ? new S3BlobStore({ bucket: cfg.blobstore.bucket }) + ? new S3BlobStore({ + bucket: cfg.blobstore.bucket, + region: cfg.blobstore.region, + endpoint: cfg.blobstore.endpoint, + forcePathStyle: cfg.blobstore.forcePathStyle, + }) : await DiskBlobStore.create( cfg.blobstore.location, cfg.blobstore.tempLocation, From 2df6f2b836509aed83b7559c644f63797f617861 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 31 Oct 2023 00:00:00 -0400 Subject: [PATCH 4/5] Support S3-compatible credentials for pds blobstore (#1787) * support s3 credentials for pds blobstore * tidy --- packages/pds/src/config/config.ts | 28 ++++++++++++++++++++-------- packages/pds/src/config/env.ts | 4 ++++ packages/pds/src/context.ts | 1 + 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index ab43773dd8e..75227099835 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -54,15 +54,23 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { throw new Error('Cannot set both S3 and disk blobstore env vars') } if (env.blobstoreS3Bucket) { - blobstoreCfg = { provider: 's3', bucket: env.blobstoreS3Bucket } - if (env.blobstoreS3Region) { - blobstoreCfg.region = env.blobstoreS3Region - } - if (env.blobstoreS3Endpoint) { - blobstoreCfg.endpoint = env.blobstoreS3Endpoint + blobstoreCfg = { + provider: 's3', + bucket: env.blobstoreS3Bucket, + region: env.blobstoreS3Region, + endpoint: env.blobstoreS3Endpoint, + forcePathStyle: env.blobstoreS3ForcePathStyle, } - if (env.blobstoreS3ForcePathStyle !== undefined) { - blobstoreCfg.forcePathStyle = env.blobstoreS3ForcePathStyle + if (env.blobstoreS3AccessKeyId || env.blobstoreS3SecretAccessKey) { + if (!env.blobstoreS3AccessKeyId || !env.blobstoreS3SecretAccessKey) { + throw new Error( + 'Must specify both S3 access key id and secret access key blobstore env vars', + ) + } + blobstoreCfg.credentials = { + accessKeyId: env.blobstoreS3AccessKeyId, + secretAccessKey: env.blobstoreS3SecretAccessKey, + } } } else if (env.blobstoreDiskLocation) { blobstoreCfg = { @@ -250,6 +258,10 @@ export type S3BlobstoreConfig = { region?: string endpoint?: string forcePathStyle?: boolean + credentials?: { + accessKeyId: string + secretAccessKey: string + } } export type DiskBlobstoreConfig = { diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index 3948d633883..a7f1c2636af 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -27,6 +27,8 @@ export const readEnv = (): ServerEnvironment => { blobstoreS3Region: envStr('PDS_BLOBSTORE_S3_REGION'), blobstoreS3Endpoint: envStr('PDS_BLOBSTORE_S3_ENDPOINT'), blobstoreS3ForcePathStyle: envBool('PDS_BLOBSTORE_S3_FORCE_PATH_STYLE'), + blobstoreS3AccessKeyId: envStr('PDS_BLOBSTORE_S3_ACCESS_KEY_ID'), + blobstoreS3SecretAccessKey: envStr('PDS_BLOBSTORE_S3_SECRET_ACCESS_KEY'), // disk blobstoreDiskLocation: envStr('PDS_BLOBSTORE_DISK_LOCATION'), blobstoreDiskTmpLocation: envStr('PDS_BLOBSTORE_DISK_TMP_LOCATION'), @@ -125,6 +127,8 @@ export type ServerEnvironment = { blobstoreS3Region?: string blobstoreS3Endpoint?: string blobstoreS3ForcePathStyle?: boolean + blobstoreS3AccessKeyId?: string + blobstoreS3SecretAccessKey?: string // identity didPlcUrl?: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index dfa969e84f7..56eead1dace 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -108,6 +108,7 @@ export class AppContext { region: cfg.blobstore.region, endpoint: cfg.blobstore.endpoint, forcePathStyle: cfg.blobstore.forcePathStyle, + credentials: cfg.blobstore.credentials, }) : await DiskBlobStore.create( cfg.blobstore.location, From 8637c367fe2c0e7444ed67868c075ab9b3b65809 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 31 Oct 2023 18:09:02 -0400 Subject: [PATCH 5/5] Respect updated service auth keys (#1765) * bust key cache when verifying service auth * unit tests for xrpc auth * fix * support option for verifying non-low-s signatures * fix verifyJwt tests --- .../crypto/signature-fixtures.json | 12 +- packages/bsky/src/auth.ts | 15 ++- packages/bsky/tests/auth.test.ts | 64 +++++++++++ packages/crypto/src/p256/operations.ts | 9 +- packages/crypto/src/secp256k1/operations.ts | 9 +- packages/crypto/src/types.ts | 5 + packages/crypto/src/verify.ts | 9 +- packages/crypto/tests/signatures.test.ts | 44 +++++++ packages/xrpc-server/package.json | 2 + packages/xrpc-server/src/auth.ts | 28 ++++- packages/xrpc-server/tests/auth.test.ts | 108 +++++++++++++++++- pnpm-lock.yaml | 21 ++-- 12 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 packages/bsky/tests/auth.test.ts diff --git a/interop-test-files/crypto/signature-fixtures.json b/interop-test-files/crypto/signature-fixtures.json index 917c6d02455..7cdeb55ea75 100644 --- a/interop-test-files/crypto/signature-fixtures.json +++ b/interop-test-files/crypto/signature-fixtures.json @@ -7,7 +7,8 @@ "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoJWExHptCfduPleDbG3rko3YZnn9Lw0IjpixVmexJDegg", - "validSignature": true + "validSignature": true, + "tags": [] }, { "comment": "valid K-256 key and signature, with low-S signature", @@ -17,7 +18,8 @@ "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdUn/FEznOndsz/qgiYb89zwxYCbB71f7yQK5Lr7NasfoA", - "validSignature": true + "validSignature": true, + "tags": [] }, { "comment": "P-256 key and signature, with non-low-S signature which is invalid in atproto", @@ -27,7 +29,8 @@ "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoKp7O4VS9giSAah8k5IUbXIW00SuOrjfEqQ9HEkN9JGzw", - "validSignature": false + "validSignature": false, + "tags": ["high-s"] }, { "comment": "K-256 key and signature, with non-low-S signature which is invalid in atproto", @@ -37,6 +40,7 @@ "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdXYA67MYxYiTMAVfdnkDCMN9S5B3vHosRe07aORmoshoQ", - "validSignature": false + "validSignature": false, + "tags": ["high-s"] } ] diff --git a/packages/bsky/src/auth.ts b/packages/bsky/src/auth.ts index 290ef3c7a42..b19e6860e5c 100644 --- a/packages/bsky/src/auth.ts +++ b/packages/bsky/src/auth.ts @@ -14,10 +14,17 @@ export const authVerifier = if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } - const payload = await verifyJwt(jwtStr, opts.aud, async (did: string) => { - const atprotoData = await idResolver.did.resolveAtprotoData(did) - return atprotoData.signingKey - }) + const payload = await verifyJwt( + jwtStr, + opts.aud, + async (did, forceRefresh) => { + const atprotoData = await idResolver.did.resolveAtprotoData( + did, + forceRefresh, + ) + return atprotoData.signingKey + }, + ) return { credentials: { did: payload.iss }, artifacts: { aud: opts.aud } } } diff --git a/packages/bsky/tests/auth.test.ts b/packages/bsky/tests/auth.test.ts new file mode 100644 index 00000000000..a3ac3d1d9f9 --- /dev/null +++ b/packages/bsky/tests/auth.test.ts @@ -0,0 +1,64 @@ +import AtpAgent from '@atproto/api' +import { SeedClient, TestNetwork } from '@atproto/dev-env' +import usersSeed from './seeds/users' +import { createServiceJwt } from '@atproto/xrpc-server' +import { Keypair, Secp256k1Keypair } from '@atproto/crypto' + +describe('auth', () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_auth', + }) + agent = network.bsky.getClient() + sc = network.getSeedClient() + await usersSeed(sc) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('handles signing key change for service auth.', async () => { + const issuer = sc.dids.alice + const attemptWithKey = async (keypair: Keypair) => { + const jwt = await createServiceJwt({ + iss: issuer, + aud: network.bsky.ctx.cfg.serverDid, + keypair, + }) + return agent.api.app.bsky.actor.getProfile( + { actor: sc.dids.carol }, + { headers: { authorization: `Bearer ${jwt}` } }, + ) + } + const origSigningKey = network.pds.ctx.repoSigningKey + const newSigningKey = await Secp256k1Keypair.create({ exportable: true }) + // confirm original signing key works + await expect(attemptWithKey(origSigningKey)).resolves.toBeDefined() + // confirm next signing key doesn't work yet + await expect(attemptWithKey(newSigningKey)).rejects.toThrow( + 'jwt signature does not match jwt issuer', + ) + // update to new signing key + await network.plc + .getClient() + .updateAtprotoKey( + issuer, + network.pds.ctx.plcRotationKey, + newSigningKey.did(), + ) + // old signing key still works due to did doc cache + await expect(attemptWithKey(origSigningKey)).resolves.toBeDefined() + // new signing key works + await expect(attemptWithKey(newSigningKey)).resolves.toBeDefined() + // old signing key no longer works after cache is updated + await expect(attemptWithKey(origSigningKey)).rejects.toThrow( + 'jwt signature does not match jwt issuer', + ) + }) +}) diff --git a/packages/crypto/src/p256/operations.ts b/packages/crypto/src/p256/operations.ts index f5292f4bd80..6f81b0371a9 100644 --- a/packages/crypto/src/p256/operations.ts +++ b/packages/crypto/src/p256/operations.ts @@ -2,24 +2,29 @@ import { p256 } from '@noble/curves/p256' import { sha256 } from '@noble/hashes/sha256' import { P256_JWT_ALG } from '../const' import { parseDidKey } from '../did' +import { VerifyOptions } from '../types' export const verifyDidSig = async ( did: string, data: Uint8Array, sig: Uint8Array, + opts?: VerifyOptions, ): Promise => { const { jwtAlg, keyBytes } = parseDidKey(did) if (jwtAlg !== P256_JWT_ALG) { throw new Error(`Not a P-256 did:key: ${did}`) } - return verifySig(keyBytes, data, sig) + return verifySig(keyBytes, data, sig, opts) } export const verifySig = async ( publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array, + opts?: VerifyOptions, ): Promise => { const msgHash = await sha256(data) - return p256.verify(sig, msgHash, publicKey, { lowS: true }) + return p256.verify(sig, msgHash, publicKey, { + lowS: opts?.lowS ?? true, + }) } diff --git a/packages/crypto/src/secp256k1/operations.ts b/packages/crypto/src/secp256k1/operations.ts index 5d31a812506..f470c2da54c 100644 --- a/packages/crypto/src/secp256k1/operations.ts +++ b/packages/crypto/src/secp256k1/operations.ts @@ -2,24 +2,29 @@ import { secp256k1 as k256 } from '@noble/curves/secp256k1' import { sha256 } from '@noble/hashes/sha256' import { SECP256K1_JWT_ALG } from '../const' import { parseDidKey } from '../did' +import { VerifyOptions } from '../types' export const verifyDidSig = async ( did: string, data: Uint8Array, sig: Uint8Array, + opts?: VerifyOptions, ): Promise => { const { jwtAlg, keyBytes } = parseDidKey(did) if (jwtAlg !== SECP256K1_JWT_ALG) { throw new Error(`Not a secp256k1 did:key: ${did}`) } - return verifySig(keyBytes, data, sig) + return verifySig(keyBytes, data, sig, opts) } export const verifySig = async ( publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array, + opts?: VerifyOptions, ): Promise => { const msgHash = await sha256(data) - return k256.verify(sig, msgHash, publicKey, { lowS: true }) + return k256.verify(sig, msgHash, publicKey, { + lowS: opts?.lowS ?? true, + }) } diff --git a/packages/crypto/src/types.ts b/packages/crypto/src/types.ts index e8cbdc57b62..a1089134f0a 100644 --- a/packages/crypto/src/types.ts +++ b/packages/crypto/src/types.ts @@ -16,5 +16,10 @@ export type DidKeyPlugin = { did: string, msg: Uint8Array, data: Uint8Array, + opts?: VerifyOptions, ) => Promise } + +export type VerifyOptions = { + lowS?: boolean +} diff --git a/packages/crypto/src/verify.ts b/packages/crypto/src/verify.ts index 43b2670c7cd..50ba87aba2e 100644 --- a/packages/crypto/src/verify.ts +++ b/packages/crypto/src/verify.ts @@ -1,26 +1,29 @@ import * as uint8arrays from 'uint8arrays' import { parseDidKey } from './did' import plugins from './plugins' +import { VerifyOptions } from './types' export const verifySignature = ( didKey: string, data: Uint8Array, sig: Uint8Array, + opts?: VerifyOptions, ): Promise => { const parsed = parseDidKey(didKey) const plugin = plugins.find((p) => p.jwtAlg === parsed.jwtAlg) if (!plugin) { - throw new Error(`Unsupported signature alg: :${parsed.jwtAlg}`) + throw new Error(`Unsupported signature alg: ${parsed.jwtAlg}`) } - return plugin.verifySignature(didKey, data, sig) + return plugin.verifySignature(didKey, data, sig, opts) } export const verifySignatureUtf8 = async ( didKey: string, data: string, sig: string, + opts?: VerifyOptions, ): Promise => { const dataBytes = uint8arrays.fromString(data, 'utf8') const sigBytes = uint8arrays.fromString(sig, 'base64url') - return verifySignature(didKey, dataBytes, sigBytes) + return verifySignature(didKey, dataBytes, sigBytes, opts) } diff --git a/packages/crypto/tests/signatures.test.ts b/packages/crypto/tests/signatures.test.ts index cebc8126b3a..83d2b6b72f0 100644 --- a/packages/crypto/tests/signatures.test.ts +++ b/packages/crypto/tests/signatures.test.ts @@ -57,6 +57,45 @@ describe('signatures', () => { } } }) + + it('verifies high-s signatures with explicit option', async () => { + const highSVectors = vectors.filter((vec) => vec.tags.includes('high-s')) + expect(highSVectors.length).toBeGreaterThanOrEqual(2) + for (const vector of highSVectors) { + const messageBytes = uint8arrays.fromString( + vector.messageBase64, + 'base64', + ) + const signatureBytes = uint8arrays.fromString( + vector.signatureBase64, + 'base64', + ) + const keyBytes = multibaseToBytes(vector.publicKeyMultibase) + const didKey = parseDidKey(vector.publicKeyDid) + expect(uint8arrays.equals(keyBytes, didKey.keyBytes)) + if (vector.algorithm === P256_JWT_ALG) { + const verified = await p256.verifySig( + keyBytes, + messageBytes, + signatureBytes, + { lowS: false }, + ) + expect(verified).toEqual(true) + expect(vector.validSignature).toEqual(false) // otherwise would fail per low-s requirement + } else if (vector.algorithm === SECP256K1_JWT_ALG) { + const verified = await secp.verifySig( + keyBytes, + messageBytes, + signatureBytes, + { lowS: false }, + ) + expect(verified).toEqual(true) + expect(vector.validSignature).toEqual(false) // otherwise would fail per low-s requirement + } else { + throw new Error('Unsupported test vector') + } + } + }) }) // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -79,6 +118,7 @@ async function generateTestVectors(): Promise { 'base64', ), validSignature: true, + tags: [], }, { messageBase64, @@ -93,6 +133,7 @@ async function generateTestVectors(): Promise { 'base64', ), validSignature: true, + tags: [], }, // these vectors test to ensure we don't allow high-s signatures { @@ -109,6 +150,7 @@ async function generateTestVectors(): Promise { P256_JWT_ALG, ), validSignature: false, + tags: ['high-s'], }, { messageBase64, @@ -124,6 +166,7 @@ async function generateTestVectors(): Promise { SECP256K1_JWT_ALG, ), validSignature: false, + tags: ['high-s'], }, ] } @@ -159,4 +202,5 @@ type TestVector = { messageBase64: string signatureBase64: string validSignature: boolean + tags: string[] } diff --git a/packages/xrpc-server/package.json b/packages/xrpc-server/package.json index d32319f301b..21589c26f6b 100644 --- a/packages/xrpc-server/package.json +++ b/packages/xrpc-server/package.json @@ -45,6 +45,8 @@ "@types/http-errors": "^2.0.1", "@types/ws": "^8.5.4", "get-port": "^6.1.2", + "jose": "^4.15.4", + "key-encoder": "^2.0.3", "multiformats": "^9.9.0" } } diff --git a/packages/xrpc-server/src/auth.ts b/packages/xrpc-server/src/auth.ts index 9283b13815b..0b0cbe03127 100644 --- a/packages/xrpc-server/src/auth.ts +++ b/packages/xrpc-server/src/auth.ts @@ -48,7 +48,7 @@ const jsonToB64Url = (json: Record): string => { export const verifyJwt = async ( jwtStr: string, ownDid: string | null, // null indicates to skip the audience check - getSigningKey: (did: string) => Promise, + getSigningKey: (did: string, forceRefresh: boolean) => Promise, ): Promise => { const parts = jwtStr.split('.') if (parts.length !== 3) { @@ -69,18 +69,40 @@ export const verifyJwt = async ( const msgBytes = ui8.fromString(parts.slice(0, 2).join('.'), 'utf8') const sigBytes = ui8.fromString(sig, 'base64url') + const verifySignatureWithKey = (key: string) => { + return crypto.verifySignature(key, msgBytes, sigBytes, { + lowS: false, + }) + } - const signingKey = await getSigningKey(payload.iss) + const signingKey = await getSigningKey(payload.iss, false) let validSig: boolean try { - validSig = await crypto.verifySignature(signingKey, msgBytes, sigBytes) + validSig = await verifySignatureWithKey(signingKey) } catch (err) { throw new AuthRequiredError( 'could not verify jwt signature', 'BadJwtSignature', ) } + + if (!validSig) { + // get fresh signing key in case it failed due to a recent rotation + const freshSigningKey = await getSigningKey(payload.iss, true) + try { + validSig = + freshSigningKey !== signingKey + ? await verifySignatureWithKey(freshSigningKey) + : false + } catch (err) { + throw new AuthRequiredError( + 'could not verify jwt signature', + 'BadJwtSignature', + ) + } + } + if (!validSig) { throw new AuthRequiredError( 'jwt signature does not match jwt issuer', diff --git a/packages/xrpc-server/tests/auth.test.ts b/packages/xrpc-server/tests/auth.test.ts index d36c05b6c3a..53f3a6c6d24 100644 --- a/packages/xrpc-server/tests/auth.test.ts +++ b/packages/xrpc-server/tests/auth.test.ts @@ -1,5 +1,11 @@ -import * as http from 'http' +import * as http from 'node:http' +import { KeyObject, createPrivateKey } from 'node:crypto' import getPort from 'get-port' +import * as jose from 'jose' +import KeyEncoder from 'key-encoder' +import * as ui8 from 'uint8arrays' +import { MINUTE } from '@atproto/common' +import { Secp256k1Keypair } from '@atproto/crypto' import xrpc, { ServiceClient, XRPCError } from '@atproto/xrpc' import * as xrpcServer from '../src' import { @@ -131,4 +137,104 @@ describe('Auth', () => { original: 'YWRtaW46cGFzc3dvcmQ=', }) }) + + describe('verifyJwt()', () => { + it('fails on expired jwt.', async () => { + const keypair = await Secp256k1Keypair.create() + const jwt = await xrpcServer.createServiceJwt({ + aud: 'did:example:aud', + iss: 'did:example:iss', + keypair, + exp: Math.floor((Date.now() - MINUTE) / 1000), + }) + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud', + async () => { + return keypair.did() + }, + ) + await expect(tryVerify).rejects.toThrow('jwt expired') + }) + + it('fails on bad audience.', async () => { + const keypair = await Secp256k1Keypair.create() + const jwt = await xrpcServer.createServiceJwt({ + aud: 'did:example:aud1', + iss: 'did:example:iss', + keypair, + }) + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud2', + async () => { + return keypair.did() + }, + ) + await expect(tryVerify).rejects.toThrow( + 'jwt audience does not match service did', + ) + }) + + it('refreshes key on verification failure.', async () => { + const keypair1 = await Secp256k1Keypair.create() + const keypair2 = await Secp256k1Keypair.create() + const jwt = await xrpcServer.createServiceJwt({ + aud: 'did:example:aud', + iss: 'did:example:iss', + keypair: keypair2, + }) + let usedKeypair1 = false + let usedKeypair2 = false + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud', + async (_did, forceRefresh) => { + if (forceRefresh) { + usedKeypair2 = true + return keypair2.did() + } else { + usedKeypair1 = true + return keypair1.did() + } + }, + ) + await expect(tryVerify).resolves.toMatchObject({ + aud: 'did:example:aud', + iss: 'did:example:iss', + }) + expect(usedKeypair1).toBe(true) + expect(usedKeypair2).toBe(true) + }) + + it('interoperates with jwts signed by other libraries.', async () => { + const keypair = await Secp256k1Keypair.create({ exportable: true }) + const signingKey = await createPrivateKeyObject(keypair) + const payload = { + aud: 'did:example:aud', + iss: 'did:example:iss', + exp: Math.floor((Date.now() + MINUTE) / 1000), + } + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader({ typ: 'JWT', alg: keypair.jwtAlg }) + .sign(signingKey) + const tryVerify = xrpcServer.verifyJwt( + jwt, + 'did:example:aud', + async () => { + return keypair.did() + }, + ) + await expect(tryVerify).resolves.toEqual(payload) + }) + }) }) + +const createPrivateKeyObject = async ( + privateKey: Secp256k1Keypair, +): Promise => { + const raw = await privateKey.export() + const encoder = new KeyEncoder('secp256k1') + const key = encoder.encodePrivate(ui8.toString(raw, 'hex'), 'raw', 'pem') + return createPrivateKey({ format: 'pem', key }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1500b3ece5d..aac3ef2bcba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -734,6 +734,12 @@ importers: get-port: specifier: ^6.1.2 version: 6.1.2 + jose: + specifier: ^4.15.4 + version: 4.15.4 + key-encoder: + specifier: ^2.0.3 + version: 2.0.3 multiformats: specifier: ^9.9.0 version: 9.9.0 @@ -5294,7 +5300,6 @@ packages: resolution: {integrity: sha512-qNrYbZqMx0uJAfKnKclPh+dTwK33KfLHYqtyODwd5HnXOjnkhc4qgn3BrK6RWyGZm5+sIFE7Q7Vz6QQtJB7w7g==} dependencies: '@types/node': 18.17.8 - dev: false /@types/body-parser@1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} @@ -5321,7 +5326,6 @@ packages: resolution: {integrity: sha512-z4OBcDAU0GVwDTuwJzQCiL6188QvZMkvoERgcVjq0/mPM8jCfdwZ3x5zQEVoL9WCAru3aG5wl3Z5Ww5wBWn7ZQ==} dependencies: '@types/bn.js': 5.1.1 - dev: false /@types/express-serve-static-core@4.17.36: resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==} @@ -5833,7 +5837,6 @@ packages: inherits: 2.0.4 minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 - dev: false /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -6031,7 +6034,6 @@ packages: /bn.js@4.12.0: resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} - dev: false /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} @@ -6085,7 +6087,6 @@ packages: /brorand@1.1.0: resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} - dev: false /browserslist@4.21.10: resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==} @@ -6739,7 +6740,6 @@ packages: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - dev: false /emittery@0.10.2: resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==} @@ -7849,7 +7849,6 @@ packages: dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 - dev: false /he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} @@ -7869,7 +7868,6 @@ packages: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - dev: false /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -8735,6 +8733,10 @@ packages: - ts-node dev: true + /jose@4.15.4: + resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} + dev: true + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -8856,7 +8858,6 @@ packages: asn1.js: 5.4.1 bn.js: 4.12.0 elliptic: 6.5.4 - dev: false /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} @@ -9150,11 +9151,9 @@ packages: /minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - dev: false /minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - dev: false /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}