about summary refs log tree commit diff
path: root/bskyogcard/src
diff options
context:
space:
mode:
Diffstat (limited to 'bskyogcard/src')
-rw-r--r--bskyogcard/src/assets/Inter-Bold.ttfbin0 -> 316584 bytes
-rw-r--r--bskyogcard/src/bin.ts48
-rw-r--r--bskyogcard/src/components/Butterfly.tsx16
-rw-r--r--bskyogcard/src/components/Img.tsx10
-rw-r--r--bskyogcard/src/components/StarterPack.tsx149
-rw-r--r--bskyogcard/src/config.ts40
-rw-r--r--bskyogcard/src/context.ts44
-rw-r--r--bskyogcard/src/index.ts41
-rw-r--r--bskyogcard/src/logger.ts3
-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
13 files changed, 516 insertions, 0 deletions
diff --git a/bskyogcard/src/assets/Inter-Bold.ttf b/bskyogcard/src/assets/Inter-Bold.ttf
new file mode 100644
index 000000000..fe23eeb9c
--- /dev/null
+++ b/bskyogcard/src/assets/Inter-Bold.ttf
Binary files differdiff --git a/bskyogcard/src/bin.ts b/bskyogcard/src/bin.ts
new file mode 100644
index 000000000..ff550809d
--- /dev/null
+++ b/bskyogcard/src/bin.ts
@@ -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
+}
diff --git a/bskyogcard/src/components/Butterfly.tsx b/bskyogcard/src/components/Butterfly.tsx
new file mode 100644
index 000000000..5a4124975
--- /dev/null
+++ b/bskyogcard/src/components/Butterfly.tsx
@@ -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>
+  )
+}
diff --git a/bskyogcard/src/components/Img.tsx b/bskyogcard/src/components/Img.tsx
new file mode 100644
index 000000000..dac223180
--- /dev/null
+++ b/bskyogcard/src/components/Img.tsx
@@ -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')}`} />
+  )
+}
diff --git a/bskyogcard/src/components/StarterPack.tsx b/bskyogcard/src/components/StarterPack.tsx
new file mode 100644
index 000000000..f73442190
--- /dev/null
+++ b/bskyogcard/src/components/StarterPack.tsx
@@ -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>
+  )
+}
diff --git a/bskyogcard/src/config.ts b/bskyogcard/src/config.ts
new file mode 100644
index 000000000..fafa18e74
--- /dev/null
+++ b/bskyogcard/src/config.ts
@@ -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,
+  }
+}
diff --git a/bskyogcard/src/context.ts b/bskyogcard/src/context.ts
new file mode 100644
index 000000000..f92651caf
--- /dev/null
+++ b/bskyogcard/src/context.ts
@@ -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,
+    })
+  }
+}
diff --git a/bskyogcard/src/index.ts b/bskyogcard/src/index.ts
new file mode 100644
index 000000000..ef8d48494
--- /dev/null
+++ b/bskyogcard/src/index.ts
@@ -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()
+  }
+}
diff --git a/bskyogcard/src/logger.ts b/bskyogcard/src/logger.ts
new file mode 100644
index 000000000..04b5d9046
--- /dev/null
+++ b/bskyogcard/src/logger.ts
@@ -0,0 +1,3 @@
+import {subsystemLogger} from '@atproto/common'
+
+export const httpLogger = subsystemLogger('bskyogcard')
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')
+}