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"