about summary refs log tree commit diff
path: root/bskyogcard/src/routes/starter-pack.tsx
blob: cb3a553272330cbf4e812c2efa22905cf50ea6fa (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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',
])