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