Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bsky link card service #4547

Merged
merged 25 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f8b123a
setup bskycard
devinivy Jun 10, 2024
9ae29e8
quick proof of concept for png card generation
devinivy Jun 10, 2024
3872974
bskycard: use jsx
devinivy Jun 10, 2024
d133d72
bskycard: 3x5 profile layout
devinivy Jun 10, 2024
464fd16
bskycard: add butterfly overlay
devinivy Jun 10, 2024
7494a03
bskycard: tidy
devinivy Jun 10, 2024
262e59d
bskycard: separate and reorganize
devinivy Jun 11, 2024
2d4cd0f
bskycard: tidy
devinivy Jun 11, 2024
65eccd1
bskycard: tidy
devinivy Jun 11, 2024
7e9c40f
bskycard: tidy
devinivy Jun 11, 2024
f876213
bskycard: poc of transparent overlay and box shadow
devinivy Jun 11, 2024
aeda563
bskycard: reorg impl into src/ directory
devinivy Jun 17, 2024
5c67196
bskycard: use more standard app structure
devinivy Jun 17, 2024
a28e84b
bskycard: setup dockerfile, fix build
devinivy Jun 17, 2024
f3a030f
bskycard: support for x-origin-verify
devinivy Jun 17, 2024
7f84c9b
bskycard: card layout, filter images based on labels
devinivy Jun 18, 2024
c07557c
bskycard: tidy
devinivy Jun 18, 2024
8ce6bbb
bskycard: support cluster mode
devinivy Jun 18, 2024
f8c058d
bskycard: handle error fetching starter pack info
devinivy Jun 18, 2024
b13398c
bskycard: tidy
devinivy Jun 18, 2024
a1a67ca
bskycard: fix leak on failed image fetch
devinivy Jun 18, 2024
a1a881d
bskycard: build workflow
devinivy Jun 19, 2024
fab409a
bskyogcard: rename from bskycard
devinivy Jun 19, 2024
f907aa5
bskyogcard: fix some express plumbing
devinivy Jun 20, 2024
754d9d1
bskyogcard: add cdn tags, tidy
devinivy Jun 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/build-and-push-ogcard-aws.yaml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions Dockerfile.bskyogcard
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions bskyogcard/package.json
Original file line number Diff line number Diff line change
@@ -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 added bskyogcard/src/assets/Inter-Bold.ttf
Binary file not shown.
48 changes: 48 additions & 0 deletions bskyogcard/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions bskyogcard/src/components/Butterfly.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
10 changes: 10 additions & 0 deletions bskyogcard/src/components/Img.tsx
Original file line number Diff line number Diff line change
@@ -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')}`} />
)
}
149 changes: 149 additions & 0 deletions bskyogcard/src/components/StarterPack.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
40 changes: 40 additions & 0 deletions bskyogcard/src/config.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading
Loading