Skip to content

Commit

Permalink
✨ Expose takendown profile, their follows and followers to mods (blue…
Browse files Browse the repository at this point in the history
…sky-social#1456)

* ✨ Allow moderators to see takendown profiles

* ✨ Allow moderators to see follows and followers of takendown account

* ♻️ Let auth check fail on optional verifier

* ♻️ Use role type to check moderator access
  • Loading branch information
foysalit authored Aug 17, 2023
1 parent 853fe6f commit 8d61760
Show file tree
Hide file tree
Showing 10 changed files with 156 additions and 53 deletions.
9 changes: 6 additions & 3 deletions packages/bsky/src/api/app/bsky/actor/getProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { setRepoRev } from '../../../util'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.actor.getProfile({
auth: ctx.authOptionalVerifier,
auth: ctx.authOptionalAccessOrRoleVerifier,
handler: async ({ auth, params, res }) => {
const { actor } = params
const requester = auth.credentials.did
const requester = 'did' in auth.credentials ? auth.credentials.did : null
const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const db = ctx.db.getReplica()
const actorService = ctx.services.actor(db)

Expand All @@ -22,7 +24,7 @@ export default function (server: Server, ctx: AppContext) {
if (!actorRes) {
throw new InvalidRequestError('Profile not found')
}
if (softDeleted(actorRes)) {
if (!canViewTakendownProfile && softDeleted(actorRes)) {
throw new InvalidRequestError(
'Account has been taken down',
'AccountTakedown',
Expand All @@ -31,6 +33,7 @@ export default function (server: Server, ctx: AppContext) {
const profile = await actorService.views.profileDetailed(
actorRes,
requester,
{ includeSoftDeleted: canViewTakendownProfile },
)
if (!profile) {
throw new InvalidRequestError('Profile not found')
Expand Down
23 changes: 17 additions & 6 deletions packages/bsky/src/api/app/bsky/graph/getFollowers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import { notSoftDeletedClause } from '../../../../db/util'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.getFollowers({
auth: ctx.authOptionalVerifier,
auth: ctx.authOptionalAccessOrRoleVerifier,
handler: async ({ params, auth }) => {
const { actor, limit, cursor } = params
const requester = auth.credentials.did
const requester = 'did' in auth.credentials ? auth.credentials.did : null
const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const db = ctx.db.getReplica()
const { ref } = db.db.dynamic

const actorService = ctx.services.actor(db)
const graphService = ctx.services.graph(db)

const subjectRes = await actorService.getActor(actor)
const subjectRes = await actorService.getActor(
actor,
canViewTakendownProfile,
)
if (!subjectRes) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
}
Expand All @@ -25,7 +30,9 @@ export default function (server: Server, ctx: AppContext) {
.selectFrom('follow')
.where('follow.subjectDid', '=', subjectRes.did)
.innerJoin('actor as creator', 'creator.did', 'follow.creator')
.where(notSoftDeletedClause(ref('creator')))
.if(!canViewTakendownProfile, (qb) =>
qb.where(notSoftDeletedClause(ref('creator'))),
)
.whereNotExists(
graphService.blockQb(requester, [ref('follow.creator')]),
)
Expand All @@ -47,8 +54,12 @@ export default function (server: Server, ctx: AppContext) {

const followersRes = await followersReq.execute()
const [followers, subject] = await Promise.all([
actorService.views.hydrateProfiles(followersRes, requester),
actorService.views.profile(subjectRes, requester),
actorService.views.hydrateProfiles(followersRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
actorService.views.profile(subjectRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
])
if (!subject) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
Expand Down
23 changes: 17 additions & 6 deletions packages/bsky/src/api/app/bsky/graph/getFollows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import { notSoftDeletedClause } from '../../../../db/util'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.getFollows({
auth: ctx.authOptionalVerifier,
auth: ctx.authOptionalAccessOrRoleVerifier,
handler: async ({ params, auth }) => {
const { actor, limit, cursor } = params
const requester = auth.credentials.did
const requester = 'did' in auth.credentials ? auth.credentials.did : null
const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const db = ctx.db.getReplica()
const { ref } = db.db.dynamic

const actorService = ctx.services.actor(db)
const graphService = ctx.services.graph(db)

const creatorRes = await actorService.getActor(actor)
const creatorRes = await actorService.getActor(
actor,
canViewTakendownProfile,
)
if (!creatorRes) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
}
Expand All @@ -25,7 +30,9 @@ export default function (server: Server, ctx: AppContext) {
.selectFrom('follow')
.where('follow.creator', '=', creatorRes.did)
.innerJoin('actor as subject', 'subject.did', 'follow.subjectDid')
.where(notSoftDeletedClause(ref('subject')))
.if(!canViewTakendownProfile, (qb) =>
qb.where(notSoftDeletedClause(ref('subject'))),
)
.whereNotExists(
graphService.blockQb(requester, [ref('follow.subjectDid')]),
)
Expand All @@ -47,8 +54,12 @@ export default function (server: Server, ctx: AppContext) {

const followsRes = await followsReq.execute()
const [follows, subject] = await Promise.all([
actorService.views.hydrateProfiles(followsRes, requester),
actorService.views.profile(creatorRes, requester),
actorService.views.hydrateProfiles(followsRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
actorService.views.profile(creatorRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
])
if (!subject) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
Expand Down
36 changes: 36 additions & 0 deletions packages/bsky/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ export const authOptionalVerifier =
return authVerifier(idResolver, opts)(reqCtx)
}

export const authOptionalAccessOrRoleVerifier = (
idResolver: IdResolver,
cfg: ServerConfig,
) => {
const verifyAccess = authVerifier(idResolver, { aud: cfg.serverDid })
const verifyRole = roleVerifier(cfg)
return async (ctx: { req: express.Request; res: express.Response }) => {
const defaultUnAuthorizedCredentials = {
credentials: { did: null, type: 'unauthed' as const },
}
if (!ctx.req.headers.authorization) {
return defaultUnAuthorizedCredentials
}
// For non-admin tokens, we don't want to consider alternative verifiers and let it fail if it fails
const isRoleAuthToken = ctx.req.headers.authorization?.startsWith(BASIC)
if (isRoleAuthToken) {
const result = await verifyRole(ctx)
return {
...result,
credentials: {
type: 'role' as const,
...result.credentials,
},
}
}
const result = await verifyAccess(ctx)
return {
...result,
credentials: {
type: 'access' as const,
...result.credentials,
},
}
}
}

export const roleVerifier =
(cfg: ServerConfig) =>
async (reqCtx: { req: express.Request; res: express.Response }) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/bsky/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export class AppContext {
})
}

get authOptionalAccessOrRoleVerifier() {
return auth.authOptionalAccessOrRoleVerifier(this.idResolver, this.cfg)
}

get roleVerifier() {
return auth.roleVerifier(this.cfg)
}
Expand Down
16 changes: 12 additions & 4 deletions packages/pds/src/app-view/api/app/bsky/actor/getProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../../lexicon'
import { softDeleted } from '../../../../../db/util'
import AppContext from '../../../../../context'
import { authPassthru } from '../../../../../api/com/atproto/admin/util'
import { OutputSchema } from '../../../../../lexicon/types/app/bsky/actor/getProfile'
import { handleReadAfterWrite } from '../util/read-after-write'
import { LocalRecords } from '../../../../../services/local'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.actor.getProfile({
auth: ctx.accessVerifier,
auth: ctx.accessOrRoleVerifier,
handler: async ({ req, auth, params }) => {
const requester = auth.credentials.did
const requester =
auth.credentials.type === 'access' ? auth.credentials.did : null
if (ctx.canProxyRead(req)) {
const res = await ctx.appviewAgent.api.app.bsky.actor.getProfile(
params,
await ctx.serviceAuthHeaders(requester),
requester
? await ctx.serviceAuthHeaders(requester)
: authPassthru(req),
)
if (res.data.did === requester) {
return await handleReadAfterWrite(
Expand All @@ -30,6 +34,9 @@ export default function (server: Server, ctx: AppContext) {
}
}

// As long as user has triage permission, we know that they are a moderator user and can see taken down profiles
const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const { actor } = params
const { db, services } = ctx
const actorService = services.appView.actor(db)
Expand All @@ -39,7 +46,7 @@ export default function (server: Server, ctx: AppContext) {
if (!actorRes) {
throw new InvalidRequestError('Profile not found')
}
if (softDeleted(actorRes)) {
if (!canViewTakendownProfile && softDeleted(actorRes)) {
throw new InvalidRequestError(
'Account has been taken down',
'AccountTakedown',
Expand All @@ -48,6 +55,7 @@ export default function (server: Server, ctx: AppContext) {
const profile = await actorService.views.profileDetailed(
actorRes,
requester,
{ includeSoftDeleted: canViewTakendownProfile },
)
if (!profile) {
throw new InvalidRequestError('Profile not found')
Expand Down
29 changes: 22 additions & 7 deletions packages/pds/src/app-view/api/app/bsky/graph/getFollowers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,40 @@ import { Server } from '../../../../../lexicon'
import { paginate, TimeCidKeyset } from '../../../../../db/pagination'
import AppContext from '../../../../../context'
import { notSoftDeletedClause } from '../../../../../db/util'
import { authPassthru } from '../../../../../api/com/atproto/admin/util'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.getFollowers({
auth: ctx.accessVerifier,
auth: ctx.accessOrRoleVerifier,
handler: async ({ req, params, auth }) => {
const requester = auth.credentials.did
const requester =
auth.credentials.type === 'access' ? auth.credentials.did : null
if (ctx.canProxyRead(req)) {
const res = await ctx.appviewAgent.api.app.bsky.graph.getFollowers(
params,
await ctx.serviceAuthHeaders(requester),
requester
? await ctx.serviceAuthHeaders(requester)
: authPassthru(req),
)
return {
encoding: 'application/json',
body: res.data,
}
}

const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const { actor, limit, cursor } = params
const { services, db } = ctx
const { ref } = db.db.dynamic

const actorService = services.appView.actor(db)
const graphService = services.appView.graph(db)

const subjectRes = await actorService.getActor(actor)
const subjectRes = await actorService.getActor(
actor,
canViewTakendownProfile,
)
if (!subjectRes) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
}
Expand All @@ -41,7 +50,9 @@ export default function (server: Server, ctx: AppContext) {
'creator_repo.did',
'follow.creator',
)
.where(notSoftDeletedClause(ref('creator_repo')))
.if(canViewTakendownProfile, (qb) =>
qb.where(notSoftDeletedClause(ref('creator_repo'))),
)
.whereNotExists(
graphService.blockQb(requester, [ref('follow.creator')]),
)
Expand All @@ -66,8 +77,12 @@ export default function (server: Server, ctx: AppContext) {

const followersRes = await followersReq.execute()
const [followers, subject] = await Promise.all([
actorService.views.hydrateProfiles(followersRes, requester),
actorService.views.profile(subjectRes, requester),
actorService.views.hydrateProfiles(followersRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
actorService.views.profile(subjectRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
])
if (!subject) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
Expand Down
29 changes: 22 additions & 7 deletions packages/pds/src/app-view/api/app/bsky/graph/getFollows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,40 @@ import { Server } from '../../../../../lexicon'
import { paginate, TimeCidKeyset } from '../../../../../db/pagination'
import AppContext from '../../../../../context'
import { notSoftDeletedClause } from '../../../../../db/util'
import { authPassthru } from '../../../../../api/com/atproto/admin/util'

export default function (server: Server, ctx: AppContext) {
server.app.bsky.graph.getFollows({
auth: ctx.accessVerifier,
auth: ctx.accessOrRoleVerifier,
handler: async ({ req, params, auth }) => {
const requester = auth.credentials.did
const requester =
auth.credentials.type === 'access' ? auth.credentials.did : null
if (ctx.canProxyRead(req)) {
const res = await ctx.appviewAgent.api.app.bsky.graph.getFollows(
params,
await ctx.serviceAuthHeaders(requester),
requester
? await ctx.serviceAuthHeaders(requester)
: authPassthru(req),
)
return {
encoding: 'application/json',
body: res.data,
}
}

const canViewTakendownProfile =
auth.credentials.type === 'role' && auth.credentials.triage
const { actor, limit, cursor } = params
const { services, db } = ctx
const { ref } = db.db.dynamic

const actorService = services.appView.actor(db)
const graphService = services.appView.graph(db)

const creatorRes = await actorService.getActor(actor)
const creatorRes = await actorService.getActor(
actor,
canViewTakendownProfile,
)
if (!creatorRes) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
}
Expand All @@ -41,7 +50,9 @@ export default function (server: Server, ctx: AppContext) {
'subject_repo.did',
'follow.subjectDid',
)
.where(notSoftDeletedClause(ref('subject_repo')))
.if(!canViewTakendownProfile, (qb) =>
qb.where(notSoftDeletedClause(ref('subject_repo'))),
)
.whereNotExists(
graphService.blockQb(requester, [ref('follow.subjectDid')]),
)
Expand All @@ -66,8 +77,12 @@ export default function (server: Server, ctx: AppContext) {

const followsRes = await followsReq.execute()
const [follows, subject] = await Promise.all([
actorService.views.hydrateProfiles(followsRes, requester),
actorService.views.profile(creatorRes, requester),
actorService.views.hydrateProfiles(followsRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
actorService.views.profile(creatorRes, requester, {
includeSoftDeleted: canViewTakendownProfile,
}),
])
if (!subject) {
throw new InvalidRequestError(`Actor not found: ${actor}`)
Expand Down
Loading

0 comments on commit 8d61760

Please sign in to comment.