Bsky link card service (#4547)
* setup bskycard * quick proof of concept for png card generation * bskycard: use jsx * bskycard: 3x5 profile layout * bskycard: add butterfly overlay * bskycard: tidy * bskycard: separate and reorganize * bskycard: tidy * bskycard: tidy * bskycard: tidy * bskycard: poc of transparent overlay and box shadow * bskycard: reorg impl into src/ directory * bskycard: use more standard app structure * bskycard: setup dockerfile, fix build * bskycard: support for x-origin-verify * bskycard: card layout, filter images based on labels * bskycard: tidy * bskycard: support cluster mode * bskycard: handle error fetching starter pack info * bskycard: tidy * bskycard: fix leak on failed image fetch * bskycard: build workflow * bskyogcard: rename from bskycard * bskyogcard: fix some express plumbing * bskyogcard: add cdn tags, tidyzio/stable
parent
eac4668d73
commit
51f5e6bf90
|
@ -0,0 +1,55 @@
|
|||
name: build-and-push-ogcard-aws
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- divy/bskycard
|
||||
|
||||
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: bskyogcard
|
||||
|
||||
jobs:
|
||||
ogcard-container-aws:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
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@v1
|
||||
|
||||
- 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: ./Dockerfile.bskyogcard
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
|
@ -0,0 +1,41 @@
|
|||
FROM node:20.11-alpine3.18 as build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
|
||||
# build then prune dev deps
|
||||
RUN yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:20.11-alpine3.18
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
# Avoid zombie processes, handle signal forwarding
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN mkdir /app/data && chown node /app/data
|
||||
|
||||
VOLUME /app/data
|
||||
EXPOSE 3000
|
||||
ENV CARD_PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
# potential perf issues w/ io_uring on this version of node
|
||||
ENV UV_USE_IO_URING=0
|
||||
|
||||
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
|
||||
USER node
|
||||
CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "dist/bin.js"]
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
|
||||
LABEL org.opencontainers.image.description="Bsky Card Service"
|
||||
LABEL org.opencontainers.image.licenses=UNLICENSED
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "bskyogcard",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --loader ts-node/esm ./src/bin.ts",
|
||||
"build": "tsc && cp -r src/assets dist/assets"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.12.19-next.0",
|
||||
"@atproto/common": "^0.4.0",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
"http-terminator": "^3.2.0",
|
||||
"pino": "^9.2.0",
|
||||
"react": "^18.3.1",
|
||||
"satori": "^0.10.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -0,0 +1,48 @@
|
|||
import cluster, {Worker} from 'node:cluster'
|
||||
|
||||
import {envInt} from '@atproto/common'
|
||||
|
||||
import {CardService, envToCfg, httpLogger, readEnv} from './index.js'
|
||||
|
||||
async function main() {
|
||||
const env = readEnv()
|
||||
const cfg = envToCfg(env)
|
||||
const card = await CardService.create(cfg)
|
||||
await card.start()
|
||||
httpLogger.info('card service is running')
|
||||
process.on('SIGTERM', async () => {
|
||||
httpLogger.info('card service is stopping')
|
||||
await card.destroy()
|
||||
httpLogger.info('card service is stopped')
|
||||
if (cluster.isWorker) process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
const workerCount = envInt('CARD_CLUSTER_WORKER_COUNT')
|
||||
|
||||
if (workerCount) {
|
||||
if (cluster.isPrimary) {
|
||||
httpLogger.info(`primary ${process.pid} is running`)
|
||||
const workers = new Set<Worker>()
|
||||
for (let i = 0; i < workerCount; ++i) {
|
||||
workers.add(cluster.fork())
|
||||
}
|
||||
let teardown = false
|
||||
cluster.on('exit', worker => {
|
||||
workers.delete(worker)
|
||||
if (!teardown) {
|
||||
workers.add(cluster.fork()) // restart on crash
|
||||
}
|
||||
})
|
||||
process.on('SIGTERM', () => {
|
||||
teardown = true
|
||||
httpLogger.info('disconnecting workers')
|
||||
workers.forEach(w => w.kill('SIGTERM'))
|
||||
})
|
||||
} else {
|
||||
httpLogger.info(`worker ${process.pid} is running`)
|
||||
main()
|
||||
}
|
||||
} else {
|
||||
main() // non-clustering
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
|
||||
export function Butterfly(props: React.SVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 568 501"
|
||||
{...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
|
||||
export function Img(
|
||||
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
|
||||
) {
|
||||
const {src, ...others} = props
|
||||
return (
|
||||
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
|
||||
)
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
/* eslint-disable bsky-internal/avoid-unwrapped-text */
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
|
||||
import {Butterfly} from './Butterfly.js'
|
||||
import {Img} from './Img.js'
|
||||
|
||||
export const STARTERPACK_HEIGHT = 630
|
||||
export const STARTERPACK_WIDTH = 1200
|
||||
export const TILE_SIZE = STARTERPACK_HEIGHT / 3
|
||||
|
||||
const GRADIENT_TOP = '#0A7AFF'
|
||||
const GRADIENT_BOTTOM = '#59B9FF'
|
||||
const IMAGE_STROKE = '#359CFF'
|
||||
|
||||
export function StarterPack(props: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
images: Map<string, Buffer>
|
||||
}) {
|
||||
const {starterPack, images} = props
|
||||
const record = AppBskyGraphStarterpack.isRecord(starterPack.record)
|
||||
? starterPack.record
|
||||
: null
|
||||
const imagesArray = [...images.values()]
|
||||
const imageOfCreator = images.get(starterPack.creator.did)
|
||||
const imagesExceptCreator = [...images.entries()]
|
||||
.filter(([did]) => did !== starterPack.creator.did)
|
||||
.map(([, image]) => image)
|
||||
const imagesAcross: Buffer[] = []
|
||||
if (imageOfCreator) {
|
||||
if (imagesExceptCreator.length >= 6) {
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, 3))
|
||||
imagesAcross.push(imageOfCreator)
|
||||
imagesAcross.push(...imagesExceptCreator.slice(3, 6))
|
||||
} else {
|
||||
const firstHalf = Math.floor(imagesExceptCreator.length / 2)
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, firstHalf))
|
||||
imagesAcross.push(imageOfCreator)
|
||||
imagesAcross.push(
|
||||
...imagesExceptCreator.slice(firstHalf, imagesExceptCreator.length),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
imagesAcross.push(...imagesExceptCreator.slice(0, 7))
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: STARTERPACK_WIDTH,
|
||||
height: STARTERPACK_HEIGHT,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
fontFamily: 'Inter',
|
||||
}}>
|
||||
{/* image tiles */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'stretch',
|
||||
width: TILE_SIZE * 6,
|
||||
height: TILE_SIZE * 3,
|
||||
}}>
|
||||
{[...Array(18)].map((_, i) => {
|
||||
const image = imagesArray.at(i % imagesArray.length)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: TILE_SIZE,
|
||||
width: TILE_SIZE,
|
||||
}}>
|
||||
{image && <Img height="100%" width="100%" src={image} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* background overlay */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
backgroundImage: `linear-gradient(to bottom, ${GRADIENT_TOP}, ${GRADIENT_BOTTOM})`,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* foreground text & images */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
color: 'white',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
color: 'white',
|
||||
padding: 60,
|
||||
fontSize: 40,
|
||||
}}>
|
||||
JOIN THE CONVERSATION
|
||||
</div>
|
||||
<div style={{display: 'flex'}}>
|
||||
{imagesAcross.map((image, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 172 + 15 * 2,
|
||||
width: 172 + 15 * 2,
|
||||
margin: -15,
|
||||
border: `15px solid ${IMAGE_STROKE}`,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Img height="100%" width="100%" src={image} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '75px 30px 0px',
|
||||
fontSize: 65,
|
||||
}}>
|
||||
{record?.name || 'Starter Pack'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
fontSize: 40,
|
||||
justifyContent: 'center',
|
||||
padding: '30px 30px 10px',
|
||||
}}>
|
||||
on <Butterfly width="65" style={{margin: '-7px 10px 0'}} /> Bluesky
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import {envInt, envStr} from '@atproto/common'
|
||||
|
||||
export type Config = {
|
||||
service: ServiceConfig
|
||||
}
|
||||
|
||||
export type ServiceConfig = {
|
||||
port: number
|
||||
version?: string
|
||||
appviewUrl: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
export type Environment = {
|
||||
port?: number
|
||||
version?: string
|
||||
appviewUrl?: string
|
||||
originVerify?: string
|
||||
}
|
||||
|
||||
export const readEnv = (): Environment => {
|
||||
return {
|
||||
port: envInt('CARD_PORT'),
|
||||
version: envStr('CARD_VERSION'),
|
||||
appviewUrl: envStr('CARD_APPVIEW_URL'),
|
||||
originVerify: envStr('CARD_ORIGIN_VERIFY'),
|
||||
}
|
||||
}
|
||||
|
||||
export const envToCfg = (env: Environment): Config => {
|
||||
const serviceCfg: ServiceConfig = {
|
||||
port: env.port ?? 3000,
|
||||
version: env.version,
|
||||
appviewUrl: env.appviewUrl ?? 'https://api.bsky.app',
|
||||
originVerify: env.originVerify,
|
||||
}
|
||||
return {
|
||||
service: serviceCfg,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import {readFileSync} from 'node:fs'
|
||||
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import * as path from 'path'
|
||||
import {fileURLToPath} from 'url'
|
||||
|
||||
import {Config} from './config.js'
|
||||
|
||||
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export type AppContextOptions = {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
}
|
||||
|
||||
export class AppContext {
|
||||
cfg: Config
|
||||
appviewAgent: AtpAgent
|
||||
fonts: {name: string; data: Buffer}[]
|
||||
abortController = new AbortController()
|
||||
|
||||
constructor(private opts: AppContextOptions) {
|
||||
this.cfg = this.opts.cfg
|
||||
this.appviewAgent = this.opts.appviewAgent
|
||||
this.fonts = this.opts.fonts
|
||||
}
|
||||
|
||||
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
|
||||
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
|
||||
const fonts = [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')),
|
||||
},
|
||||
]
|
||||
return new AppContext({
|
||||
cfg,
|
||||
appviewAgent,
|
||||
fonts,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import events from 'node:events'
|
||||
import http from 'node:http'
|
||||
|
||||
import express from 'express'
|
||||
import {createHttpTerminator, HttpTerminator} from 'http-terminator'
|
||||
|
||||
import {Config} from './config.js'
|
||||
import {AppContext} from './context.js'
|
||||
import {default as routes, errorHandler} from './routes/index.js'
|
||||
|
||||
export * from './config.js'
|
||||
export * from './logger.js'
|
||||
|
||||
export class CardService {
|
||||
public server?: http.Server
|
||||
private terminator?: HttpTerminator
|
||||
|
||||
constructor(public app: express.Application, public ctx: AppContext) {}
|
||||
|
||||
static async create(cfg: Config): Promise<CardService> {
|
||||
let app = express()
|
||||
|
||||
const ctx = await AppContext.fromConfig(cfg)
|
||||
app = routes(ctx, app)
|
||||
app.use(errorHandler)
|
||||
|
||||
return new CardService(app, ctx)
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.server = this.app.listen(this.ctx.cfg.service.port)
|
||||
this.server.keepAliveTimeout = 90000
|
||||
this.terminator = createHttpTerminator({server: this.server})
|
||||
await events.once(this.server, 'listening')
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.ctx.abortController.abort()
|
||||
await this.terminator?.terminate()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import {subsystemLogger} from '@atproto/common'
|
||||
|
||||
export const httpLogger = subsystemLogger('bskyogcard')
|
|
@ -0,0 +1,14 @@
|
|||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {handler} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/_health',
|
||||
handler(async (_req, res) => {
|
||||
const {version} = ctx.cfg.service
|
||||
return res.send({version})
|
||||
}),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as starterPack} from './starter-pack.js'
|
||||
|
||||
export * from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = starterPack(ctx, app) // GET /start/:actor/:rkey
|
||||
return app
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import assert from 'node:assert'
|
||||
|
||||
import React from 'react'
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {
|
||||
StarterPack,
|
||||
STARTERPACK_HEIGHT,
|
||||
STARTERPACK_WIDTH,
|
||||
} from '../components/StarterPack.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/start/:actor/:rkey',
|
||||
originVerifyMiddleware(ctx),
|
||||
handler(async (req, res) => {
|
||||
const {actor, rkey} = req.params
|
||||
const uri = AtUri.make(actor, 'app.bsky.graph.starterpack', rkey)
|
||||
let starterPack: AppBskyGraphDefs.StarterPackView
|
||||
try {
|
||||
const result = await ctx.appviewAgent.api.app.bsky.graph.getStarterPack(
|
||||
{starterPack: uri.toString()},
|
||||
)
|
||||
starterPack = result.data.starterPack
|
||||
} catch (err) {
|
||||
httpLogger.warn(
|
||||
{err, uri: uri.toString()},
|
||||
'could not fetch starter pack',
|
||||
)
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
const imageEntries = await Promise.all(
|
||||
[starterPack.creator]
|
||||
.concat(starterPack.listItemsSample.map(li => li.subject))
|
||||
// has avatar
|
||||
.filter(p => p.avatar)
|
||||
// no sensitive labels
|
||||
.filter(p => !p.labels.some(l => hideAvatarLabels.has(l.val)))
|
||||
.map(async p => {
|
||||
try {
|
||||
assert(p.avatar)
|
||||
const image = await getImage(p.avatar)
|
||||
return [p.did, image] as const
|
||||
} catch (err) {
|
||||
httpLogger.warn(
|
||||
{err, uri: uri.toString(), did: p.did},
|
||||
'could not fetch image',
|
||||
)
|
||||
return [p.did, null] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
const images = new Map(
|
||||
imageEntries.filter(([_, image]) => image !== null).slice(0, 7),
|
||||
)
|
||||
const svg = await satori(
|
||||
<StarterPack starterPack={starterPack} images={images} />,
|
||||
{
|
||||
fonts: ctx.fonts,
|
||||
height: STARTERPACK_HEIGHT,
|
||||
width: STARTERPACK_WIDTH,
|
||||
},
|
||||
)
|
||||
const output = await resvg.renderAsync(svg)
|
||||
res.statusCode = 200
|
||||
res.setHeader('content-type', 'image/png')
|
||||
res.setHeader('cdn-tag', [...images.keys()].join(','))
|
||||
return res.end(output.asPng())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function getImage(url: string) {
|
||||
const response = await fetch(url)
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return Buffer.from(arrayBuf)
|
||||
}
|
||||
|
||||
const hideAvatarLabels = new Set([
|
||||
'!hide',
|
||||
'!warn',
|
||||
'porn',
|
||||
'sexual',
|
||||
'nudity',
|
||||
'sexual-figurative',
|
||||
'graphic-media',
|
||||
'self-harm',
|
||||
'sensitive',
|
||||
'security',
|
||||
'impersonation',
|
||||
'scam',
|
||||
'spam',
|
||||
'misleading',
|
||||
'inauthentic',
|
||||
])
|
|
@ -0,0 +1,36 @@
|
|||
import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
|
||||
export type Handler = (req: Request, res: Response) => Awaited<void>
|
||||
|
||||
export const handler = (runHandler: Handler): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
await runHandler(req, res)
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function originVerifyMiddleware(ctx: AppContext): RequestHandler {
|
||||
const {originVerify} = ctx.cfg.service
|
||||
if (!originVerify) return (_req, _res, next) => next()
|
||||
return (req, res, next) => {
|
||||
const verifyHeader = req.headers['x-origin-verify']
|
||||
if (verifyHeader !== originVerify) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
httpLogger.error({err}, 'request error')
|
||||
if (res.headersSent) {
|
||||
return next(err)
|
||||
}
|
||||
return res.status(500).end('server error')
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./src/index.ts", "./src/bin.ts"]
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue