diff --git a/bskyogcard/package.json b/bskyogcard/package.json index e9fa78c8ee..55c6c6dba5 100644 --- a/bskyogcard/package.json +++ b/bskyogcard/package.json @@ -3,14 +3,11 @@ "version": "0.0.0", "type": "module", "main": "src/index.ts", - "engines": { - "node": ">=24.15.0" - }, "scripts": { "start": "tsx ./src/bin.ts", - "dev": "tsx watch --clear-screen=false ./src/bin.ts", + "dev": "node --watch-path ./src --import tsx ./src/bin.ts", "build": "tsc && cp -r src/assets dist/", - "install-fonts": "node scripts/install-fonts.ts" + "install-fonts": "tsx scripts/install-fonts.ts" }, "dependencies": { "@atproto/api": "0.12.19-next.0", @@ -28,7 +25,7 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^24.12.2", - "@types/react": "^18.3.0", + "@types/react": "^18.3.1", "tsx": "^4.21.0", "typescript": "^6.0.2" } diff --git a/bskyogcard/src/bin.ts b/bskyogcard/src/bin.ts index ff550809db..b7bf261817 100644 --- a/bskyogcard/src/bin.ts +++ b/bskyogcard/src/bin.ts @@ -1,4 +1,4 @@ -import cluster, {Worker} from 'node:cluster' +import cluster, {type Worker} from 'node:cluster' import {envInt} from '@atproto/common' diff --git a/bskyogcard/src/components/AvatarBubbles.tsx b/bskyogcard/src/components/AvatarBubbles.tsx new file mode 100644 index 0000000000..ef7f2c1e89 --- /dev/null +++ b/bskyogcard/src/components/AvatarBubbles.tsx @@ -0,0 +1,121 @@ +import {Img} from './Img.js' + +const SCALE = 3 +const BASE_SIZE = 120 +const PADDING = 4 +const CONTENT_SCALE = (BASE_SIZE - PADDING * 2) / BASE_SIZE + +export const AVATAR_BUBBLES_SIZE = BASE_SIZE * SCALE + +type BubbleConfig = {size: number; x: number; y: number} + +const LAYOUTS: Record<1 | 2 | 3 | 4, BubbleConfig[]> = { + 1: [ + {size: 76, x: 0, y: 0}, + {size: 76, x: 44, y: 44}, + ], + 2: [ + {size: 76, x: 0, y: 0}, + {size: 76, x: 44, y: 44}, + ], + 3: [ + {size: 68, x: 0.5, y: 0}, + {size: 56, x: 40.5, y: 64}, + {size: 46, x: 73.5, y: 20}, + ], + 4: [ + {size: 68, x: 1, y: 1}, + {size: 56, x: 63, y: 52}, + {size: 42, x: 17, y: 77}, + {size: 32, x: 75, y: 12}, + ], +} + +const PERSON_PATH = + 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z' + +export function AvatarBubbles(props: {images: (Buffer | null)[]}) { + const count = Math.min(props.images.length, 4) as 1 | 2 | 3 | 4 + const layout = LAYOUTS[count] ?? LAYOUTS[2] + + const bubbles = layout.map((bubble, i) => ({ + ...bubble, + image: props.images[i] ?? null, + })) + + // For the 2-avi layout, render in reverse so the first (top-left) paints last (on top) + if (count <= 2) { + bubbles.reverse() + } + + return ( +
+ {bubbles.map((bubble, i) => ( + + ))} +
+ ) +} + +function AvatarBubble(props: { + image: Buffer | null + size: number + x: number + y: number +}) { + return ( +
+ {props.image ? ( + + ) : ( + + )} +
+ ) +} + +function Placeholder(props: {size: number}) { + const iconSize = props.size * 0.5 + const offset = (props.size - iconSize) / 2 + return ( +
+ + + +
+ ) +} diff --git a/bskyogcard/src/components/Butterfly.tsx b/bskyogcard/src/components/Butterfly.tsx index 5a4124975c..f84e816d1e 100644 --- a/bskyogcard/src/components/Butterfly.tsx +++ b/bskyogcard/src/components/Butterfly.tsx @@ -1,5 +1,3 @@ -import React from 'react' - export function Butterfly(props: React.SVGAttributes) { return ( { + const didsParam = req.query.dids + if (typeof didsParam !== 'string') { + return res.status(400).end('missing dids parameter') + } + const dids = didsParam.split(',').filter(Boolean) + if (dids.length < 1 || dids.length > 4) { + return res.status(400).end('dids must contain 1-4 DIDs') + } + + let profiles + try { + const result = await ctx.appviewAgent.api.app.bsky.actor.getProfiles({ + actors: dids, + }) + profiles = result.data.profiles + } catch (err) { + httpLogger.warn({err, dids}, 'could not fetch profiles') + return res.status(502).end('could not fetch profiles') + } + + const images = await Promise.all( + dids.map(async did => { + const profile = profiles.find(p => p.did === did) + if (!profile?.avatar) return null + if (profile.labels?.some(l => hideAvatarLabels.has(l.val))) + return null + try { + return await getImage(profile.avatar) + } catch (err) { + httpLogger.warn({err, did}, 'could not fetch avatar image') + return null + } + }), + ) + + const svg = await satori(, { + fonts: ctx.fonts, + height: AVATAR_BUBBLES_SIZE, + width: AVATAR_BUBBLES_SIZE, + }) + const output = await resvg.renderAsync(svg) + res.statusCode = 200 + res.setHeader('content-type', 'image/png') + res.setHeader('cdn-tag', dids.join(',')) + return res.end(output.asPng()) + }), + ) +} diff --git a/bskyogcard/src/routes/health.ts b/bskyogcard/src/routes/health.ts index 0cc69515eb..7486386348 100644 --- a/bskyogcard/src/routes/health.ts +++ b/bskyogcard/src/routes/health.ts @@ -1,6 +1,6 @@ -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {handler} from './util.js' export default function (ctx: AppContext, app: Express) { diff --git a/bskyogcard/src/routes/index.ts b/bskyogcard/src/routes/index.ts index 0c40f89d3b..112c6a3548 100644 --- a/bskyogcard/src/routes/index.ts +++ b/bskyogcard/src/routes/index.ts @@ -1,6 +1,7 @@ -import {Express} from 'express' +import {type Express} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' +import {default as avatarBubbles} from './avatar-bubbles.js' import {default as health} from './health.js' import {default as starterPack} from './starter-pack.js' @@ -9,5 +10,6 @@ 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 + app = avatarBubbles(ctx, app) // GET /avatar-bubbles?dids=... return app } diff --git a/bskyogcard/src/routes/starter-pack.tsx b/bskyogcard/src/routes/starter-pack.tsx index d895b744a2..684d0a7c13 100644 --- a/bskyogcard/src/routes/starter-pack.tsx +++ b/bskyogcard/src/routes/starter-pack.tsx @@ -1,6 +1,5 @@ import assert from 'node:assert' -import React from 'react' import {type AppBskyGraphDefs, AtUri} from '@atproto/api' import resvg from '@resvg/resvg-js' import {type Express} from 'express' @@ -14,7 +13,12 @@ import { import {type AppContext} from '../context.js' import {httpLogger} from '../logger.js' import {loadEmojiAsSvg} from '../util.js' -import {handler, originVerifyMiddleware} from './util.js' +import { + getImage, + handler, + hideAvatarLabels, + originVerifyMiddleware, +} from './util.js' export default function (ctx: AppContext, app: Express) { return app.get( @@ -87,35 +91,3 @@ export default function (ctx: AppContext, app: Express) { }), ) } - -async function getImage(url: string) { - const response = await fetch(ensureJpeg(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) -} - -// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp). -// We want to ensure the image URLs we use are for jpegs, required for compat with satori. -function ensureJpeg(url: string) { - return url.replace(/(@[a-z]{3,5})?$/, '@jpeg') -} - -const hideAvatarLabels = new Set([ - '!hide', - '!warn', - 'porn', - 'sexual', - 'nudity', - 'sexual-figurative', - 'graphic-media', - 'gore', - 'self-harm', - 'sensitive', - 'security', - 'impersonation', - 'scam', - 'spam', - 'misleading', - 'inauthentic', -]) diff --git a/bskyogcard/src/routes/util.ts b/bskyogcard/src/routes/util.ts index 718ed592a1..9c441207b5 100644 --- a/bskyogcard/src/routes/util.ts +++ b/bskyogcard/src/routes/util.ts @@ -1,6 +1,11 @@ -import {ErrorRequestHandler, Request, RequestHandler, Response} from 'express' +import { + type ErrorRequestHandler, + type Request, + type RequestHandler, + type Response, +} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {httpLogger} from '../logger.js' export type Handler = (req: Request, res: Response) => Awaited @@ -34,3 +39,35 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { } return res.status(500).end('server error') } + +export async function getImage(url: string) { + const response = await fetch(ensureJpeg(url)) + const arrayBuf = await response.arrayBuffer() + if (response.status !== 200) return null + return Buffer.from(arrayBuf) +} + +// CDN URLs end with @jpeg, @webp, or no extension (which may default to webp). +// We want to ensure the image URLs we use are for jpegs, required for compat with satori. +function ensureJpeg(url: string) { + return url.replace(/(@[a-z]{3,5})?$/, '@jpeg') +} + +export const hideAvatarLabels = new Set([ + '!hide', + '!warn', + 'porn', + 'sexual', + 'nudity', + 'sexual-figurative', + 'graphic-media', + 'gore', + 'self-harm', + 'sensitive', + 'security', + 'impersonation', + 'scam', + 'spam', + 'misleading', + 'inauthentic', +]) diff --git a/bskyogcard/yarn.lock b/bskyogcard/yarn.lock index 3af9d5b7ca..3b921829d0 100644 --- a/bskyogcard/yarn.lock +++ b/bskyogcard/yarn.lock @@ -393,7 +393,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react@^18.3.0": +"@types/react@^18.3.1": version "18.3.28" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781" integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw== diff --git a/eslint.config.mjs b/eslint.config.mjs index 508524b62d..0580e96330 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -280,6 +280,16 @@ export default defineConfig( }, }, + /** + * bskyogcard - server-side, Node.js imports are fine + */ + { + files: ['bskyogcard/**/*.{js,jsx,ts,tsx}'], + rules: { + 'import-x/no-nodejs-modules': 'off', + }, + }, + /** * Test files configuration */