Split out moderation backend (#1970)
* mv appview * copy * finalize copy * package names * big WIP * first pass at mod servce * some tidy * tidy & fix compiler errors * rename to ozone, db migrations, add to dev-env & pds cfg * getRecord & getRepo mostly working * fix open handle * get record tests all working * moderation events working * statuses working * tidy test suite * search repos * server & db tests * moderation tests * wip daemon + push events * pds fanout working * fix db test * fanning takedowns out to appview * rm try/catch * bsky moderation test * introduce mod subject wrappers * more tidy * refactor event reversal * tidy some db stuff * tidy * rename service to mod-service * fix test * tidy config * refactor auth in bsky * wip patching up auto-mod * add label ingester in appview * fix a couple build issues * fix some timing bugs * tidy polling logic * fix up tests * fix some pds tests * eslint ignore * fix ozone tests * move seeds to dev-env * move images around * fix db schemas * use service auth admin reqs * fix remaining tests * auth tests bsky * another test * random tidy * fix up search * clean up bsky mod service * more tidy * default attempts to 0 * tidy old test * random tidy * tidy package.json * tidy logger * takedownId -> takedownRef * misc pr feedback * split daemon out from ozone application * fix blob takedown mgiration * refactor ozone config * do push event fanout on write instead of on read * make suspend error work again * add attempts check & add supporting index * fix takedown test ref * get tests working * rm old test * fix timing bug in event pusher tests * attempt another fix for timing bug * await req * service files * remove labelerDid cfg * update snaps for labeler did + some cfg changes * fix more snaps * pnpm i * build ozone images * build * make label provider optional * fix build issues * fix build * fix build * build pds * build on ghcr * fix syntax in entry * another fix * use correct import * export logger * remove event reverser * adjust push event fanout * push out multiple * remove builds
This commit is contained in:
parent
65254ab148
commit
de2dbc2903
@ -1,3 +1,4 @@
|
|||||||
packages/api/src/client
|
packages/api/src/client
|
||||||
packages/bsky/src/lexicon
|
packages/bsky/src/lexicon
|
||||||
packages/pds/src/lexicon
|
packages/pds/src/lexicon
|
||||||
|
packages/ozone/src/lexicon
|
||||||
|
@ -3,7 +3,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- appeal-report
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||||
|
54
.github/workflows/build-and-push-ozone-aws.yaml
vendored
Normal file
54
.github/workflows/build-and-push-ozone-aws.yaml
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
name: build-and-push-ozone-aws
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||||
|
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
|
||||||
|
IMAGE_NAME: ozone
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ozone-container-aws:
|
||||||
|
if: github.repository == 'bluesky-social/atproto'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.USERNAME}}
|
||||||
|
password: ${{ env.PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
file: ./services/ozone/Dockerfile
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
56
.github/workflows/build-and-push-ozone-ghcr.yaml
vendored
Normal file
56
.github/workflows/build-and-push-ozone-ghcr.yaml
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
name: build-and-push-ozone-ghcr
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
USERNAME: ${{ github.actor }}
|
||||||
|
PASSWORD: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# github.repository as <account>/<repo>
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ozone-container-ghcr:
|
||||||
|
if: github.repository == 'bluesky-social/atproto'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.USERNAME }}
|
||||||
|
password: ${{ env.PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,enable=true,priority=100,prefix=ozone:,suffix=,format=long
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
file: ./services/ozone/Dockerfile
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
1
Makefile
1
Makefile
@ -31,6 +31,7 @@ codegen: ## Re-generate packages from lexicon/ files
|
|||||||
cd packages/api; pnpm run codegen
|
cd packages/api; pnpm run codegen
|
||||||
cd packages/pds; pnpm run codegen
|
cd packages/pds; pnpm run codegen
|
||||||
cd packages/bsky; pnpm run codegen
|
cd packages/bsky; pnpm run codegen
|
||||||
|
cd packages/ozone; pnpm run codegen
|
||||||
# clean up codegen output
|
# clean up codegen output
|
||||||
pnpm format
|
pnpm format
|
||||||
|
|
||||||
|
@ -294,6 +294,7 @@
|
|||||||
"did": { "type": "string", "format": "did" },
|
"did": { "type": "string", "format": "did" },
|
||||||
"handle": { "type": "string", "format": "handle" },
|
"handle": { "type": "string", "format": "handle" },
|
||||||
"email": { "type": "string" },
|
"email": { "type": "string" },
|
||||||
|
"relatedRecords": { "type": "array", "items": { "type": "unknown" } },
|
||||||
"indexedAt": { "type": "string", "format": "datetime" },
|
"indexedAt": { "type": "string", "format": "datetime" },
|
||||||
"invitedBy": {
|
"invitedBy": {
|
||||||
"type": "ref",
|
"type": "ref",
|
||||||
|
36
lexicons/com/atproto/admin/getAccountInfos.json
Normal file
36
lexicons/com/atproto/admin/getAccountInfos.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "com.atproto.admin.getAccountInfos",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "query",
|
||||||
|
"description": "Get details about some accounts.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "params",
|
||||||
|
"required": ["dids"],
|
||||||
|
"properties": {
|
||||||
|
"dids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "format": "did" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"encoding": "application/json",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["infos"],
|
||||||
|
"properties": {
|
||||||
|
"infos": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "ref",
|
||||||
|
"ref": "com.atproto.admin.defs#accountView"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,6 +14,7 @@ import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di
|
|||||||
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
||||||
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
||||||
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
||||||
|
import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos'
|
||||||
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
||||||
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
||||||
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
||||||
@ -153,6 +154,7 @@ export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di
|
|||||||
export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
export * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
||||||
export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
||||||
export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
export * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
||||||
|
export * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos'
|
||||||
export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
export * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
||||||
export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
export * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
||||||
export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
export * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
||||||
@ -441,6 +443,17 @@ export class AdminNS {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAccountInfos(
|
||||||
|
params?: ComAtprotoAdminGetAccountInfos.QueryParams,
|
||||||
|
opts?: ComAtprotoAdminGetAccountInfos.CallOptions,
|
||||||
|
): Promise<ComAtprotoAdminGetAccountInfos.Response> {
|
||||||
|
return this._service.xrpc
|
||||||
|
.call('com.atproto.admin.getAccountInfos', params, undefined, opts)
|
||||||
|
.catch((e) => {
|
||||||
|
throw ComAtprotoAdminGetAccountInfos.toKnownErr(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getInviteCodes(
|
getInviteCodes(
|
||||||
params?: ComAtprotoAdminGetInviteCodes.QueryParams,
|
params?: ComAtprotoAdminGetInviteCodes.QueryParams,
|
||||||
opts?: ComAtprotoAdminGetInviteCodes.CallOptions,
|
opts?: ComAtprotoAdminGetInviteCodes.CallOptions,
|
||||||
|
@ -436,6 +436,12 @@ export const schemaDict = {
|
|||||||
email: {
|
email: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
relatedRecords: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'unknown',
|
||||||
|
},
|
||||||
|
},
|
||||||
indexedAt: {
|
indexedAt: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'datetime',
|
format: 'datetime',
|
||||||
@ -1046,6 +1052,45 @@ export const schemaDict = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ComAtprotoAdminGetAccountInfos: {
|
||||||
|
lexicon: 1,
|
||||||
|
id: 'com.atproto.admin.getAccountInfos',
|
||||||
|
defs: {
|
||||||
|
main: {
|
||||||
|
type: 'query',
|
||||||
|
description: 'Get details about some accounts.',
|
||||||
|
parameters: {
|
||||||
|
type: 'params',
|
||||||
|
required: ['dids'],
|
||||||
|
properties: {
|
||||||
|
dids: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'did',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
encoding: 'application/json',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['infos'],
|
||||||
|
properties: {
|
||||||
|
infos: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'ref',
|
||||||
|
ref: 'lex:com.atproto.admin.defs#accountView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ComAtprotoAdminGetInviteCodes: {
|
ComAtprotoAdminGetInviteCodes: {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: 'com.atproto.admin.getInviteCodes',
|
id: 'com.atproto.admin.getInviteCodes',
|
||||||
@ -7875,6 +7920,7 @@ export const ids = {
|
|||||||
ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent',
|
ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent',
|
||||||
ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites',
|
ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites',
|
||||||
ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo',
|
ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo',
|
||||||
|
ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos',
|
||||||
ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes',
|
ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes',
|
||||||
ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent',
|
ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent',
|
||||||
ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord',
|
ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord',
|
||||||
|
@ -255,6 +255,7 @@ export interface AccountView {
|
|||||||
did: string
|
did: string
|
||||||
handle: string
|
handle: string
|
||||||
email?: string
|
email?: string
|
||||||
|
relatedRecords?: {}[]
|
||||||
indexedAt: string
|
indexedAt: string
|
||||||
invitedBy?: ComAtprotoServerDefs.InviteCode
|
invitedBy?: ComAtprotoServerDefs.InviteCode
|
||||||
invites?: ComAtprotoServerDefs.InviteCode[]
|
invites?: ComAtprotoServerDefs.InviteCode[]
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
dids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputSchema = undefined
|
||||||
|
|
||||||
|
export interface OutputSchema {
|
||||||
|
infos: ComAtprotoAdminDefs.AccountView[]
|
||||||
|
[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
|
||||||
|
}
|
@ -16,18 +16,16 @@ import { ModerationService } from '../../../../services/moderation'
|
|||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
|
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
|
||||||
server.app.bsky.actor.getProfile({
|
server.app.bsky.actor.getProfile({
|
||||||
auth: ctx.authOptionalAccessOrRoleVerifier,
|
auth: ctx.authVerifier.optionalStandardOrRole,
|
||||||
handler: async ({ auth, params, res }) => {
|
handler: async ({ auth, params, res }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const modService = ctx.services.moderation(ctx.db.getPrimary())
|
const modService = ctx.services.moderation(ctx.db.getPrimary())
|
||||||
const viewer = 'did' in auth.credentials ? auth.credentials.did : null
|
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
|
||||||
const canViewTakendownProfile =
|
|
||||||
auth.credentials.type === 'role' && auth.credentials.triage
|
|
||||||
|
|
||||||
const [result, repoRev] = await Promise.allSettled([
|
const [result, repoRev] = await Promise.allSettled([
|
||||||
getProfile(
|
getProfile(
|
||||||
{ ...params, viewer, canViewTakendownProfile },
|
{ ...params, viewer, canViewTakedowns },
|
||||||
{ db, actorService, modService },
|
{ db, actorService, modService },
|
||||||
),
|
),
|
||||||
actorService.getRepoRev(viewer),
|
actorService.getRepoRev(viewer),
|
||||||
@ -52,15 +50,14 @@ const skeleton = async (
|
|||||||
params: Params,
|
params: Params,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
): Promise<SkeletonState> => {
|
): Promise<SkeletonState> => {
|
||||||
const { actorService, modService } = ctx
|
const { actorService } = ctx
|
||||||
const { canViewTakendownProfile } = params
|
const { canViewTakedowns } = params
|
||||||
const actor = await actorService.getActor(params.actor, true)
|
const actor = await actorService.getActor(params.actor, true)
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
throw new InvalidRequestError('Profile not found')
|
throw new InvalidRequestError('Profile not found')
|
||||||
}
|
}
|
||||||
if (!canViewTakendownProfile && softDeleted(actor)) {
|
if (!canViewTakedowns && softDeleted(actor)) {
|
||||||
const isSuspended = await modService.isSubjectSuspended(actor.did)
|
if (actor.takedownRef?.includes('SUSPEND')) {
|
||||||
if (isSuspended) {
|
|
||||||
throw new InvalidRequestError(
|
throw new InvalidRequestError(
|
||||||
'Account has been temporarily suspended',
|
'Account has been temporarily suspended',
|
||||||
'AccountTakedown',
|
'AccountTakedown',
|
||||||
@ -78,10 +75,10 @@ const skeleton = async (
|
|||||||
const hydration = async (state: SkeletonState, ctx: Context) => {
|
const hydration = async (state: SkeletonState, ctx: Context) => {
|
||||||
const { actorService } = ctx
|
const { actorService } = ctx
|
||||||
const { params, actor } = state
|
const { params, actor } = state
|
||||||
const { viewer, canViewTakendownProfile } = params
|
const { viewer, canViewTakedowns } = params
|
||||||
const hydration = await actorService.views.profileDetailHydration(
|
const hydration = await actorService.views.profileDetailHydration(
|
||||||
[actor.did],
|
[actor.did],
|
||||||
{ viewer, includeSoftDeleted: canViewTakendownProfile },
|
{ viewer, includeSoftDeleted: canViewTakedowns },
|
||||||
)
|
)
|
||||||
return { ...state, ...hydration }
|
return { ...state, ...hydration }
|
||||||
}
|
}
|
||||||
@ -110,7 +107,7 @@ type Context = {
|
|||||||
|
|
||||||
type Params = QueryParams & {
|
type Params = QueryParams & {
|
||||||
viewer: string | null
|
viewer: string | null
|
||||||
canViewTakendownProfile: boolean
|
canViewTakedowns: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkeletonState = { params: Params; actor: Actor }
|
type SkeletonState = { params: Params; actor: Actor }
|
||||||
|
@ -13,11 +13,11 @@ import { createPipeline, noRules } from '../../../../pipeline'
|
|||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
|
const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
|
||||||
server.app.bsky.actor.getProfiles({
|
server.app.bsky.actor.getProfiles({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth, params, res }) => {
|
handler: async ({ auth, params, res }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const [result, repoRev] = await Promise.all([
|
const [result, repoRev] = await Promise.all([
|
||||||
getProfile({ ...params, viewer }, { db, actorService }),
|
getProfile({ ...params, viewer }, { db, actorService }),
|
||||||
|
@ -17,12 +17,12 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.actor.getSuggestions({
|
server.app.bsky.actor.getSuggestions({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await getSuggestions(
|
const result = await getSuggestions(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -1,18 +1,13 @@
|
|||||||
import { sql } from 'kysely'
|
|
||||||
import AppContext from '../../../../context'
|
import AppContext from '../../../../context'
|
||||||
import { Server } from '../../../../lexicon'
|
import { Server } from '../../../../lexicon'
|
||||||
import {
|
import { cleanQuery } from '../../../../services/util/search'
|
||||||
cleanQuery,
|
|
||||||
getUserSearchQuery,
|
|
||||||
SearchKeyset,
|
|
||||||
} from '../../../../services/util/search'
|
|
||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.actor.searchActors({
|
server.app.bsky.actor.searchActors({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const { cursor, limit } = params
|
const { cursor, limit } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const rawQuery = params.q ?? params.term
|
const rawQuery = params.q ?? params.term
|
||||||
const query = cleanQuery(rawQuery || '')
|
const query = cleanQuery(rawQuery || '')
|
||||||
const db = ctx.db.getReplica('search')
|
const db = ctx.db.getReplica('search')
|
||||||
@ -29,15 +24,11 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
results = res.data.actors.map((a) => a.did)
|
results = res.data.actors.map((a) => a.did)
|
||||||
resCursor = res.data.cursor
|
resCursor = res.data.cursor
|
||||||
} else {
|
} else {
|
||||||
const res = query
|
const res = await ctx.services
|
||||||
? await getUserSearchQuery(db, { query, limit, cursor })
|
.actor(ctx.db.getReplica('search'))
|
||||||
.select('distance')
|
.getSearchResults({ query, limit, cursor })
|
||||||
.selectAll('actor')
|
results = res.results.map((a) => a.did)
|
||||||
.execute()
|
resCursor = res.cursor
|
||||||
: []
|
|
||||||
results = res.map((a) => a.did)
|
|
||||||
const keyset = new SearchKeyset(sql``, sql``)
|
|
||||||
resCursor = keyset.packFromResult(res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const actors = await ctx.services
|
const actors = await ctx.services
|
||||||
|
@ -7,10 +7,10 @@ import {
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.actor.searchActorsTypeahead({
|
server.app.bsky.actor.searchActorsTypeahead({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { limit } = params
|
const { limit } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const rawQuery = params.q ?? params.term
|
const rawQuery = params.q ?? params.term
|
||||||
const query = cleanQuery(rawQuery || '')
|
const query = cleanQuery(rawQuery || '')
|
||||||
const db = ctx.db.getReplica('search')
|
const db = ctx.db.getReplica('search')
|
||||||
|
@ -6,10 +6,10 @@ import { TimeCidKeyset, paginate } from '../../../../db/pagination'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.feed.getActorFeeds({
|
server.app.bsky.feed.getActorFeeds({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const { actor, limit, cursor } = params
|
const { actor, limit, cursor } = params
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -23,9 +23,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getActorLikes({
|
server.app.bsky.feed.getActorLikes({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth, res }) => {
|
handler: async ({ params, auth, res }) => {
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
|
@ -23,14 +23,13 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getAuthorFeed({
|
server.app.bsky.feed.getAuthorFeed({
|
||||||
auth: ctx.authOptionalAccessOrRoleVerifier,
|
auth: ctx.authVerifier.optionalStandardOrRole,
|
||||||
handler: async ({ params, auth, res }) => {
|
handler: async ({ params, auth, res }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer =
|
const { viewer } = ctx.authVerifier.parseCreds(auth)
|
||||||
auth.credentials.type === 'access' ? auth.credentials.did : null
|
|
||||||
|
|
||||||
const [result, repoRev] = await Promise.all([
|
const [result, repoRev] = await Promise.all([
|
||||||
getAuthorFeed(
|
getAuthorFeed(
|
||||||
|
@ -33,11 +33,11 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getFeed({
|
server.app.bsky.feed.getFeed({
|
||||||
auth: ctx.authOptionalVerifierAnyAudience,
|
auth: ctx.authVerifier.standardOptionalAnyAud,
|
||||||
handler: async ({ params, auth, req }) => {
|
handler: async ({ params, auth, req }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const { timerSkele, timerHydr, ...result } = await getFeed(
|
const { timerSkele, timerHydr, ...result } = await getFeed(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -9,10 +9,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.feed.getFeedGenerator({
|
server.app.bsky.feed.getFeedGenerator({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { feed } = params
|
const { feed } = params
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
|
@ -14,10 +14,10 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getFeedGenerators({
|
server.app.bsky.feed.getFeedGenerators({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { feeds } = params
|
const { feeds } = params
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -5,10 +5,10 @@ import { toSkeletonItem } from '../../../../feed-gen/types'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.feed.getFeedSkeleton({
|
server.app.bsky.feed.getFeedSkeleton({
|
||||||
auth: ctx.authVerifierAnyAudience,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { feed } = params
|
const { feed } = params
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const localAlgo = ctx.algos[feed]
|
const localAlgo = ctx.algos[feed]
|
||||||
|
|
||||||
if (!localAlgo) {
|
if (!localAlgo) {
|
||||||
|
@ -13,12 +13,12 @@ import { createPipeline } from '../../../../pipeline'
|
|||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation)
|
const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation)
|
||||||
server.app.bsky.feed.getLikes({
|
server.app.bsky.feed.getLikes({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await getLikes(
|
const result = await getLikes(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -22,9 +22,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getListFeed({
|
server.app.bsky.feed.getListFeed({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth, res }) => {
|
handler: async ({ params, auth, res }) => {
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
|
@ -31,9 +31,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getPostThread({
|
server.app.bsky.feed.getPostThread({
|
||||||
auth: ctx.authOptionalAccessOrRoleVerifier,
|
auth: ctx.authVerifier.optionalStandardOrRole,
|
||||||
handler: async ({ params, auth, res }) => {
|
handler: async ({ params, auth, res }) => {
|
||||||
const viewer = 'did' in auth.credentials ? auth.credentials.did : null
|
const { viewer } = ctx.authVerifier.parseCreds(auth)
|
||||||
const db = ctx.db.getReplica('thread')
|
const db = ctx.db.getReplica('thread')
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -14,12 +14,12 @@ import { ActorService } from '../../../../services/actor'
|
|||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation)
|
const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation)
|
||||||
server.app.bsky.feed.getPosts({
|
server.app.bsky.feed.getPosts({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const results = await getPosts(
|
const results = await getPosts(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -18,12 +18,12 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getRepostedBy({
|
server.app.bsky.feed.getRepostedBy({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await getRepostedBy(
|
const result = await getRepostedBy(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -4,9 +4,9 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.feed.getSuggestedFeeds({
|
server.app.bsky.feed.getSuggestedFeeds({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth }) => {
|
handler: async ({ auth }) => {
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
|
@ -22,9 +22,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.getTimeline({
|
server.app.bsky.feed.getTimeline({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth, res }) => {
|
handler: async ({ params, auth, res }) => {
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica('timeline')
|
const db = ctx.db.getReplica('timeline')
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -21,9 +21,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.feed.searchPosts({
|
server.app.bsky.feed.searchPosts({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica('search')
|
const db = ctx.db.getReplica('search')
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -5,10 +5,10 @@ import { notSoftDeletedClause } from '../../../../db/util'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.getBlocks({
|
server.app.bsky.graph.getBlocks({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { limit, cursor } = params
|
const { limit, cursor } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
|
@ -19,17 +19,15 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.graph.getFollowers({
|
server.app.bsky.graph.getFollowers({
|
||||||
auth: ctx.authOptionalAccessOrRoleVerifier,
|
auth: ctx.authVerifier.optionalStandardOrRole,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer = 'did' in auth.credentials ? auth.credentials.did : null
|
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
|
||||||
const canViewTakendownProfile =
|
|
||||||
auth.credentials.type === 'role' && auth.credentials.triage
|
|
||||||
|
|
||||||
const result = await getFollowers(
|
const result = await getFollowers(
|
||||||
{ ...params, viewer, canViewTakendownProfile },
|
{ ...params, viewer, canViewTakedowns },
|
||||||
{ db, actorService, graphService },
|
{ db, actorService, graphService },
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,10 +44,10 @@ const skeleton = async (
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
): Promise<SkeletonState> => {
|
): Promise<SkeletonState> => {
|
||||||
const { db, actorService } = ctx
|
const { db, actorService } = ctx
|
||||||
const { limit, cursor, actor, canViewTakendownProfile } = params
|
const { limit, cursor, actor, canViewTakedowns } = params
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
const subject = await actorService.getActor(actor, canViewTakendownProfile)
|
const subject = await actorService.getActor(actor, canViewTakedowns)
|
||||||
if (!subject) {
|
if (!subject) {
|
||||||
throw new InvalidRequestError(`Actor not found: ${actor}`)
|
throw new InvalidRequestError(`Actor not found: ${actor}`)
|
||||||
}
|
}
|
||||||
@ -58,7 +56,7 @@ const skeleton = async (
|
|||||||
.selectFrom('follow')
|
.selectFrom('follow')
|
||||||
.where('follow.subjectDid', '=', subject.did)
|
.where('follow.subjectDid', '=', subject.did)
|
||||||
.innerJoin('actor as creator', 'creator.did', 'follow.creator')
|
.innerJoin('actor as creator', 'creator.did', 'follow.creator')
|
||||||
.if(!canViewTakendownProfile, (qb) =>
|
.if(!canViewTakedowns, (qb) =>
|
||||||
qb.where(notSoftDeletedClause(ref('creator'))),
|
qb.where(notSoftDeletedClause(ref('creator'))),
|
||||||
)
|
)
|
||||||
.selectAll('creator')
|
.selectAll('creator')
|
||||||
@ -130,7 +128,7 @@ type Context = {
|
|||||||
|
|
||||||
type Params = QueryParams & {
|
type Params = QueryParams & {
|
||||||
viewer: string | null
|
viewer: string | null
|
||||||
canViewTakendownProfile: boolean
|
canViewTakedowns: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkeletonState = {
|
type SkeletonState = {
|
||||||
|
@ -19,17 +19,15 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.graph.getFollows({
|
server.app.bsky.graph.getFollows({
|
||||||
auth: ctx.authOptionalAccessOrRoleVerifier,
|
auth: ctx.authVerifier.optionalStandardOrRole,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const viewer = 'did' in auth.credentials ? auth.credentials.did : null
|
const { viewer, canViewTakedowns } = ctx.authVerifier.parseCreds(auth)
|
||||||
const canViewTakendownProfile =
|
|
||||||
auth.credentials.type === 'role' && auth.credentials.triage
|
|
||||||
|
|
||||||
const result = await getFollows(
|
const result = await getFollows(
|
||||||
{ ...params, viewer, canViewTakendownProfile },
|
{ ...params, viewer, canViewTakedowns },
|
||||||
{ db, actorService, graphService },
|
{ db, actorService, graphService },
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,10 +44,10 @@ const skeleton = async (
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
): Promise<SkeletonState> => {
|
): Promise<SkeletonState> => {
|
||||||
const { db, actorService } = ctx
|
const { db, actorService } = ctx
|
||||||
const { limit, cursor, actor, canViewTakendownProfile } = params
|
const { limit, cursor, actor, canViewTakedowns } = params
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
const creator = await actorService.getActor(actor, canViewTakendownProfile)
|
const creator = await actorService.getActor(actor, canViewTakedowns)
|
||||||
if (!creator) {
|
if (!creator) {
|
||||||
throw new InvalidRequestError(`Actor not found: ${actor}`)
|
throw new InvalidRequestError(`Actor not found: ${actor}`)
|
||||||
}
|
}
|
||||||
@ -58,7 +56,7 @@ const skeleton = async (
|
|||||||
.selectFrom('follow')
|
.selectFrom('follow')
|
||||||
.where('follow.creator', '=', creator.did)
|
.where('follow.creator', '=', creator.did)
|
||||||
.innerJoin('actor as subject', 'subject.did', 'follow.subjectDid')
|
.innerJoin('actor as subject', 'subject.did', 'follow.subjectDid')
|
||||||
.if(!canViewTakendownProfile, (qb) =>
|
.if(!canViewTakedowns, (qb) =>
|
||||||
qb.where(notSoftDeletedClause(ref('subject'))),
|
qb.where(notSoftDeletedClause(ref('subject'))),
|
||||||
)
|
)
|
||||||
.selectAll('subject')
|
.selectAll('subject')
|
||||||
@ -131,7 +129,7 @@ type Context = {
|
|||||||
|
|
||||||
type Params = QueryParams & {
|
type Params = QueryParams & {
|
||||||
viewer: string | null
|
viewer: string | null
|
||||||
canViewTakendownProfile: boolean
|
canViewTakedowns: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkeletonState = {
|
type SkeletonState = {
|
||||||
|
@ -13,12 +13,12 @@ import { createPipeline, noRules } from '../../../../pipeline'
|
|||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
const getList = createPipeline(skeleton, hydration, noRules, presentation)
|
const getList = createPipeline(skeleton, hydration, noRules, presentation)
|
||||||
server.app.bsky.graph.getList({
|
server.app.bsky.graph.getList({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await getList(
|
const result = await getList(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -17,12 +17,12 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.graph.getListBlocks({
|
server.app.bsky.graph.getListBlocks({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await getListBlocks(
|
const result = await getListBlocks(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -5,10 +5,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.getListMutes({
|
server.app.bsky.graph.getListMutes({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { limit, cursor } = params
|
const { limit, cursor } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
|
@ -6,10 +6,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.getLists({
|
server.app.bsky.graph.getLists({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { actor, limit, cursor } = params
|
const { actor, limit, cursor } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
|
@ -5,10 +5,10 @@ import { notSoftDeletedClause } from '../../../../db/util'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.getMutes({
|
server.app.bsky.graph.getMutes({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const { limit, cursor } = params
|
const { limit, cursor } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
|
|
||||||
|
@ -9,10 +9,10 @@ const RESULT_LENGTH = 10
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.getSuggestedFollowsByActor({
|
server.app.bsky.graph.getSuggestedFollowsByActor({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const { actor } = params
|
const { actor } = params
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
|
@ -4,10 +4,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.muteActor({
|
server.app.bsky.graph.muteActor({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, input }) => {
|
handler: async ({ auth, input }) => {
|
||||||
const { actor } = input.body
|
const { actor } = input.body
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getPrimary()
|
const db = ctx.db.getPrimary()
|
||||||
|
|
||||||
const subjectDid = await ctx.services.actor(db).getActorDid(actor)
|
const subjectDid = await ctx.services.actor(db).getActorDid(actor)
|
||||||
|
@ -6,10 +6,10 @@ import { AtUri } from '@atproto/syntax'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.muteActorList({
|
server.app.bsky.graph.muteActorList({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, input }) => {
|
handler: async ({ auth, input }) => {
|
||||||
const { list } = input.body
|
const { list } = input.body
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
|
|
||||||
const db = ctx.db.getPrimary()
|
const db = ctx.db.getPrimary()
|
||||||
|
|
||||||
|
@ -4,10 +4,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.unmuteActor({
|
server.app.bsky.graph.unmuteActor({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, input }) => {
|
handler: async ({ auth, input }) => {
|
||||||
const { actor } = input.body
|
const { actor } = input.body
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getPrimary()
|
const db = ctx.db.getPrimary()
|
||||||
|
|
||||||
const subjectDid = await ctx.services.actor(db).getActorDid(actor)
|
const subjectDid = await ctx.services.actor(db).getActorDid(actor)
|
||||||
|
@ -3,10 +3,10 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.graph.unmuteActorList({
|
server.app.bsky.graph.unmuteActorList({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, input }) => {
|
handler: async ({ auth, input }) => {
|
||||||
const { list } = input.body
|
const { list } = input.body
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getPrimary()
|
const db = ctx.db.getPrimary()
|
||||||
|
|
||||||
await ctx.services.graph(db).unmuteActorList({
|
await ctx.services.graph(db).unmuteActorList({
|
||||||
|
@ -6,9 +6,9 @@ import AppContext from '../../../../context'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.notification.getUnreadCount({
|
server.app.bsky.notification.getUnreadCount({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
if (params.seenAt) {
|
if (params.seenAt) {
|
||||||
throw new InvalidRequestError('The seenAt parameter is unsupported')
|
throw new InvalidRequestError('The seenAt parameter is unsupported')
|
||||||
}
|
}
|
||||||
|
@ -20,13 +20,13 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
presentation,
|
presentation,
|
||||||
)
|
)
|
||||||
server.app.bsky.notification.listNotifications({
|
server.app.bsky.notification.listNotifications({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ params, auth }) => {
|
handler: async ({ params, auth }) => {
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const actorService = ctx.services.actor(db)
|
const actorService = ctx.services.actor(db)
|
||||||
const graphService = ctx.services.graph(db)
|
const graphService = ctx.services.graph(db)
|
||||||
const labelService = ctx.services.label(db)
|
const labelService = ctx.services.label(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await listNotifications(
|
const result = await listNotifications(
|
||||||
{ ...params, viewer },
|
{ ...params, viewer },
|
||||||
|
@ -5,13 +5,11 @@ import { Platform } from '../../../../notifications'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.notification.registerPush({
|
server.app.bsky.notification.registerPush({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, input }) => {
|
handler: async ({ auth, input }) => {
|
||||||
const { token, platform, serviceDid, appId } = input.body
|
const { token, platform, serviceDid, appId } = input.body
|
||||||
const {
|
const did = auth.credentials.iss
|
||||||
credentials: { did },
|
if (serviceDid !== auth.credentials.aud) {
|
||||||
} = auth
|
|
||||||
if (serviceDid !== auth.artifacts.aud) {
|
|
||||||
throw new InvalidRequestError('Invalid serviceDid.')
|
throw new InvalidRequestError('Invalid serviceDid.')
|
||||||
}
|
}
|
||||||
const { notifServer } = ctx
|
const { notifServer } = ctx
|
||||||
|
@ -5,10 +5,10 @@ import { excluded } from '../../../../db/util'
|
|||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.notification.updateSeen({
|
server.app.bsky.notification.updateSeen({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ input, auth }) => {
|
handler: async ({ input, auth }) => {
|
||||||
const { seenAt } = input.body
|
const { seenAt } = input.body
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
let parsed: string
|
let parsed: string
|
||||||
try {
|
try {
|
||||||
|
@ -8,10 +8,10 @@ import { GeneratorView } from '../../../../lexicon/types/app/bsky/feed/defs'
|
|||||||
// THIS IS A TEMPORARY UNSPECCED ROUTE
|
// THIS IS A TEMPORARY UNSPECCED ROUTE
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.unspecced.getPopularFeedGenerators({
|
server.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
auth: ctx.authOptionalVerifier,
|
auth: ctx.authVerifier.standardOptional,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const { limit, cursor, query } = params
|
const { limit, cursor, query } = params
|
||||||
const requester = auth.credentials.did
|
const requester = auth.credentials.iss
|
||||||
const db = ctx.db.getReplica()
|
const db = ctx.db.getReplica()
|
||||||
const { ref } = db.db.dynamic
|
const { ref } = db.db.dynamic
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
|
@ -6,11 +6,11 @@ import { toSkeletonItem } from '../../../../feed-gen/types'
|
|||||||
// THIS IS A TEMPORARY UNSPECCED ROUTE
|
// THIS IS A TEMPORARY UNSPECCED ROUTE
|
||||||
export default function (server: Server, ctx: AppContext) {
|
export default function (server: Server, ctx: AppContext) {
|
||||||
server.app.bsky.unspecced.getTimelineSkeleton({
|
server.app.bsky.unspecced.getTimelineSkeleton({
|
||||||
auth: ctx.authVerifier,
|
auth: ctx.authVerifier.standard,
|
||||||
handler: async ({ auth, params }) => {
|
handler: async ({ auth, params }) => {
|
||||||
const db = ctx.db.getReplica('timeline')
|
const db = ctx.db.getReplica('timeline')
|
||||||
const feedService = ctx.services.feed(db)
|
const feedService = ctx.services.feed(db)
|
||||||
const viewer = auth.credentials.did
|
const viewer = auth.credentials.iss
|
||||||
|
|
||||||
const result = await skeleton({ ...params, viewer }, { db, feedService })
|
const result = await skeleton({ ...params, viewer }, { db, feedService })
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import AppContext from '../context'
|
|||||||
import { httpLogger as log } from '../logger'
|
import { httpLogger as log } from '../logger'
|
||||||
import { retryHttp } from '../util/retry'
|
import { retryHttp } from '../util/retry'
|
||||||
import { Database } from '../db'
|
import { Database } from '../db'
|
||||||
import { sql } from 'kysely'
|
|
||||||
|
|
||||||
// Resolve and verify blob from its origin host
|
// Resolve and verify blob from its origin host
|
||||||
|
|
||||||
@ -88,10 +87,10 @@ export async function resolveBlob(
|
|||||||
const [{ pds }, takedown] = await Promise.all([
|
const [{ pds }, takedown] = await Promise.all([
|
||||||
idResolver.did.resolveAtprotoData(did), // @TODO cache did info
|
idResolver.did.resolveAtprotoData(did), // @TODO cache did info
|
||||||
db.db
|
db.db
|
||||||
.selectFrom('moderation_subject_status')
|
.selectFrom('blob_takedown')
|
||||||
.select('id')
|
.select('takedownRef')
|
||||||
.where('blobCids', '@>', sql`CAST(${JSON.stringify([cidStr])} AS JSONB)`)
|
.where('did', '=', did)
|
||||||
.where('takendown', 'is', true)
|
.where('cid', '=', cid.toString())
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
])
|
])
|
||||||
if (takedown) {
|
if (takedown) {
|
||||||
|
@ -1,220 +0,0 @@
|
|||||||
import { CID } from 'multiformats/cid'
|
|
||||||
import { AtUri } from '@atproto/syntax'
|
|
||||||
import {
|
|
||||||
AuthRequiredError,
|
|
||||||
InvalidRequestError,
|
|
||||||
UpstreamFailureError,
|
|
||||||
} from '@atproto/xrpc-server'
|
|
||||||
import { Server } from '../../../../lexicon'
|
|
||||||
import AppContext from '../../../../context'
|
|
||||||
import { getSubject } from '../moderation/util'
|
|
||||||
import {
|
|
||||||
isModEventLabel,
|
|
||||||
isModEventReverseTakedown,
|
|
||||||
isModEventTakedown,
|
|
||||||
} from '../../../../lexicon/types/com/atproto/admin/defs'
|
|
||||||
import { TakedownSubjects } from '../../../../services/moderation'
|
|
||||||
import { retryHttp } from '../../../../util/retry'
|
|
||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
|
||||||
server.com.atproto.admin.emitModerationEvent({
|
|
||||||
auth: ctx.roleVerifier,
|
|
||||||
handler: async ({ input, auth }) => {
|
|
||||||
const access = auth.credentials
|
|
||||||
const db = ctx.db.getPrimary()
|
|
||||||
const moderationService = ctx.services.moderation(db)
|
|
||||||
const { subject, createdBy, subjectBlobCids, event } = input.body
|
|
||||||
const isTakedownEvent = isModEventTakedown(event)
|
|
||||||
const isReverseTakedownEvent = isModEventReverseTakedown(event)
|
|
||||||
const isLabelEvent = isModEventLabel(event)
|
|
||||||
|
|
||||||
// apply access rules
|
|
||||||
|
|
||||||
// if less than moderator access then can not takedown an account
|
|
||||||
if (!access.moderator && isTakedownEvent && '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 && (isTakedownEvent || isReverseTakedownEvent)) {
|
|
||||||
throw new AuthRequiredError(
|
|
||||||
'Must be a full moderator to take this type of action',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// if less than moderator access then can not apply labels
|
|
||||||
if (!access.moderator && isLabelEvent) {
|
|
||||||
throw new AuthRequiredError('Must be a full moderator to label content')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLabelEvent) {
|
|
||||||
validateLabels([
|
|
||||||
...(event.createLabelVals ?? []),
|
|
||||||
...(event.negateLabelVals ?? []),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const subjectInfo = getSubject(subject)
|
|
||||||
|
|
||||||
if (isTakedownEvent || isReverseTakedownEvent) {
|
|
||||||
const isSubjectTakendown = await moderationService.isSubjectTakendown(
|
|
||||||
subjectInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isSubjectTakendown && isTakedownEvent) {
|
|
||||||
throw new InvalidRequestError(`Subject is already taken down`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSubjectTakendown && isReverseTakedownEvent) {
|
|
||||||
throw new InvalidRequestError(`Subject is not taken down`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { result: moderationEvent, takenDown } = await db.transaction(
|
|
||||||
async (dbTxn) => {
|
|
||||||
const moderationTxn = ctx.services.moderation(dbTxn)
|
|
||||||
const labelTxn = ctx.services.label(dbTxn)
|
|
||||||
|
|
||||||
const result = await moderationTxn.logEvent({
|
|
||||||
event,
|
|
||||||
subject: subjectInfo,
|
|
||||||
subjectBlobCids:
|
|
||||||
subjectBlobCids?.map((cid) => CID.parse(cid)) ?? [],
|
|
||||||
createdBy,
|
|
||||||
})
|
|
||||||
|
|
||||||
let takenDown: TakedownSubjects | undefined
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.subjectType === 'com.atproto.admin.defs#repoRef' &&
|
|
||||||
result.subjectDid
|
|
||||||
) {
|
|
||||||
// No credentials to revoke on appview
|
|
||||||
if (isTakedownEvent) {
|
|
||||||
takenDown = await moderationTxn.takedownRepo({
|
|
||||||
takedownId: result.id,
|
|
||||||
did: result.subjectDid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReverseTakedownEvent) {
|
|
||||||
await moderationTxn.reverseTakedownRepo({
|
|
||||||
did: result.subjectDid,
|
|
||||||
})
|
|
||||||
takenDown = {
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
$type: 'com.atproto.admin.defs#repoRef',
|
|
||||||
did: result.subjectDid,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
did: result.subjectDid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.subjectType === 'com.atproto.repo.strongRef' &&
|
|
||||||
result.subjectUri
|
|
||||||
) {
|
|
||||||
const blobCids = subjectBlobCids?.map((cid) => CID.parse(cid)) ?? []
|
|
||||||
if (isTakedownEvent) {
|
|
||||||
takenDown = await moderationTxn.takedownRecord({
|
|
||||||
takedownId: result.id,
|
|
||||||
uri: new AtUri(result.subjectUri),
|
|
||||||
// TODO: I think this will always be available for strongRefs?
|
|
||||||
cid: CID.parse(result.subjectCid as string),
|
|
||||||
blobCids,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReverseTakedownEvent) {
|
|
||||||
await moderationTxn.reverseTakedownRecord({
|
|
||||||
uri: new AtUri(result.subjectUri),
|
|
||||||
})
|
|
||||||
takenDown = {
|
|
||||||
did: result.subjectDid,
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
$type: 'com.atproto.repo.strongRef',
|
|
||||||
uri: result.subjectUri,
|
|
||||||
cid: result.subjectCid ?? '',
|
|
||||||
},
|
|
||||||
...blobCids.map((cid) => ({
|
|
||||||
$type: 'com.atproto.admin.defs#repoBlobRef',
|
|
||||||
did: result.subjectDid,
|
|
||||||
cid: cid.toString(),
|
|
||||||
recordUri: result.subjectUri,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLabelEvent) {
|
|
||||||
await labelTxn.formatAndCreate(
|
|
||||||
ctx.cfg.labelerDid,
|
|
||||||
result.subjectUri ?? result.subjectDid,
|
|
||||||
result.subjectCid,
|
|
||||||
{
|
|
||||||
create: result.createLabelVals?.length
|
|
||||||
? result.createLabelVals.split(' ')
|
|
||||||
: undefined,
|
|
||||||
negate: result.negateLabelVals?.length
|
|
||||||
? result.negateLabelVals.split(' ')
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { result, takenDown }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (takenDown && ctx.moderationPushAgent) {
|
|
||||||
const { did, subjects } = takenDown
|
|
||||||
if (did && subjects.length > 0) {
|
|
||||||
const agent = ctx.moderationPushAgent
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
subjects.map((subject) =>
|
|
||||||
retryHttp(() =>
|
|
||||||
agent.api.com.atproto.admin.updateSubjectStatus({
|
|
||||||
subject,
|
|
||||||
takedown: isTakedownEvent
|
|
||||||
? {
|
|
||||||
applied: true,
|
|
||||||
ref: moderationEvent.id.toString(),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
applied: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const hadFailure = results.some((r) => r.status === 'rejected')
|
|
||||||
if (hadFailure) {
|
|
||||||
throw new UpstreamFailureError('failed to apply action on PDS')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
encoding: 'application/json',
|
|
||||||
body: await moderationService.views.event(moderationEvent),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = [' ', ',', ';', `'`, `"`]
|
|
42
packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts
Normal file
42
packages/bsky/src/api/com/atproto/admin/getAccountInfos.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Server } from '../../../../lexicon'
|
||||||
|
import AppContext from '../../../../context'
|
||||||
|
import { Actor } from '../../../../db/tables/actor'
|
||||||
|
import { mapDefined } from '@atproto/common'
|
||||||
|
import { INVALID_HANDLE } from '@atproto/syntax'
|
||||||
|
|
||||||
|
export default function (server: Server, ctx: AppContext) {
|
||||||
|
server.com.atproto.admin.getAccountInfos({
|
||||||
|
auth: ctx.authVerifier.roleOrAdminService,
|
||||||
|
handler: async ({ params }) => {
|
||||||
|
const { dids } = params
|
||||||
|
const db = ctx.db.getPrimary()
|
||||||
|
const actorService = ctx.services.actor(db)
|
||||||
|
const [actors, profiles] = await Promise.all([
|
||||||
|
actorService.getActors(dids, true),
|
||||||
|
actorService.getProfileRecords(dids, true),
|
||||||
|
])
|
||||||
|
const actorByDid = actors.reduce((acc, cur) => {
|
||||||
|
return acc.set(cur.did, cur)
|
||||||
|
}, new Map<string, Actor>())
|
||||||
|
|
||||||
|
const infos = mapDefined(dids, (did) => {
|
||||||
|
const info = actorByDid.get(did)
|
||||||
|
if (!info) return
|
||||||
|
const profile = profiles.get(did)
|
||||||
|
return {
|
||||||
|
did,
|
||||||
|
handle: info.handle ?? INVALID_HANDLE,
|
||||||
|
relatedRecords: profile ? [profile] : undefined,
|
||||||
|
indexedAt: info.indexedAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
encoding: 'application/json',
|
||||||
|
body: {
|
||||||
|
infos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
@ -1,19 +0,0 @@
|
|||||||
import { Server } from '../../../../lexicon'
|
|
||||||
import AppContext from '../../../../context'
|
|
||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
|
||||||
server.com.atproto.admin.getModerationEvent({
|
|
||||||
auth: ctx.roleVerifier,
|
|
||||||
handler: async ({ params }) => {
|
|
||||||
const { id } = params
|
|
||||||
const db = ctx.db.getPrimary()
|
|
||||||
const moderationService = ctx.services.moderation(db)
|
|
||||||
const event = await moderationService.getEventOrThrow(id)
|
|
||||||
const eventDetail = await moderationService.views.eventDetail(event)
|
|
||||||
return {
|
|
||||||
encoding: 'application/json',
|
|
||||||
body: eventDetail,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
73
packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts
Normal file
73
packages/bsky/src/api/com/atproto/admin/getSubjectStatus.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { InvalidRequestError } from '@atproto/xrpc-server'
|
||||||
|
import { Server } from '../../../../lexicon'
|
||||||
|
import AppContext from '../../../../context'
|
||||||
|
import { OutputSchema } from '../../../../lexicon/types/com/atproto/admin/getSubjectStatus'
|
||||||
|
|
||||||
|
export default function (server: Server, ctx: AppContext) {
|
||||||
|
server.com.atproto.admin.getSubjectStatus({
|
||||||
|
auth: ctx.authVerifier.roleOrAdminService,
|
||||||
|
handler: async ({ params }) => {
|
||||||
|
const { did, uri, blob } = params
|
||||||
|
const modService = ctx.services.moderation(ctx.db.getPrimary())
|
||||||
|
let body: OutputSchema | null = null
|
||||||
|
if (blob) {
|
||||||
|
if (!did) {
|
||||||
|
throw new InvalidRequestError(
|
||||||
|
'Must provide a did to request blob state',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const takedown = await modService.getBlobTakedownRef(did, blob)
|
||||||
|
if (takedown) {
|
||||||
|
body = {
|
||||||
|
subject: {
|
||||||
|
$type: 'com.atproto.admin.defs#repoBlobRef',
|
||||||
|
did: did,
|
||||||
|
cid: blob,
|
||||||
|
},
|
||||||
|
takedown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (uri) {
|
||||||
|
const [takedown, cidRes] = await Promise.all([
|
||||||
|
modService.getRecordTakedownRef(uri),
|
||||||
|
ctx.db
|
||||||
|
.getPrimary()
|
||||||
|
.db.selectFrom('record')
|
||||||
|
.where('uri', '=', uri)
|
||||||
|
.select('cid')
|
||||||
|
.executeTakeFirst(),
|
||||||
|
])
|
||||||
|
if (cidRes && takedown) {
|
||||||
|
body = {
|
||||||
|
subject: {
|
||||||
|
$type: 'com.atproto.repo.strongRef',
|
||||||
|
uri,
|
||||||
|
cid: cidRes.cid,
|
||||||
|
},
|
||||||
|
takedown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (did) {
|
||||||
|
const takedown = await modService.getRepoTakedownRef(did)
|
||||||
|
if (takedown) {
|
||||||
|
body = {
|
||||||
|
subject: {
|
||||||
|
$type: 'com.atproto.admin.defs#repoRef',
|
||||||
|
did: did,
|
||||||
|
},
|
||||||
|
takedown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidRequestError('No provided subject')
|
||||||
|
}
|
||||||
|
if (body === null) {
|
||||||
|
throw new InvalidRequestError('Subject not found', 'NotFound')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
encoding: 'application/json',
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
@ -1,27 +0,0 @@
|
|||||||
import { Server } from '../../../../lexicon'
|
|
||||||
import AppContext from '../../../../context'
|
|
||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
|
||||||
server.com.atproto.admin.searchRepos({
|
|
||||||
auth: ctx.roleVerifier,
|
|
||||||
handler: async ({ params }) => {
|
|
||||||
const db = ctx.db.getPrimary()
|
|
||||||
const moderationService = ctx.services.moderation(db)
|
|
||||||
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: {
|
|
||||||
cursor: resCursor,
|
|
||||||
repos: await moderationService.views.repo(results),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
@ -0,0 +1,74 @@
|
|||||||
|
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 { CID } from 'multiformats/cid'
|
||||||
|
|
||||||
|
export default function (server: Server, ctx: AppContext) {
|
||||||
|
server.com.atproto.admin.updateSubjectStatus({
|
||||||
|
auth: ctx.authVerifier.roleOrAdminService,
|
||||||
|
handler: async ({ input, auth }) => {
|
||||||
|
const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth)
|
||||||
|
if (!canPerformTakedown) {
|
||||||
|
throw new AuthRequiredError(
|
||||||
|
'Must be a full moderator to update subject state',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modService = ctx.services.moderation(ctx.db.getPrimary())
|
||||||
|
|
||||||
|
const { subject, takedown } = input.body
|
||||||
|
if (takedown) {
|
||||||
|
if (isRepoRef(subject)) {
|
||||||
|
const did = subject.did
|
||||||
|
if (takedown.applied) {
|
||||||
|
await modService.takedownRepo({
|
||||||
|
takedownRef: takedown.ref ?? new Date().toISOString(),
|
||||||
|
did,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await modService.reverseTakedownRepo({ did })
|
||||||
|
}
|
||||||
|
} else if (isStrongRef(subject)) {
|
||||||
|
const uri = new AtUri(subject.uri)
|
||||||
|
const cid = CID.parse(subject.cid)
|
||||||
|
if (takedown.applied) {
|
||||||
|
await modService.takedownRecord({
|
||||||
|
takedownRef: takedown.ref ?? new Date().toISOString(),
|
||||||
|
uri,
|
||||||
|
cid,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await modService.reverseTakedownRecord({ uri })
|
||||||
|
}
|
||||||
|
} else if (isRepoBlobRef(subject)) {
|
||||||
|
const { did, cid } = subject
|
||||||
|
if (takedown.applied) {
|
||||||
|
await modService.takedownBlob({
|
||||||
|
takedownRef: takedown.ref ?? new Date().toISOString(),
|
||||||
|
did,
|
||||||
|
cid,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await modService.reverseTakedownBlob({ did, cid })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidRequestError('Invalid subject')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encoding: 'application/json',
|
||||||
|
body: {
|
||||||
|
subject,
|
||||||
|
takedown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
@ -1,53 +0,0 @@
|
|||||||
import { AuthRequiredError, ForbiddenError } from '@atproto/xrpc-server'
|
|
||||||
import { Server } from '../../../../lexicon'
|
|
||||||
import AppContext from '../../../../context'
|
|
||||||
import { getReasonType, getSubject } from './util'
|
|
||||||
import { softDeleted } from '../../../../db/util'
|
|
||||||
import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs'
|
|
||||||
|
|
||||||
export default function (server: Server, ctx: AppContext) {
|
|
||||||
server.com.atproto.moderation.createReport({
|
|
||||||
// @TODO anonymous reports w/ optional auth are a temporary measure
|
|
||||||
auth: ctx.authOptionalVerifier,
|
|
||||||
handler: async ({ input, auth }) => {
|
|
||||||
const { reasonType, reason, subject } = input.body
|
|
||||||
const requester = auth.credentials.did
|
|
||||||
|
|
||||||
const db = ctx.db.getPrimary()
|
|
||||||
|
|
||||||
if (requester) {
|
|
||||||
// Don't accept reports from users that are fully taken-down
|
|
||||||
const actor = await ctx.services.actor(db).getActor(requester, true)
|
|
||||||
if (actor && softDeleted(actor)) {
|
|
||||||
throw new AuthRequiredError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportReasonType = getReasonType(reasonType)
|
|
||||||
const reportSubject = getSubject(subject)
|
|
||||||
const subjectDid =
|
|
||||||
'did' in reportSubject ? reportSubject.did : reportSubject.uri.host
|
|
||||||
|
|
||||||
// If the report is an appeal, the requester must be the author of the subject
|
|
||||||
if (reasonType === REASONAPPEAL && requester !== subjectDid) {
|
|
||||||
throw new ForbiddenError('You cannot appeal this report')
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = await db.transaction(async (dbTxn) => {
|
|
||||||
const moderationTxn = ctx.services.moderation(dbTxn)
|
|
||||||
return moderationTxn.report({
|
|
||||||
reasonType: reportReasonType,
|
|
||||||
reason,
|
|
||||||
subject: reportSubject,
|
|
||||||
reportedBy: requester || ctx.cfg.serverDid,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const moderationService = ctx.services.moderation(db)
|
|
||||||
return {
|
|
||||||
encoding: 'application/json',
|
|
||||||
body: moderationService.views.reportPublic(report),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
@ -40,16 +40,11 @@ import updateSeen from './app/bsky/notification/updateSeen'
|
|||||||
import registerPush from './app/bsky/notification/registerPush'
|
import registerPush from './app/bsky/notification/registerPush'
|
||||||
import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
|
import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
|
||||||
import getTimelineSkeleton from './app/bsky/unspecced/getTimelineSkeleton'
|
import getTimelineSkeleton from './app/bsky/unspecced/getTimelineSkeleton'
|
||||||
import createReport from './com/atproto/moderation/createReport'
|
import getSubjectStatus from './com/atproto/admin/getSubjectStatus'
|
||||||
import emitModerationEvent from './com/atproto/admin/emitModerationEvent'
|
import updateSubjectStatus from './com/atproto/admin/updateSubjectStatus'
|
||||||
import searchRepos from './com/atproto/admin/searchRepos'
|
import getAccountInfos from './com/atproto/admin/getAccountInfos'
|
||||||
import adminGetRecord from './com/atproto/admin/getRecord'
|
|
||||||
import getRepo from './com/atproto/admin/getRepo'
|
|
||||||
import queryModerationStatuses from './com/atproto/admin/queryModerationStatuses'
|
|
||||||
import resolveHandle from './com/atproto/identity/resolveHandle'
|
import resolveHandle from './com/atproto/identity/resolveHandle'
|
||||||
import getRecord from './com/atproto/repo/getRecord'
|
import getRecord from './com/atproto/repo/getRecord'
|
||||||
import queryModerationEvents from './com/atproto/admin/queryModerationEvents'
|
|
||||||
import getModerationEvent from './com/atproto/admin/getModerationEvent'
|
|
||||||
import fetchLabels from './com/atproto/temp/fetchLabels'
|
import fetchLabels from './com/atproto/temp/fetchLabels'
|
||||||
|
|
||||||
export * as health from './health'
|
export * as health from './health'
|
||||||
@ -101,14 +96,9 @@ export default function (server: Server, ctx: AppContext) {
|
|||||||
getPopularFeedGenerators(server, ctx)
|
getPopularFeedGenerators(server, ctx)
|
||||||
getTimelineSkeleton(server, ctx)
|
getTimelineSkeleton(server, ctx)
|
||||||
// com.atproto
|
// com.atproto
|
||||||
createReport(server, ctx)
|
getSubjectStatus(server, ctx)
|
||||||
emitModerationEvent(server, ctx)
|
updateSubjectStatus(server, ctx)
|
||||||
searchRepos(server, ctx)
|
getAccountInfos(server, ctx)
|
||||||
adminGetRecord(server, ctx)
|
|
||||||
getRepo(server, ctx)
|
|
||||||
getModerationEvent(server, ctx)
|
|
||||||
queryModerationEvents(server, ctx)
|
|
||||||
queryModerationStatuses(server, ctx)
|
|
||||||
resolveHandle(server, ctx)
|
resolveHandle(server, ctx)
|
||||||
getRecord(server, ctx)
|
getRecord(server, ctx)
|
||||||
fetchLabels(server, ctx)
|
fetchLabels(server, ctx)
|
||||||
|
275
packages/bsky/src/auth-verifier.ts
Normal file
275
packages/bsky/src/auth-verifier.ts
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import {
|
||||||
|
AuthRequiredError,
|
||||||
|
verifyJwt as verifyServiceJwt,
|
||||||
|
} from '@atproto/xrpc-server'
|
||||||
|
import { IdResolver } from '@atproto/identity'
|
||||||
|
import * as ui8 from 'uint8arrays'
|
||||||
|
import express from 'express'
|
||||||
|
|
||||||
|
type ReqCtx = {
|
||||||
|
req: express.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RoleStatus {
|
||||||
|
Valid,
|
||||||
|
Invalid,
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullOutput = {
|
||||||
|
credentials: {
|
||||||
|
type: 'null'
|
||||||
|
iss: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StandardOutput = {
|
||||||
|
credentials: {
|
||||||
|
type: 'standard'
|
||||||
|
aud: string
|
||||||
|
iss: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleOutput = {
|
||||||
|
credentials: {
|
||||||
|
type: 'role'
|
||||||
|
admin: boolean
|
||||||
|
moderator: boolean
|
||||||
|
triage: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminServiceOutput = {
|
||||||
|
credentials: {
|
||||||
|
type: 'admin_service'
|
||||||
|
aud: string
|
||||||
|
iss: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthVerifierOpts = {
|
||||||
|
ownDid: string
|
||||||
|
adminDid: string
|
||||||
|
adminPass: string
|
||||||
|
moderatorPass: string
|
||||||
|
triagePass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthVerifier {
|
||||||
|
private _adminPass: string
|
||||||
|
private _moderatorPass: string
|
||||||
|
private _triagePass: string
|
||||||
|
public ownDid: string
|
||||||
|
public adminDid: string
|
||||||
|
|
||||||
|
constructor(public idResolver: IdResolver, opts: AuthVerifierOpts) {
|
||||||
|
this._adminPass = opts.adminPass
|
||||||
|
this._moderatorPass = opts.moderatorPass
|
||||||
|
this._triagePass = opts.triagePass
|
||||||
|
this.ownDid = opts.ownDid
|
||||||
|
this.adminDid = opts.adminDid
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifiers (arrow fns to preserve scope)
|
||||||
|
|
||||||
|
standard = async (ctx: ReqCtx): Promise<StandardOutput> => {
|
||||||
|
const { iss, aud } = await this.verifyServiceJwt(ctx, {
|
||||||
|
aud: this.ownDid,
|
||||||
|
iss: null,
|
||||||
|
})
|
||||||
|
return { credentials: { type: 'standard', iss, aud } }
|
||||||
|
}
|
||||||
|
|
||||||
|
standardOptional = async (
|
||||||
|
ctx: ReqCtx,
|
||||||
|
): Promise<StandardOutput | NullOutput> => {
|
||||||
|
if (isBearerToken(ctx.req)) {
|
||||||
|
return this.standard(ctx)
|
||||||
|
}
|
||||||
|
return this.nullCreds()
|
||||||
|
}
|
||||||
|
|
||||||
|
standardOptionalAnyAud = async (
|
||||||
|
ctx: ReqCtx,
|
||||||
|
): Promise<StandardOutput | NullOutput> => {
|
||||||
|
if (!isBearerToken(ctx.req)) {
|
||||||
|
return this.nullCreds()
|
||||||
|
}
|
||||||
|
const { iss, aud } = await this.verifyServiceJwt(ctx, {
|
||||||
|
aud: null,
|
||||||
|
iss: null,
|
||||||
|
})
|
||||||
|
return { credentials: { type: 'standard', iss, aud } }
|
||||||
|
}
|
||||||
|
|
||||||
|
role = (ctx: ReqCtx): RoleOutput => {
|
||||||
|
const creds = this.parseRoleCreds(ctx.req)
|
||||||
|
if (creds.status !== RoleStatus.Valid) {
|
||||||
|
throw new AuthRequiredError()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
credentials: {
|
||||||
|
...creds,
|
||||||
|
type: 'role',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
standardOrRole = async (
|
||||||
|
ctx: ReqCtx,
|
||||||
|
): Promise<StandardOutput | RoleOutput> => {
|
||||||
|
if (isBearerToken(ctx.req)) {
|
||||||
|
return this.standard(ctx)
|
||||||
|
} else {
|
||||||
|
return this.role(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionalStandardOrRole = async (
|
||||||
|
ctx: ReqCtx,
|
||||||
|
): Promise<StandardOutput | RoleOutput | NullOutput> => {
|
||||||
|
if (isBearerToken(ctx.req)) {
|
||||||
|
return await this.standard(ctx)
|
||||||
|
} else {
|
||||||
|
const creds = this.parseRoleCreds(ctx.req)
|
||||||
|
if (creds.status === RoleStatus.Valid) {
|
||||||
|
return {
|
||||||
|
credentials: {
|
||||||
|
...creds,
|
||||||
|
type: 'role',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (creds.status === RoleStatus.Missing) {
|
||||||
|
return this.nullCreds()
|
||||||
|
} else {
|
||||||
|
throw new AuthRequiredError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminService = async (reqCtx: ReqCtx): Promise<AdminServiceOutput> => {
|
||||||
|
const { iss, aud } = await this.verifyServiceJwt(reqCtx, {
|
||||||
|
aud: this.ownDid,
|
||||||
|
iss: [this.adminDid],
|
||||||
|
})
|
||||||
|
return { credentials: { type: 'admin_service', aud, iss } }
|
||||||
|
}
|
||||||
|
|
||||||
|
roleOrAdminService = async (
|
||||||
|
reqCtx: ReqCtx,
|
||||||
|
): Promise<RoleOutput | AdminServiceOutput> => {
|
||||||
|
if (isBearerToken(reqCtx.req)) {
|
||||||
|
return this.adminService(reqCtx)
|
||||||
|
} else {
|
||||||
|
return this.role(reqCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseRoleCreds(req: express.Request) {
|
||||||
|
const parsed = parseBasicAuth(req.headers.authorization || '')
|
||||||
|
const { Missing, Valid, Invalid } = RoleStatus
|
||||||
|
if (!parsed) {
|
||||||
|
return { status: Missing, admin: false, moderator: false, triage: false }
|
||||||
|
}
|
||||||
|
const { username, password } = parsed
|
||||||
|
if (username === 'admin' && password === this._adminPass) {
|
||||||
|
return { status: Valid, admin: true, moderator: true, triage: true }
|
||||||
|
}
|
||||||
|
if (username === 'admin' && password === this._moderatorPass) {
|
||||||
|
return { status: Valid, admin: false, moderator: true, triage: true }
|
||||||
|
}
|
||||||
|
if (username === 'admin' && password === this._triagePass) {
|
||||||
|
return { status: Valid, admin: false, moderator: false, triage: true }
|
||||||
|
}
|
||||||
|
return { status: Invalid, admin: false, moderator: false, triage: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyServiceJwt(
|
||||||
|
reqCtx: ReqCtx,
|
||||||
|
opts: { aud: string | null; iss: string[] | null },
|
||||||
|
) {
|
||||||
|
const getSigningKey = async (
|
||||||
|
did: string,
|
||||||
|
forceRefresh: boolean,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (opts.iss !== null && !opts.iss.includes(did)) {
|
||||||
|
throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss')
|
||||||
|
}
|
||||||
|
return this.idResolver.did.resolveAtprotoKey(did, forceRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtStr = bearerTokenFromReq(reqCtx.req)
|
||||||
|
if (!jwtStr) {
|
||||||
|
throw new AuthRequiredError('missing jwt', 'MissingJwt')
|
||||||
|
}
|
||||||
|
const payload = await verifyServiceJwt(jwtStr, opts.aud, getSigningKey)
|
||||||
|
return { iss: payload.iss, aud: payload.aud }
|
||||||
|
}
|
||||||
|
|
||||||
|
nullCreds(): NullOutput {
|
||||||
|
return {
|
||||||
|
credentials: {
|
||||||
|
type: 'null',
|
||||||
|
iss: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCreds(
|
||||||
|
creds: StandardOutput | RoleOutput | AdminServiceOutput | NullOutput,
|
||||||
|
) {
|
||||||
|
const viewer =
|
||||||
|
creds.credentials.type === 'standard' ? creds.credentials.iss : null
|
||||||
|
const canViewTakedowns =
|
||||||
|
(creds.credentials.type === 'role' && creds.credentials.triage) ||
|
||||||
|
creds.credentials.type === 'admin_service'
|
||||||
|
const canPerformTakedown =
|
||||||
|
(creds.credentials.type === 'role' && creds.credentials.moderator) ||
|
||||||
|
creds.credentials.type === 'admin_service'
|
||||||
|
return {
|
||||||
|
viewer,
|
||||||
|
canViewTakedowns,
|
||||||
|
canPerformTakedown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HELPERS
|
||||||
|
// ---------
|
||||||
|
|
||||||
|
const BEARER = 'Bearer '
|
||||||
|
const BASIC = 'Basic '
|
||||||
|
|
||||||
|
const isBearerToken = (req: express.Request): boolean => {
|
||||||
|
return req.headers.authorization?.startsWith(BEARER) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerTokenFromReq = (req: express.Request) => {
|
||||||
|
const header = req.headers.authorization || ''
|
||||||
|
if (!header.startsWith(BEARER)) return null
|
||||||
|
return header.slice(BEARER.length).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseBasicAuth = (
|
||||||
|
token: string,
|
||||||
|
): { username: string; password: string } | null => {
|
||||||
|
if (!token.startsWith(BASIC)) return null
|
||||||
|
const b64 = token.slice(BASIC.length)
|
||||||
|
let parsed: string[]
|
||||||
|
try {
|
||||||
|
parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':')
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const [username, password] = parsed
|
||||||
|
if (!username || !password) return null
|
||||||
|
return { username, password }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildBasicAuth = (username: string, password: string): string => {
|
||||||
|
return (
|
||||||
|
BASIC +
|
||||||
|
ui8.toString(ui8.fromString(`${username}:${password}`, 'utf8'), 'base64pad')
|
||||||
|
)
|
||||||
|
}
|
@ -6,16 +6,12 @@ import { PrimaryDatabase } from '../db'
|
|||||||
import { IdResolver } from '@atproto/identity'
|
import { IdResolver } from '@atproto/identity'
|
||||||
import { BackgroundQueue } from '../background'
|
import { BackgroundQueue } from '../background'
|
||||||
import { IndexerConfig } from '../indexer/config'
|
import { IndexerConfig } from '../indexer/config'
|
||||||
import { buildBasicAuth } from '../auth'
|
import { buildBasicAuth } from '../auth-verifier'
|
||||||
import { CID } from 'multiformats/cid'
|
import { CID } from 'multiformats/cid'
|
||||||
import { LabelService } from '../services/label'
|
|
||||||
import { ModerationService } from '../services/moderation'
|
|
||||||
import { ImageFlagger } from './abyss'
|
import { ImageFlagger } from './abyss'
|
||||||
import { HiveLabeler, ImgLabeler } from './hive'
|
import { HiveLabeler, ImgLabeler } from './hive'
|
||||||
import { KeywordLabeler, TextLabeler } from './keyword'
|
import { KeywordLabeler, TextLabeler } from './keyword'
|
||||||
import { ids } from '../lexicon/lexicons'
|
import { ids } from '../lexicon/lexicons'
|
||||||
import { ImageUriBuilder } from '../image/uri'
|
|
||||||
import { ImageInvalidator } from '../image/invalidator'
|
|
||||||
import { Abyss } from './abyss'
|
import { Abyss } from './abyss'
|
||||||
import { FuzzyMatcher, TextFlagger } from './fuzzy-matcher'
|
import { FuzzyMatcher, TextFlagger } from './fuzzy-matcher'
|
||||||
import {
|
import {
|
||||||
@ -24,43 +20,21 @@ import {
|
|||||||
} from '../lexicon/types/com/atproto/moderation/defs'
|
} from '../lexicon/types/com/atproto/moderation/defs'
|
||||||
|
|
||||||
export class AutoModerator {
|
export class AutoModerator {
|
||||||
public pushAgent?: AtpAgent
|
public pushAgent: AtpAgent
|
||||||
public imageFlagger?: ImageFlagger
|
public imageFlagger?: ImageFlagger
|
||||||
public textFlagger?: TextFlagger
|
public textFlagger?: TextFlagger
|
||||||
public imgLabeler?: ImgLabeler
|
public imgLabeler?: ImgLabeler
|
||||||
public textLabeler?: TextLabeler
|
public textLabeler?: TextLabeler
|
||||||
|
|
||||||
services: {
|
|
||||||
label: (db: PrimaryDatabase) => LabelService
|
|
||||||
moderation?: (db: PrimaryDatabase) => ModerationService
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public ctx: {
|
public ctx: {
|
||||||
db: PrimaryDatabase
|
db: PrimaryDatabase
|
||||||
idResolver: IdResolver
|
idResolver: IdResolver
|
||||||
cfg: IndexerConfig
|
cfg: IndexerConfig
|
||||||
backgroundQueue: BackgroundQueue
|
backgroundQueue: BackgroundQueue
|
||||||
imgUriBuilder?: ImageUriBuilder
|
|
||||||
imgInvalidator?: ImageInvalidator
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { imgUriBuilder, imgInvalidator } = ctx
|
|
||||||
const { hiveApiKey, abyssEndpoint, abyssPassword } = ctx.cfg
|
const { hiveApiKey, abyssEndpoint, abyssPassword } = ctx.cfg
|
||||||
this.services = {
|
|
||||||
label: LabelService.creator(null),
|
|
||||||
}
|
|
||||||
if (imgUriBuilder && imgInvalidator) {
|
|
||||||
this.services.moderation = ModerationService.creator(
|
|
||||||
imgUriBuilder,
|
|
||||||
imgInvalidator,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
log.error(
|
|
||||||
{ imgUriBuilder, imgInvalidator },
|
|
||||||
'moderation service not properly configured',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.imgLabeler = hiveApiKey ? new HiveLabeler(hiveApiKey, ctx) : undefined
|
this.imgLabeler = hiveApiKey ? new HiveLabeler(hiveApiKey, ctx) : undefined
|
||||||
this.textLabeler = new KeywordLabeler(ctx.cfg.labelerKeywords)
|
this.textLabeler = new KeywordLabeler(ctx.cfg.labelerKeywords)
|
||||||
if (abyssEndpoint && abyssPassword) {
|
if (abyssEndpoint && abyssPassword) {
|
||||||
@ -79,14 +53,12 @@ export class AutoModerator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.cfg.moderationPushUrl) {
|
const url = new URL(ctx.cfg.moderationPushUrl)
|
||||||
const url = new URL(ctx.cfg.moderationPushUrl)
|
this.pushAgent = new AtpAgent({ service: url.origin })
|
||||||
this.pushAgent = new AtpAgent({ service: url.origin })
|
this.pushAgent.api.setHeader(
|
||||||
this.pushAgent.api.setHeader(
|
'authorization',
|
||||||
'authorization',
|
buildBasicAuth(url.username, url.password),
|
||||||
buildBasicAuth(url.username, url.password),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processRecord(uri: AtUri, cid: CID, obj: unknown) {
|
processRecord(uri: AtUri, cid: CID, obj: unknown) {
|
||||||
@ -133,7 +105,7 @@ export class AutoModerator {
|
|||||||
...imgs.map((cid) => this.imgLabeler?.labelImg(uri.host, cid)),
|
...imgs.map((cid) => this.imgLabeler?.labelImg(uri.host, cid)),
|
||||||
])
|
])
|
||||||
const labels = dedupe(allLabels.flat())
|
const labels = dedupe(allLabels.flat())
|
||||||
await this.storeLabels(uri, recordCid, labels)
|
await this.pushLabels(uri, recordCid, labels)
|
||||||
}
|
}
|
||||||
|
|
||||||
async flagRecordText(uri: AtUri, cid: CID, text: string[]) {
|
async flagRecordText(uri: AtUri, cid: CID, text: string[]) {
|
||||||
@ -156,22 +128,22 @@ export class AutoModerator {
|
|||||||
if (!this.textFlagger) return
|
if (!this.textFlagger) return
|
||||||
const matches = this.textFlagger.getMatches(text)
|
const matches = this.textFlagger.getMatches(text)
|
||||||
if (matches.length < 1) return
|
if (matches.length < 1) return
|
||||||
await this.ctx.db.transaction(async (dbTxn) => {
|
const formattedSubject =
|
||||||
if (!this.services.moderation) {
|
'did' in subject
|
||||||
log.error(
|
? {
|
||||||
{ subject, text, matches },
|
$type: 'com.atproto.admin.defs#repoRef',
|
||||||
'no moderation service setup to flag record text',
|
did: subject.did,
|
||||||
)
|
}
|
||||||
return
|
: {
|
||||||
}
|
$type: 'com.atproto.repo.strongRef',
|
||||||
return this.services.moderation(dbTxn).report({
|
uri: subject.uri.toString(),
|
||||||
reasonType: REASONOTHER,
|
cid: subject.cid.toString(),
|
||||||
reason: `Automatically flagged for possible slurs: ${matches.join(
|
}
|
||||||
', ',
|
await this.pushAgent.api.com.atproto.moderation.createReport({
|
||||||
)}`,
|
reasonType: REASONOTHER,
|
||||||
subject,
|
reason: `Automatically flagged for possible slurs: ${matches.join(', ')}`,
|
||||||
reportedBy: this.ctx.cfg.labelerDid,
|
subject: formattedSubject,
|
||||||
})
|
reportedBy: this.ctx.cfg.serverDid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,93 +198,49 @@ export class AutoModerator {
|
|||||||
'hard takedown of record (and blobs) based on auto-matching',
|
'hard takedown of record (and blobs) based on auto-matching',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (this.services.moderation) {
|
await this.pushAgent.com.atproto.moderation.createReport({
|
||||||
await this.ctx.db.transaction(async (dbTxn) => {
|
reportedBy: this.ctx.cfg.serverDid,
|
||||||
// directly/locally create report, even if we use pushAgent for the takedown. don't have acctual account credentials for pushAgent, only admin auth
|
reasonType: REASONVIOLATION,
|
||||||
if (!this.services.moderation) {
|
subject: {
|
||||||
// checked above, outside the transaction
|
$type: 'com.atproto.repo.strongRef',
|
||||||
return
|
uri: uri.toString(),
|
||||||
}
|
cid: recordCid.toString(),
|
||||||
const modSrvc = this.services.moderation(dbTxn)
|
},
|
||||||
await modSrvc.report({
|
reason: reportReason,
|
||||||
reportedBy: this.ctx.cfg.labelerDid,
|
})
|
||||||
reasonType: REASONVIOLATION,
|
|
||||||
subject: {
|
|
||||||
uri: uri,
|
|
||||||
cid: recordCid,
|
|
||||||
},
|
|
||||||
reason: reportReason,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pushAgent) {
|
await this.pushAgent.com.atproto.admin.emitModerationEvent({
|
||||||
await this.pushAgent.com.atproto.admin.emitModerationEvent({
|
event: {
|
||||||
event: {
|
$type: 'com.atproto.admin.defs#modEventTakedown',
|
||||||
$type: 'com.atproto.admin.defs#modEventTakedown',
|
comment: takedownReason,
|
||||||
comment: takedownReason,
|
},
|
||||||
},
|
subject: {
|
||||||
subject: {
|
$type: 'com.atproto.repo.strongRef',
|
||||||
$type: 'com.atproto.repo.strongRef',
|
uri: uri.toString(),
|
||||||
uri: uri.toString(),
|
cid: recordCid.toString(),
|
||||||
cid: recordCid.toString(),
|
},
|
||||||
},
|
subjectBlobCids: takedownCids.map((c) => c.toString()),
|
||||||
subjectBlobCids: takedownCids.map((c) => c.toString()),
|
createdBy: this.ctx.cfg.serverDid,
|
||||||
createdBy: this.ctx.cfg.labelerDid,
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await this.ctx.db.transaction(async (dbTxn) => {
|
|
||||||
if (!this.services.moderation) {
|
|
||||||
throw new Error('no mod push agent or uri invalidator setup')
|
|
||||||
}
|
|
||||||
const modSrvc = this.services.moderation(dbTxn)
|
|
||||||
const action = await modSrvc.logEvent({
|
|
||||||
event: {
|
|
||||||
$type: 'com.atproto.admin.defs#modEventTakedown',
|
|
||||||
comment: takedownReason,
|
|
||||||
},
|
|
||||||
subject: { uri, cid: recordCid },
|
|
||||||
subjectBlobCids: takedownCids,
|
|
||||||
createdBy: this.ctx.cfg.labelerDid,
|
|
||||||
})
|
|
||||||
await modSrvc.takedownRecord({
|
|
||||||
takedownId: action.id,
|
|
||||||
uri: uri,
|
|
||||||
cid: recordCid,
|
|
||||||
blobCids: takedownCids,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeLabels(uri: AtUri, cid: CID, labels: string[]): Promise<void> {
|
async pushLabels(uri: AtUri, cid: CID, labels: string[]): Promise<void> {
|
||||||
if (labels.length < 1) return
|
if (labels.length < 1) return
|
||||||
|
|
||||||
// Given that moderation service is available, log the labeling event for historical purposes
|
await this.pushAgent.com.atproto.admin.emitModerationEvent({
|
||||||
if (this.services.moderation) {
|
event: {
|
||||||
await this.ctx.db.transaction(async (dbTxn) => {
|
$type: 'com.atproto.admin.defs#modEventLabel',
|
||||||
if (!this.services.moderation) return
|
comment: '[AutoModerator]: Applying labels',
|
||||||
const modSrvc = this.services.moderation(dbTxn)
|
createLabelVals: labels,
|
||||||
await modSrvc.logEvent({
|
negateLabelVals: [],
|
||||||
event: {
|
},
|
||||||
$type: 'com.atproto.admin.defs#modEventLabel',
|
subject: {
|
||||||
createLabelVals: labels,
|
$type: 'com.atproto.repo.strongRef',
|
||||||
negateLabelVals: [],
|
uri: uri.toString(),
|
||||||
comment: '[AutoModerator]: Applying labels',
|
cid: cid.toString(),
|
||||||
},
|
},
|
||||||
subject: { uri, cid },
|
createdBy: this.ctx.cfg.serverDid,
|
||||||
createdBy: this.ctx.cfg.labelerDid,
|
})
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelSrvc = this.services.label(this.ctx.db)
|
|
||||||
await labelSrvc.formatAndCreate(
|
|
||||||
this.ctx.cfg.labelerDid,
|
|
||||||
uri.toString(),
|
|
||||||
cid.toString(),
|
|
||||||
{ create: labels },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async processAll() {
|
async processAll() {
|
||||||
|
@ -31,11 +31,10 @@ export interface ServerConfigValues {
|
|||||||
imgUriEndpoint?: string
|
imgUriEndpoint?: string
|
||||||
blobCacheLocation?: string
|
blobCacheLocation?: string
|
||||||
searchEndpoint?: string
|
searchEndpoint?: string
|
||||||
labelerDid: string
|
|
||||||
adminPassword: string
|
adminPassword: string
|
||||||
moderatorPassword?: string
|
moderatorPassword: string
|
||||||
triagePassword?: string
|
triagePassword: string
|
||||||
moderationPushUrl?: string
|
modServiceDid: string
|
||||||
rateLimitsEnabled: boolean
|
rateLimitsEnabled: boolean
|
||||||
rateLimitBypassKey?: string
|
rateLimitBypassKey?: string
|
||||||
rateLimitBypassIps?: string[]
|
rateLimitBypassIps?: string[]
|
||||||
@ -110,14 +109,17 @@ export class ServerConfig {
|
|||||||
)
|
)
|
||||||
const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA
|
const dbPostgresSchema = process.env.DB_POSTGRES_SCHEMA
|
||||||
assert(dbPrimaryPostgresUrl)
|
assert(dbPrimaryPostgresUrl)
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin'
|
const adminPassword = process.env.ADMIN_PASSWORD || undefined
|
||||||
|
assert(adminPassword)
|
||||||
const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined
|
const moderatorPassword = process.env.MODERATOR_PASSWORD || undefined
|
||||||
|
assert(moderatorPassword)
|
||||||
const triagePassword = process.env.TRIAGE_PASSWORD || undefined
|
const triagePassword = process.env.TRIAGE_PASSWORD || undefined
|
||||||
const labelerDid = process.env.LABELER_DID || 'did:example:labeler'
|
assert(triagePassword)
|
||||||
const moderationPushUrl =
|
const modServiceDid =
|
||||||
overrides?.moderationPushUrl ||
|
overrides?.modServiceDid ||
|
||||||
process.env.MODERATION_PUSH_URL ||
|
process.env.MODERATION_SERVICE_DID ||
|
||||||
undefined
|
undefined
|
||||||
|
assert(modServiceDid)
|
||||||
const rateLimitsEnabled = process.env.RATE_LIMITS_ENABLED === 'true'
|
const rateLimitsEnabled = process.env.RATE_LIMITS_ENABLED === 'true'
|
||||||
const rateLimitBypassKey = process.env.RATE_LIMIT_BYPASS_KEY
|
const rateLimitBypassKey = process.env.RATE_LIMIT_BYPASS_KEY
|
||||||
const rateLimitBypassIps = process.env.RATE_LIMIT_BYPASS_IPS
|
const rateLimitBypassIps = process.env.RATE_LIMIT_BYPASS_IPS
|
||||||
@ -150,11 +152,10 @@ export class ServerConfig {
|
|||||||
imgUriEndpoint,
|
imgUriEndpoint,
|
||||||
blobCacheLocation,
|
blobCacheLocation,
|
||||||
searchEndpoint,
|
searchEndpoint,
|
||||||
labelerDid,
|
|
||||||
adminPassword,
|
adminPassword,
|
||||||
moderatorPassword,
|
moderatorPassword,
|
||||||
triagePassword,
|
triagePassword,
|
||||||
moderationPushUrl,
|
modServiceDid,
|
||||||
rateLimitsEnabled,
|
rateLimitsEnabled,
|
||||||
rateLimitBypassKey,
|
rateLimitBypassKey,
|
||||||
rateLimitBypassIps,
|
rateLimitBypassIps,
|
||||||
@ -267,10 +268,6 @@ export class ServerConfig {
|
|||||||
return this.cfg.searchEndpoint
|
return this.cfg.searchEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
get labelerDid() {
|
|
||||||
return this.cfg.labelerDid
|
|
||||||
}
|
|
||||||
|
|
||||||
get adminPassword() {
|
get adminPassword() {
|
||||||
return this.cfg.adminPassword
|
return this.cfg.adminPassword
|
||||||
}
|
}
|
||||||
@ -283,8 +280,8 @@ export class ServerConfig {
|
|||||||
return this.cfg.triagePassword
|
return this.cfg.triagePassword
|
||||||
}
|
}
|
||||||
|
|
||||||
get moderationPushUrl() {
|
get modServiceDid() {
|
||||||
return this.cfg.moderationPushUrl
|
return this.cfg.modServiceDid
|
||||||
}
|
}
|
||||||
|
|
||||||
get rateLimitsEnabled() {
|
get rateLimitsEnabled() {
|
||||||
|
@ -7,15 +7,14 @@ import { DatabaseCoordinator } from './db'
|
|||||||
import { ServerConfig } from './config'
|
import { ServerConfig } from './config'
|
||||||
import { ImageUriBuilder } from './image/uri'
|
import { ImageUriBuilder } from './image/uri'
|
||||||
import { Services } from './services'
|
import { Services } from './services'
|
||||||
import * as auth from './auth'
|
|
||||||
import DidRedisCache from './did-cache'
|
import DidRedisCache from './did-cache'
|
||||||
import { BackgroundQueue } from './background'
|
import { BackgroundQueue } from './background'
|
||||||
import { MountedAlgos } from './feed-gen/types'
|
import { MountedAlgos } from './feed-gen/types'
|
||||||
import { NotificationServer } from './notifications'
|
import { NotificationServer } from './notifications'
|
||||||
import { Redis } from './redis'
|
import { Redis } from './redis'
|
||||||
|
import { AuthVerifier } from './auth-verifier'
|
||||||
|
|
||||||
export class AppContext {
|
export class AppContext {
|
||||||
public moderationPushAgent: AtpAgent | undefined
|
|
||||||
constructor(
|
constructor(
|
||||||
private opts: {
|
private opts: {
|
||||||
db: DatabaseCoordinator
|
db: DatabaseCoordinator
|
||||||
@ -30,17 +29,9 @@ export class AppContext {
|
|||||||
searchAgent?: AtpAgent
|
searchAgent?: AtpAgent
|
||||||
algos: MountedAlgos
|
algos: MountedAlgos
|
||||||
notifServer: NotificationServer
|
notifServer: NotificationServer
|
||||||
|
authVerifier: AuthVerifier
|
||||||
},
|
},
|
||||||
) {
|
) {}
|
||||||
if (opts.cfg.moderationPushUrl) {
|
|
||||||
const url = new URL(opts.cfg.moderationPushUrl)
|
|
||||||
this.moderationPushAgent = new AtpAgent({ service: url.origin })
|
|
||||||
this.moderationPushAgent.api.setHeader(
|
|
||||||
'authorization',
|
|
||||||
auth.buildBasicAuth(url.username, url.password),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get db(): DatabaseCoordinator {
|
get db(): DatabaseCoordinator {
|
||||||
return this.opts.db
|
return this.opts.db
|
||||||
@ -86,30 +77,8 @@ export class AppContext {
|
|||||||
return this.opts.searchAgent
|
return this.opts.searchAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
get authVerifier() {
|
get authVerifier(): AuthVerifier {
|
||||||
return auth.authVerifier(this.idResolver, { aud: this.cfg.serverDid })
|
return this.opts.authVerifier
|
||||||
}
|
|
||||||
|
|
||||||
get authVerifierAnyAudience() {
|
|
||||||
return auth.authVerifier(this.idResolver, { aud: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
get authOptionalVerifierAnyAudience() {
|
|
||||||
return auth.authOptionalVerifier(this.idResolver, { aud: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
get authOptionalVerifier() {
|
|
||||||
return auth.authOptionalVerifier(this.idResolver, {
|
|
||||||
aud: this.cfg.serverDid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
get authOptionalAccessOrRoleVerifier() {
|
|
||||||
return auth.authOptionalAccessOrRoleVerifier(this.idResolver, this.cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
get roleVerifier() {
|
|
||||||
return auth.roleVerifier(this.cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async serviceAuthJwt(aud: string) {
|
async serviceAuthJwt(aud: string) {
|
||||||
|
@ -30,6 +30,7 @@ import * as algo from './tables/algo'
|
|||||||
import * as viewParam from './tables/view-param'
|
import * as viewParam from './tables/view-param'
|
||||||
import * as suggestedFollow from './tables/suggested-follow'
|
import * as suggestedFollow from './tables/suggested-follow'
|
||||||
import * as suggestedFeed from './tables/suggested-feed'
|
import * as suggestedFeed from './tables/suggested-feed'
|
||||||
|
import * as blobTakedown from './tables/blob-takedown'
|
||||||
|
|
||||||
export type DatabaseSchemaType = duplicateRecord.PartialDB &
|
export type DatabaseSchemaType = duplicateRecord.PartialDB &
|
||||||
profile.PartialDB &
|
profile.PartialDB &
|
||||||
@ -61,7 +62,8 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB &
|
|||||||
algo.PartialDB &
|
algo.PartialDB &
|
||||||
viewParam.PartialDB &
|
viewParam.PartialDB &
|
||||||
suggestedFollow.PartialDB &
|
suggestedFollow.PartialDB &
|
||||||
suggestedFeed.PartialDB
|
suggestedFeed.PartialDB &
|
||||||
|
blobTakedown.PartialDB
|
||||||
|
|
||||||
export type DatabaseSchema = Kysely<DatabaseSchemaType>
|
export type DatabaseSchema = Kysely<DatabaseSchemaType>
|
||||||
|
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import { Kysely } from 'kysely'
|
|
||||||
|
|
||||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('moderation_subject_status')
|
|
||||||
.addColumn('lastAppealedAt', 'varchar')
|
|
||||||
.execute()
|
|
||||||
await db.schema
|
|
||||||
.alterTable('moderation_subject_status')
|
|
||||||
.addColumn('appealed', 'boolean')
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('moderation_subject_status')
|
|
||||||
.dropColumn('lastAppealedAt')
|
|
||||||
.execute()
|
|
||||||
await db.schema
|
|
||||||
.alterTable('moderation_subject_status')
|
|
||||||
.dropColumn('appealed')
|
|
||||||
.execute()
|
|
||||||
}
|
|
@ -0,0 +1,66 @@
|
|||||||
|
import { Kysely } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('blob_takedown')
|
||||||
|
.addColumn('did', 'varchar', (col) => col.notNull())
|
||||||
|
.addColumn('cid', 'varchar', (col) => col.notNull())
|
||||||
|
.addColumn('takedownRef', 'varchar', (col) => col.notNull())
|
||||||
|
.addPrimaryKeyConstraint('blob_takedown_pkey', ['did', 'cid'])
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('actor')
|
||||||
|
.dropConstraint('actor_takedown_id_fkey')
|
||||||
|
.execute()
|
||||||
|
await db.schema.alterTable('actor').dropColumn('takedownId').execute()
|
||||||
|
await db.schema
|
||||||
|
.alterTable('actor')
|
||||||
|
.addColumn('takedownRef', 'varchar')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('record')
|
||||||
|
.dropConstraint('record_takedown_id_fkey')
|
||||||
|
.execute()
|
||||||
|
await db.schema.alterTable('record').dropColumn('takedownId').execute()
|
||||||
|
await db.schema
|
||||||
|
.alterTable('record')
|
||||||
|
.addColumn('takedownRef', 'varchar')
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||||
|
await db.schema.dropTable('blob_takedown').execute()
|
||||||
|
|
||||||
|
await db.schema.alterTable('actor').dropColumn('takedownRef').execute()
|
||||||
|
await db.schema
|
||||||
|
.alterTable('actor')
|
||||||
|
.addColumn('takedownId', 'integer')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.alterTable('actor')
|
||||||
|
.addForeignKeyConstraint(
|
||||||
|
'actor_takedown_id_fkey',
|
||||||
|
['takedownId'],
|
||||||
|
'moderation_event',
|
||||||
|
['id'],
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema.alterTable('record').dropColumn('takedownRef').execute()
|
||||||
|
await db.schema
|
||||||
|
.alterTable('record')
|
||||||
|
.addColumn('takedownId', 'integer')
|
||||||
|
.execute()
|
||||||
|
await db.schema
|
||||||
|
.alterTable('record')
|
||||||
|
.addForeignKeyConstraint(
|
||||||
|
'record_takedown_id_fkey',
|
||||||
|
['takedownId'],
|
||||||
|
'moderation_event',
|
||||||
|
['id'],
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
}
|
@ -32,4 +32,4 @@ export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post'
|
|||||||
export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes'
|
export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes'
|
||||||
export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status'
|
export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status'
|
||||||
export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache'
|
export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache'
|
||||||
export * as _20231213T181744386Z from './20231213T181744386Z-moderation-subject-appeal'
|
export * as _20231220T225126090Z from './20231220T225126090Z-blob-takedowns'
|
||||||
|
@ -1,125 +0,0 @@
|
|||||||
import { wait } from '@atproto/common'
|
|
||||||
import { Leader } from './leader'
|
|
||||||
import { dbLogger } from '../logger'
|
|
||||||
import AppContext from '../context'
|
|
||||||
import { AtUri } from '@atproto/api'
|
|
||||||
import { ModerationSubjectStatusRow } from '../services/moderation/types'
|
|
||||||
import { CID } from 'multiformats/cid'
|
|
||||||
import AtpAgent from '@atproto/api'
|
|
||||||
import { retryHttp } from '../util/retry'
|
|
||||||
|
|
||||||
export const MODERATION_ACTION_REVERSAL_ID = 1011
|
|
||||||
|
|
||||||
export class PeriodicModerationEventReversal {
|
|
||||||
leader = new Leader(
|
|
||||||
MODERATION_ACTION_REVERSAL_ID,
|
|
||||||
this.appContext.db.getPrimary(),
|
|
||||||
)
|
|
||||||
destroyed = false
|
|
||||||
pushAgent?: AtpAgent
|
|
||||||
|
|
||||||
constructor(private appContext: AppContext) {
|
|
||||||
this.pushAgent = appContext.moderationPushAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
async revertState(eventRow: ModerationSubjectStatusRow) {
|
|
||||||
await this.appContext.db.getPrimary().transaction(async (dbTxn) => {
|
|
||||||
const moderationTxn = this.appContext.services.moderation(dbTxn)
|
|
||||||
const originalEvent =
|
|
||||||
await moderationTxn.getLastReversibleEventForSubject(eventRow)
|
|
||||||
if (originalEvent) {
|
|
||||||
const { restored } = await moderationTxn.revertState({
|
|
||||||
action: originalEvent.action,
|
|
||||||
createdBy: originalEvent.createdBy,
|
|
||||||
comment:
|
|
||||||
'[SCHEDULED_REVERSAL] Reverting action as originally scheduled',
|
|
||||||
subject:
|
|
||||||
eventRow.recordPath && eventRow.recordCid
|
|
||||||
? {
|
|
||||||
uri: AtUri.make(
|
|
||||||
eventRow.did,
|
|
||||||
...eventRow.recordPath.split('/'),
|
|
||||||
),
|
|
||||||
cid: CID.parse(eventRow.recordCid),
|
|
||||||
}
|
|
||||||
: { did: eventRow.did },
|
|
||||||
createdAt: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { pushAgent } = this
|
|
||||||
if (
|
|
||||||
originalEvent.action === 'com.atproto.admin.defs#modEventTakedown' &&
|
|
||||||
restored?.subjects?.length &&
|
|
||||||
pushAgent
|
|
||||||
) {
|
|
||||||
await Promise.allSettled(
|
|
||||||
restored.subjects.map((subject) =>
|
|
||||||
retryHttp(() =>
|
|
||||||
pushAgent.api.com.atproto.admin.updateSubjectStatus({
|
|
||||||
subject,
|
|
||||||
takedown: {
|
|
||||||
applied: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAndRevertDueActions() {
|
|
||||||
const moderationService = this.appContext.services.moderation(
|
|
||||||
this.appContext.db.getPrimary(),
|
|
||||||
)
|
|
||||||
const subjectsDueForReversal =
|
|
||||||
await moderationService.getSubjectsDueForReversal()
|
|
||||||
|
|
||||||
// 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.all(subjectsDueForReversal.map(this.revertState.bind(this)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async run() {
|
|
||||||
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)
|
|
||||||
}
|
|
@ -2,7 +2,7 @@ export interface Actor {
|
|||||||
did: string
|
did: string
|
||||||
handle: string | null
|
handle: string | null
|
||||||
indexedAt: string
|
indexedAt: string
|
||||||
takedownId: number | null // @TODO(bsky)
|
takedownRef: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tableName = 'actor'
|
export const tableName = 'actor'
|
||||||
|
9
packages/bsky/src/db/tables/blob-takedown.ts
Normal file
9
packages/bsky/src/db/tables/blob-takedown.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface BlobTakedown {
|
||||||
|
did: string
|
||||||
|
cid: string
|
||||||
|
takedownRef: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tableName = 'blob_takedown'
|
||||||
|
|
||||||
|
export type PartialDB = { [tableName]: BlobTakedown }
|
@ -20,7 +20,6 @@ export interface ModerationEvent {
|
|||||||
| 'com.atproto.admin.defs#modEventMute'
|
| 'com.atproto.admin.defs#modEventMute'
|
||||||
| 'com.atproto.admin.defs#modEventReverseTakedown'
|
| 'com.atproto.admin.defs#modEventReverseTakedown'
|
||||||
| 'com.atproto.admin.defs#modEventEmail'
|
| 'com.atproto.admin.defs#modEventEmail'
|
||||||
| 'com.atproto.admin.defs#modEventResolveAppeal'
|
|
||||||
subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
|
subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
|
||||||
subjectDid: string
|
subjectDid: string
|
||||||
subjectUri: string | null
|
subjectUri: string | null
|
||||||
@ -48,11 +47,9 @@ export interface ModerationSubjectStatus {
|
|||||||
lastReviewedBy: string | null
|
lastReviewedBy: string | null
|
||||||
lastReviewedAt: string | null
|
lastReviewedAt: string | null
|
||||||
lastReportedAt: string | null
|
lastReportedAt: string | null
|
||||||
lastAppealedAt: string | null
|
|
||||||
muteUntil: string | null
|
muteUntil: string | null
|
||||||
suspendUntil: string | null
|
suspendUntil: string | null
|
||||||
takendown: boolean
|
takendown: boolean
|
||||||
appealed: boolean | null
|
|
||||||
comment: string | null
|
comment: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ export interface Record {
|
|||||||
did: string
|
did: string
|
||||||
json: string
|
json: string
|
||||||
indexedAt: string
|
indexedAt: string
|
||||||
takedownId: number | null // @TODO(bsky)
|
takedownRef: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tableName = 'record'
|
export const tableName = 'record'
|
||||||
|
@ -20,11 +20,11 @@ export const actorWhereClause = (actor: string) => {
|
|||||||
|
|
||||||
// Applies to actor or record table
|
// Applies to actor or record table
|
||||||
export const notSoftDeletedClause = (alias: DbRef) => {
|
export const notSoftDeletedClause = (alias: DbRef) => {
|
||||||
return sql`${alias}."takedownId" is null`
|
return sql`${alias}."takedownRef" is null`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const softDeleted = (actorOrRecord: { takedownId: number | null }) => {
|
export const softDeleted = (actorOrRecord: { takedownRef: string | null }) => {
|
||||||
return actorOrRecord.takedownId !== null
|
return actorOrRecord.takedownRef !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const countAll = sql<number>`count(*)`
|
export const countAll = sql<number>`count(*)`
|
||||||
|
@ -33,20 +33,20 @@ import { NotificationServer } from './notifications'
|
|||||||
import { AtpAgent } from '@atproto/api'
|
import { AtpAgent } from '@atproto/api'
|
||||||
import { Keypair } from '@atproto/crypto'
|
import { Keypair } from '@atproto/crypto'
|
||||||
import { Redis } from './redis'
|
import { Redis } from './redis'
|
||||||
|
import { AuthVerifier } from './auth-verifier'
|
||||||
|
|
||||||
export type { ServerConfigValues } from './config'
|
export type { ServerConfigValues } from './config'
|
||||||
export type { MountedAlgos } from './feed-gen/types'
|
export type { MountedAlgos } from './feed-gen/types'
|
||||||
export { ServerConfig } from './config'
|
export { ServerConfig } from './config'
|
||||||
export { Database, PrimaryDatabase, DatabaseCoordinator } from './db'
|
export { Database, PrimaryDatabase, DatabaseCoordinator } from './db'
|
||||||
export { PeriodicModerationEventReversal } from './db/periodic-moderation-event-reversal'
|
|
||||||
export { Redis } from './redis'
|
export { Redis } from './redis'
|
||||||
export { ViewMaintainer } from './db/views'
|
export { ViewMaintainer } from './db/views'
|
||||||
export { AppContext } from './context'
|
export { AppContext } from './context'
|
||||||
|
export type { ImageInvalidator } from './image/invalidator'
|
||||||
export { makeAlgos } from './feed-gen'
|
export { makeAlgos } from './feed-gen'
|
||||||
export * from './daemon'
|
export * from './daemon'
|
||||||
export * from './indexer'
|
export * from './indexer'
|
||||||
export * from './ingester'
|
export * from './ingester'
|
||||||
export { MigrateModerationData } from './migrate-moderation-data'
|
|
||||||
|
|
||||||
export class BskyAppView {
|
export class BskyAppView {
|
||||||
public ctx: AppContext
|
public ctx: AppContext
|
||||||
@ -127,6 +127,14 @@ export class BskyAppView {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const authVerifier = new AuthVerifier(idResolver, {
|
||||||
|
ownDid: config.serverDid,
|
||||||
|
adminDid: config.modServiceDid,
|
||||||
|
adminPass: config.adminPassword,
|
||||||
|
moderatorPass: config.moderatorPassword,
|
||||||
|
triagePass: config.triagePassword,
|
||||||
|
})
|
||||||
|
|
||||||
const ctx = new AppContext({
|
const ctx = new AppContext({
|
||||||
db,
|
db,
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@ -140,6 +148,7 @@ export class BskyAppView {
|
|||||||
searchAgent,
|
searchAgent,
|
||||||
algos,
|
algos,
|
||||||
notifServer,
|
notifServer,
|
||||||
|
authVerifier,
|
||||||
})
|
})
|
||||||
|
|
||||||
const xrpcOpts: XrpcServerOptions = {
|
const xrpcOpts: XrpcServerOptions = {
|
||||||
|
@ -3,6 +3,7 @@ import { DAY, HOUR, parseIntWithFallback } from '@atproto/common'
|
|||||||
|
|
||||||
export interface IndexerConfigValues {
|
export interface IndexerConfigValues {
|
||||||
version: string
|
version: string
|
||||||
|
serverDid: string
|
||||||
dbPostgresUrl: string
|
dbPostgresUrl: string
|
||||||
dbPostgresSchema?: string
|
dbPostgresSchema?: string
|
||||||
redisHost?: string // either set redis host, or both sentinel name and hosts
|
redisHost?: string // either set redis host, or both sentinel name and hosts
|
||||||
@ -13,7 +14,6 @@ export interface IndexerConfigValues {
|
|||||||
didCacheStaleTTL: number
|
didCacheStaleTTL: number
|
||||||
didCacheMaxTTL: number
|
didCacheMaxTTL: number
|
||||||
handleResolveNameservers?: string[]
|
handleResolveNameservers?: string[]
|
||||||
labelerDid: string
|
|
||||||
hiveApiKey?: string
|
hiveApiKey?: string
|
||||||
abyssEndpoint?: string
|
abyssEndpoint?: string
|
||||||
abyssPassword?: string
|
abyssPassword?: string
|
||||||
@ -21,7 +21,7 @@ export interface IndexerConfigValues {
|
|||||||
fuzzyMatchB64?: string
|
fuzzyMatchB64?: string
|
||||||
fuzzyFalsePositiveB64?: string
|
fuzzyFalsePositiveB64?: string
|
||||||
labelerKeywords: Record<string, string>
|
labelerKeywords: Record<string, string>
|
||||||
moderationPushUrl?: string
|
moderationPushUrl: string
|
||||||
indexerConcurrency?: number
|
indexerConcurrency?: number
|
||||||
indexerPartitionIds: number[]
|
indexerPartitionIds: number[]
|
||||||
indexerPartitionBatchSize?: number
|
indexerPartitionBatchSize?: number
|
||||||
@ -37,6 +37,7 @@ export class IndexerConfig {
|
|||||||
|
|
||||||
static readEnv(overrides?: Partial<IndexerConfigValues>) {
|
static readEnv(overrides?: Partial<IndexerConfigValues>) {
|
||||||
const version = process.env.BSKY_VERSION || '0.0.0'
|
const version = process.env.BSKY_VERSION || '0.0.0'
|
||||||
|
const serverDid = process.env.SERVER_DID || 'did:example:test'
|
||||||
const dbPostgresUrl =
|
const dbPostgresUrl =
|
||||||
overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL
|
overrides?.dbPostgresUrl || process.env.DB_PRIMARY_POSTGRES_URL
|
||||||
const dbPostgresSchema =
|
const dbPostgresSchema =
|
||||||
@ -66,11 +67,11 @@ export class IndexerConfig {
|
|||||||
const handleResolveNameservers = process.env.HANDLE_RESOLVE_NAMESERVERS
|
const handleResolveNameservers = process.env.HANDLE_RESOLVE_NAMESERVERS
|
||||||
? process.env.HANDLE_RESOLVE_NAMESERVERS.split(',')
|
? process.env.HANDLE_RESOLVE_NAMESERVERS.split(',')
|
||||||
: []
|
: []
|
||||||
const labelerDid = process.env.LABELER_DID || 'did:example:labeler'
|
|
||||||
const moderationPushUrl =
|
const moderationPushUrl =
|
||||||
overrides?.moderationPushUrl ||
|
overrides?.moderationPushUrl ||
|
||||||
process.env.MODERATION_PUSH_URL ||
|
process.env.MODERATION_PUSH_URL ||
|
||||||
undefined
|
undefined
|
||||||
|
assert(moderationPushUrl)
|
||||||
const hiveApiKey = process.env.HIVE_API_KEY || undefined
|
const hiveApiKey = process.env.HIVE_API_KEY || undefined
|
||||||
const abyssEndpoint = process.env.ABYSS_ENDPOINT
|
const abyssEndpoint = process.env.ABYSS_ENDPOINT
|
||||||
const abyssPassword = process.env.ABYSS_PASSWORD
|
const abyssPassword = process.env.ABYSS_PASSWORD
|
||||||
@ -101,6 +102,7 @@ export class IndexerConfig {
|
|||||||
assert(indexerPartitionIds.length > 0)
|
assert(indexerPartitionIds.length > 0)
|
||||||
return new IndexerConfig({
|
return new IndexerConfig({
|
||||||
version,
|
version,
|
||||||
|
serverDid,
|
||||||
dbPostgresUrl,
|
dbPostgresUrl,
|
||||||
dbPostgresSchema,
|
dbPostgresSchema,
|
||||||
redisHost,
|
redisHost,
|
||||||
@ -111,7 +113,6 @@ export class IndexerConfig {
|
|||||||
didCacheStaleTTL,
|
didCacheStaleTTL,
|
||||||
didCacheMaxTTL,
|
didCacheMaxTTL,
|
||||||
handleResolveNameservers,
|
handleResolveNameservers,
|
||||||
labelerDid,
|
|
||||||
moderationPushUrl,
|
moderationPushUrl,
|
||||||
hiveApiKey,
|
hiveApiKey,
|
||||||
abyssEndpoint,
|
abyssEndpoint,
|
||||||
@ -136,6 +137,10 @@ export class IndexerConfig {
|
|||||||
return this.cfg.version
|
return this.cfg.version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get serverDid() {
|
||||||
|
return this.cfg.serverDid
|
||||||
|
}
|
||||||
|
|
||||||
get dbPostgresUrl() {
|
get dbPostgresUrl() {
|
||||||
return this.cfg.dbPostgresUrl
|
return this.cfg.dbPostgresUrl
|
||||||
}
|
}
|
||||||
@ -176,10 +181,6 @@ export class IndexerConfig {
|
|||||||
return this.cfg.handleResolveNameservers
|
return this.cfg.handleResolveNameservers
|
||||||
}
|
}
|
||||||
|
|
||||||
get labelerDid() {
|
|
||||||
return this.cfg.labelerDid
|
|
||||||
}
|
|
||||||
|
|
||||||
get moderationPushUrl() {
|
get moderationPushUrl() {
|
||||||
return this.cfg.moderationPushUrl
|
return this.cfg.moderationPushUrl
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,6 @@ import { AutoModerator } from '../auto-moderator'
|
|||||||
import { Redis } from '../redis'
|
import { Redis } from '../redis'
|
||||||
import { NotificationServer } from '../notifications'
|
import { NotificationServer } from '../notifications'
|
||||||
import { CloseFn, createServer, startServer } from './server'
|
import { CloseFn, createServer, startServer } from './server'
|
||||||
import { ImageUriBuilder } from '../image/uri'
|
|
||||||
import { ImageInvalidator } from '../image/invalidator'
|
|
||||||
|
|
||||||
export { IndexerConfig } from './config'
|
export { IndexerConfig } from './config'
|
||||||
export type { IndexerConfigValues } from './config'
|
export type { IndexerConfigValues } from './config'
|
||||||
@ -42,7 +40,6 @@ export class BskyIndexer {
|
|||||||
redis: Redis
|
redis: Redis
|
||||||
redisCache: Redis
|
redisCache: Redis
|
||||||
cfg: IndexerConfig
|
cfg: IndexerConfig
|
||||||
imgInvalidator?: ImageInvalidator
|
|
||||||
}): BskyIndexer {
|
}): BskyIndexer {
|
||||||
const { db, redis, redisCache, cfg } = opts
|
const { db, redis, redisCache, cfg } = opts
|
||||||
const didCache = new DidRedisCache(redisCache.withNamespace('did-doc'), {
|
const didCache = new DidRedisCache(redisCache.withNamespace('did-doc'), {
|
||||||
@ -56,17 +53,11 @@ export class BskyIndexer {
|
|||||||
})
|
})
|
||||||
const backgroundQueue = new BackgroundQueue(db)
|
const backgroundQueue = new BackgroundQueue(db)
|
||||||
|
|
||||||
const imgUriBuilder = cfg.imgUriEndpoint
|
|
||||||
? new ImageUriBuilder(cfg.imgUriEndpoint)
|
|
||||||
: undefined
|
|
||||||
const imgInvalidator = opts.imgInvalidator
|
|
||||||
const autoMod = new AutoModerator({
|
const autoMod = new AutoModerator({
|
||||||
db,
|
db,
|
||||||
idResolver,
|
idResolver,
|
||||||
cfg,
|
cfg,
|
||||||
backgroundQueue,
|
backgroundQueue,
|
||||||
imgUriBuilder,
|
|
||||||
imgInvalidator,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const notifServer = cfg.pushNotificationEndpoint
|
const notifServer = cfg.pushNotificationEndpoint
|
||||||
|
@ -9,6 +9,7 @@ export interface IngesterConfigValues {
|
|||||||
redisSentinelHosts?: string[]
|
redisSentinelHosts?: string[]
|
||||||
redisPassword?: string
|
redisPassword?: string
|
||||||
repoProvider: string
|
repoProvider: string
|
||||||
|
labelProvider?: string
|
||||||
ingesterPartitionCount: number
|
ingesterPartitionCount: number
|
||||||
ingesterNamespace?: string
|
ingesterNamespace?: string
|
||||||
ingesterSubLockId?: number
|
ingesterSubLockId?: number
|
||||||
@ -40,6 +41,7 @@ export class IngesterConfig {
|
|||||||
const redisPassword =
|
const redisPassword =
|
||||||
overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined
|
overrides?.redisPassword || process.env.REDIS_PASSWORD || undefined
|
||||||
const repoProvider = overrides?.repoProvider || process.env.REPO_PROVIDER // E.g. ws://abc.com:4000
|
const repoProvider = overrides?.repoProvider || process.env.REPO_PROVIDER // E.g. ws://abc.com:4000
|
||||||
|
const labelProvider = overrides?.labelProvider || process.env.LABEL_PROVIDER
|
||||||
const ingesterPartitionCount =
|
const ingesterPartitionCount =
|
||||||
overrides?.ingesterPartitionCount ||
|
overrides?.ingesterPartitionCount ||
|
||||||
maybeParseInt(process.env.INGESTER_PARTITION_COUNT)
|
maybeParseInt(process.env.INGESTER_PARTITION_COUNT)
|
||||||
@ -69,6 +71,7 @@ export class IngesterConfig {
|
|||||||
redisSentinelHosts,
|
redisSentinelHosts,
|
||||||
redisPassword,
|
redisPassword,
|
||||||
repoProvider,
|
repoProvider,
|
||||||
|
labelProvider,
|
||||||
ingesterPartitionCount,
|
ingesterPartitionCount,
|
||||||
ingesterSubLockId,
|
ingesterSubLockId,
|
||||||
ingesterNamespace,
|
ingesterNamespace,
|
||||||
@ -110,6 +113,10 @@ export class IngesterConfig {
|
|||||||
return this.cfg.repoProvider
|
return this.cfg.repoProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get labelProvider() {
|
||||||
|
return this.cfg.labelProvider
|
||||||
|
}
|
||||||
|
|
||||||
get ingesterPartitionCount() {
|
get ingesterPartitionCount() {
|
||||||
return this.cfg.ingesterPartitionCount
|
return this.cfg.ingesterPartitionCount
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { PrimaryDatabase } from '../db'
|
import { PrimaryDatabase } from '../db'
|
||||||
import { Redis } from '../redis'
|
import { Redis } from '../redis'
|
||||||
import { IngesterConfig } from './config'
|
import { IngesterConfig } from './config'
|
||||||
|
import { LabelSubscription } from './label-subscription'
|
||||||
|
|
||||||
export class IngesterContext {
|
export class IngesterContext {
|
||||||
constructor(
|
constructor(
|
||||||
@ -8,6 +9,7 @@ export class IngesterContext {
|
|||||||
db: PrimaryDatabase
|
db: PrimaryDatabase
|
||||||
redis: Redis
|
redis: Redis
|
||||||
cfg: IngesterConfig
|
cfg: IngesterConfig
|
||||||
|
labelSubscription?: LabelSubscription
|
||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -22,6 +24,10 @@ export class IngesterContext {
|
|||||||
get cfg(): IngesterConfig {
|
get cfg(): IngesterConfig {
|
||||||
return this.opts.cfg
|
return this.opts.cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get labelSubscription(): LabelSubscription | undefined {
|
||||||
|
return this.opts.labelSubscription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IngesterContext
|
export default IngesterContext
|
||||||
|
@ -5,6 +5,7 @@ import { Redis } from '../redis'
|
|||||||
import { IngesterConfig } from './config'
|
import { IngesterConfig } from './config'
|
||||||
import { IngesterContext } from './context'
|
import { IngesterContext } from './context'
|
||||||
import { IngesterSubscription } from './subscription'
|
import { IngesterSubscription } from './subscription'
|
||||||
|
import { LabelSubscription } from './label-subscription'
|
||||||
|
|
||||||
export { IngesterConfig } from './config'
|
export { IngesterConfig } from './config'
|
||||||
export type { IngesterConfigValues } from './config'
|
export type { IngesterConfigValues } from './config'
|
||||||
@ -26,7 +27,15 @@ export class BskyIngester {
|
|||||||
cfg: IngesterConfig
|
cfg: IngesterConfig
|
||||||
}): BskyIngester {
|
}): BskyIngester {
|
||||||
const { db, redis, cfg } = opts
|
const { db, redis, cfg } = opts
|
||||||
const ctx = new IngesterContext({ db, redis, cfg })
|
const labelSubscription = cfg.labelProvider
|
||||||
|
? new LabelSubscription(db, cfg.labelProvider)
|
||||||
|
: undefined
|
||||||
|
const ctx = new IngesterContext({
|
||||||
|
db,
|
||||||
|
redis,
|
||||||
|
cfg,
|
||||||
|
labelSubscription,
|
||||||
|
})
|
||||||
const sub = new IngesterSubscription(ctx, {
|
const sub = new IngesterSubscription(ctx, {
|
||||||
service: cfg.repoProvider,
|
service: cfg.repoProvider,
|
||||||
subLockId: cfg.ingesterSubLockId,
|
subLockId: cfg.ingesterSubLockId,
|
||||||
@ -63,11 +72,13 @@ export class BskyIngester {
|
|||||||
'ingester stats',
|
'ingester stats',
|
||||||
)
|
)
|
||||||
}, 500)
|
}, 500)
|
||||||
|
await this.ctx.labelSubscription?.start()
|
||||||
this.sub.run()
|
this.sub.run()
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy(opts?: { skipDb: boolean }): Promise<void> {
|
async destroy(opts?: { skipDb: boolean }): Promise<void> {
|
||||||
|
await this.ctx.labelSubscription?.destroy()
|
||||||
await this.sub.destroy()
|
await this.sub.destroy()
|
||||||
clearInterval(this.subStatsInterval)
|
clearInterval(this.subStatsInterval)
|
||||||
await this.ctx.redis.destroy()
|
await this.ctx.redis.destroy()
|
||||||
|
76
packages/bsky/src/ingester/label-subscription.ts
Normal file
76
packages/bsky/src/ingester/label-subscription.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import AtpAgent from '@atproto/api'
|
||||||
|
import { PrimaryDatabase } from '../db'
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
import { dbLogger } from '../logger'
|
||||||
|
import { SECOND } from '@atproto/common'
|
||||||
|
|
||||||
|
export class LabelSubscription {
|
||||||
|
destroyed = false
|
||||||
|
promise: Promise<void> = Promise.resolve()
|
||||||
|
timer: NodeJS.Timer | undefined
|
||||||
|
lastLabel: number | undefined
|
||||||
|
labelAgent: AtpAgent
|
||||||
|
|
||||||
|
constructor(public db: PrimaryDatabase, public labelProvider: string) {
|
||||||
|
this.labelAgent = new AtpAgent({ service: labelProvider })
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
const res = await this.db.db
|
||||||
|
.selectFrom('label')
|
||||||
|
.select('cts')
|
||||||
|
.orderBy('cts', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst()
|
||||||
|
this.lastLabel = res ? new Date(res.cts).getTime() : undefined
|
||||||
|
this.poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
poll() {
|
||||||
|
if (this.destroyed) return
|
||||||
|
this.promise = this.fetchLabels()
|
||||||
|
.catch((err) =>
|
||||||
|
dbLogger.error({ err }, 'failed to fetch and store labels'),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
this.timer = setTimeout(() => this.poll(), SECOND)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLabels() {
|
||||||
|
const res = await this.labelAgent.api.com.atproto.temp.fetchLabels({
|
||||||
|
since: this.lastLabel,
|
||||||
|
})
|
||||||
|
const last = res.data.labels.at(-1)
|
||||||
|
if (!last) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dbVals = res.data.labels.map((l) => ({
|
||||||
|
...l,
|
||||||
|
cid: l.cid ?? '',
|
||||||
|
neg: l.neg ?? false,
|
||||||
|
}))
|
||||||
|
const { ref } = this.db.db.dynamic
|
||||||
|
const excluded = (col: string) => ref(`excluded.${col}`)
|
||||||
|
await this.db
|
||||||
|
.asPrimary()
|
||||||
|
.db.insertInto('label')
|
||||||
|
.values(dbVals)
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.columns(['src', 'uri', 'cid', 'val']).doUpdateSet({
|
||||||
|
neg: sql`${excluded('neg')}`,
|
||||||
|
cts: sql`${excluded('cts')}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
this.lastLabel = new Date(last.cts).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
this.destroyed = true
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
await this.promise
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,7 @@ import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/di
|
|||||||
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
import * as ComAtprotoAdminEmitModerationEvent from './types/com/atproto/admin/emitModerationEvent'
|
||||||
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites'
|
||||||
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
import * as ComAtprotoAdminGetAccountInfo from './types/com/atproto/admin/getAccountInfo'
|
||||||
|
import * as ComAtprotoAdminGetAccountInfos from './types/com/atproto/admin/getAccountInfos'
|
||||||
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
import * as ComAtprotoAdminGetInviteCodes from './types/com/atproto/admin/getInviteCodes'
|
||||||
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
import * as ComAtprotoAdminGetModerationEvent from './types/com/atproto/admin/getModerationEvent'
|
||||||
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
import * as ComAtprotoAdminGetRecord from './types/com/atproto/admin/getRecord'
|
||||||
@ -265,6 +266,17 @@ export class AdminNS {
|
|||||||
return this._server.xrpc.method(nsid, cfg)
|
return this._server.xrpc.method(nsid, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAccountInfos<AV extends AuthVerifier>(
|
||||||
|
cfg: ConfigOf<
|
||||||
|
AV,
|
||||||
|
ComAtprotoAdminGetAccountInfos.Handler<ExtractAuth<AV>>,
|
||||||
|
ComAtprotoAdminGetAccountInfos.HandlerReqCtx<ExtractAuth<AV>>
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const nsid = 'com.atproto.admin.getAccountInfos' // @ts-ignore
|
||||||
|
return this._server.xrpc.method(nsid, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
getInviteCodes<AV extends AuthVerifier>(
|
getInviteCodes<AV extends AuthVerifier>(
|
||||||
cfg: ConfigOf<
|
cfg: ConfigOf<
|
||||||
AV,
|
AV,
|
||||||
|
@ -436,6 +436,12 @@ export const schemaDict = {
|
|||||||
email: {
|
email: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
relatedRecords: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'unknown',
|
||||||
|
},
|
||||||
|
},
|
||||||
indexedAt: {
|
indexedAt: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'datetime',
|
format: 'datetime',
|
||||||
@ -1046,6 +1052,45 @@ export const schemaDict = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ComAtprotoAdminGetAccountInfos: {
|
||||||
|
lexicon: 1,
|
||||||
|
id: 'com.atproto.admin.getAccountInfos',
|
||||||
|
defs: {
|
||||||
|
main: {
|
||||||
|
type: 'query',
|
||||||
|
description: 'Get details about some accounts.',
|
||||||
|
parameters: {
|
||||||
|
type: 'params',
|
||||||
|
required: ['dids'],
|
||||||
|
properties: {
|
||||||
|
dids: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'did',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
encoding: 'application/json',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['infos'],
|
||||||
|
properties: {
|
||||||
|
infos: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'ref',
|
||||||
|
ref: 'lex:com.atproto.admin.defs#accountView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ComAtprotoAdminGetInviteCodes: {
|
ComAtprotoAdminGetInviteCodes: {
|
||||||
lexicon: 1,
|
lexicon: 1,
|
||||||
id: 'com.atproto.admin.getInviteCodes',
|
id: 'com.atproto.admin.getInviteCodes',
|
||||||
@ -7875,6 +7920,7 @@ export const ids = {
|
|||||||
ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent',
|
ComAtprotoAdminEmitModerationEvent: 'com.atproto.admin.emitModerationEvent',
|
||||||
ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites',
|
ComAtprotoAdminEnableAccountInvites: 'com.atproto.admin.enableAccountInvites',
|
||||||
ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo',
|
ComAtprotoAdminGetAccountInfo: 'com.atproto.admin.getAccountInfo',
|
||||||
|
ComAtprotoAdminGetAccountInfos: 'com.atproto.admin.getAccountInfos',
|
||||||
ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes',
|
ComAtprotoAdminGetInviteCodes: 'com.atproto.admin.getInviteCodes',
|
||||||
ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent',
|
ComAtprotoAdminGetModerationEvent: 'com.atproto.admin.getModerationEvent',
|
||||||
ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord',
|
ComAtprotoAdminGetRecord: 'com.atproto.admin.getRecord',
|
||||||
|
@ -255,6 +255,7 @@ export interface AccountView {
|
|||||||
did: string
|
did: string
|
||||||
handle: string
|
handle: string
|
||||||
email?: string
|
email?: string
|
||||||
|
relatedRecords?: {}[]
|
||||||
indexedAt: string
|
indexedAt: string
|
||||||
invitedBy?: ComAtprotoServerDefs.InviteCode
|
invitedBy?: ComAtprotoServerDefs.InviteCode
|
||||||
invites?: ComAtprotoServerDefs.InviteCode[]
|
invites?: ComAtprotoServerDefs.InviteCode[]
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
dids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InputSchema = undefined
|
||||||
|
|
||||||
|
export interface OutputSchema {
|
||||||
|
infos: ComAtprotoAdminDefs.AccountView[]
|
||||||
|
[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<HA extends HandlerAuth = never> = {
|
||||||
|
auth: HA
|
||||||
|
params: QueryParams
|
||||||
|
input: HandlerInput
|
||||||
|
req: express.Request
|
||||||
|
res: express.Response
|
||||||
|
}
|
||||||
|
export type Handler<HA extends HandlerAuth = never> = (
|
||||||
|
ctx: HandlerReqCtx<HA>,
|
||||||
|
) => Promise<HandlerOutput> | HandlerOutput
|
@ -1,414 +0,0 @@
|
|||||||
import { sql } from 'kysely'
|
|
||||||
import { DatabaseCoordinator, PrimaryDatabase } from './index'
|
|
||||||
import { adjustModerationSubjectStatus } from './services/moderation/status'
|
|
||||||
import { ModerationEventRow } from './services/moderation/types'
|
|
||||||
|
|
||||||
type ModerationActionRow = Omit<ModerationEventRow, 'comment' | 'meta'> & {
|
|
||||||
reason: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEnv = () => ({
|
|
||||||
DB_URL:
|
|
||||||
process.env.MODERATION_MIGRATION_DB_URL ||
|
|
||||||
'postgresql://pg:password@127.0.0.1:5433/postgres',
|
|
||||||
DB_POOL_SIZE: Number(process.env.MODERATION_MIGRATION_DB_POOL_SIZE) || 10,
|
|
||||||
DB_SCHEMA: process.env.MODERATION_MIGRATION_DB_SCHEMA || 'bsky',
|
|
||||||
})
|
|
||||||
|
|
||||||
const countEntries = async (db: PrimaryDatabase) => {
|
|
||||||
const [allActions, allReports] = await Promise.all([
|
|
||||||
db.db
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_action')
|
|
||||||
// @ts-ignore
|
|
||||||
.select((eb) => eb.fn.count<number>('id').as('count'))
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
db.db
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_report')
|
|
||||||
// @ts-ignore
|
|
||||||
.select((eb) => eb.fn.count<number>('id').as('count'))
|
|
||||||
.executeTakeFirstOrThrow(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return { reportsCount: allReports.count, actionsCount: allActions.count }
|
|
||||||
}
|
|
||||||
|
|
||||||
const countEvents = async (db: PrimaryDatabase) => {
|
|
||||||
const events = await db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.select((eb) => eb.fn.count<number>('id').as('count'))
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
return events.count
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLatestReportLegacyRefId = async (db: PrimaryDatabase) => {
|
|
||||||
const events = await db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.select((eb) => eb.fn.max('legacyRefId').as('latestLegacyRefId'))
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
return events.latestLegacyRefId
|
|
||||||
}
|
|
||||||
|
|
||||||
const countStatuses = async (db: PrimaryDatabase) => {
|
|
||||||
const events = await db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.select((eb) => eb.fn.count<number>('id').as('count'))
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
return events.count
|
|
||||||
}
|
|
||||||
|
|
||||||
const processLegacyReports = async (
|
|
||||||
db: PrimaryDatabase,
|
|
||||||
legacyIds: number[],
|
|
||||||
) => {
|
|
||||||
if (!legacyIds.length) {
|
|
||||||
console.log('No legacy reports to process')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const reports = await db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
|
||||||
.where('legacyRefId', 'in', legacyIds)
|
|
||||||
.orderBy('legacyRefId', 'asc')
|
|
||||||
.selectAll()
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
console.log(`Processing ${reports.length} reports from ${legacyIds.length}`)
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// This will be slow but we need to run this in sequence
|
|
||||||
for (const report of reports) {
|
|
||||||
await adjustModerationSubjectStatus(tx, report)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log(`Completed processing ${reports.length} reports`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReportEventsAboveLegacyId = async (
|
|
||||||
db: PrimaryDatabase,
|
|
||||||
aboveLegacyId: number,
|
|
||||||
) => {
|
|
||||||
return await db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
|
||||||
.where('legacyRefId', '>', aboveLegacyId)
|
|
||||||
.select(sql<number>`"legacyRefId"`.as('legacyRefId'))
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEvents = async (
|
|
||||||
db: PrimaryDatabase,
|
|
||||||
opts?: { onlyReportsAboveId: number },
|
|
||||||
) => {
|
|
||||||
const commonColumnsToSelect = [
|
|
||||||
'subjectDid',
|
|
||||||
'subjectUri',
|
|
||||||
'subjectType',
|
|
||||||
'subjectCid',
|
|
||||||
sql`reason`.as('comment'),
|
|
||||||
'createdAt',
|
|
||||||
]
|
|
||||||
const commonColumnsToInsert = [
|
|
||||||
'subjectDid',
|
|
||||||
'subjectUri',
|
|
||||||
'subjectType',
|
|
||||||
'subjectCid',
|
|
||||||
'comment',
|
|
||||||
'createdAt',
|
|
||||||
'action',
|
|
||||||
'createdBy',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
let totalActions: number
|
|
||||||
if (!opts?.onlyReportsAboveId) {
|
|
||||||
await db.db
|
|
||||||
.insertInto('moderation_event')
|
|
||||||
.columns([
|
|
||||||
'id',
|
|
||||||
...commonColumnsToInsert,
|
|
||||||
'createLabelVals',
|
|
||||||
'negateLabelVals',
|
|
||||||
'durationInHours',
|
|
||||||
'expiresAt',
|
|
||||||
])
|
|
||||||
.expression((eb) =>
|
|
||||||
eb
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_action')
|
|
||||||
// @ts-ignore
|
|
||||||
.select([
|
|
||||||
'id',
|
|
||||||
...commonColumnsToSelect,
|
|
||||||
sql`CONCAT('com.atproto.admin.defs#modEvent', UPPER(SUBSTRING(SPLIT_PART(action, '#', 2) FROM 1 FOR 1)), SUBSTRING(SPLIT_PART(action, '#', 2) FROM 2))`.as(
|
|
||||||
'action',
|
|
||||||
),
|
|
||||||
'createdBy',
|
|
||||||
'createLabelVals',
|
|
||||||
'negateLabelVals',
|
|
||||||
'durationInHours',
|
|
||||||
'expiresAt',
|
|
||||||
])
|
|
||||||
.orderBy('id', 'asc'),
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
totalActions = await countEvents(db)
|
|
||||||
console.log(`Created ${totalActions} events from actions`)
|
|
||||||
|
|
||||||
await sql`SELECT setval(pg_get_serial_sequence('moderation_event', 'id'), (select max(id) from moderation_event))`.execute(
|
|
||||||
db.db,
|
|
||||||
)
|
|
||||||
console.log('Reset the id sequence for moderation_event')
|
|
||||||
} else {
|
|
||||||
totalActions = await countEvents(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.db
|
|
||||||
.insertInto('moderation_event')
|
|
||||||
.columns([...commonColumnsToInsert, 'meta', 'legacyRefId'])
|
|
||||||
.expression((eb) => {
|
|
||||||
const builder = eb
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_report')
|
|
||||||
// @ts-ignore
|
|
||||||
.select([
|
|
||||||
...commonColumnsToSelect,
|
|
||||||
sql`'com.atproto.admin.defs#modEventReport'`.as('action'),
|
|
||||||
sql`"reportedByDid"`.as('createdBy'),
|
|
||||||
sql`json_build_object('reportType', "reasonType")`.as('meta'),
|
|
||||||
sql`id`.as('legacyRefId'),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (opts?.onlyReportsAboveId) {
|
|
||||||
// @ts-ignore
|
|
||||||
return builder.where('id', '>', opts.onlyReportsAboveId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder
|
|
||||||
})
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
const totalEvents = await countEvents(db)
|
|
||||||
console.log(`Created ${totalEvents - totalActions} events from reports`)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const setReportedAtTimestamp = async (db: PrimaryDatabase) => {
|
|
||||||
console.log('Initiating lastReportedAt timestamp sync')
|
|
||||||
const didUpdate = await sql`
|
|
||||||
UPDATE moderation_subject_status
|
|
||||||
SET "lastReportedAt" = reports."createdAt"
|
|
||||||
FROM (
|
|
||||||
select "subjectDid", "subjectUri", MAX("createdAt") as "createdAt"
|
|
||||||
from moderation_report
|
|
||||||
where "subjectUri" is null
|
|
||||||
group by "subjectDid", "subjectUri"
|
|
||||||
) as reports
|
|
||||||
WHERE reports."subjectDid" = moderation_subject_status."did"
|
|
||||||
AND "recordPath" = ''
|
|
||||||
AND ("lastReportedAt" is null OR "lastReportedAt" < reports."createdAt")
|
|
||||||
`.execute(db.db)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Updated lastReportedAt for ${didUpdate.numUpdatedOrDeletedRows} did subject`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const contentUpdate = await sql`
|
|
||||||
UPDATE moderation_subject_status
|
|
||||||
SET "lastReportedAt" = reports."createdAt"
|
|
||||||
FROM (
|
|
||||||
select "subjectDid", "subjectUri", MAX("createdAt") as "createdAt"
|
|
||||||
from moderation_report
|
|
||||||
where "subjectUri" is not null
|
|
||||||
group by "subjectDid", "subjectUri"
|
|
||||||
) as reports
|
|
||||||
WHERE reports."subjectDid" = moderation_subject_status."did"
|
|
||||||
AND "recordPath" is not null
|
|
||||||
AND POSITION(moderation_subject_status."recordPath" IN reports."subjectUri") > 0
|
|
||||||
AND ("lastReportedAt" is null OR "lastReportedAt" < reports."createdAt")
|
|
||||||
`.execute(db.db)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Updated lastReportedAt for ${contentUpdate.numUpdatedOrDeletedRows} subject with uri`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createStatusFromActions = async (db: PrimaryDatabase) => {
|
|
||||||
const allEvents = await db.db
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_action')
|
|
||||||
// @ts-ignore
|
|
||||||
.where('reversedAt', 'is', null)
|
|
||||||
// @ts-ignore
|
|
||||||
.select((eb) => eb.fn.count<number>('id').as('count'))
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
const chunkSize = 2500
|
|
||||||
const totalChunks = Math.ceil(allEvents.count / chunkSize)
|
|
||||||
|
|
||||||
console.log(`Processing ${allEvents.count} actions in ${totalChunks} chunks`)
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// This is not used for pagination but only for logging purposes
|
|
||||||
let currentChunk = 1
|
|
||||||
let lastProcessedId: undefined | number = 0
|
|
||||||
do {
|
|
||||||
const eventsQuery = tx.db
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_action')
|
|
||||||
// @ts-ignore
|
|
||||||
.where('reversedAt', 'is', null)
|
|
||||||
// @ts-ignore
|
|
||||||
.where('id', '>', lastProcessedId)
|
|
||||||
.limit(chunkSize)
|
|
||||||
.selectAll()
|
|
||||||
const events = (await eventsQuery.execute()) as ModerationActionRow[]
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
// Remap action to event data type
|
|
||||||
const actionParts = event.action.split('#')
|
|
||||||
await adjustModerationSubjectStatus(tx, {
|
|
||||||
...event,
|
|
||||||
action: `com.atproto.admin.defs#modEvent${actionParts[1]
|
|
||||||
.charAt(0)
|
|
||||||
.toUpperCase()}${actionParts[1].slice(
|
|
||||||
1,
|
|
||||||
)}` as ModerationEventRow['action'],
|
|
||||||
comment: event.reason,
|
|
||||||
meta: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Processed events chunk ${currentChunk} of ${totalChunks}`)
|
|
||||||
lastProcessedId = events.at(-1)?.id
|
|
||||||
currentChunk++
|
|
||||||
} while (lastProcessedId !== undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Events migration complete!`)
|
|
||||||
|
|
||||||
const totalStatuses = await countStatuses(db)
|
|
||||||
console.log(`Created ${totalStatuses} statuses`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remapFlagToAcknlowedge = async (db: PrimaryDatabase) => {
|
|
||||||
console.log('Initiating flag to ack remap')
|
|
||||||
const results = await sql`
|
|
||||||
UPDATE moderation_event
|
|
||||||
SET "action" = 'com.atproto.admin.defs#modEventAcknowledge'
|
|
||||||
WHERE action = 'com.atproto.admin.defs#modEventFlag'
|
|
||||||
`.execute(db.db)
|
|
||||||
console.log(`Remapped ${results.numUpdatedOrDeletedRows} flag actions to ack`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncBlobCids = async (db: PrimaryDatabase) => {
|
|
||||||
console.log('Initiating blob cid sync')
|
|
||||||
const results = await sql`
|
|
||||||
UPDATE moderation_subject_status
|
|
||||||
SET "blobCids" = blob_action."cids"
|
|
||||||
FROM (
|
|
||||||
SELECT moderation_action."subjectUri", moderation_action."subjectDid", jsonb_agg(moderation_action_subject_blob."cid") as cids
|
|
||||||
FROM moderation_action_subject_blob
|
|
||||||
JOIN moderation_action
|
|
||||||
ON moderation_action.id = moderation_action_subject_blob."actionId"
|
|
||||||
WHERE moderation_action."reversedAt" is NULL
|
|
||||||
GROUP by moderation_action."subjectUri", moderation_action."subjectDid"
|
|
||||||
) as blob_action
|
|
||||||
WHERE did = "subjectDid" AND position("recordPath" IN "subjectUri") > 0
|
|
||||||
`.execute(db.db)
|
|
||||||
console.log(`Updated blob cids on ${results.numUpdatedOrDeletedRows} rows`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStatusFromUnresolvedReports(db: PrimaryDatabase) {
|
|
||||||
const { ref } = db.db.dynamic
|
|
||||||
const reports = await db.db
|
|
||||||
// @ts-ignore
|
|
||||||
.selectFrom('moderation_report')
|
|
||||||
.whereNotExists((qb) =>
|
|
||||||
qb
|
|
||||||
.selectFrom('moderation_report_resolution')
|
|
||||||
.selectAll()
|
|
||||||
// @ts-ignore
|
|
||||||
.whereRef('reportId', '=', ref('moderation_report.id')),
|
|
||||||
)
|
|
||||||
.select(sql<number>`moderation_report.id`.as('legacyId'))
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
console.log('Updating statuses based on unresolved reports')
|
|
||||||
await processLegacyReports(
|
|
||||||
db,
|
|
||||||
reports.map((report) => report.legacyId),
|
|
||||||
)
|
|
||||||
console.log('Completed updating statuses based on unresolved reports')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function MigrateModerationData() {
|
|
||||||
const env = getEnv()
|
|
||||||
const db = new DatabaseCoordinator({
|
|
||||||
schema: env.DB_SCHEMA,
|
|
||||||
primary: {
|
|
||||||
url: env.DB_URL,
|
|
||||||
poolSize: env.DB_POOL_SIZE,
|
|
||||||
},
|
|
||||||
replicas: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const primaryDb = db.getPrimary()
|
|
||||||
|
|
||||||
const [counts, existingEventsCount] = await Promise.all([
|
|
||||||
countEntries(primaryDb),
|
|
||||||
countEvents(primaryDb),
|
|
||||||
])
|
|
||||||
|
|
||||||
// If there are existing events in the moderation_event table, we assume that the migration has already been run
|
|
||||||
// so we just bring over any new reports since last run
|
|
||||||
if (existingEventsCount) {
|
|
||||||
console.log(
|
|
||||||
`Found ${existingEventsCount} existing events. Migrating ${counts.reportsCount} reports only, ignoring actions`,
|
|
||||||
)
|
|
||||||
const reportMigrationStartedAt = Date.now()
|
|
||||||
const latestReportLegacyRefId = await getLatestReportLegacyRefId(primaryDb)
|
|
||||||
|
|
||||||
if (latestReportLegacyRefId) {
|
|
||||||
await createEvents(primaryDb, {
|
|
||||||
onlyReportsAboveId: latestReportLegacyRefId,
|
|
||||||
})
|
|
||||||
const newReportEvents = await getReportEventsAboveLegacyId(
|
|
||||||
primaryDb,
|
|
||||||
latestReportLegacyRefId,
|
|
||||||
)
|
|
||||||
await processLegacyReports(
|
|
||||||
primaryDb,
|
|
||||||
newReportEvents.map((evt) => evt.legacyRefId),
|
|
||||||
)
|
|
||||||
await setReportedAtTimestamp(primaryDb)
|
|
||||||
} else {
|
|
||||||
console.log('No reports have been migrated into events yet, bailing.')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Time spent: ${(Date.now() - reportMigrationStartedAt) / 1000} seconds`,
|
|
||||||
)
|
|
||||||
console.log('Migration complete!')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalEntries = counts.actionsCount + counts.reportsCount
|
|
||||||
console.log(`Migrating ${totalEntries} rows of actions and reports`)
|
|
||||||
const startedAt = Date.now()
|
|
||||||
await createEvents(primaryDb)
|
|
||||||
// Important to run this before creation statuses from actions to ensure that we are not attempting to map flag actions
|
|
||||||
await remapFlagToAcknlowedge(primaryDb)
|
|
||||||
await createStatusFromActions(primaryDb)
|
|
||||||
await updateStatusFromUnresolvedReports(primaryDb)
|
|
||||||
await setReportedAtTimestamp(primaryDb)
|
|
||||||
await syncBlobCids(primaryDb)
|
|
||||||
|
|
||||||
console.log(`Time spent: ${(Date.now() - startedAt) / 1000 / 60} minutes`)
|
|
||||||
console.log('Migration complete!')
|
|
||||||
}
|
|
@ -10,6 +10,8 @@ import { SearchKeyset, getUserSearchQuery } from '../util/search'
|
|||||||
import { FromDb } from '../types'
|
import { FromDb } from '../types'
|
||||||
import { GraphService } from '../graph'
|
import { GraphService } from '../graph'
|
||||||
import { LabelService } from '../label'
|
import { LabelService } from '../label'
|
||||||
|
import { AtUri } from '@atproto/syntax'
|
||||||
|
import { ids } from '../../lexicon/lexicons'
|
||||||
|
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
@ -96,6 +98,26 @@ export class ActorService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProfileRecords(dids: string[], includeSoftDeleted = false) {
|
||||||
|
if (dids.length === 0) return new Map()
|
||||||
|
const profileUris = dids.map((did) =>
|
||||||
|
AtUri.make(did, ids.AppBskyActorProfile, 'self').toString(),
|
||||||
|
)
|
||||||
|
const { ref } = this.db.db.dynamic
|
||||||
|
const res = await this.db.db
|
||||||
|
.selectFrom('record')
|
||||||
|
.innerJoin('actor', 'actor.did', 'record.did')
|
||||||
|
.if(!includeSoftDeleted, (qb) =>
|
||||||
|
qb.where(notSoftDeletedClause(ref('actor'))),
|
||||||
|
)
|
||||||
|
.where('uri', 'in', profileUris)
|
||||||
|
.select(['record.did', 'record.json'])
|
||||||
|
.execute()
|
||||||
|
return res.reduce((acc, cur) => {
|
||||||
|
return acc.set(cur.did, JSON.parse(cur.json))
|
||||||
|
}, new Map<string, JSON>())
|
||||||
|
}
|
||||||
|
|
||||||
async getSearchResults({
|
async getSearchResults({
|
||||||
cursor,
|
cursor,
|
||||||
limit = 25,
|
limit = 25,
|
||||||
|
@ -1,37 +1,9 @@
|
|||||||
import { CID } from 'multiformats/cid'
|
import { CID } from 'multiformats/cid'
|
||||||
import { AtUri } from '@atproto/syntax'
|
import { AtUri } from '@atproto/syntax'
|
||||||
import { InvalidRequestError } from '@atproto/xrpc-server'
|
|
||||||
import { PrimaryDatabase } from '../../db'
|
import { PrimaryDatabase } from '../../db'
|
||||||
import { ModerationViews } from './views'
|
|
||||||
import { ImageUriBuilder } from '../../image/uri'
|
import { ImageUriBuilder } from '../../image/uri'
|
||||||
import { Main as StrongRef } from '../../lexicon/types/com/atproto/repo/strongRef'
|
|
||||||
import { ImageInvalidator } from '../../image/invalidator'
|
import { ImageInvalidator } from '../../image/invalidator'
|
||||||
import {
|
import { StatusAttr } from '../../lexicon/types/com/atproto/admin/defs'
|
||||||
isModEventComment,
|
|
||||||
isModEventLabel,
|
|
||||||
isModEventMute,
|
|
||||||
isModEventReport,
|
|
||||||
isModEventTakedown,
|
|
||||||
isModEventEmail,
|
|
||||||
RepoRef,
|
|
||||||
RepoBlobRef,
|
|
||||||
} from '../../lexicon/types/com/atproto/admin/defs'
|
|
||||||
import { addHoursToDate } from '../../util/date'
|
|
||||||
import {
|
|
||||||
adjustModerationSubjectStatus,
|
|
||||||
getStatusIdentifierFromSubject,
|
|
||||||
} from './status'
|
|
||||||
import {
|
|
||||||
ModEventType,
|
|
||||||
ModerationEventRow,
|
|
||||||
ModerationEventRowWithHandle,
|
|
||||||
ModerationSubjectStatusRow,
|
|
||||||
ReversibleModerationEvent,
|
|
||||||
SubjectInfo,
|
|
||||||
} from './types'
|
|
||||||
import { ModerationEvent } from '../../db/tables/moderation'
|
|
||||||
import { paginate } from '../../db/pagination'
|
|
||||||
import { StatusKeyset, TimeIdKeyset } from './pagination'
|
|
||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -48,630 +20,99 @@ export class ModerationService {
|
|||||||
new ModerationService(db, imgUriBuilder, imgInvalidator)
|
new ModerationService(db, imgUriBuilder, imgInvalidator)
|
||||||
}
|
}
|
||||||
|
|
||||||
views = new ModerationViews(this.db)
|
async takedownRepo(info: { takedownRef: string; did: string }) {
|
||||||
|
const { takedownRef, did } = info
|
||||||
async getEvent(id: number): Promise<ModerationEventRow | undefined> {
|
|
||||||
return await this.db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', id)
|
|
||||||
.executeTakeFirst()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEventOrThrow(id: number): Promise<ModerationEventRow> {
|
|
||||||
const event = await this.getEvent(id)
|
|
||||||
if (!event) throw new InvalidRequestError('Moderation event not found')
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEvents(opts: {
|
|
||||||
subject?: string
|
|
||||||
createdBy?: string
|
|
||||||
limit: number
|
|
||||||
cursor?: string
|
|
||||||
includeAllUserRecords: boolean
|
|
||||||
types: ModerationEvent['action'][]
|
|
||||||
sortDirection?: 'asc' | 'desc'
|
|
||||||
}): Promise<{ cursor?: string; events: ModerationEventRowWithHandle[] }> {
|
|
||||||
const {
|
|
||||||
subject,
|
|
||||||
createdBy,
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
includeAllUserRecords,
|
|
||||||
sortDirection = 'desc',
|
|
||||||
types,
|
|
||||||
} = opts
|
|
||||||
let builder = this.db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.leftJoin(
|
|
||||||
'actor as creatorActor',
|
|
||||||
'creatorActor.did',
|
|
||||||
'moderation_event.createdBy',
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
'actor as subjectActor',
|
|
||||||
'subjectActor.did',
|
|
||||||
'moderation_event.subjectDid',
|
|
||||||
)
|
|
||||||
if (subject) {
|
|
||||||
builder = builder.where((qb) => {
|
|
||||||
if (includeAllUserRecords) {
|
|
||||||
// If subject is an at-uri, we need to extract the DID from the at-uri
|
|
||||||
// otherwise, subject is probably a DID already
|
|
||||||
if (subject.startsWith('at://')) {
|
|
||||||
const uri = new AtUri(subject)
|
|
||||||
return qb.where('subjectDid', '=', uri.hostname)
|
|
||||||
}
|
|
||||||
return qb.where('subjectDid', '=', subject)
|
|
||||||
}
|
|
||||||
return qb
|
|
||||||
.where((subQb) =>
|
|
||||||
subQb
|
|
||||||
.where('subjectDid', '=', subject)
|
|
||||||
.where('subjectUri', 'is', null),
|
|
||||||
)
|
|
||||||
.orWhere('subjectUri', '=', subject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (types.length) {
|
|
||||||
builder = builder.where((qb) => {
|
|
||||||
if (types.length === 1) {
|
|
||||||
return qb.where('action', '=', types[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return qb.where('action', 'in', types)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (createdBy) {
|
|
||||||
builder = builder.where('createdBy', '=', createdBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ref } = this.db.db.dynamic
|
|
||||||
const keyset = new TimeIdKeyset(
|
|
||||||
ref(`moderation_event.createdAt`),
|
|
||||||
ref('moderation_event.id'),
|
|
||||||
)
|
|
||||||
const paginatedBuilder = paginate(builder, {
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
keyset,
|
|
||||||
direction: sortDirection,
|
|
||||||
tryIndex: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await paginatedBuilder
|
|
||||||
.selectAll(['moderation_event'])
|
|
||||||
.select([
|
|
||||||
'subjectActor.handle as subjectHandle',
|
|
||||||
'creatorActor.handle as creatorHandle',
|
|
||||||
])
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
return { cursor: keyset.packFromResult(result), events: result }
|
|
||||||
}
|
|
||||||
|
|
||||||
async getReport(id: number): Promise<ModerationEventRow | undefined> {
|
|
||||||
return await this.db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', id)
|
|
||||||
.executeTakeFirst()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCurrentStatus(
|
|
||||||
subject: { did: string } | { uri: AtUri } | { cids: CID[] },
|
|
||||||
) {
|
|
||||||
let builder = this.db.db.selectFrom('moderation_subject_status').selectAll()
|
|
||||||
if ('did' in subject) {
|
|
||||||
builder = builder.where('did', '=', subject.did)
|
|
||||||
} else if ('uri' in subject) {
|
|
||||||
builder = builder.where('recordPath', '=', subject.uri.toString())
|
|
||||||
}
|
|
||||||
// TODO: Handle the cid status
|
|
||||||
return await builder.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildSubjectInfo(
|
|
||||||
subject: { did: string } | { uri: AtUri; cid: CID },
|
|
||||||
subjectBlobCids?: CID[],
|
|
||||||
): SubjectInfo {
|
|
||||||
if ('did' in subject) {
|
|
||||||
if (subjectBlobCids?.length) {
|
|
||||||
throw new InvalidRequestError('Blobs do not apply to repo subjects')
|
|
||||||
}
|
|
||||||
// Allowing dids that may not exist: may have been deleted but needs to remain actionable.
|
|
||||||
return {
|
|
||||||
subjectType: 'com.atproto.admin.defs#repoRef',
|
|
||||||
subjectDid: subject.did,
|
|
||||||
subjectUri: null,
|
|
||||||
subjectCid: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowing records/blobs that may not exist: may have been deleted but needs to remain actionable.
|
|
||||||
return {
|
|
||||||
subjectType: 'com.atproto.repo.strongRef',
|
|
||||||
subjectDid: subject.uri.host,
|
|
||||||
subjectUri: subject.uri.toString(),
|
|
||||||
subjectCid: subject.cid.toString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async logEvent(info: {
|
|
||||||
event: ModEventType
|
|
||||||
subject: { did: string } | { uri: AtUri; cid: CID }
|
|
||||||
subjectBlobCids?: CID[]
|
|
||||||
createdBy: string
|
|
||||||
createdAt?: Date
|
|
||||||
}): Promise<ModerationEventRow> {
|
|
||||||
this.db.assertTransaction()
|
|
||||||
const {
|
|
||||||
event,
|
|
||||||
createdBy,
|
|
||||||
subject,
|
|
||||||
subjectBlobCids,
|
|
||||||
createdAt = new Date(),
|
|
||||||
} = info
|
|
||||||
|
|
||||||
// Resolve subject info
|
|
||||||
const subjectInfo = this.buildSubjectInfo(subject, subjectBlobCids)
|
|
||||||
|
|
||||||
const createLabelVals =
|
|
||||||
isModEventLabel(event) && event.createLabelVals.length > 0
|
|
||||||
? event.createLabelVals.join(' ')
|
|
||||||
: undefined
|
|
||||||
const negateLabelVals =
|
|
||||||
isModEventLabel(event) && event.negateLabelVals.length > 0
|
|
||||||
? event.negateLabelVals.join(' ')
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const meta: Record<string, string | boolean> = {}
|
|
||||||
|
|
||||||
if (isModEventReport(event)) {
|
|
||||||
meta.reportType = event.reportType
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isModEventComment(event) && event.sticky) {
|
|
||||||
meta.sticky = event.sticky
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isModEventEmail(event)) {
|
|
||||||
meta.subjectLine = event.subjectLine
|
|
||||||
}
|
|
||||||
|
|
||||||
const modEvent = await this.db.db
|
|
||||||
.insertInto('moderation_event')
|
|
||||||
.values({
|
|
||||||
comment: event.comment ? `${event.comment}` : null,
|
|
||||||
action: event.$type as ModerationEvent['action'],
|
|
||||||
createdAt: createdAt.toISOString(),
|
|
||||||
createdBy,
|
|
||||||
createLabelVals,
|
|
||||||
negateLabelVals,
|
|
||||||
durationInHours: event.durationInHours
|
|
||||||
? Number(event.durationInHours)
|
|
||||||
: null,
|
|
||||||
meta,
|
|
||||||
expiresAt:
|
|
||||||
(isModEventTakedown(event) || isModEventMute(event)) &&
|
|
||||||
event.durationInHours
|
|
||||||
? addHoursToDate(event.durationInHours, createdAt).toISOString()
|
|
||||||
: undefined,
|
|
||||||
...subjectInfo,
|
|
||||||
})
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
await adjustModerationSubjectStatus(this.db, modEvent, subjectBlobCids)
|
|
||||||
|
|
||||||
return modEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLastReversibleEventForSubject({
|
|
||||||
did,
|
|
||||||
muteUntil,
|
|
||||||
recordPath,
|
|
||||||
suspendUntil,
|
|
||||||
}: ModerationSubjectStatusRow) {
|
|
||||||
const isSuspended = suspendUntil && new Date(suspendUntil) < new Date()
|
|
||||||
const isMuted = muteUntil && new Date(muteUntil) < new Date()
|
|
||||||
|
|
||||||
// If the subject is neither suspended nor muted don't bother finding the last reversible event
|
|
||||||
// Ideally, this should never happen because the caller of this method should only call this
|
|
||||||
// after ensuring that the suspended or muted subjects are being reversed
|
|
||||||
if (!isSuspended && !isMuted) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let builder = this.db.db
|
|
||||||
.selectFrom('moderation_event')
|
|
||||||
.where('subjectDid', '=', did)
|
|
||||||
|
|
||||||
if (recordPath) {
|
|
||||||
builder = builder.where('subjectUri', 'like', `%${recordPath}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Means the subject was suspended and needs to be unsuspended
|
|
||||||
if (isSuspended) {
|
|
||||||
builder = builder
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
|
|
||||||
.where('durationInHours', 'is not', null)
|
|
||||||
}
|
|
||||||
if (isMuted) {
|
|
||||||
builder = builder
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventMute')
|
|
||||||
.where('durationInHours', 'is not', null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await builder
|
|
||||||
.orderBy('id', 'desc')
|
|
||||||
.selectAll()
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst()
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSubjectsDueForReversal(): Promise<ModerationSubjectStatusRow[]> {
|
|
||||||
const subjectsDueForReversal = await this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.where('suspendUntil', '<', new Date().toISOString())
|
|
||||||
.orWhere('muteUntil', '<', new Date().toISOString())
|
|
||||||
.selectAll()
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
return subjectsDueForReversal
|
|
||||||
}
|
|
||||||
|
|
||||||
async isSubjectSuspended(did: string): Promise<boolean> {
|
|
||||||
const res = await this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.where('did', '=', did)
|
|
||||||
.where('recordPath', '=', '')
|
|
||||||
.where('suspendUntil', '>', new Date().toISOString())
|
|
||||||
.select('did')
|
|
||||||
.limit(1)
|
|
||||||
.executeTakeFirst()
|
|
||||||
return !!res
|
|
||||||
}
|
|
||||||
|
|
||||||
async revertState({
|
|
||||||
createdBy,
|
|
||||||
createdAt,
|
|
||||||
comment,
|
|
||||||
action,
|
|
||||||
subject,
|
|
||||||
}: ReversibleModerationEvent): Promise<{
|
|
||||||
result: ModerationEventRow
|
|
||||||
restored?: TakedownSubjects
|
|
||||||
}> {
|
|
||||||
const isRevertingTakedown =
|
|
||||||
action === 'com.atproto.admin.defs#modEventTakedown'
|
|
||||||
this.db.assertTransaction()
|
|
||||||
const result = await this.logEvent({
|
|
||||||
event: {
|
|
||||||
$type: isRevertingTakedown
|
|
||||||
? 'com.atproto.admin.defs#modEventReverseTakedown'
|
|
||||||
: 'com.atproto.admin.defs#modEventUnmute',
|
|
||||||
comment: comment ?? undefined,
|
|
||||||
},
|
|
||||||
createdAt,
|
|
||||||
createdBy,
|
|
||||||
subject,
|
|
||||||
})
|
|
||||||
|
|
||||||
let restored: TakedownSubjects | undefined
|
|
||||||
|
|
||||||
if (!isRevertingTakedown) {
|
|
||||||
return { result, restored }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.subjectType === 'com.atproto.admin.defs#repoRef' &&
|
|
||||||
result.subjectDid
|
|
||||||
) {
|
|
||||||
await this.reverseTakedownRepo({
|
|
||||||
did: result.subjectDid,
|
|
||||||
})
|
|
||||||
restored = {
|
|
||||||
did: result.subjectDid,
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
$type: 'com.atproto.admin.defs#repoRef',
|
|
||||||
did: result.subjectDid,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.subjectType === 'com.atproto.repo.strongRef' &&
|
|
||||||
result.subjectUri
|
|
||||||
) {
|
|
||||||
const uri = new AtUri(result.subjectUri)
|
|
||||||
await this.reverseTakedownRecord({
|
|
||||||
uri,
|
|
||||||
})
|
|
||||||
const did = uri.hostname
|
|
||||||
// TODO: MOD_EVENT This bit needs testing
|
|
||||||
const subjectStatus = await this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.where('did', '=', uri.host)
|
|
||||||
.where('recordPath', '=', `${uri.collection}/${uri.rkey}`)
|
|
||||||
.select('blobCids')
|
|
||||||
.executeTakeFirst()
|
|
||||||
const blobCids = subjectStatus?.blobCids || []
|
|
||||||
restored = {
|
|
||||||
did,
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
$type: 'com.atproto.repo.strongRef',
|
|
||||||
uri: result.subjectUri,
|
|
||||||
cid: result.subjectCid ?? '',
|
|
||||||
},
|
|
||||||
...blobCids.map((cid) => ({
|
|
||||||
$type: 'com.atproto.admin.defs#repoBlobRef',
|
|
||||||
did,
|
|
||||||
cid,
|
|
||||||
recordUri: result.subjectUri,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { result, restored }
|
|
||||||
}
|
|
||||||
|
|
||||||
async takedownRepo(info: {
|
|
||||||
takedownId: number
|
|
||||||
did: string
|
|
||||||
}): Promise<TakedownSubjects> {
|
|
||||||
const { takedownId, did } = info
|
|
||||||
await this.db.db
|
await this.db.db
|
||||||
.updateTable('actor')
|
.updateTable('actor')
|
||||||
.set({ takedownId })
|
.set({ takedownRef })
|
||||||
.where('did', '=', did)
|
.where('did', '=', did)
|
||||||
.where('takedownId', 'is', null)
|
.where('takedownRef', 'is', null)
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
|
|
||||||
return {
|
|
||||||
did,
|
|
||||||
subjects: [
|
|
||||||
{
|
|
||||||
$type: 'com.atproto.admin.defs#repoRef',
|
|
||||||
did,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseTakedownRepo(info: { did: string }) {
|
async reverseTakedownRepo(info: { did: string }) {
|
||||||
await this.db.db
|
await this.db.db
|
||||||
.updateTable('actor')
|
.updateTable('actor')
|
||||||
.set({ takedownId: null })
|
.set({ takedownRef: null })
|
||||||
.where('did', '=', info.did)
|
.where('did', '=', info.did)
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
async takedownRecord(info: {
|
async takedownRecord(info: { takedownRef: string; uri: AtUri; cid: CID }) {
|
||||||
takedownId: number
|
const { takedownRef, uri } = info
|
||||||
uri: AtUri
|
|
||||||
cid: CID
|
|
||||||
blobCids?: CID[]
|
|
||||||
}): Promise<TakedownSubjects> {
|
|
||||||
const { takedownId, uri, cid, blobCids } = info
|
|
||||||
const did = uri.hostname
|
|
||||||
this.db.assertTransaction()
|
|
||||||
await this.db.db
|
await this.db.db
|
||||||
.updateTable('record')
|
.updateTable('record')
|
||||||
.set({ takedownId })
|
.set({ takedownRef })
|
||||||
.where('uri', '=', uri.toString())
|
.where('uri', '=', uri.toString())
|
||||||
.where('takedownId', 'is', null)
|
.where('takedownRef', 'is', null)
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (blobCids) {
|
|
||||||
await Promise.all(
|
|
||||||
blobCids.map(async (cid) => {
|
|
||||||
const paths = ImageUriBuilder.presets.map((id) => {
|
|
||||||
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 }) {
|
async reverseTakedownRecord(info: { uri: AtUri }) {
|
||||||
this.db.assertTransaction()
|
|
||||||
await this.db.db
|
await this.db.db
|
||||||
.updateTable('record')
|
.updateTable('record')
|
||||||
.set({ takedownId: null })
|
.set({ takedownRef: null })
|
||||||
.where('uri', '=', info.uri.toString())
|
.where('uri', '=', info.uri.toString())
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
async report(info: {
|
async takedownBlob(info: { takedownRef: string; did: string; cid: string }) {
|
||||||
reasonType: NonNullable<ModerationEventRow['meta']>['reportType']
|
const { takedownRef, did, cid } = info
|
||||||
reason?: string
|
await this.db.db
|
||||||
subject: { did: string } | { uri: AtUri; cid: CID }
|
.insertInto('blob_takedown')
|
||||||
reportedBy: string
|
.values({ did, cid, takedownRef })
|
||||||
createdAt?: Date
|
.onConflict((oc) => oc.doNothing())
|
||||||
}): Promise<ModerationEventRow> {
|
|
||||||
const {
|
|
||||||
reasonType,
|
|
||||||
reason,
|
|
||||||
reportedBy,
|
|
||||||
createdAt = new Date(),
|
|
||||||
subject,
|
|
||||||
} = info
|
|
||||||
|
|
||||||
const event = await this.logEvent({
|
|
||||||
event: {
|
|
||||||
$type: 'com.atproto.admin.defs#modEventReport',
|
|
||||||
reportType: reasonType,
|
|
||||||
comment: reason,
|
|
||||||
},
|
|
||||||
createdBy: reportedBy,
|
|
||||||
subject,
|
|
||||||
createdAt,
|
|
||||||
})
|
|
||||||
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSubjectStatuses({
|
|
||||||
cursor,
|
|
||||||
limit = 50,
|
|
||||||
takendown,
|
|
||||||
appealed,
|
|
||||||
reviewState,
|
|
||||||
reviewedAfter,
|
|
||||||
reviewedBefore,
|
|
||||||
reportedAfter,
|
|
||||||
reportedBefore,
|
|
||||||
includeMuted,
|
|
||||||
ignoreSubjects,
|
|
||||||
sortDirection,
|
|
||||||
lastReviewedBy,
|
|
||||||
sortField,
|
|
||||||
subject,
|
|
||||||
}: {
|
|
||||||
cursor?: string
|
|
||||||
limit?: number
|
|
||||||
takendown?: boolean
|
|
||||||
appealed?: boolean | null
|
|
||||||
reviewedBefore?: string
|
|
||||||
reviewState?: ModerationSubjectStatusRow['reviewState']
|
|
||||||
reviewedAfter?: string
|
|
||||||
reportedAfter?: string
|
|
||||||
reportedBefore?: string
|
|
||||||
includeMuted?: boolean
|
|
||||||
subject?: string
|
|
||||||
ignoreSubjects?: string[]
|
|
||||||
sortDirection: 'asc' | 'desc'
|
|
||||||
lastReviewedBy?: string
|
|
||||||
sortField: 'lastReviewedAt' | 'lastReportedAt'
|
|
||||||
}) {
|
|
||||||
let builder = this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.leftJoin('actor', 'actor.did', 'moderation_subject_status.did')
|
|
||||||
|
|
||||||
if (subject) {
|
|
||||||
const subjectInfo = getStatusIdentifierFromSubject(subject)
|
|
||||||
builder = builder
|
|
||||||
.where('moderation_subject_status.did', '=', subjectInfo.did)
|
|
||||||
.where((qb) =>
|
|
||||||
subjectInfo.recordPath
|
|
||||||
? qb.where('recordPath', '=', subjectInfo.recordPath)
|
|
||||||
: qb.where('recordPath', '=', ''),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ignoreSubjects?.length) {
|
|
||||||
builder = builder
|
|
||||||
.where('moderation_subject_status.did', 'not in', ignoreSubjects)
|
|
||||||
.where('recordPath', 'not in', ignoreSubjects)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviewState) {
|
|
||||||
builder = builder.where('reviewState', '=', reviewState)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastReviewedBy) {
|
|
||||||
builder = builder.where('lastReviewedBy', '=', lastReviewedBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviewedAfter) {
|
|
||||||
builder = builder.where('lastReviewedAt', '>', reviewedAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reviewedBefore) {
|
|
||||||
builder = builder.where('lastReviewedAt', '<', reviewedBefore)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportedAfter) {
|
|
||||||
builder = builder.where('lastReviewedAt', '>', reportedAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportedBefore) {
|
|
||||||
builder = builder.where('lastReportedAt', '<', reportedBefore)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (takendown) {
|
|
||||||
builder = builder.where('takendown', '=', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appealed !== undefined) {
|
|
||||||
builder =
|
|
||||||
appealed === null
|
|
||||||
? builder.where('appealed', 'is', null)
|
|
||||||
: builder.where('appealed', '=', appealed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeMuted) {
|
|
||||||
builder = builder.where((qb) =>
|
|
||||||
qb
|
|
||||||
.where('muteUntil', '<', new Date().toISOString())
|
|
||||||
.orWhere('muteUntil', 'is', null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ref } = this.db.db.dynamic
|
|
||||||
const keyset = new StatusKeyset(
|
|
||||||
ref(`moderation_subject_status.${sortField}`),
|
|
||||||
ref('moderation_subject_status.id'),
|
|
||||||
)
|
|
||||||
const paginatedBuilder = paginate(builder, {
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
keyset,
|
|
||||||
direction: sortDirection,
|
|
||||||
tryIndex: true,
|
|
||||||
nullsLast: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await paginatedBuilder
|
|
||||||
.select('actor.handle as handle')
|
|
||||||
.selectAll('moderation_subject_status')
|
|
||||||
.execute()
|
.execute()
|
||||||
|
const paths = ImageUriBuilder.presets.map((id) => {
|
||||||
return { statuses: results, cursor: keyset.packFromResult(results) }
|
const imgUri = this.imgUriBuilder.getPresetUri(id, did, cid)
|
||||||
|
return imgUri.replace(this.imgUriBuilder.endpoint, '')
|
||||||
|
})
|
||||||
|
await this.imgInvalidator.invalidate(cid.toString(), paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
async isSubjectTakendown(
|
async reverseTakedownBlob(info: { did: string; cid: string }) {
|
||||||
subject: { did: string } | { uri: AtUri },
|
const { did, cid } = info
|
||||||
): Promise<boolean> {
|
await this.db.db
|
||||||
const { did, recordPath } = getStatusIdentifierFromSubject(
|
.deleteFrom('blob_takedown')
|
||||||
'did' in subject ? subject.did : subject.uri,
|
|
||||||
)
|
|
||||||
const builder = this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.where('did', '=', did)
|
.where('did', '=', did)
|
||||||
.where('recordPath', '=', recordPath || '')
|
.where('cid', '=', cid)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
const result = await builder.select('takendown').executeTakeFirst()
|
async getRepoTakedownRef(did: string): Promise<StatusAttr | null> {
|
||||||
|
const res = await this.db.db
|
||||||
|
.selectFrom('actor')
|
||||||
|
.where('did', '=', did)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst()
|
||||||
|
return res ? formatStatus(res.takedownRef) : null
|
||||||
|
}
|
||||||
|
|
||||||
return !!result?.takendown
|
async getRecordTakedownRef(uri: string): Promise<StatusAttr | null> {
|
||||||
|
const res = await this.db.db
|
||||||
|
.selectFrom('record')
|
||||||
|
.where('uri', '=', uri)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst()
|
||||||
|
return res ? formatStatus(res.takedownRef) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlobTakedownRef(
|
||||||
|
did: string,
|
||||||
|
cid: string,
|
||||||
|
): Promise<StatusAttr | null> {
|
||||||
|
const res = await this.db.db
|
||||||
|
.selectFrom('blob_takedown')
|
||||||
|
.where('did', '=', did)
|
||||||
|
.where('cid', '=', cid)
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirst()
|
||||||
|
// this table only tracks takedowns not all blobs
|
||||||
|
// so if no result is returned then the blob is not taken down (rather than not found)
|
||||||
|
return formatStatus(res?.takedownRef ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TakedownSubjects = {
|
const formatStatus = (ref: string | null): StatusAttr => {
|
||||||
did: string
|
return ref ? { applied: true, ref } : { applied: false }
|
||||||
subjects: (RepoRef | RepoBlobRef | StrongRef)[]
|
|
||||||
}
|
}
|
||||||
|
@ -1,96 +0,0 @@
|
|||||||
import { InvalidRequestError } from '@atproto/xrpc-server'
|
|
||||||
import { DynamicModule, sql } from 'kysely'
|
|
||||||
|
|
||||||
import { Cursor, GenericKeyset } from '../../db/pagination'
|
|
||||||
|
|
||||||
type StatusKeysetParam = {
|
|
||||||
lastReviewedAt: string | null
|
|
||||||
lastReportedAt: string | null
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StatusKeyset extends GenericKeyset<StatusKeysetParam, Cursor> {
|
|
||||||
labelResult(result: StatusKeysetParam): Cursor
|
|
||||||
labelResult(result: StatusKeysetParam) {
|
|
||||||
const primaryField = (
|
|
||||||
this.primary as ReturnType<DynamicModule['ref']>
|
|
||||||
).dynamicReference.includes('lastReviewedAt')
|
|
||||||
? 'lastReviewedAt'
|
|
||||||
: 'lastReportedAt'
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: result[primaryField]
|
|
||||||
? new Date(`${result[primaryField]}`).getTime().toString()
|
|
||||||
: '',
|
|
||||||
secondary: result.id.toString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
labeledResultToCursor(labeled: Cursor) {
|
|
||||||
return {
|
|
||||||
primary: labeled.primary,
|
|
||||||
secondary: labeled.secondary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursorToLabeledResult(cursor: Cursor) {
|
|
||||||
return {
|
|
||||||
primary: cursor.primary
|
|
||||||
? new Date(parseInt(cursor.primary, 10)).toISOString()
|
|
||||||
: '',
|
|
||||||
secondary: cursor.secondary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unpackCursor(cursorStr?: string): Cursor | undefined {
|
|
||||||
if (!cursorStr) return
|
|
||||||
const result = cursorStr.split('::')
|
|
||||||
const [primary, secondary, ...others] = result
|
|
||||||
if (!secondary || others.length > 0) {
|
|
||||||
throw new InvalidRequestError('Malformed cursor')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
primary,
|
|
||||||
secondary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// This is specifically built to handle nullable columns as primary sorting column
|
|
||||||
getSql(labeled?: Cursor, direction?: 'asc' | 'desc') {
|
|
||||||
if (labeled === undefined) return
|
|
||||||
if (direction === 'asc') {
|
|
||||||
return !labeled.primary
|
|
||||||
? sql`(${this.primary} IS NULL AND ${this.secondary} > ${labeled.secondary})`
|
|
||||||
: sql`((${this.primary}, ${this.secondary}) > (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`
|
|
||||||
} else {
|
|
||||||
return !labeled.primary
|
|
||||||
? sql`(${this.primary} IS NULL AND ${this.secondary} < ${labeled.secondary})`
|
|
||||||
: sql`((${this.primary}, ${this.secondary}) < (${labeled.primary}, ${labeled.secondary}) OR (${this.primary} is null))`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeIdKeysetParam = {
|
|
||||||
id: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
type TimeIdResult = TimeIdKeysetParam
|
|
||||||
|
|
||||||
export class TimeIdKeyset extends GenericKeyset<TimeIdKeysetParam, Cursor> {
|
|
||||||
labelResult(result: TimeIdResult): Cursor
|
|
||||||
labelResult(result: TimeIdResult) {
|
|
||||||
return { primary: result.createdAt, secondary: result.id.toString() }
|
|
||||||
}
|
|
||||||
labeledResultToCursor(labeled: Cursor) {
|
|
||||||
return {
|
|
||||||
primary: new Date(labeled.primary).getTime().toString(),
|
|
||||||
secondary: labeled.secondary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursorToLabeledResult(cursor: Cursor) {
|
|
||||||
const primaryDate = new Date(parseInt(cursor.primary, 10))
|
|
||||||
if (isNaN(primaryDate.getTime())) {
|
|
||||||
throw new InvalidRequestError('Malformed cursor')
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
primary: primaryDate.toISOString(),
|
|
||||||
secondary: cursor.secondary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,551 +0,0 @@
|
|||||||
import { sql } from 'kysely'
|
|
||||||
import { ArrayEl } from '@atproto/common'
|
|
||||||
import { AtUri } from '@atproto/syntax'
|
|
||||||
import { INVALID_HANDLE } from '@atproto/syntax'
|
|
||||||
import { BlobRef, jsonStringToLex } from '@atproto/lexicon'
|
|
||||||
import { Database } from '../../db'
|
|
||||||
import { Actor } from '../../db/tables/actor'
|
|
||||||
import { Record as RecordRow } from '../../db/tables/record'
|
|
||||||
import {
|
|
||||||
ModEventView,
|
|
||||||
RepoView,
|
|
||||||
RepoViewDetail,
|
|
||||||
RecordView,
|
|
||||||
RecordViewDetail,
|
|
||||||
ReportViewDetail,
|
|
||||||
BlobView,
|
|
||||||
SubjectStatusView,
|
|
||||||
ModEventViewDetail,
|
|
||||||
} from '../../lexicon/types/com/atproto/admin/defs'
|
|
||||||
import { OutputSchema as ReportOutput } from '../../lexicon/types/com/atproto/moderation/createReport'
|
|
||||||
import { Label } from '../../lexicon/types/com/atproto/label/defs'
|
|
||||||
import {
|
|
||||||
ModerationEventRowWithHandle,
|
|
||||||
ModerationSubjectStatusRowWithHandle,
|
|
||||||
} from './types'
|
|
||||||
import { getSelfLabels } from '../label'
|
|
||||||
import { REASONOTHER } from '../../lexicon/types/com/atproto/moderation/defs'
|
|
||||||
|
|
||||||
export class ModerationViews {
|
|
||||||
constructor(private db: Database) {}
|
|
||||||
|
|
||||||
repo(result: RepoResult): Promise<RepoView>
|
|
||||||
repo(result: RepoResult[]): Promise<RepoView[]>
|
|
||||||
async repo(
|
|
||||||
result: RepoResult | RepoResult[],
|
|
||||||
): Promise<RepoView | RepoView[]> {
|
|
||||||
const results = Array.isArray(result) ? result : [result]
|
|
||||||
if (results.length === 0) return []
|
|
||||||
|
|
||||||
const [info, subjectStatuses] = await Promise.all([
|
|
||||||
await this.db.db
|
|
||||||
.selectFrom('actor')
|
|
||||||
.leftJoin('profile', 'profile.creator', 'actor.did')
|
|
||||||
.leftJoin(
|
|
||||||
'record as profile_record',
|
|
||||||
'profile_record.uri',
|
|
||||||
'profile.uri',
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
'actor.did',
|
|
||||||
'in',
|
|
||||||
results.map((r) => r.did),
|
|
||||||
)
|
|
||||||
.select(['actor.did as did', 'profile_record.json as profileJson'])
|
|
||||||
.execute(),
|
|
||||||
this.getSubjectStatus(results.map((r) => ({ did: r.did }))),
|
|
||||||
])
|
|
||||||
|
|
||||||
const infoByDid = info.reduce(
|
|
||||||
(acc, cur) => Object.assign(acc, { [cur.did]: cur }),
|
|
||||||
{} as Record<string, ArrayEl<typeof info>>,
|
|
||||||
)
|
|
||||||
const subjectStatusByDid = subjectStatuses.reduce(
|
|
||||||
(acc, cur) =>
|
|
||||||
Object.assign(acc, { [cur.did ?? '']: this.subjectStatus(cur) }),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
const views = results.map((r) => {
|
|
||||||
const { profileJson } = infoByDid[r.did] ?? {}
|
|
||||||
const relatedRecords: object[] = []
|
|
||||||
if (profileJson) {
|
|
||||||
relatedRecords.push(
|
|
||||||
jsonStringToLex(profileJson) as Record<string, unknown>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
// No email or invite info on appview
|
|
||||||
did: r.did,
|
|
||||||
handle: r.handle ?? INVALID_HANDLE,
|
|
||||||
relatedRecords,
|
|
||||||
indexedAt: r.indexedAt,
|
|
||||||
moderation: {
|
|
||||||
subjectStatus: subjectStatusByDid[r.did] ?? undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.isArray(result) ? views : views[0]
|
|
||||||
}
|
|
||||||
event(result: EventResult): Promise<ModEventView>
|
|
||||||
event(result: EventResult[]): Promise<ModEventView[]>
|
|
||||||
async event(
|
|
||||||
result: EventResult | EventResult[],
|
|
||||||
): Promise<ModEventView | ModEventView[]> {
|
|
||||||
const results = Array.isArray(result) ? result : [result]
|
|
||||||
if (results.length === 0) return []
|
|
||||||
|
|
||||||
const views = results.map((res) => {
|
|
||||||
const eventView: ModEventView = {
|
|
||||||
id: res.id,
|
|
||||||
event: {
|
|
||||||
$type: res.action,
|
|
||||||
comment: res.comment ?? 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: [],
|
|
||||||
createdBy: res.createdBy,
|
|
||||||
createdAt: res.createdAt,
|
|
||||||
subjectHandle: res.subjectHandle ?? undefined,
|
|
||||||
creatorHandle: res.creatorHandle ?? undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
'com.atproto.admin.defs#modEventTakedown',
|
|
||||||
'com.atproto.admin.defs#modEventMute',
|
|
||||||
].includes(res.action)
|
|
||||||
) {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
durationInHours: res.durationInHours ?? undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.action === 'com.atproto.admin.defs#modEventLabel') {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
createLabelVals: res.createLabelVals?.length
|
|
||||||
? res.createLabelVals.split(' ')
|
|
||||||
: [],
|
|
||||||
negateLabelVals: res.negateLabelVals?.length
|
|
||||||
? res.negateLabelVals.split(' ')
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is for legacy data only, for new events, these types of events won't have labels attached
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
'com.atproto.admin.defs#modEventAcknowledge',
|
|
||||||
'com.atproto.admin.defs#modEventTakedown',
|
|
||||||
'com.atproto.admin.defs#modEventEscalate',
|
|
||||||
].includes(res.action)
|
|
||||||
) {
|
|
||||||
if (res.createLabelVals?.length) {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
createLabelVals: res.createLabelVals.split(' '),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.negateLabelVals?.length) {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
negateLabelVals: res.negateLabelVals.split(' '),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.action === 'com.atproto.admin.defs#modEventReport') {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
reportType: res.meta?.reportType ?? undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.action === 'com.atproto.admin.defs#modEventEmail') {
|
|
||||||
eventView.event = {
|
|
||||||
...eventView.event,
|
|
||||||
subjectLine: res.meta?.subjectLine ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
res.action === 'com.atproto.admin.defs#modEventComment' &&
|
|
||||||
res.meta?.sticky
|
|
||||||
) {
|
|
||||||
eventView.event.sticky = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return eventView
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.isArray(result) ? views : views[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
async eventDetail(result: EventResult): Promise<ModEventViewDetail> {
|
|
||||||
const [event, subject] = await Promise.all([
|
|
||||||
this.event(result),
|
|
||||||
this.subject(result),
|
|
||||||
])
|
|
||||||
const allBlobs = findBlobRefs(subject.value)
|
|
||||||
const subjectBlobs = await this.blob(
|
|
||||||
allBlobs.filter((blob) =>
|
|
||||||
event.subjectBlobCids.includes(blob.ref.toString()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
subject,
|
|
||||||
subjectBlobs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async repoDetail(result: RepoResult): Promise<RepoViewDetail> {
|
|
||||||
const [repo, labels] = await Promise.all([
|
|
||||||
this.repo(result),
|
|
||||||
this.labels(result.did),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...repo,
|
|
||||||
moderation: {
|
|
||||||
...repo.moderation,
|
|
||||||
},
|
|
||||||
labels,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
record(result: RecordResult): Promise<RecordView>
|
|
||||||
record(result: RecordResult[]): Promise<RecordView[]>
|
|
||||||
async record(
|
|
||||||
result: RecordResult | RecordResult[],
|
|
||||||
): Promise<RecordView | RecordView[]> {
|
|
||||||
const results = Array.isArray(result) ? result : [result]
|
|
||||||
if (results.length === 0) return []
|
|
||||||
|
|
||||||
const [repoResults, subjectStatuses] = await Promise.all([
|
|
||||||
this.db.db
|
|
||||||
.selectFrom('actor')
|
|
||||||
.where(
|
|
||||||
'actor.did',
|
|
||||||
'in',
|
|
||||||
results.map((r) => didFromUri(r.uri)),
|
|
||||||
)
|
|
||||||
.selectAll()
|
|
||||||
.execute(),
|
|
||||||
this.getSubjectStatus(results.map((r) => didAndRecordPathFromUri(r.uri))),
|
|
||||||
])
|
|
||||||
const repos = await this.repo(repoResults)
|
|
||||||
|
|
||||||
const reposByDid = repos.reduce(
|
|
||||||
(acc, cur) => Object.assign(acc, { [cur.did]: cur }),
|
|
||||||
{} as Record<string, ArrayEl<typeof repos>>,
|
|
||||||
)
|
|
||||||
const subjectStatusByUri = subjectStatuses.reduce(
|
|
||||||
(acc, cur) =>
|
|
||||||
Object.assign(acc, {
|
|
||||||
[`${cur.did}/${cur.recordPath}` ?? '']: this.subjectStatus(cur),
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
const views = results.map((res) => {
|
|
||||||
const repo = reposByDid[didFromUri(res.uri)]
|
|
||||||
const { did, recordPath } = didAndRecordPathFromUri(res.uri)
|
|
||||||
const subjectStatus = subjectStatusByUri[`${did}/${recordPath}`]
|
|
||||||
if (!repo) throw new Error(`Record repo is missing: ${res.uri}`)
|
|
||||||
const value = jsonStringToLex(res.json) as Record<string, unknown>
|
|
||||||
return {
|
|
||||||
uri: res.uri,
|
|
||||||
cid: res.cid,
|
|
||||||
value,
|
|
||||||
blobCids: findBlobRefs(value).map((blob) => blob.ref.toString()),
|
|
||||||
indexedAt: res.indexedAt,
|
|
||||||
repo,
|
|
||||||
moderation: {
|
|
||||||
subjectStatus,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.isArray(result) ? views : views[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordDetail(result: RecordResult): Promise<RecordViewDetail> {
|
|
||||||
const [record, subjectStatusResult] = await Promise.all([
|
|
||||||
this.record(result),
|
|
||||||
this.getSubjectStatus(didAndRecordPathFromUri(result.uri)),
|
|
||||||
])
|
|
||||||
|
|
||||||
const [blobs, labels, subjectStatus] = await Promise.all([
|
|
||||||
this.blob(findBlobRefs(record.value)),
|
|
||||||
this.labels(record.uri),
|
|
||||||
subjectStatusResult?.length
|
|
||||||
? this.subjectStatus(subjectStatusResult[0])
|
|
||||||
: Promise.resolve(undefined),
|
|
||||||
])
|
|
||||||
const selfLabels = getSelfLabels({
|
|
||||||
uri: result.uri,
|
|
||||||
cid: result.cid,
|
|
||||||
record: jsonStringToLex(result.json) as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
...record,
|
|
||||||
blobs,
|
|
||||||
moderation: {
|
|
||||||
...record.moderation,
|
|
||||||
subjectStatus,
|
|
||||||
},
|
|
||||||
labels: [...labels, ...selfLabels],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reportPublic(report: ReportResult): ReportOutput {
|
|
||||||
return {
|
|
||||||
id: report.id,
|
|
||||||
createdAt: report.createdAt,
|
|
||||||
// Ideally, we would never have a report entry that does not have a reasonType but at the schema level
|
|
||||||
// we are not guarantying that so in whatever case, if we end up with such entries, default to 'other'
|
|
||||||
reasonType: report.meta?.reportType
|
|
||||||
? (report.meta?.reportType as string)
|
|
||||||
: REASONOTHER,
|
|
||||||
reason: report.comment ?? undefined,
|
|
||||||
reportedBy: report.createdBy,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Partial view for subjects
|
|
||||||
|
|
||||||
async subject(result: SubjectResult): Promise<SubjectView> {
|
|
||||||
let subject: SubjectView
|
|
||||||
if (result.subjectType === 'com.atproto.admin.defs#repoRef') {
|
|
||||||
const repoResult = await this.db.db
|
|
||||||
.selectFrom('actor')
|
|
||||||
.selectAll()
|
|
||||||
.where('did', '=', result.subjectDid)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (repoResult) {
|
|
||||||
subject = await this.repo(repoResult)
|
|
||||||
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.db.db
|
|
||||||
.selectFrom('record')
|
|
||||||
.selectAll()
|
|
||||||
.where('uri', '=', result.subjectUri)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (recordResult) {
|
|
||||||
subject = await this.record(recordResult)
|
|
||||||
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(blobs: BlobRef[]): Promise<BlobView[]> {
|
|
||||||
if (!blobs.length) return []
|
|
||||||
const { ref } = this.db.db.dynamic
|
|
||||||
const modStatusResults = await this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.where(
|
|
||||||
sql<string>`${ref(
|
|
||||||
'moderation_subject_status.blobCids',
|
|
||||||
)} @> ${JSON.stringify(blobs.map((blob) => blob.ref.toString()))}`,
|
|
||||||
)
|
|
||||||
.selectAll()
|
|
||||||
.executeTakeFirst()
|
|
||||||
const statusByCid = (modStatusResults?.blobCids || [])?.reduce(
|
|
||||||
(acc, cur) => Object.assign(acc, { [cur]: modStatusResults }),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
// Intentionally missing details field, since we don't have any on appview.
|
|
||||||
// We also don't know when the blob was created, so we use a canned creation time.
|
|
||||||
const unknownTime = new Date(0).toISOString()
|
|
||||||
return blobs.map((blob) => {
|
|
||||||
const cid = blob.ref.toString()
|
|
||||||
const subjectStatus = statusByCid[cid]
|
|
||||||
? this.subjectStatus(statusByCid[cid])
|
|
||||||
: undefined
|
|
||||||
return {
|
|
||||||
cid,
|
|
||||||
mimeType: blob.mimeType,
|
|
||||||
size: blob.size,
|
|
||||||
createdAt: unknownTime,
|
|
||||||
moderation: {
|
|
||||||
subjectStatus,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async labels(subject: string, includeNeg?: boolean): Promise<Label[]> {
|
|
||||||
const res = await this.db.db
|
|
||||||
.selectFrom('label')
|
|
||||||
.where('label.uri', '=', subject)
|
|
||||||
.if(!includeNeg, (qb) => qb.where('neg', '=', false))
|
|
||||||
.selectAll()
|
|
||||||
.execute()
|
|
||||||
return res.map((l) => ({
|
|
||||||
...l,
|
|
||||||
cid: l.cid === '' ? undefined : l.cid,
|
|
||||||
neg: l.neg,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSubjectStatus(
|
|
||||||
subject:
|
|
||||||
| { did: string; recordPath?: string }
|
|
||||||
| { did: string; recordPath?: string }[],
|
|
||||||
): Promise<ModerationSubjectStatusRowWithHandle[]> {
|
|
||||||
const subjectFilters = Array.isArray(subject) ? subject : [subject]
|
|
||||||
const filterForSubject =
|
|
||||||
({ did, recordPath }: { did: string; recordPath?: string }) =>
|
|
||||||
// TODO: Fix the typing here?
|
|
||||||
(clause: any) => {
|
|
||||||
clause = clause
|
|
||||||
.where('moderation_subject_status.did', '=', did)
|
|
||||||
.where('moderation_subject_status.recordPath', '=', recordPath || '')
|
|
||||||
return clause
|
|
||||||
}
|
|
||||||
|
|
||||||
const builder = this.db.db
|
|
||||||
.selectFrom('moderation_subject_status')
|
|
||||||
.leftJoin('actor', 'actor.did', 'moderation_subject_status.did')
|
|
||||||
.where((clause) => {
|
|
||||||
subjectFilters.forEach(({ did, recordPath }, i) => {
|
|
||||||
const applySubjectFilter = filterForSubject({ did, recordPath })
|
|
||||||
if (i === 0) {
|
|
||||||
clause = clause.where(applySubjectFilter)
|
|
||||||
} else {
|
|
||||||
clause = clause.orWhere(applySubjectFilter)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return clause
|
|
||||||
})
|
|
||||||
.selectAll('moderation_subject_status')
|
|
||||||
.select('actor.handle as handle')
|
|
||||||
|
|
||||||
return builder.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
subjectStatus(result: ModerationSubjectStatusRowWithHandle): SubjectStatusView
|
|
||||||
subjectStatus(
|
|
||||||
result: ModerationSubjectStatusRowWithHandle[],
|
|
||||||
): SubjectStatusView[]
|
|
||||||
subjectStatus(
|
|
||||||
result:
|
|
||||||
| ModerationSubjectStatusRowWithHandle
|
|
||||||
| ModerationSubjectStatusRowWithHandle[],
|
|
||||||
): SubjectStatusView | SubjectStatusView[] {
|
|
||||||
const results = Array.isArray(result) ? result : [result]
|
|
||||||
if (results.length === 0) return []
|
|
||||||
|
|
||||||
const decoratedSubjectStatuses = results.map((subjectStatus) => ({
|
|
||||||
id: subjectStatus.id,
|
|
||||||
reviewState: subjectStatus.reviewState,
|
|
||||||
createdAt: subjectStatus.createdAt,
|
|
||||||
updatedAt: subjectStatus.updatedAt,
|
|
||||||
comment: subjectStatus.comment ?? undefined,
|
|
||||||
lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined,
|
|
||||||
lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined,
|
|
||||||
lastReportedAt: subjectStatus.lastReportedAt ?? undefined,
|
|
||||||
lastAppealedAt: subjectStatus.lastAppealedAt ?? undefined,
|
|
||||||
muteUntil: subjectStatus.muteUntil ?? undefined,
|
|
||||||
suspendUntil: subjectStatus.suspendUntil ?? undefined,
|
|
||||||
takendown: subjectStatus.takendown ?? undefined,
|
|
||||||
appealed: subjectStatus.appealed ?? undefined,
|
|
||||||
subjectRepoHandle: subjectStatus.handle ?? undefined,
|
|
||||||
subjectBlobCids: subjectStatus.blobCids || [],
|
|
||||||
subject: !subjectStatus.recordPath
|
|
||||||
? {
|
|
||||||
$type: 'com.atproto.admin.defs#repoRef',
|
|
||||||
did: subjectStatus.did,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
$type: 'com.atproto.repo.strongRef',
|
|
||||||
uri: AtUri.make(
|
|
||||||
subjectStatus.did,
|
|
||||||
// Not too intuitive but the recordpath is basically <collection>/<rkey>
|
|
||||||
// which is what the last 2 params of .make() arguments are
|
|
||||||
...subjectStatus.recordPath.split('/'),
|
|
||||||
).toString(),
|
|
||||||
cid: subjectStatus.recordCid,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
return Array.isArray(result)
|
|
||||||
? decoratedSubjectStatuses
|
|
||||||
: decoratedSubjectStatuses[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RepoResult = Actor
|
|
||||||
|
|
||||||
type EventResult = ModerationEventRowWithHandle
|
|
||||||
|
|
||||||
type ReportResult = ModerationEventRowWithHandle
|
|
||||||
|
|
||||||
type RecordResult = RecordRow
|
|
||||||
|
|
||||||
type SubjectResult = Pick<
|
|
||||||
EventResult & ReportResult,
|
|
||||||
'id' | 'subjectType' | 'subjectDid' | 'subjectUri' | 'subjectCid'
|
|
||||||
>
|
|
||||||
|
|
||||||
type SubjectView = ModEventViewDetail['subject'] & ReportViewDetail['subject']
|
|
||||||
|
|
||||||
function didFromUri(uri: string) {
|
|
||||||
return new AtUri(uri).host
|
|
||||||
}
|
|
||||||
|
|
||||||
function didAndRecordPathFromUri(uri: string) {
|
|
||||||
const atUri = new AtUri(uri)
|
|
||||||
return { did: atUri.host, recordPath: `${atUri.collection}/${atUri.rkey}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
function findBlobRefs(value: unknown, refs: BlobRef[] = []) {
|
|
||||||
if (value instanceof BlobRef) {
|
|
||||||
refs.push(value)
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
value.forEach((val) => findBlobRefs(val, refs))
|
|
||||||
} else if (value && typeof value === 'object') {
|
|
||||||
Object.values(value).forEach((val) => findBlobRefs(val, refs))
|
|
||||||
}
|
|
||||||
return refs
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* This function takes a number as input and returns a Date object,
|
|
||||||
* which is the current date and time plus the input number of hours.
|
|
||||||
*
|
|
||||||
* @param {number} hours - The number of hours to add to the current date and time.
|
|
||||||
* @param {Date} startingDate - If provided, the function will add `hours` to the provided date instead of the current date.
|
|
||||||
* @returns {Date} - The new Date object, which is the current date and time plus the input number of hours.
|
|
||||||
*/
|
|
||||||
export function addHoursToDate(hours: number, startingDate?: Date): Date {
|
|
||||||
// When date is passe, let's clone before calling `setHours()` so that we are not mutating the original date
|
|
||||||
const currentDate = startingDate ? new Date(startingDate) : new Date()
|
|
||||||
currentDate.setHours(currentDate.getHours() + hours)
|
|
||||||
return currentDate
|
|
||||||
}
|
|
@ -461,12 +461,12 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images#view",
|
"$type": "app.bsky.embed.images#view",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(6)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(6)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(6)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(6)@jpeg",
|
||||||
},
|
},
|
||||||
@ -517,7 +517,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -528,7 +528,7 @@ Array [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -721,12 +721,12 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images#view",
|
"$type": "app.bsky.embed.images#view",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(6)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(6)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(6)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(6)@jpeg",
|
||||||
},
|
},
|
||||||
@ -777,7 +777,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -788,7 +788,7 @@ Array [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -937,12 +937,12 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images#view",
|
"$type": "app.bsky.embed.images#view",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
||||||
},
|
},
|
||||||
@ -987,7 +987,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -998,7 +998,7 @@ Array [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -1222,12 +1222,12 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images#view",
|
"$type": "app.bsky.embed.images#view",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(4)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(4)@jpeg",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(5)/cids(5)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(5)/cids(5)@jpeg",
|
||||||
},
|
},
|
||||||
@ -1278,7 +1278,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -1289,7 +1289,7 @@ Array [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
|
@ -101,7 +101,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images#view",
|
"$type": "app.bsky.embed.images#view",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(2)/cids(5)@jpeg",
|
"fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(2)/cids(5)@jpeg",
|
||||||
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(2)/cids(5)@jpeg",
|
"thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(2)/cids(5)@jpeg",
|
||||||
},
|
},
|
||||||
@ -113,7 +113,7 @@ Array [
|
|||||||
"cid": "cids(3)",
|
"cid": "cids(3)",
|
||||||
"cts": "1970-01-01T00:00:00.000Z",
|
"cts": "1970-01-01T00:00:00.000Z",
|
||||||
"neg": false,
|
"neg": false,
|
||||||
"src": "did:example:labeler",
|
"src": "user(3)",
|
||||||
"uri": "record(3)",
|
"uri": "record(3)",
|
||||||
"val": "test-label",
|
"val": "test-label",
|
||||||
},
|
},
|
||||||
@ -121,7 +121,7 @@ Array [
|
|||||||
"cid": "cids(3)",
|
"cid": "cids(3)",
|
||||||
"cts": "1970-01-01T00:00:00.000Z",
|
"cts": "1970-01-01T00:00:00.000Z",
|
||||||
"neg": false,
|
"neg": false,
|
||||||
"src": "did:example:labeler",
|
"src": "user(3)",
|
||||||
"uri": "record(3)",
|
"uri": "record(3)",
|
||||||
"val": "test-label-2",
|
"val": "test-label-2",
|
||||||
},
|
},
|
||||||
@ -134,7 +134,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -207,7 +207,7 @@ Array [
|
|||||||
"record": Object {
|
"record": Object {
|
||||||
"$type": "app.bsky.embed.record#viewRecord",
|
"$type": "app.bsky.embed.record#viewRecord",
|
||||||
"author": Object {
|
"author": Object {
|
||||||
"did": "user(3)",
|
"did": "user(4)",
|
||||||
"handle": "dan.test",
|
"handle": "dan.test",
|
||||||
"labels": Array [],
|
"labels": Array [],
|
||||||
"viewer": Object {
|
"viewer": Object {
|
||||||
@ -223,7 +223,7 @@ Array [
|
|||||||
"record": Object {
|
"record": Object {
|
||||||
"$type": "app.bsky.embed.record#viewRecord",
|
"$type": "app.bsky.embed.record#viewRecord",
|
||||||
"author": Object {
|
"author": Object {
|
||||||
"did": "user(4)",
|
"did": "user(5)",
|
||||||
"handle": "carol.test",
|
"handle": "carol.test",
|
||||||
"labels": Array [],
|
"labels": Array [],
|
||||||
"viewer": Object {
|
"viewer": Object {
|
||||||
@ -245,7 +245,7 @@ Array [
|
|||||||
"$type": "app.bsky.embed.images",
|
"$type": "app.bsky.embed.images",
|
||||||
"images": Array [
|
"images": Array [
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-landscape-small.jpg",
|
"alt": "../dev-env/src/seed/img/key-landscape-small.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -256,7 +256,7 @@ Array [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"alt": "tests/sample-img/key-alt.jpg",
|
"alt": "../dev-env/src/seed/img/key-alt.jpg",
|
||||||
"image": Object {
|
"image": Object {
|
||||||
"$type": "blob",
|
"$type": "blob",
|
||||||
"mimeType": "image/jpeg",
|
"mimeType": "image/jpeg",
|
||||||
@ -317,7 +317,7 @@ Array [
|
|||||||
"cid": "cids(6)",
|
"cid": "cids(6)",
|
||||||
"cts": "1970-01-01T00:00:00.000Z",
|
"cts": "1970-01-01T00:00:00.000Z",
|
||||||
"neg": false,
|
"neg": false,
|
||||||
"src": "did:example:labeler",
|
"src": "user(3)",
|
||||||
"uri": "record(6)",
|
"uri": "record(6)",
|
||||||
"val": "test-label",
|
"val": "test-label",
|
||||||
},
|
},
|
||||||
@ -416,7 +416,7 @@ Array [
|
|||||||
"cursor": "0000000000000::bafycid",
|
"cursor": "0000000000000::bafycid",
|
||||||
"follows": Array [
|
"follows": Array [
|
||||||
Object {
|
Object {
|
||||||
"did": "user(3)",
|
"did": "user(4)",
|
||||||
"handle": "dan.test",
|
"handle": "dan.test",
|
||||||
"labels": Array [],
|
"labels": Array [],
|
||||||
"viewer": Object {
|
"viewer": Object {
|
||||||
|
164
packages/bsky/tests/admin/admin-auth.test.ts
Normal file
164
packages/bsky/tests/admin/admin-auth.test.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { SeedClient, usersSeed, TestNetwork } from '@atproto/dev-env'
|
||||||
|
import AtpAgent from '@atproto/api'
|
||||||
|
import { Secp256k1Keypair } from '@atproto/crypto'
|
||||||
|
import { createServiceAuthHeaders } from '@atproto/xrpc-server'
|
||||||
|
import { RepoRef } from '../../src/lexicon/types/com/atproto/admin/defs'
|
||||||
|
|
||||||
|
describe('admin auth', () => {
|
||||||
|
let network: TestNetwork
|
||||||
|
let agent: AtpAgent
|
||||||
|
let sc: SeedClient
|
||||||
|
|
||||||
|
let repoSubject: RepoRef
|
||||||
|
|
||||||
|
const modServiceDid = 'did:example:mod'
|
||||||
|
const altModDid = 'did:example:alt'
|
||||||
|
let modServiceKey: Secp256k1Keypair
|
||||||
|
let bskyDid: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
network = await TestNetwork.create({
|
||||||
|
dbPostgresSchema: 'bsky_admin_auth',
|
||||||
|
bsky: {
|
||||||
|
modServiceDid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bskyDid = network.bsky.ctx.cfg.serverDid
|
||||||
|
|
||||||
|
modServiceKey = await Secp256k1Keypair.create()
|
||||||
|
const origResolve = network.bsky.ctx.idResolver.did.resolveAtprotoKey
|
||||||
|
network.bsky.ctx.idResolver.did.resolveAtprotoKey = async (
|
||||||
|
did: string,
|
||||||
|
forceRefresh?: boolean,
|
||||||
|
) => {
|
||||||
|
if (did === modServiceDid || did === altModDid) {
|
||||||
|
return modServiceKey.did()
|
||||||
|
}
|
||||||
|
return origResolve(did, forceRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = network.bsky.getClient()
|
||||||
|
sc = network.getSeedClient()
|
||||||
|
await usersSeed(sc)
|
||||||
|
await network.processAll()
|
||||||
|
repoSubject = {
|
||||||
|
$type: 'com.atproto.admin.defs#repoRef',
|
||||||
|
did: sc.dids.bob,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await network.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows service auth requests from the configured appview did', async () => {
|
||||||
|
const headers = await createServiceAuthHeaders({
|
||||||
|
iss: modServiceDid,
|
||||||
|
aud: bskyDid,
|
||||||
|
keypair: modServiceKey,
|
||||||
|
})
|
||||||
|
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: altModDid,
|
||||||
|
aud: bskyDid,
|
||||||
|
keypair: modServiceKey,
|
||||||
|
})
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not allow requests from an authenticated user', async () => {
|
||||||
|
const aliceKey = await network.pds.ctx.actorStore.keypair(sc.dids.alice)
|
||||||
|
const headers = await createServiceAuthHeaders({
|
||||||
|
iss: sc.dids.alice,
|
||||||
|
aud: bskyDid,
|
||||||
|
keypair: aliceKey,
|
||||||
|
})
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not allow requests with a bad signature', async () => {
|
||||||
|
const badKey = await Secp256k1Keypair.create()
|
||||||
|
const headers = await createServiceAuthHeaders({
|
||||||
|
iss: modServiceDid,
|
||||||
|
aud: bskyDid,
|
||||||
|
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 aud', async () => {
|
||||||
|
// repo subject is bob, so we set alice as the audience
|
||||||
|
const headers = await createServiceAuthHeaders({
|
||||||
|
iss: modServiceDid,
|
||||||
|
aud: sc.dids.alice,
|
||||||
|
keypair: modServiceKey,
|
||||||
|
})
|
||||||
|
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 service did',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,5 @@
|
|||||||
import AtpAgent, { AtUri } from '@atproto/api'
|
import AtpAgent, { AtUri } from '@atproto/api'
|
||||||
import { TestNetwork, SeedClient } from '@atproto/dev-env'
|
import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env'
|
||||||
import basicSeed from '../seeds/basic'
|
|
||||||
import { makeAlgos } from '../../src'
|
import { makeAlgos } from '../../src'
|
||||||
|
|
||||||
describe('algo hot-classic', () => {
|
describe('algo hot-classic', () => {
|
||||||
@ -40,7 +39,7 @@ describe('algo hot-classic', () => {
|
|||||||
it('returns well liked posts', async () => {
|
it('returns well liked posts', async () => {
|
||||||
const img = await sc.uploadFile(
|
const img = await sc.uploadFile(
|
||||||
alice,
|
alice,
|
||||||
'tests/sample-img/key-landscape-small.jpg',
|
'../dev-env/src/seed/img/key-landscape-small.jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
)
|
)
|
||||||
const one = await sc.post(alice, 'first post', undefined, [img])
|
const one = await sc.post(alice, 'first post', undefined, [img])
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import AtpAgent from '@atproto/api'
|
import AtpAgent from '@atproto/api'
|
||||||
import { SeedClient, TestNetwork } from '@atproto/dev-env'
|
import { SeedClient, TestNetwork, usersSeed } from '@atproto/dev-env'
|
||||||
import usersSeed from './seeds/users'
|
|
||||||
import { createServiceJwt } from '@atproto/xrpc-server'
|
import { createServiceJwt } from '@atproto/xrpc-server'
|
||||||
import { Keypair, Secp256k1Keypair } from '@atproto/crypto'
|
import { Keypair, Secp256k1Keypair } from '@atproto/crypto'
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { TestNetwork, SeedClient } from '@atproto/dev-env'
|
import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env'
|
||||||
import { FuzzyMatcher, encode } from '../../src/auto-moderator/fuzzy-matcher'
|
import { FuzzyMatcher, encode } from '../../src/auto-moderator/fuzzy-matcher'
|
||||||
import basicSeed from '../seeds/basic'
|
|
||||||
import { AtpAgent } from '@atproto/api'
|
import { AtpAgent } from '@atproto/api'
|
||||||
import { ImageInvalidator } from '../../src/image/invalidator'
|
import { ImageInvalidator } from '../../src/image/invalidator'
|
||||||
|
|
||||||
@ -35,9 +34,8 @@ describe('fuzzy matcher', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getAllReports = () => {
|
const getAllReports = () => {
|
||||||
return network.bsky.ctx.db
|
return network.ozone.ctx.db.db
|
||||||
.getPrimary()
|
.selectFrom('moderation_event')
|
||||||
.db.selectFrom('moderation_event')
|
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
.where('action', '=', 'com.atproto.admin.defs#modEventReport')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.orderBy('id', 'asc')
|
.orderBy('id', 'asc')
|
||||||
|
@ -1,16 +1,13 @@
|
|||||||
import { TestNetwork } from '@atproto/dev-env'
|
import { TestNetwork, usersSeed } from '@atproto/dev-env'
|
||||||
import { AtUri, BlobRef } from '@atproto/api'
|
import { AtUri, BlobRef } from '@atproto/api'
|
||||||
import { Readable } from 'stream'
|
import { Readable } from 'stream'
|
||||||
import { AutoModerator } from '../../src/auto-moderator'
|
import { AutoModerator } from '../../src/auto-moderator'
|
||||||
import IndexerContext from '../../src/indexer/context'
|
import IndexerContext from '../../src/indexer/context'
|
||||||
import { cidForRecord } from '@atproto/repo'
|
import { cidForRecord } from '@atproto/repo'
|
||||||
import { TID } from '@atproto/common'
|
import { TID } from '@atproto/common'
|
||||||
import { LabelService } from '../../src/services/label'
|
|
||||||
import usersSeed from '../seeds/users'
|
|
||||||
import { CID } from 'multiformats/cid'
|
import { CID } from 'multiformats/cid'
|
||||||
import { ImgLabeler } from '../../src/auto-moderator/hive'
|
import { ImgLabeler } from '../../src/auto-moderator/hive'
|
||||||
import { ModerationService } from '../../src/services/moderation'
|
import { TestOzone } from '@atproto/dev-env/src/ozone'
|
||||||
import { ImageInvalidator } from '../../src/image/invalidator'
|
|
||||||
|
|
||||||
// outside of test suite so that TestLabeler can access them
|
// outside of test suite so that TestLabeler can access them
|
||||||
let badCid1: CID | undefined = undefined
|
let badCid1: CID | undefined = undefined
|
||||||
@ -18,10 +15,9 @@ let badCid2: CID | undefined = undefined
|
|||||||
|
|
||||||
describe('labeler', () => {
|
describe('labeler', () => {
|
||||||
let network: TestNetwork
|
let network: TestNetwork
|
||||||
|
let ozone: TestOzone
|
||||||
let autoMod: AutoModerator
|
let autoMod: AutoModerator
|
||||||
let labelSrvc: LabelService
|
|
||||||
let ctx: IndexerContext
|
let ctx: IndexerContext
|
||||||
let labelerDid: string
|
|
||||||
let badBlob1: BlobRef
|
let badBlob1: BlobRef
|
||||||
let badBlob2: BlobRef
|
let badBlob2: BlobRef
|
||||||
let goodBlob: BlobRef
|
let goodBlob: BlobRef
|
||||||
@ -32,12 +28,11 @@ describe('labeler', () => {
|
|||||||
network = await TestNetwork.create({
|
network = await TestNetwork.create({
|
||||||
dbPostgresSchema: 'bsky_labeler',
|
dbPostgresSchema: 'bsky_labeler',
|
||||||
})
|
})
|
||||||
|
ozone = network.ozone
|
||||||
ctx = network.bsky.indexer.ctx
|
ctx = network.bsky.indexer.ctx
|
||||||
const pdsCtx = network.pds.ctx
|
const pdsCtx = network.pds.ctx
|
||||||
labelerDid = ctx.cfg.labelerDid
|
|
||||||
autoMod = ctx.autoMod
|
autoMod = ctx.autoMod
|
||||||
autoMod.imgLabeler = new TestImgLabeler()
|
autoMod.imgLabeler = new TestImgLabeler()
|
||||||
labelSrvc = ctx.services.label(ctx.db)
|
|
||||||
const sc = network.getSeedClient()
|
const sc = network.getSeedClient()
|
||||||
await usersSeed(sc)
|
await usersSeed(sc)
|
||||||
await network.processAll()
|
await network.processAll()
|
||||||
@ -54,11 +49,7 @@ describe('labeler', () => {
|
|||||||
constraints: {},
|
constraints: {},
|
||||||
}
|
}
|
||||||
await store.repo.blob.verifyBlobAndMakePermanent(preparedBlobRef)
|
await store.repo.blob.verifyBlobAndMakePermanent(preparedBlobRef)
|
||||||
await store.repo.blob.associateBlob(
|
await store.repo.blob.associateBlob(preparedBlobRef, postUri())
|
||||||
preparedBlobRef,
|
|
||||||
postUri(),
|
|
||||||
TID.nextStr(),
|
|
||||||
)
|
|
||||||
return blobRef
|
return blobRef
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -76,11 +67,15 @@ describe('labeler', () => {
|
|||||||
await network.close()
|
await network.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getLabels = async (subject: string) => {
|
||||||
|
return ozone.ctx.db.db
|
||||||
|
.selectFrom('label')
|
||||||
|
.selectAll()
|
||||||
|
.where('uri', '=', subject)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
it('labels text in posts', async () => {
|
it('labels text in posts', async () => {
|
||||||
autoMod.services.moderation = ModerationService.creator(
|
|
||||||
new NoopImageUriBuilder(''),
|
|
||||||
new NoopInvalidator(),
|
|
||||||
)
|
|
||||||
const post = {
|
const post = {
|
||||||
$type: 'app.bsky.feed.post',
|
$type: 'app.bsky.feed.post',
|
||||||
text: 'blah blah label_me',
|
text: 'blah blah label_me',
|
||||||
@ -89,11 +84,11 @@ describe('labeler', () => {
|
|||||||
const cid = await cidForRecord(post)
|
const cid = await cidForRecord(post)
|
||||||
const uri = postUri()
|
const uri = postUri()
|
||||||
autoMod.processRecord(uri, cid, post)
|
autoMod.processRecord(uri, cid, post)
|
||||||
await autoMod.processAll()
|
await network.processAll()
|
||||||
const labels = await labelSrvc.getLabels(uri.toString())
|
const labels = await getLabels(uri.toString())
|
||||||
expect(labels.length).toBe(1)
|
expect(labels.length).toBe(1)
|
||||||
expect(labels[0]).toMatchObject({
|
expect(labels[0]).toMatchObject({
|
||||||
src: labelerDid,
|
src: ozone.ctx.cfg.service.did,
|
||||||
uri: uri.toString(),
|
uri: uri.toString(),
|
||||||
cid: cid.toString(),
|
cid: cid.toString(),
|
||||||
val: 'test-label',
|
val: 'test-label',
|
||||||
@ -102,7 +97,7 @@ describe('labeler', () => {
|
|||||||
|
|
||||||
// Verify that along with applying the labels, we are also leaving trace of the label as moderation event
|
// Verify that along with applying the labels, we are also leaving trace of the label as moderation event
|
||||||
// Temporarily assign an instance of moderation service to the autoMod so that we can validate label event
|
// Temporarily assign an instance of moderation service to the autoMod so that we can validate label event
|
||||||
const modSrvc = autoMod.services.moderation(ctx.db)
|
const modSrvc = ozone.ctx.modService(ozone.ctx.db)
|
||||||
const { events } = await modSrvc.getEvents({
|
const { events } = await modSrvc.getEvents({
|
||||||
includeAllUserRecords: false,
|
includeAllUserRecords: false,
|
||||||
subject: uri.toString(),
|
subject: uri.toString(),
|
||||||
@ -116,11 +111,8 @@ describe('labeler', () => {
|
|||||||
createLabelVals: 'test-label',
|
createLabelVals: 'test-label',
|
||||||
negateLabelVals: null,
|
negateLabelVals: null,
|
||||||
comment: `[AutoModerator]: Applying labels`,
|
comment: `[AutoModerator]: Applying labels`,
|
||||||
createdBy: labelerDid,
|
createdBy: network.bsky.indexer.ctx.cfg.serverDid,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup the temporary assignment, knowing that by default, moderation service is not available
|
|
||||||
autoMod.services.moderation = undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels embeds in posts', async () => {
|
it('labels embeds in posts', async () => {
|
||||||
@ -150,36 +142,12 @@ describe('labeler', () => {
|
|||||||
const cid = await cidForRecord(post)
|
const cid = await cidForRecord(post)
|
||||||
autoMod.processRecord(uri, cid, post)
|
autoMod.processRecord(uri, cid, post)
|
||||||
await autoMod.processAll()
|
await autoMod.processAll()
|
||||||
const dbLabels = await labelSrvc.getLabels(uri.toString())
|
const dbLabels = await getLabels(uri.toString())
|
||||||
const labels = dbLabels.map((row) => row.val).sort()
|
const labels = dbLabels.map((row) => row.val).sort()
|
||||||
expect(labels).toEqual(
|
expect(labels).toEqual(
|
||||||
['test-label', 'test-label-2', 'img-label', 'other-img-label'].sort(),
|
['test-label', 'test-label-2', 'img-label', 'other-img-label'].sort(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('retrieves repo labels on profile views', async () => {
|
|
||||||
await ctx.db.db
|
|
||||||
.insertInto('label')
|
|
||||||
.values({
|
|
||||||
src: labelerDid,
|
|
||||||
uri: alice,
|
|
||||||
cid: '',
|
|
||||||
val: 'repo-label',
|
|
||||||
neg: false,
|
|
||||||
cts: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.execute()
|
|
||||||
|
|
||||||
const labels = await labelSrvc.getLabelsForProfile(alice)
|
|
||||||
|
|
||||||
expect(labels.length).toBe(1)
|
|
||||||
expect(labels[0]).toMatchObject({
|
|
||||||
src: labelerDid,
|
|
||||||
uri: alice,
|
|
||||||
val: 'repo-label',
|
|
||||||
neg: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
class TestImgLabeler implements ImgLabeler {
|
class TestImgLabeler implements ImgLabeler {
|
||||||
@ -193,14 +161,3 @@ class TestImgLabeler implements ImgLabeler {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NoopInvalidator implements ImageInvalidator {
|
|
||||||
async invalidate() {}
|
|
||||||
}
|
|
||||||
class NoopImageUriBuilder {
|
|
||||||
constructor(public endpoint: string) {}
|
|
||||||
|
|
||||||
getPresetUri() {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import { TestNetwork, SeedClient, ImageRef } from '@atproto/dev-env'
|
import { TestNetwork, SeedClient, ImageRef, usersSeed } from '@atproto/dev-env'
|
||||||
import { AtpAgent } from '@atproto/api'
|
import { AtpAgent } from '@atproto/api'
|
||||||
import { AutoModerator } from '../../src/auto-moderator'
|
import { AutoModerator } from '../../src/auto-moderator'
|
||||||
import IndexerContext from '../../src/indexer/context'
|
|
||||||
import { sha256RawToCid } from '@atproto/common'
|
import { sha256RawToCid } from '@atproto/common'
|
||||||
import usersSeed from '../seeds/users'
|
|
||||||
import { CID } from 'multiformats/cid'
|
import { CID } from 'multiformats/cid'
|
||||||
import { AtUri } from '@atproto/syntax'
|
import { AtUri } from '@atproto/syntax'
|
||||||
import { ImageFlagger } from '../../src/auto-moderator/abyss'
|
import { ImageFlagger } from '../../src/auto-moderator/abyss'
|
||||||
import { ImageInvalidator } from '../../src/image/invalidator'
|
import { ImageInvalidator } from '../../src/image/invalidator'
|
||||||
import { sha256 } from '@atproto/crypto'
|
import { sha256 } from '@atproto/crypto'
|
||||||
import { ids } from '../../src/lexicon/lexicons'
|
import { ids } from '../../src/lexicon/lexicons'
|
||||||
|
import { TestOzone } from '@atproto/dev-env/src/ozone'
|
||||||
|
import { PrimaryDatabase } from '../../src'
|
||||||
|
|
||||||
// outside of test suite so that TestLabeler can access them
|
// outside of test suite so that TestLabeler can access them
|
||||||
let badCid1: CID | undefined = undefined
|
let badCid1: CID | undefined = undefined
|
||||||
@ -18,9 +18,10 @@ let badCid2: CID | undefined = undefined
|
|||||||
|
|
||||||
describe('takedowner', () => {
|
describe('takedowner', () => {
|
||||||
let network: TestNetwork
|
let network: TestNetwork
|
||||||
|
let ozone: TestOzone
|
||||||
|
let bskyDb: PrimaryDatabase
|
||||||
let autoMod: AutoModerator
|
let autoMod: AutoModerator
|
||||||
let testInvalidator: TestInvalidator
|
let testInvalidator: TestInvalidator
|
||||||
let ctx: IndexerContext
|
|
||||||
let pdsAgent: AtpAgent
|
let pdsAgent: AtpAgent
|
||||||
let sc: SeedClient
|
let sc: SeedClient
|
||||||
let alice: string
|
let alice: string
|
||||||
@ -36,8 +37,9 @@ describe('takedowner', () => {
|
|||||||
imgInvalidator: testInvalidator,
|
imgInvalidator: testInvalidator,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
ctx = network.bsky.indexer.ctx
|
ozone = network.ozone
|
||||||
autoMod = ctx.autoMod
|
bskyDb = network.bsky.ctx.db.getPrimary()
|
||||||
|
autoMod = network.bsky.indexer.ctx.autoMod
|
||||||
autoMod.imageFlagger = new TestFlagger()
|
autoMod.imageFlagger = new TestFlagger()
|
||||||
pdsAgent = new AtpAgent({ service: network.pds.url })
|
pdsAgent = new AtpAgent({ service: network.pds.url })
|
||||||
sc = network.getSeedClient()
|
sc = network.getSeedClient()
|
||||||
@ -45,26 +47,26 @@ describe('takedowner', () => {
|
|||||||
await network.processAll()
|
await network.processAll()
|
||||||
alice = sc.dids.alice
|
alice = sc.dids.alice
|
||||||
const fileBytes1 = await fs.readFile(
|
const fileBytes1 = await fs.readFile(
|
||||||
'tests/sample-img/key-portrait-small.jpg',
|
'../dev-env/src/seed/img/key-portrait-small.jpg',
|
||||||
)
|
)
|
||||||
const fileBytes2 = await fs.readFile(
|
const fileBytes2 = await fs.readFile(
|
||||||
'tests/sample-img/key-portrait-large.jpg',
|
'../dev-env/src/seed/img/key-portrait-large.jpg',
|
||||||
)
|
)
|
||||||
badCid1 = sha256RawToCid(await sha256(fileBytes1))
|
badCid1 = sha256RawToCid(await sha256(fileBytes1))
|
||||||
badCid2 = sha256RawToCid(await sha256(fileBytes2))
|
badCid2 = sha256RawToCid(await sha256(fileBytes2))
|
||||||
goodBlob = await sc.uploadFile(
|
goodBlob = await sc.uploadFile(
|
||||||
alice,
|
alice,
|
||||||
'tests/sample-img/key-landscape-small.jpg',
|
'../dev-env/src/seed/img/key-landscape-small.jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
)
|
)
|
||||||
badBlob1 = await sc.uploadFile(
|
badBlob1 = await sc.uploadFile(
|
||||||
alice,
|
alice,
|
||||||
'tests/sample-img/key-portrait-small.jpg',
|
'../dev-env/src/seed/img/key-portrait-small.jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
)
|
)
|
||||||
badBlob2 = await sc.uploadFile(
|
badBlob2 = await sc.uploadFile(
|
||||||
alice,
|
alice,
|
||||||
'tests/sample-img/key-portrait-large.jpg',
|
'../dev-env/src/seed/img/key-portrait-large.jpg',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -76,9 +78,8 @@ describe('takedowner', () => {
|
|||||||
it('takes down flagged content in posts', async () => {
|
it('takes down flagged content in posts', async () => {
|
||||||
const post = await sc.post(alice, 'blah', undefined, [goodBlob, badBlob1])
|
const post = await sc.post(alice, 'blah', undefined, [goodBlob, badBlob1])
|
||||||
await network.processAll()
|
await network.processAll()
|
||||||
await autoMod.processAll()
|
|
||||||
const [modStatus, takedownEvent] = await Promise.all([
|
const [modStatus, takedownEvent] = await Promise.all([
|
||||||
ctx.db.db
|
ozone.ctx.db.db
|
||||||
.selectFrom('moderation_subject_status')
|
.selectFrom('moderation_subject_status')
|
||||||
.where('did', '=', alice)
|
.where('did', '=', alice)
|
||||||
.where(
|
.where(
|
||||||
@ -88,7 +89,7 @@ describe('takedowner', () => {
|
|||||||
)
|
)
|
||||||
.select(['takendown', 'id'])
|
.select(['takendown', 'id'])
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
ctx.db.db
|
ozone.ctx.db.db
|
||||||
.selectFrom('moderation_event')
|
.selectFrom('moderation_event')
|
||||||
.where('subjectDid', '=', alice)
|
.where('subjectDid', '=', alice)
|
||||||
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
|
.where('action', '=', 'com.atproto.admin.defs#modEventTakedown')
|
||||||
@ -99,12 +100,12 @@ describe('takedowner', () => {
|
|||||||
throw new Error('expected mod action')
|
throw new Error('expected mod action')
|
||||||
}
|
}
|
||||||
expect(modStatus.takendown).toEqual(true)
|
expect(modStatus.takendown).toEqual(true)
|
||||||
const record = await ctx.db.db
|
const record = await bskyDb.db
|
||||||
.selectFrom('record')
|
.selectFrom('record')
|
||||||
.where('uri', '=', post.ref.uriStr)
|
.where('uri', '=', post.ref.uriStr)
|
||||||
.select('takedownId')
|
.select('takedownRef')
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
expect(record?.takedownId).toBeGreaterThan(0)
|
expect(record?.takedownRef).toEqual(`BSKY-TAKEDOWN-${takedownEvent.id}`)
|
||||||
|
|
||||||
const recordPds = await network.pds.ctx.actorStore.read(
|
const recordPds = await network.pds.ctx.actorStore.read(
|
||||||
post.ref.uri.hostname,
|
post.ref.uri.hostname,
|
||||||
@ -115,7 +116,7 @@ describe('takedowner', () => {
|
|||||||
.select('takedownRef')
|
.select('takedownRef')
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
)
|
)
|
||||||
expect(recordPds?.takedownRef).toEqual(takedownEvent.id.toString())
|
expect(recordPds?.takedownRef).toEqual(`BSKY-TAKEDOWN-${takedownEvent.id}`)
|
||||||
|
|
||||||
expect(testInvalidator.invalidated.length).toBe(1)
|
expect(testInvalidator.invalidated.length).toBe(1)
|
||||||
expect(testInvalidator.invalidated[0].subject).toBe(
|
expect(testInvalidator.invalidated[0].subject).toBe(
|
||||||
@ -137,13 +138,13 @@ describe('takedowner', () => {
|
|||||||
)
|
)
|
||||||
await network.processAll()
|
await network.processAll()
|
||||||
const [modStatus, takedownEvent] = await Promise.all([
|
const [modStatus, takedownEvent] = await Promise.all([
|
||||||
ctx.db.db
|
ozone.ctx.db.db
|
||||||
.selectFrom('moderation_subject_status')
|
.selectFrom('moderation_subject_status')
|
||||||
.where('did', '=', alice)
|
.where('did', '=', alice)
|
||||||
.where('recordPath', '=', `${ids.AppBskyActorProfile}/self`)
|
.where('recordPath', '=', `${ids.AppBskyActorProfile}/self`)
|
||||||
.select(['takendown', 'id'])
|
.select(['takendown', 'id'])
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
ctx.db.db
|
ozone.ctx.db.db
|
||||||
.selectFrom('moderation_event')
|
.selectFrom('moderation_event')
|
||||||
.where('subjectDid', '=', alice)
|
.where('subjectDid', '=', alice)
|
||||||
.where(
|
.where(
|
||||||
@ -159,12 +160,12 @@ describe('takedowner', () => {
|
|||||||
throw new Error('expected mod action')
|
throw new Error('expected mod action')
|
||||||
}
|
}
|
||||||
expect(modStatus.takendown).toEqual(true)
|
expect(modStatus.takendown).toEqual(true)
|
||||||
const record = await ctx.db.db
|
const recordBsky = await bskyDb.db
|
||||||
.selectFrom('record')
|
.selectFrom('record')
|
||||||
.where('uri', '=', res.data.uri)
|
.where('uri', '=', res.data.uri)
|
||||||
.select('takedownId')
|
.select('takedownRef')
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
expect(record?.takedownId).toBeGreaterThan(0)
|
expect(recordBsky?.takedownRef).toEqual(`BSKY-TAKEDOWN-${takedownEvent.id}`)
|
||||||
|
|
||||||
const recordPds = await network.pds.ctx.actorStore.read(alice, (store) =>
|
const recordPds = await network.pds.ctx.actorStore.read(alice, (store) =>
|
||||||
store.db.db
|
store.db.db
|
||||||
@ -173,7 +174,7 @@ describe('takedowner', () => {
|
|||||||
.select('takedownRef')
|
.select('takedownRef')
|
||||||
.executeTakeFirst(),
|
.executeTakeFirst(),
|
||||||
)
|
)
|
||||||
expect(recordPds?.takedownRef).toEqual(takedownEvent.id.toString())
|
expect(recordPds?.takedownRef).toEqual(`BSKY-TAKEDOWN-${takedownEvent.id}`)
|
||||||
|
|
||||||
expect(testInvalidator.invalidated.length).toBe(2)
|
expect(testInvalidator.invalidated.length).toBe(2)
|
||||||
expect(testInvalidator.invalidated[1].subject).toBe(
|
expect(testInvalidator.invalidated[1].subject).toBe(
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
import { CID } from 'multiformats/cid'
|
import { CID } from 'multiformats/cid'
|
||||||
import { verifyCidForBytes } from '@atproto/common'
|
import { verifyCidForBytes } from '@atproto/common'
|
||||||
import { TestNetwork } from '@atproto/dev-env'
|
import { TestNetwork, basicSeed } from '@atproto/dev-env'
|
||||||
import basicSeed from './seeds/basic'
|
|
||||||
import { randomBytes } from '@atproto/crypto'
|
import { randomBytes } from '@atproto/crypto'
|
||||||
|
|
||||||
describe('blob resolver', () => {
|
describe('blob resolver', () => {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user