about summary refs log tree commit diff
path: root/bskyogcard/src/routes
diff options
context:
space:
mode:
authordevin ivy <devinivy@gmail.com>2024-06-20 17:45:52 -0400
committerGitHub <noreply@github.com>2024-06-20 17:45:52 -0400
commit51f5e6bf900685ef92191f22949d09035733c682 (patch)
tree7e613992c1b131f4fe082a794ae9c32555d87b12 /bskyogcard/src/routes
parenteac4668d7312b35721e147e808c181b2be0256bf (diff)
downloadvoidsky-51f5e6bf900685ef92191f22949d09035733c682.tar.zst
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, tidy
Diffstat (limited to 'bskyogcard/src/routes')
-rw-r--r--bskyogcard/src/routes/health.ts14
-rw-r--r--bskyogcard/src/routes/index.ts13
-rw-r--r--bskyogcard/src/routes/starter-pack.tsx102
-rw-r--r--bskyogcard/src/routes/util.ts36
4 files changed, 165 insertions, 0 deletions
diff --git a/bskyogcard/src/routes/health.ts b/bskyogcard/src/routes/health.ts
new file mode 100644
index 000000000..0cc69515e
--- /dev/null
+++ b/bskyogcard/src/routes/health.ts
@@ -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})
+    }),
+  )
+}
diff --git a/bskyogcard/src/routes/index.ts b/bskyogcard/src/routes/index.ts
new file mode 100644
index 000000000..0c40f89d3
--- /dev/null
+++ b/bskyogcard/src/routes/index.ts
@@ -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
+}
diff --git a/bskyogcard/src/routes/starter-pack.tsx b/bskyogcard/src/routes/starter-pack.tsx
new file mode 100644
index 000000000..cb3a55327
--- /dev/null
+++ b/bskyogcard/src/routes/starter-pack.tsx
@@ -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',
+])
diff --git a/bskyogcard/src/routes/util.ts b/bskyogcard/src/routes/util.ts
new file mode 100644
index 000000000..718ed592a
--- /dev/null
+++ b/bskyogcard/src/routes/util.ts
@@ -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')
+}