diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..598547d --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,30 @@ +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..59d8aea --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,38 @@ +import { AppBskyFeedDefs } from "@atproto/api"; + +import { Layout } from "./Layout"; +import { OEmbedTypes } from "../routes/getOEmbed"; +import { parseEmbedImage } from "../lib/parseEmbedImage"; +import { parseEmbedDescription } from "../lib/parseEmbedDescription"; + +interface PostProps { + post: AppBskyFeedDefs.PostView; + url: string; + appDomain: string; +} + +export const Post = ({ post, url, appDomain }: PostProps) => ( + + + + + + {!(parseEmbedImage(post) === post.author.avatar) && ( + + )} + + + + + +); diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx new file mode 100644 index 0000000..b2e9008 --- /dev/null +++ b/src/components/Profile.tsx @@ -0,0 +1,31 @@ +import { AppBskyActorDefs } from "@atproto/api"; + +import { Layout } from "./Layout"; +import { OEmbedTypes } from "../routes/getOEmbed"; + +interface ProfileProps { + profile: AppBskyActorDefs.ProfileViewDetailed; + url: string; + appDomain: string; +} + +export const Profile = ({ profile, url, appDomain }: ProfileProps) => ( + + + + + + + + +); diff --git a/src/globals.d.ts b/src/globals.d.ts index 47d605d..b4b9687 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -6,6 +6,7 @@ declare global { BSKY_SERVICE_URL: string; BSKY_AUTH_USERNAME: string; BSKY_AUTH_PASSWORD: string; + FIXBLUESKY_APP_DOMAIN: string; }; Variables: { Agent: BskyAgent; diff --git a/src/index.ts b/src/index.ts index e818869..04788f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ import { Hono } from "hono"; import { BskyAgent } from "@atproto/api"; -import { getPost } from "./lib/getPost"; -import { getPostData } from "./lib/getPostData"; -import { getPostOEmbed } from "./lib/getPostOEmbed"; +import { getPost } from "./routes/getPost"; +import { getPostData } from "./routes/getPostData"; +import { getOEmbed } from "./routes/getOEmbed"; +import { getProfileData } from "./routes/getProfileData"; +import { getProfile } from "./routes/getProfile"; const app = new Hono(); @@ -26,6 +28,12 @@ app.get("/https://bsky.app/profile/:user/post/:post", getPost); app.get("/profile/:user/post/:post/json", getPostData); app.get("/https://bsky.app/profile/:user/post/:post/json", getPostData); -app.get("/oembed", getPostOEmbed); +app.get("/profile/:user", getProfile); +app.get("/https://bsky.app/profile/:user", getProfile); + +app.get("/profile/:user/json", getProfileData); +app.get("/https://bsky.app/profile/:user/json", getProfileData); + +app.get("/oembed", getOEmbed); export default app; diff --git a/src/lib/fetchPostData.ts b/src/lib/fetchPostData.ts new file mode 100644 index 0000000..3bd4a51 --- /dev/null +++ b/src/lib/fetchPostData.ts @@ -0,0 +1,18 @@ +import { 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/fetchProfile.ts b/src/lib/fetchProfile.ts new file mode 100644 index 0000000..4a82a3e --- /dev/null +++ b/src/lib/fetchProfile.ts @@ -0,0 +1,14 @@ +import { BskyAgent } from "@atproto/api"; + +export interface fetchProfileOptions { + user: string; +} + +export async function fetchProfile( + agent: BskyAgent, + { user }: fetchProfileOptions +) { + return agent.getProfile({ + actor: user, + }); +} diff --git a/src/lib/getPost.ts b/src/lib/getPost.ts deleted file mode 100644 index 4da1b6a..0000000 --- a/src/lib/getPost.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Handler } from "hono"; -import { genHTML } from "../util/genHTML"; - -export const getPost: Handler< - Env, - "/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: userData } = await agent.getProfile({ - actor: user, - }); - const { data: postData } = await agent.getPosts({ - uris: [`at://${userData.did}/app.bsky.feed.post/${post}`], - }); - return c.html(genHTML(postData.posts[0], c.req.path)); -}; diff --git a/src/lib/getPostOEmbed.ts b/src/lib/getPostOEmbed.ts deleted file mode 100644 index 6553661..0000000 --- a/src/lib/getPostOEmbed.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Handler } from "hono"; - -export const getPostOEmbed: Handler = async (c) => { - const { handle, display_name, avatar } = c.req.query(); - return c.json({ - author_name: `${display_name} (@${handle})`, - author_url: `https://bsky.app/profile/${handle}`, - provider_name: "FixBluesky", - provider_url: "https://bsyy.app/", - thumbnail_url: avatar, - thumbnail_width: 1000, - thumbnail_height: 1000, - }); -}; diff --git a/src/lib/parseEmbedDescription.ts b/src/lib/parseEmbedDescription.ts new file mode 100644 index 0000000..7e47ca9 --- /dev/null +++ b/src/lib/parseEmbedDescription.ts @@ -0,0 +1,39 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPost, +} 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..c7a2014 --- /dev/null +++ b/src/lib/parseEmbedImage.ts @@ -0,0 +1,51 @@ +import { + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + 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 && + AppBskyEmbedImages.isView(post.embed.record.embeds[0]) + ) { + const { success: isImageView } = AppBskyEmbedImages.validateView( + post.embed.record.embeds[0] + ); + if (isImageView) { + return post.embed.record.embeds[0].images[0].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; + } + } + } + if (AppBskyEmbedImages.isView(post.embed)) { + const { success: isImageView } = AppBskyEmbedImages.validateView( + post.embed + ); + if (isImageView) { + return post.embed.images[0].fullsize; + } + } + return post.author.avatar ?? ""; +} diff --git a/src/routes/getOEmbed.ts b/src/routes/getOEmbed.ts new file mode 100644 index 0000000..d935f8f --- /dev/null +++ b/src/routes/getOEmbed.ts @@ -0,0 +1,35 @@ +import { Handler } from "hono"; + +export enum OEmbedTypes { + Post = 1, + Profile, +} + +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..1f253d0 --- /dev/null +++ b/src/routes/getPost.tsx @@ -0,0 +1,26 @@ +import { Handler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { fetchPost } from "../lib/fetchPostData"; +import { Post } from "../components/Post"; + +export const getPost: Handler< + Env, + "/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(genHTML(data.posts[0], c.req.path)); + return c.html( + + ); +}; diff --git a/src/lib/getPostData.ts b/src/routes/getPostData.ts similarity index 50% rename from src/lib/getPostData.ts rename to src/routes/getPostData.ts index a30cbeb..b1b5212 100644 --- a/src/lib/getPostData.ts +++ b/src/routes/getPostData.ts @@ -1,4 +1,6 @@ import { Handler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { fetchPost } from "../lib/fetchPostData"; export const getPostData: Handler< Env, @@ -7,11 +9,11 @@ export const getPostData: Handler< > = async (c) => { const { user, post } = c.req.param(); const agent = c.get("Agent"); - const { data: userData } = await agent.getProfile({ - actor: user, - }); - const { data } = await agent.getPosts({ - uris: [`at://${userData.did}/app.bsky.feed.post/${post}`], - }); + const { data, success } = await fetchPost(agent, { user, post }); + if (!success) { + throw new HTTPException(500, { + message: "Failed to fetch the post!", + }); + } return c.json(data); }; diff --git a/src/routes/getProfile.tsx b/src/routes/getProfile.tsx new file mode 100644 index 0000000..8068f9d --- /dev/null +++ b/src/routes/getProfile.tsx @@ -0,0 +1,25 @@ +import { Handler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { fetchProfile } from "../lib/fetchProfile"; +import { Profile } from "../components/Profile"; + +export const getProfile: Handler< + Env, + "/profile/:user" | "/https://bsky.app/profile/:user" +> = async (c) => { + const { user } = c.req.param(); + const agent = c.get("Agent"); + const { data, success } = await fetchProfile(agent, { user }); + if (!success) { + throw new HTTPException(500, { + message: "Failed to fetch the profile!", + }); + } + return c.html( + + ); +}; diff --git a/src/routes/getProfileData.ts b/src/routes/getProfileData.ts new file mode 100644 index 0000000..96c2414 --- /dev/null +++ b/src/routes/getProfileData.ts @@ -0,0 +1,20 @@ +/** @jsx jsx */ +import { jsx } from "hono/jsx"; +import { Handler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { fetchProfile } from "../lib/fetchProfile"; + +export const getProfileData: Handler< + Env, + "/profile/:user/json" | "/https://bsky.app/profile/:user/json" +> = async (c) => { + const { user } = c.req.param(); + const agent = c.get("Agent"); + const { data, success } = await fetchProfile(agent, { user }); + if (!success) { + throw new HTTPException(500, { + message: "Failed to fetch the profile!", + }); + } + return c.json(data); +}; diff --git a/src/util/genHTML.ts b/src/util/genHTML.ts deleted file mode 100644 index fd2124e..0000000 --- a/src/util/genHTML.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - AppBskyFeedDefs, - AppBskyEmbedImages, - AppBskyFeedPost, -} from "@atproto/api"; -import { html } from "hono/html"; - -export function genHTML(post: AppBskyFeedDefs.PostView, url: string) { - const removeLeadingSlash = url.substring(1); - const redirectUrl = removeLeadingSlash.startsWith("https://") - ? removeLeadingSlash - : `https://bsky.app/${removeLeadingSlash}`; - return html` - - - - - - - - - - - ${AppBskyEmbedImages.isView(post.embed) && - html``} - - - - - - - - `; -} diff --git a/tsconfig.json b/tsconfig.json index 9cd8489..4a2abdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,13 +5,9 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, - "lib": [ - "esnext" - ], - "types": [ - "@cloudflare/workers-types" - ], + "lib": ["esnext"], + "types": ["@cloudflare/workers-types"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx" - }, -} \ No newline at end of file + } +}