From 12f5b1eb687cede8aba88e6da7d133220a21725c Mon Sep 17 00:00:00 2001 From: Rose Date: Wed, 24 Apr 2024 01:32:35 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20added=20embeds=20for=20posts=20:3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .onedev-buildspec.yml | 43 ++++++++++++++++++ Dockerfile | 29 ++++++++++++ docker-compose.yml | 28 ++++++++++++ package.json | 7 +-- src/components/Layout.tsx | 27 ++++++++++++ src/components/Post.tsx | 52 ++++++++++++++++++++++ src/globals.d.ts | 9 ++++ src/lib/fetchPostData.ts | 18 ++++++++ src/lib/parseEmbedDescription.ts | 46 +++++++++++++++++++ src/lib/parseEmbedImage.ts | 58 ++++++++++++++++++++++++ src/main.ts | 57 +++++++++++++++++++++++- src/routes/getOEmbed.ts | 35 +++++++++++++++ src/routes/getPost.tsx | 27 ++++++++++++ src/util/checkEnv.ts | 15 +++++++ tsconfig.json | 2 + yarn.lock | 76 ++++++++++++++++++++++++++++++++ 16 files changed, 523 insertions(+), 6 deletions(-) create mode 100644 .onedev-buildspec.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/components/Layout.tsx create mode 100644 src/components/Post.tsx create mode 100644 src/globals.d.ts create mode 100644 src/lib/fetchPostData.ts create mode 100644 src/lib/parseEmbedDescription.ts create mode 100644 src/lib/parseEmbedImage.ts create mode 100644 src/routes/getOEmbed.ts create mode 100644 src/routes/getPost.tsx create mode 100644 src/util/checkEnv.ts diff --git a/.onedev-buildspec.yml b/.onedev-buildspec.yml new file mode 100644 index 0000000..60c2df3 --- /dev/null +++ b/.onedev-buildspec.yml @@ -0,0 +1,43 @@ +version: 31 +jobs: +- name: Build + steps: + - !CheckoutStep + name: checkout + cloneCredential: !DefaultCredential {} + withLfs: false + withSubmodules: false + condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL + - !CommandStep + name: lint + runInContainer: true + image: node:21-alpine + interpreter: !DefaultInterpreter + commands: | + apk add --no-cache libc6-compat + apk update + + yarn set version canary + yarn config set nodeLinker node-modules + + yarn install + yarn run lint + useTTY: true + condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL + - !BuildImageStep + name: '[build]: docker' + dockerfile: ./Dockerfile + tags: '@server@/rose/FixBluesky:devel' + publish: true + builtInRegistryAccessTokenSecret: DOCKER_REGISTRY_TOKEN + removeDanglingImages: true + condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL + triggers: + - !BranchUpdateTrigger + branches: devel + paths: src/** + projects: rose/FixBluesky + retryCondition: never + maxRetries: 3 + retryDelay: 30 + timeout: 3600 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eea0822 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:21-alpine AS base + +FROM base AS builder +RUN apk add --no-cache libc6-compat +RUN apk update +RUN yarn set version canary +RUN yarn config set nodeLinker node-modules + +WORKDIR /app + +COPY .gitignore .gitignore +COPY package.json ./ +COPY yarn.lock ./ +RUN yarn install + +COPY . . + +RUN yarn build + +FROM base AS runner +WORKDIR /app + +# Don't run production as root +RUN addgroup --system --gid 1001 fixbluesky +RUN adduser --system --uid 1001 fixbluesky +USER fixbluesky +COPY --from=builder /app . + +CMD node ./dist/main.js diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..236650a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + keydb: + container_name: keydb + image: eqalpha/keydb + restart: always + networks: + - fixbluesky + fixbluesky: + container_name: fixbluesky + build: + context: . + dockerfile: ./Dockerfile + restart: always + ports: + - 8787:8787 + environment: + - REDIS_HOSTNAME=${REDIS_HOSTNAME:-keydb} + - BSKY_SERVICE_URL=${BSKY_SERVICE_URL} + - BSKY_AUTH_USERNAME=${BSKY_AUTH_USERNAME} + - BSKY_AUTH_PASSWORD=${BSKY_AUTH_PASSWORD} + - FIXBLUESKY_APP_DOMAIN=${FIXBLUESKY_APP_DOMAIN} + networks: + - fixbluesky + depends_on: + - keydb +networks: + fixbluesky: + name: fixbluesky diff --git a/package.json b/package.json index da517bb..bd2adab 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,11 @@ { "name": "fixbluesky", "private": true, - "packageManager": "yarn@4.1.1", "volta": { "node": "21.7.3", "yarn": "4.1.1" }, - "type": "module", "main": "./dist/main.js", "module": "./dist/main.js", @@ -17,13 +15,11 @@ "types": "./dist/main.d.ts" } }, - "scripts": { "dev": "tsx ./src/main.ts", "lint": "biome ci ./src/**/*", "build": "pkgroll" }, - "devDependencies": { "@biomejs/biome": "1.7.1", "@types/node": "20.12.7", @@ -34,6 +30,7 @@ "dependencies": { "@atproto/api": "0.12.5", "@hono/node-server": "1.11.0", - "hono": "4.2.7" + "hono": "4.2.7", + "ioredis": "5.4.1" } } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..bf96d18 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,27 @@ +import { html } from "hono/html"; + +export interface LayoutProps { + url: string; + children: any; +} + +export const Layout = ({ url, children }: LayoutProps) => { + const removeLeadingSlash = url.substring(1); + const redirectUrl = removeLeadingSlash.startsWith("https://") + ? removeLeadingSlash + : `https://bsky.app/${removeLeadingSlash}`; + return html` + + + + + + + + + ${children} + + + + `; +}; diff --git a/src/components/Post.tsx b/src/components/Post.tsx new file mode 100644 index 0000000..53aa740 --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,52 @@ +import type { AppBskyFeedDefs } from "@atproto/api"; + +import { parseEmbedDescription } from "../lib/parseEmbedDescription.ts"; +import { parseEmbedImage } from "../lib/parseEmbedImage.ts"; +import { OEmbedTypes } from "../routes/getOEmbed.ts"; +import { Layout } from "./Layout.tsx"; + +interface PostProps { + post: AppBskyFeedDefs.PostView; + url: string; + appDomain: string; +} + +export const Post = ({ post, url, appDomain }: PostProps) => { + const image = parseEmbedImage(post); + return ( + + + + + + {!(image === post.author.avatar) && ( + + )} + + {Array.isArray(image) ? ( + image.map((i) => ( + + )) + ) : ( + + )} + + + + ); +}; diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..d0b2e50 --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,9 @@ +import type { BskyAgent } from "@atproto/api"; + +declare global { + interface HonoEnv { + Variables: { + Agent: BskyAgent; + }; + } +} diff --git a/src/lib/fetchPostData.ts b/src/lib/fetchPostData.ts new file mode 100644 index 0000000..0343bef --- /dev/null +++ b/src/lib/fetchPostData.ts @@ -0,0 +1,18 @@ +import type { BskyAgent } from "@atproto/api"; + +export interface fetchPostOptions { + user: string; + post: string; +} + +export async function fetchPost( + agent: BskyAgent, + { user, post }: fetchPostOptions, +) { + const { data: userData } = await agent.getProfile({ + actor: user, + }); + return agent.getPosts({ + uris: [`at://${userData.did}/app.bsky.feed.post/${post}`], + }); +} diff --git a/src/lib/parseEmbedDescription.ts b/src/lib/parseEmbedDescription.ts new file mode 100644 index 0000000..777fa1e --- /dev/null +++ b/src/lib/parseEmbedDescription.ts @@ -0,0 +1,46 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, + type AppBskyFeedDefs, +} from "@atproto/api"; + +export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) { + if (AppBskyFeedPost.isRecord(post.record)) { + if (AppBskyEmbedRecord.isView(post.embed)) { + const { success: isView } = AppBskyEmbedRecord.validateView( + post.embed, + ); + if ( + isView && + AppBskyEmbedRecord.isViewRecord(post.embed.record) + ) { + const { success: isViewRecord } = + AppBskyEmbedRecord.validateViewRecord(post.embed.record); + if (isViewRecord) { + // @ts-expect-error For some reason the original post value is typed as {} + return `${post.record.text}\n\nQuoting @${post.embed.record.author.handle}\n➥ ${post.embed.record.value.text}`; + } + } + } + if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + const { success: isView } = + AppBskyEmbedRecordWithMedia.validateView(post.embed); + if ( + isView && + AppBskyEmbedRecord.isViewRecord(post.embed.record.record) + ) { + const { success: isViewRecord } = + AppBskyEmbedRecord.validateViewRecord( + post.embed.record.record, + ); + if (isViewRecord) { + // @ts-expect-error For some reason the original post value is typed as {} + return `${post.record.text}\n\nQuoting @${post.embed.record.record.author.handle}\n➥ ${post.embed.record.record.value.text}`; + } + } + } + return post.record.text; + } + return ""; +} diff --git a/src/lib/parseEmbedImage.ts b/src/lib/parseEmbedImage.ts new file mode 100644 index 0000000..999f92c --- /dev/null +++ b/src/lib/parseEmbedImage.ts @@ -0,0 +1,58 @@ +import { + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, +} from "@atproto/api"; + +export function parseEmbedImage(post: AppBskyFeedDefs.PostView) { + if (AppBskyEmbedRecord.isView(post.embed)) { + const { success: isView } = AppBskyEmbedRecord.validateView( + post.embed, + ); + if (isView && AppBskyEmbedRecord.isViewRecord(post.embed.record)) { + const { success: isViewRecord } = + AppBskyEmbedRecord.validateViewRecord(post.embed.record); + if ( + isViewRecord && + post.embed.record.embeds && + post.embed.record.embeds.every((v) => + AppBskyEmbedImages.isView(v), + ) + ) { + const validImageView = post.embed.record.embeds.every((v) => + AppBskyEmbedImages.validateView(v), + ); + if (validImageView) { + // return post.embed.record.embeds[0].images[0].fullsize; + const embeds = post.embed.record + .embeds as AppBskyEmbedImages.View[]; + return embeds.flatMap((e) => e.images.map((i) => i.fullsize)); + } + } + } + } + if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + const { success: isView } = + AppBskyEmbedRecordWithMedia.validateView(post.embed); + if (isView && AppBskyEmbedImages.isView(post.embed.media)) { + const { success: isImageView } = AppBskyEmbedImages.validateView( + post.embed.media, + ); + if (isImageView) { + // return post.embed.media.images[0].fullsize; + return post.embed.media.images.map((i) => i.fullsize); + } + } + } + if (AppBskyEmbedImages.isView(post.embed)) { + const { success: isImageView } = AppBskyEmbedImages.validateView( + post.embed, + ); + if (isImageView) { + // return post.embed.images[0].fullsize; + return post.embed.images.map((i) => i.fullsize); + } + } + return post.author.avatar ?? ""; +} diff --git a/src/main.ts b/src/main.ts index 6e34bc0..31acb62 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,62 @@ +import "./util/checkEnv.ts"; + +import { BskyAgent } from "@atproto/api"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { Redis } from "ioredis"; +import { getPost } from "./routes/getPost.tsx"; -const app = new Hono(); +// biome-ignore lint/style/noNonNullAssertion: check is ran at app start +const redis = new Redis(6379, process.env.REDIS_HOSTNAME!); + +const agent = new BskyAgent({ + // biome-ignore lint/style/noNonNullAssertion: check is ran at app start + service: process.env.BSKY_SERVICE_URL!, + persistSession: async (_evt, session) => { + if (session) { + await redis.set("session", JSON.stringify(session)); + } + }, +}); + +const app = new Hono(); + +app.use("*", async (c, next) => { + const session = await redis.get("session"); + try { + if (session) { + const login = await agent.resumeSession(JSON.parse(session)); + if (!login.success) { + await agent.login({ + // biome-ignore lint/style/noNonNullAssertion: check is ran at app start + identifier: process.env.BSKY_AUTH_USERNAME!, + // biome-ignore lint/style/noNonNullAssertion: check is ran at app start + password: process.env.BSKY_AUTH_PASSWORD!, + }); + } + } else { + await agent.login({ + // biome-ignore lint/style/noNonNullAssertion: check is ran at app start + identifier: process.env.BSKY_AUTH_USERNAME!, + // biome-ignore lint/style/noNonNullAssertion: check is ran at app start + password: process.env.BSKY_AUTH_PASSWORD!, + }); + } + c.set("Agent", agent); + } catch (error) { + const err = new Error("Failed to login to Bluesky!", { + cause: error, + }); + throw new HTTPException(500, { + message: `${err.message} \n\n ${err.cause} \n\n ${err.stack}`, + }); + } + return next(); +}); + +app.get("/profile/:user/post/:post", getPost); +app.get("/https://bsky.app/profile/:user/post/:post", getPost); serve( { diff --git a/src/routes/getOEmbed.ts b/src/routes/getOEmbed.ts new file mode 100644 index 0000000..77a68b2 --- /dev/null +++ b/src/routes/getOEmbed.ts @@ -0,0 +1,35 @@ +import type { Handler } from "hono"; + +export enum OEmbedTypes { + Post = 1, + Profile = 2, +} + +export const getOEmbed: Handler = async (c) => { + const type = +(c.req.query("type") ?? 0); + const avatar = c.req.query("avatar"); + + const defaults = { + provider_name: "FixBluesky", + provider_url: "https://bsyy.app/", + thumbnail_url: avatar, + thumbnail_width: 1000, + thumbnail_height: 1000, + }; + + if (type === OEmbedTypes.Post) { + const { replies, reposts, likes } = c.req.query(); + return c.json({ + author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`, + ...defaults, + }); + } + if (type === OEmbedTypes.Profile) { + const { follows, posts } = c.req.query(); + return c.json({ + author_name: `👤 ${follows} followers\n🗨️ ${posts} skeets`, + ...defaults, + }); + } + return c.json(defaults, 400); +}; diff --git a/src/routes/getPost.tsx b/src/routes/getPost.tsx new file mode 100644 index 0000000..96bd28b --- /dev/null +++ b/src/routes/getPost.tsx @@ -0,0 +1,27 @@ +import type { Handler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { Post } from "../components/Post.tsx"; +import { fetchPost } from "../lib/fetchPostData.ts"; + +export const getPost: Handler< + HonoEnv, + | "/profile/:user/post/:post" + | "/https://bsky.app/profile/:user/post/:post" +> = async (c) => { + const { user, post } = c.req.param(); + const agent = c.get("Agent"); + const { data, success } = await fetchPost(agent, { user, post }); + if (!success) { + throw new HTTPException(500, { + message: "Failed to fetch the post!", + }); + } + + return c.html( + , + ); +}; diff --git a/src/util/checkEnv.ts b/src/util/checkEnv.ts new file mode 100644 index 0000000..08a16dd --- /dev/null +++ b/src/util/checkEnv.ts @@ -0,0 +1,15 @@ +if (!process.env.REDIS_HOSTNAME) { + throw new Error("REDIS_HOSTNAME is not defined"); +} + +if (!process.env.BSKY_SERVICE_URL) { + throw new Error("BSKY_SERVICE_URL is not defined"); +} + +if (!process.env.BSKY_AUTH_USERNAME) { + throw new Error("BSKY_AUTH_USERNAME is not defined"); +} + +if (!process.env.BSKY_AUTH_PASSWORD) { + throw new Error("BSKY_AUTH_PASSWORD is not defined"); +} diff --git a/tsconfig.json b/tsconfig.json index 3bdc03a..56f114c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,8 @@ "declarationMap": true, "esModuleInterop": true, "importHelpers": false, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", "lib": ["esnext"], "module": "NodeNext", "moduleResolution": "NodeNext", diff --git a/yarn.lock b/yarn.lock index 89ef669..aa7a480 100644 --- a/yarn.lock +++ b/yarn.lock @@ -481,6 +481,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:^1.1.1": + version: 1.2.0 + resolution: "@ioredis/commands@npm:1.2.0" + checksum: 10c0/a5d3c29dd84d8a28b7c67a441ac1715cbd7337a7b88649c0f17c345d89aa218578d2b360760017c48149ef8a70f44b051af9ac0921a0622c2b479614c4f65b36 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -892,6 +899,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:^1.1.0": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -945,6 +959,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -1172,6 +1193,7 @@ __metadata: "@hono/node-server": "npm:1.11.0" "@types/node": "npm:20.12.7" hono: "npm:4.2.7" + ioredis: "npm:5.4.1" pkgroll: "npm:2.0.2" tsx: "npm:4.7.2" typescript: "npm:5.4.5" @@ -1373,6 +1395,23 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:5.4.1": + version: 5.4.1 + resolution: "ioredis@npm:5.4.1" + dependencies: + "@ioredis/commands": "npm:^1.1.1" + cluster-key-slot: "npm:^1.1.0" + debug: "npm:^4.3.4" + denque: "npm:^2.1.0" + lodash.defaults: "npm:^4.2.0" + lodash.isarguments: "npm:^3.1.0" + redis-errors: "npm:^1.2.0" + redis-parser: "npm:^3.0.0" + standard-as-callback: "npm:^2.1.0" + checksum: 10c0/5d28b7c89a3cab5b76d75923d7d4ce79172b3a1ca9be690133f6e8e393a7a4b4ffd55513e618bbb5504fed80d9e1395c9d9531a7c5c5c84aa4c4e765cca75456 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -1472,6 +1511,20 @@ __metadata: languageName: node linkType: hard +"lodash.defaults@npm:^4.2.0": + version: 4.2.0 + resolution: "lodash.defaults@npm:4.2.0" + checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 + languageName: node + linkType: hard + +"lodash.isarguments@npm:^3.1.0": + version: 3.1.0 + resolution: "lodash.isarguments@npm:3.1.0" + checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.2.0 resolution: "lru-cache@npm:10.2.0" @@ -1770,6 +1823,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -1965,6 +2034,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:^2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": version: 4.2.3 resolution: "string-width@npm:4.2.3"