Add moderation setup
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
AppBskyGraphStarterpack,
|
AppBskyGraphStarterpack,
|
||||||
|
ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {atoms as a, gradient, theme as t} from '../theme/index.js'
|
import {atoms as a, gradient, theme as t} from '../theme/index.js'
|
||||||
@@ -31,15 +32,18 @@ import {Text} from './Text.js'
|
|||||||
export function Post({
|
export function Post({
|
||||||
post,
|
post,
|
||||||
data,
|
data,
|
||||||
|
moderation,
|
||||||
}: {
|
}: {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
data: PostData
|
data: PostData
|
||||||
|
moderation: ModerationDecision
|
||||||
}) {
|
}) {
|
||||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||||
const avatar = data.images.get(post.author.avatar)
|
const avatar = data.images.get(post.author.avatar)
|
||||||
const text = post.record.text
|
const text = post.record.text
|
||||||
const rt = data.texts.get(text)
|
const rt = data.texts.get(text)
|
||||||
const hasInteractions = post.likeCount > 0 || post.repostCount > 0
|
const hasInteractions = post.likeCount > 0 || post.repostCount > 0
|
||||||
|
console.log(moderation.ui('contentView'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
import {AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||||
import resvg from '@resvg/resvg-js'
|
import resvg from '@resvg/resvg-js'
|
||||||
import {Express} from 'express'
|
import {Express} from 'express'
|
||||||
import satori from 'satori'
|
import satori from 'satori'
|
||||||
@@ -8,6 +8,7 @@ import {Post} from '../components/Post.js'
|
|||||||
import {AppContext} from '../context.js'
|
import {AppContext} from '../context.js'
|
||||||
import {httpLogger} from '../logger.js'
|
import {httpLogger} from '../logger.js'
|
||||||
import {loadEmojiAsSvg} from '../util.js'
|
import {loadEmojiAsSvg} from '../util.js'
|
||||||
|
import {getModerationOptions, moderatePost} from '../util/moderation.js'
|
||||||
import {resolvePostData} from '../util/resolvePostData.js'
|
import {resolvePostData} from '../util/resolvePostData.js'
|
||||||
import {handler, originVerifyMiddleware} from './util.js'
|
import {handler, originVerifyMiddleware} from './util.js'
|
||||||
|
|
||||||
@@ -21,8 +22,6 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
let {actor, rkey} = req.params
|
let {actor, rkey} = req.params
|
||||||
let uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
let uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
||||||
|
|
||||||
let post: AppBskyFeedDefs.PostView
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!actor.startsWith('did:')) {
|
if (!actor.startsWith('did:')) {
|
||||||
const res = await ctx.appviewAgent.resolveHandle({
|
const res = await ctx.appviewAgent.resolveHandle({
|
||||||
@@ -34,44 +33,56 @@ export default function (ctx: AppContext, app: Express) {
|
|||||||
const {data} = await ctx.appviewAgent.getPosts({
|
const {data} = await ctx.appviewAgent.getPosts({
|
||||||
uris: [uri.toString()],
|
uris: [uri.toString()],
|
||||||
})
|
})
|
||||||
post = data.posts.at(0)
|
const post = data.posts.at(0)
|
||||||
|
|
||||||
|
if (!AppBskyFeedPost.isRecord(post.record)) {
|
||||||
|
return res.status(404).end('not found')
|
||||||
|
}
|
||||||
const notPublic = post.author.labels.some(
|
const notPublic = post.author.labels.some(
|
||||||
l => l.val === `!no-unauthenticated`,
|
l => l.val === `!no-unauthenticated`,
|
||||||
)
|
)
|
||||||
if (notPublic) {
|
if (notPublic) {
|
||||||
return res.status(404).end('not found')
|
return res.status(404).end('not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [postData, moderationOptions] = await Promise.all([
|
||||||
|
resolvePostData(post, ctx.appviewAgent),
|
||||||
|
getModerationOptions(ctx.appviewAgent),
|
||||||
|
])
|
||||||
|
|
||||||
|
const svg = await satori(
|
||||||
|
<Post
|
||||||
|
post={post}
|
||||||
|
data={postData}
|
||||||
|
moderation={moderatePost(post, moderationOptions)}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
fonts: ctx.fonts,
|
||||||
|
width: WIDTH,
|
||||||
|
loadAdditionalAsset: async (code, text) => {
|
||||||
|
if (code === 'emoji') {
|
||||||
|
return await loadEmojiAsSvg(text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const output = await resvg.renderAsync(svg, {
|
||||||
|
fitTo: {
|
||||||
|
mode: 'width',
|
||||||
|
value: WIDTH * 2,
|
||||||
|
},
|
||||||
|
logLevel: 'trace',
|
||||||
|
})
|
||||||
|
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('content-type', 'image/png')
|
||||||
|
res.setHeader('cdn-tag', [...postData.images.keys()].join(','))
|
||||||
|
|
||||||
|
return res.end(output.asPng())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
httpLogger.warn({err, uri: uri.toString()}, 'could not fetch post')
|
httpLogger.warn({err, uri: uri.toString()}, 'could not fetch post')
|
||||||
return res.status(404).end('not found')
|
return res.status(404).end('not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AppBskyFeedPost.isRecord(post.record)) {
|
|
||||||
return res.status(404).end('not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await resolvePostData(post, ctx.appviewAgent)
|
|
||||||
const svg = await satori(<Post post={post} data={data} />, {
|
|
||||||
fonts: ctx.fonts,
|
|
||||||
width: WIDTH,
|
|
||||||
loadAdditionalAsset: async (code, text) => {
|
|
||||||
if (code === 'emoji') {
|
|
||||||
return await loadEmojiAsSvg(text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const output = await resvg.renderAsync(svg, {
|
|
||||||
fitTo: {
|
|
||||||
mode: 'width',
|
|
||||||
value: WIDTH * 2,
|
|
||||||
},
|
|
||||||
logLevel: 'trace',
|
|
||||||
})
|
|
||||||
res.statusCode = 200
|
|
||||||
res.setHeader('content-type', 'image/png')
|
|
||||||
res.setHeader('cdn-tag', [...data.images.keys()].join(','))
|
|
||||||
return res.end(output.asPng())
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* eslint-disable-next-line no-restricted-imports */
|
||||||
|
import {
|
||||||
|
AppBskyLabelerDefs,
|
||||||
|
AtpAgent,
|
||||||
|
BSKY_LABELER_DID,
|
||||||
|
DEFAULT_LABEL_SETTINGS,
|
||||||
|
interpretLabelValueDefinitions,
|
||||||
|
moderatePost as defaultModeratePost,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
type ModeratePost = typeof defaultModeratePost
|
||||||
|
export type Subject = Parameters<ModeratePost>[0]
|
||||||
|
export type Options = Parameters<ModeratePost>[1]
|
||||||
|
|
||||||
|
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
|
||||||
|
if (subject.labels) {
|
||||||
|
for (const label of subject.labels) {
|
||||||
|
if (
|
||||||
|
label.val === 'gore' &&
|
||||||
|
(!label.src || label.src === BSKY_LABELER_DID)
|
||||||
|
) {
|
||||||
|
label.val = 'graphic-media'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LABELS: typeof DEFAULT_LABEL_SETTINGS = Object.fromEntries(
|
||||||
|
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function moderatePost(post: Subject, opts: Options) {
|
||||||
|
// HACK
|
||||||
|
// temporarily translate 'gore' into 'graphic-media' during the transition period
|
||||||
|
// can remove this in a few months
|
||||||
|
// -prf
|
||||||
|
translateOldLabels(post)
|
||||||
|
|
||||||
|
return defaultModeratePost(post, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModerationOptions(agent: AtpAgent) {
|
||||||
|
const labelDefs = await getLabelDefinitions(agent)
|
||||||
|
const opts: Options = {
|
||||||
|
userDid: undefined,
|
||||||
|
prefs: {
|
||||||
|
adultContentEnabled: false,
|
||||||
|
labels: DEFAULT_LABELS,
|
||||||
|
mutedWords: [],
|
||||||
|
hiddenPosts: [],
|
||||||
|
labelers: [
|
||||||
|
{
|
||||||
|
did: BSKY_LABELER_DID,
|
||||||
|
labels: DEFAULT_LABELS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
labelDefs: {
|
||||||
|
[BSKY_LABELER_DID]: labelDefs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabelDefinitions(agent: AtpAgent) {
|
||||||
|
const {data} = await agent.app.bsky.labeler.getServices({
|
||||||
|
dids: [BSKY_LABELER_DID],
|
||||||
|
detailed: true,
|
||||||
|
})
|
||||||
|
if (!data || !data.views[0]) {
|
||||||
|
throw new Error(`Could not fetch label definitions`)
|
||||||
|
}
|
||||||
|
const labeler = data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
|
||||||
|
|
||||||
|
return interpretLabelValueDefinitions(labeler)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user