diff --git a/.github/README/raw-media.png b/.github/README/raw-media.png new file mode 100644 index 0000000..e0971a4 Binary files /dev/null and b/.github/README/raw-media.png differ diff --git a/README.md b/README.md index bf6589f..69b4b44 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ Embed Bluesky links in Discord. #### Simply append `x` at the end of `bsky.app`. -## FAQ +## Direct Links -### [Video is too long to embed] in an embed description. -Due to Discord's and BlueSky limitations, the video cannot be embedded if it exceeds ~30s. You can still view the video by clicking on the `VixBluesky` in the author field. +You want to link to a media directly? You can prepend `r.` to the URL to get a direct link. +![Direct Link](./.github/README/raw-media.png) ## Authors diff --git a/conf/nginx.conf b/conf/nginx.conf new file mode 100644 index 0000000..1d2ead8 --- /dev/null +++ b/conf/nginx.conf @@ -0,0 +1,49 @@ +# r.bskyx.app +server { + set $scheme https; + set $server "bskyx.app"; + set $port 80; + + listen 80; + listen [::]:80; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + + server_name r.bskyx.app; + + + ssl_certificate /etc/letsencrypt/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/privkey.pem; + + access_log /var/nginx/log/r.bskyx.app-access.log proxy; + error_log /var/nginx/log/r.bskyx.app-error.log warn; + + location / { + rewrite ^(.*)$ $scheme://bskyx.app$1?direct=true permanent; + } +} + +# api.bskyx.app +server { + set $scheme http; + set $server "localhost"; + set $port 2598; + + listen 80; + listen [::]:80; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + + server_name api.bskyx.app; + + ssl_certificate /etc/letsencrypt/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/privkey.pem; + + + access_log /var/nginx/logs/api.bskyx.app-access.log proxy; + error_log /var/nginx/logs/api.bskyx.app-error.log warn; +} diff --git a/pkgs/app/.prettierrc b/pkgs/app/.prettierrc new file mode 100644 index 0000000..f791beb --- /dev/null +++ b/pkgs/app/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/pkgs/app/package.json b/pkgs/app/package.json index deb4f07..2220bdc 100644 --- a/pkgs/app/package.json +++ b/pkgs/app/package.json @@ -4,11 +4,13 @@ "deploy": "wrangler deploy --minify src/index.ts" }, "dependencies": { - "@atproto/api": "^0.12.24", + "@atcute/bluesky": "^1.0.7", + "@atcute/client": "^2.0.3", "hono": "^4.5.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20230628.0", + "prettier": "^3.3.3", "typescript": "^5.1.6", "wrangler": "^3.75.0" } diff --git a/pkgs/app/pnpm-lock.yaml b/pkgs/app/pnpm-lock.yaml index 9999e46..ec9afee 100644 --- a/pkgs/app/pnpm-lock.yaml +++ b/pkgs/app/pnpm-lock.yaml @@ -8,9 +8,12 @@ importers: .: dependencies: - '@atproto/api': - specifier: ^0.12.24 - version: 0.12.29 + '@atcute/bluesky': + specifier: ^1.0.7 + version: 1.0.7(@atcute/client@2.0.3) + '@atcute/client': + specifier: ^2.0.3 + version: 2.0.3 hono: specifier: ^4.5.1 version: 4.5.11 @@ -18,6 +21,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20230628.0 version: 4.20230710.1 + prettier: + specifier: ^3.3.3 + version: 3.3.3 typescript: specifier: ^5.1.6 version: 5.1.6 @@ -27,20 +33,13 @@ importers: packages: - '@atproto/api@0.12.29': - resolution: {integrity: sha512-PyzPLjGWR0qNOMrmj3Nt3N5NuuANSgOk/33Bu3j+rFjjPrHvk9CI6iQPU6zuDaDCoyOTRJRafw8X/aMQw+ilgw==} + '@atcute/bluesky@1.0.7': + resolution: {integrity: sha512-2jPHzl7WbcqRtcAXanJy4Lp638ujqnoGmPCPmBlmpEDP34D7EVKQqjN/mlvglb5n539dThA9xlSgIS8yOxwzDA==} + peerDependencies: + '@atcute/client': ^1.0.0 || ^2.0.0 - '@atproto/common-web@0.3.0': - resolution: {integrity: sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==} - - '@atproto/lexicon@0.4.1': - resolution: {integrity: sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==} - - '@atproto/syntax@0.3.0': - resolution: {integrity: sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==} - - '@atproto/xrpc@0.5.0': - resolution: {integrity: sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==} + '@atcute/client@2.0.3': + resolution: {integrity: sha512-j9GryA5l+4F0BTQWa6/1XmsuSPSq+bqNCY3mrHUGD592hMqUZxgpYDLgRWL+719V287AW/56AwvFYlbjlENp7A==} '@cloudflare/kv-asset-handler@0.3.4': resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} @@ -259,9 +258,6 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} - await-lock@2.2.2: - resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} - binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -340,9 +336,6 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -371,9 +364,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - iso-datestring-validator@2.2.2: - resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} - magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -390,9 +380,6 @@ packages: ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - multiformats@9.9.0: - resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} - mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true @@ -426,6 +413,11 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -474,10 +466,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tlds@1.240.0: - resolution: {integrity: sha512-1OYJQenswGZSOdRw7Bql5Qu7uf75b+F3HFBXbqnG/ifHa0fev1XcG+3pJf3pA/KC6RtHQzfKgIf1vkMlMG7mtQ==} - hasBin: true - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -493,9 +481,6 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - uint8arrays@3.0.0: - resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} - undici@5.28.4: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} @@ -536,45 +521,16 @@ packages: youch@3.2.3: resolution: {integrity: sha512-ZBcWz/uzZaQVdCvfV4uk616Bbpf2ee+F/AvuKDR5EwX/Y4v06xWdtMluqTD7+KlZdM93lLm9gMZYo0sKBS0pgw==} - zod@3.21.4: - resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} snapshots: - '@atproto/api@0.12.29': + '@atcute/bluesky@1.0.7(@atcute/client@2.0.3)': dependencies: - '@atproto/common-web': 0.3.0 - '@atproto/lexicon': 0.4.1 - '@atproto/syntax': 0.3.0 - '@atproto/xrpc': 0.5.0 - await-lock: 2.2.2 - multiformats: 9.9.0 - tlds: 1.240.0 + '@atcute/client': 2.0.3 - '@atproto/common-web@0.3.0': - dependencies: - graphemer: 1.4.0 - multiformats: 9.9.0 - uint8arrays: 3.0.0 - zod: 3.21.4 - - '@atproto/lexicon@0.4.1': - dependencies: - '@atproto/common-web': 0.3.0 - '@atproto/syntax': 0.3.0 - iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 - zod: 3.23.8 - - '@atproto/syntax@0.3.0': {} - - '@atproto/xrpc@0.5.0': - dependencies: - '@atproto/lexicon': 0.4.1 - zod: 3.21.4 + '@atcute/client@2.0.3': {} '@cloudflare/kv-asset-handler@0.3.4': dependencies: @@ -703,8 +659,6 @@ snapshots: dependencies: printable-characters: 1.0.42 - await-lock@2.2.2: {} - binary-extensions@2.2.0: {} blake3-wasm@2.1.5: {} @@ -795,8 +749,6 @@ snapshots: glob-to-regexp@0.4.1: {} - graphemer@1.4.0: {} - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -819,8 +771,6 @@ snapshots: is-number@7.0.0: {} - iso-datestring-validator@2.2.2: {} - magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -848,8 +798,6 @@ snapshots: ms@2.1.2: {} - multiformats@9.9.0: {} - mustache@4.2.0: {} nanoid@3.3.6: {} @@ -868,6 +816,8 @@ snapshots: picomatch@2.3.1: {} + prettier@3.3.3: {} + printable-characters@1.0.42: {} readdirp@3.6.0: @@ -913,8 +863,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tlds@1.240.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -925,10 +873,6 @@ snapshots: ufo@1.5.4: {} - uint8arrays@3.0.0: - dependencies: - multiformats: 9.9.0 - undici@5.28.4: dependencies: '@fastify/busboy': 2.1.1 @@ -986,6 +930,4 @@ snapshots: mustache: 4.2.0 stacktracey: 2.1.8 - zod@3.21.4: {} - zod@3.23.8: {} diff --git a/pkgs/app/src/components/Layout.tsx b/pkgs/app/src/components/Layout.tsx index 1a2758c..acb6036 100644 --- a/pkgs/app/src/components/Layout.tsx +++ b/pkgs/app/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { html } from "hono/html"; +import { html } from 'hono/html'; export interface LayoutProps { url: string; @@ -7,11 +7,11 @@ export interface LayoutProps { export const Layout = ({ url, children }: LayoutProps) => { const removeLeadingSlash = url.substring(1); - const redirectUrl = removeLeadingSlash.startsWith("https://") + const redirectUrl = removeLeadingSlash.startsWith('https://') ? removeLeadingSlash : `https://bsky.app/${removeLeadingSlash}`; return html` - + diff --git a/pkgs/app/src/components/Post.tsx b/pkgs/app/src/components/Post.tsx index 1cd4394..3b67fb9 100644 --- a/pkgs/app/src/components/Post.tsx +++ b/pkgs/app/src/components/Post.tsx @@ -1,18 +1,17 @@ -import { AppBskyEmbedImages, AppBskyFeedDefs } from "@atproto/api"; - -import { Layout } from "./Layout"; -import { OEmbedTypes } from "../routes/getOEmbed"; -import { parseEmbedImages } from "../lib/parseEmbedImages"; -import { parseEmbedDescription } from "../lib/parseEmbedDescription"; -import { StreamInfo } from "../lib/processVideoEmbed"; -import { checkType, join } from "../lib/utils"; +import { Layout } from './Layout'; +import { OEmbedTypes } from '../routes/getOEmbed'; +import { parseEmbedDescription } from '../lib/parseEmbedDescription'; +import { checkType } from '../lib/utils'; +import { VideoInfo } from '../routes/getPost'; +import { AppBskyEmbedImages, AppBskyFeedDefs } from '@atcute/client/lexicons'; interface PostProps { post: AppBskyFeedDefs.PostView; url: string; appDomain: string; - videoMetadata?: StreamInfo[] | undefined; + videoMetadata?: VideoInfo; apiUrl: string; + images: string | AppBskyEmbedImages.ViewImage[]; } const Meta = ({ post }: { post: AppBskyFeedDefs.PostView }) => ( @@ -21,16 +20,6 @@ const Meta = ({ post }: { post: AppBskyFeedDefs.PostView }) => ( ); -const constructVideoUrl = (streamInfo: StreamInfo, apiUrl: string) => { - const url = new URL(streamInfo.masterUri); - - const [did, id, quality] = url.pathname.split("/").slice(2); - - const parts = [did, id, quality]; - - return `${apiUrl}generate/${btoa(join(parts, ";"))}.mp4`; -}; - const Video = ({ streamInfo, apiUrl, @@ -38,13 +27,13 @@ const Video = ({ post, description, }: { - streamInfo: StreamInfo; + streamInfo: VideoInfo; apiUrl: string; appDomain: string; post: AppBskyFeedDefs.PostView; description: string; }) => { - const url = constructVideoUrl(streamInfo, apiUrl); + const url = streamInfo.url.toString(); return ( <> @@ -57,19 +46,19 @@ const Video = ({ @@ -93,7 +82,7 @@ const Images = ({ images: AppBskyEmbedImages.ViewImage[] | string; }) => ( <> - {typeof images === "string" ? ( + {typeof images === 'string' ? ( <> @@ -115,25 +104,18 @@ export const Post = ({ appDomain, videoMetadata, apiUrl, + images, }: PostProps) => { - const images = parseEmbedImages(post); const isAuthor = images === post.author.avatar; let description = parseEmbedDescription(post); const isVideo = checkType( - "app.bsky.embed.video", - post.embed?.media ?? post.embed + 'app.bsky.embed.video', + // @ts-expect-error + post.embed?.media ?? post.embed, ); - const streamInfo = videoMetadata?.at(-1); - const isTooLong = isVideo && streamInfo!.uri.length > 4; - const shouldOverrideForVideo = isVideo && isTooLong; let videoUrl; - if (isVideo && isTooLong) { - videoUrl = constructVideoUrl(streamInfo!, apiUrl); - description += `\n[Video is too long to embed!]`; - } - return ( @@ -151,21 +133,19 @@ export const Post = ({ {!isAuthor && } - {images.length !== 0 && (shouldOverrideForVideo || !isVideo) && ( - - )} + {images.length !== 0 && !isVideo && } - {isVideo && streamInfo!.uri.length <= 4 && ( + {isVideo && ( diff --git a/pkgs/app/src/components/Profile.tsx b/pkgs/app/src/components/Profile.tsx index 0ba5329..7cd2bca 100644 --- a/pkgs/app/src/components/Profile.tsx +++ b/pkgs/app/src/components/Profile.tsx @@ -1,7 +1,6 @@ -import { AppBskyActorDefs } from "@atproto/api"; - -import { Layout } from "./Layout"; -import { OEmbedTypes } from "../routes/getOEmbed"; +import { Layout } from './Layout'; +import { OEmbedTypes } from '../routes/getOEmbed'; +import { AppBskyActorDefs } from '@atcute/client/lexicons'; interface ProfileProps { profile: AppBskyActorDefs.ProfileViewDetailed; @@ -13,7 +12,7 @@ export const Profile = ({ profile, url, appDomain }: ProfileProps) => ( - + ( href={`https://${appDomain}/oembed?type=${OEmbedTypes.Profile}&follows=${ profile.followsCount }&posts=${profile.postsCount}&avatar=${encodeURIComponent( - profile.avatar ?? "" + profile.avatar ?? '', )}`} /> diff --git a/pkgs/app/src/globals.d.ts b/pkgs/app/src/globals.d.ts index b4f4326..dae7c88 100644 --- a/pkgs/app/src/globals.d.ts +++ b/pkgs/app/src/globals.d.ts @@ -1,5 +1,5 @@ -import { BskyAgent } from "@atproto/api"; -import type { KVNamespace } from "@cloudflare/workers-types"; +import { XRPC } from '@atcute/client'; +import type { KVNamespace } from '@cloudflare/workers-types'; declare global { interface Env { @@ -12,7 +12,7 @@ declare global { bskyx: KVNamespace; }; Variables: { - Agent: BskyAgent; + Agent: XRPC; }; } } diff --git a/pkgs/app/src/index.ts b/pkgs/app/src/index.ts index 6ab92a2..446125f 100644 --- a/pkgs/app/src/index.ts +++ b/pkgs/app/src/index.ts @@ -1,37 +1,43 @@ -import { Hono } from "hono"; -import { AtpSessionData, BskyAgent } from "@atproto/api"; -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"; -import { HTTPException } from "hono/http-exception"; +import { Hono } from 'hono'; +import { XRPC, CredentialManager, AtpSessionData } from '@atcute/client'; +import '@atcute/bluesky/lexicons'; +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'; +import { HTTPException } from 'hono/http-exception'; const app = new Hono(); -app.use("*", async (c, next) => { - const agent = new BskyAgent({ +app.use('*', async (c, next) => { + const creds = new CredentialManager({ service: c.env.BSKY_SERVICE_URL, - async persistSession(_, session) { - if (session) { - return c.env.bskyx.put("session", JSON.stringify(session)); - } + onRefresh(session) { + return c.env.bskyx.put('session', JSON.stringify(session)); + }, + onExpired(session) { + return c.env.bskyx.delete('session'); + }, + onSessionUpdate(session) { + return c.env.bskyx.put('session', JSON.stringify(session)); }, }); + const agent = new XRPC({ handler: creds }); try { - const rawSession = await c.env.bskyx.get("session"); + const rawSession = await c.env.bskyx.get('session'); if (rawSession) { const session = JSON.parse(rawSession) as AtpSessionData; - await agent.resumeSession(session); + await creds.resume(session); } else { - await agent.login({ + await creds.login({ identifier: c.env.BSKY_AUTH_USERNAME, password: c.env.BSKY_AUTH_PASSWORD, }); } - c.set("Agent", agent); + c.set('Agent', agent); } catch (error) { - const err = new Error("Failed to login to Bluesky!", { + const err = new Error('Failed to login to Bluesky!', { cause: error, }); throw new HTTPException(500, { @@ -41,22 +47,22 @@ app.use("*", async (c, next) => { return next(); }); -app.get("/", async (c) => { - return c.redirect("https://github.com/Rapougnac/VixBluesky"); +app.get('/', async (c) => { + return c.redirect('https://github.com/Rapougnac/VixBluesky'); }); -app.get("/profile/:user/post/:post", getPost); -app.get("/https://bsky.app/profile/:user/post/:post", getPost); +app.get('/profile/:user/post/:post', getPost); +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('/profile/:user/post/:post/json', getPostData); +app.get('/https://bsky.app/profile/:user/post/:post/json', getPostData); -app.get("/profile/:user", getProfile); -app.get("/https://bsky.app/profile/:user", getProfile); +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('/profile/:user/json', getProfileData); +app.get('/https://bsky.app/profile/:user/json', getProfileData); -app.get("/oembed", getOEmbed); +app.get('/oembed', getOEmbed); export default app; diff --git a/pkgs/app/src/lib/fetchPostData.ts b/pkgs/app/src/lib/fetchPostData.ts index 3bd4a51..3c0427e 100644 --- a/pkgs/app/src/lib/fetchPostData.ts +++ b/pkgs/app/src/lib/fetchPostData.ts @@ -1,18 +1,14 @@ -import { BskyAgent } from "@atproto/api"; +import { fetchProfile } from './fetchProfile'; +import { XRPC } from '@atcute/client'; -export interface fetchPostOptions { +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}`], +export async function fetchPost(agent: XRPC, { user, post }: FetchPostOptions) { + const { data: userData } = await fetchProfile(agent, { user }); + return agent.get('app.bsky.feed.getPosts', { + params: { uris: [`at://${userData.did}/app.bsky.feed.post/${post}`] }, }); } diff --git a/pkgs/app/src/lib/fetchProfile.ts b/pkgs/app/src/lib/fetchProfile.ts index 4a82a3e..4674698 100644 --- a/pkgs/app/src/lib/fetchProfile.ts +++ b/pkgs/app/src/lib/fetchProfile.ts @@ -1,14 +1,9 @@ -import { BskyAgent } from "@atproto/api"; +import { XRPC } from '@atcute/client'; -export interface fetchProfileOptions { +export interface FetchProfileOptions { user: string; } -export async function fetchProfile( - agent: BskyAgent, - { user }: fetchProfileOptions -) { - return agent.getProfile({ - actor: user, - }); +export async function fetchProfile(agent: XRPC, { user }: FetchProfileOptions) { + return agent.get('app.bsky.actor.getProfile', { params: { actor: user } }); } diff --git a/pkgs/app/src/lib/parseEmbedDescription.ts b/pkgs/app/src/lib/parseEmbedDescription.ts index 6032a22..050d16a 100644 --- a/pkgs/app/src/lib/parseEmbedDescription.ts +++ b/pkgs/app/src/lib/parseEmbedDescription.ts @@ -1,40 +1,18 @@ -import { - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyFeedDefs, - AppBskyFeedPost, -} from "@atproto/api"; -import { indent } from "./utils"; +import { AppBskyFeedDefs } from '@atcute/client/lexicons'; +import { checkType, indent } from './utils'; -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➥${indent(post.embed.record.value.text, 2)}`; - } - } - } - 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➥${indent(post.embed.record.record.value.text, 2)}`; - } - } - } - return post.record.text; - } - return ""; +export function parseEmbedDescription(post: AppBskyFeedDefs.PostView): string { + const isQuote = + checkType('app.bsky.feed.post', post.record) && + (checkType('app.bsky.embed.record#view', post.embed) || + checkType('app.bsky.embed.recordWithMedia#view', post.embed)); + + // @ts-expect-error + const embed = post.embed.record?.record ?? post.embed.record; + + return isQuote + ? // @ts-expect-error + `${post.record.text}\n\nQuoting @${embed.author.handle}\n➥${indent(embed.value.text, 2)}` + : // @ts-expect-error + post.record.text; } diff --git a/pkgs/app/src/lib/parseEmbedImages.ts b/pkgs/app/src/lib/parseEmbedImages.ts index 5b38dc8..9866962 100644 --- a/pkgs/app/src/lib/parseEmbedImages.ts +++ b/pkgs/app/src/lib/parseEmbedImages.ts @@ -1,56 +1,50 @@ -import { - AppBskyEmbedImages, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyFeedDefs, -} from "@atproto/api"; +import { AppBskyFeedDefs, AppBskyEmbedImages } from '@atcute/client/lexicons'; +import { checkType } from './utils'; export function parseEmbedImages( - post: AppBskyFeedDefs.PostView + post: AppBskyFeedDefs.PostView, ): string | AppBskyEmbedImages.ViewImage[] { let images: AppBskyEmbedImages.ViewImage[] = []; - 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 - ); + const embed = post.embed as typeof post.embed & { + record: any; + media: any; + images: any; + external: any; + }; + + if (checkType('app.bsky.embed.record#view', embed)) { + if (checkType('app.bsky.embed.record#viewRecord', embed?.record)) { if ( - isViewRecord && - post.embed.record.embeds && - AppBskyEmbedImages.isView(post.embed.record.embeds[0]) + embed?.record.embeds && + checkType('app.bsky.embed.images#view', embed.record.embeds[0]) ) { - const { success: isImageView } = AppBskyEmbedImages.validateView( - post.embed.record.embeds[0] - ); - if (isImageView) { - images = [...images, ...post.embed.record.embeds[0].images]; - } + images = [ + ...images, + ...(embed.record.embeds[0].images as AppBskyEmbedImages.ViewImage[]), + ]; } } } - 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) { - images = [...images, ...post.embed.media.images]; - } + if (checkType('app.bsky.embed.recordWithMedia#view', embed)) { + if (checkType('app.bsky.embed.images#view', embed.media)) { + images = [ + ...images, + ...(embed.media.images as AppBskyEmbedImages.ViewImage[]), + ]; } } - if (AppBskyEmbedImages.isView(post.embed)) { - const { success: isImageView } = AppBskyEmbedImages.validateView( - post.embed - ); - if (isImageView) { - images = [...images, ...post.embed.images]; + if (checkType('app.bsky.embed.images#view', embed)) { + images = [...images, ...embed.images]; + } + + const isEmptyImages = images.length === 0; + + if (isEmptyImages) { + if (checkType('app.bsky.embed.external#view', embed)) { + return embed.external.uri; } } - return images.length === 0 ? post.author.avatar ?? "" : images; + return isEmptyImages ? (post.author.avatar ?? '') : images; } diff --git a/pkgs/app/src/lib/processVideoEmbed.ts b/pkgs/app/src/lib/processVideoEmbed.ts deleted file mode 100644 index ff918b8..0000000 --- a/pkgs/app/src/lib/processVideoEmbed.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { AppBskyFeedDefs } from "@atproto/api"; - -export interface StreamInfo { - bandwidth: number; - resolution: { - width: number; - height: number; - }; - codecs: string; - uri: string | string[]; - masterUri: string; -} - -export interface M3U8Data { - version: number; - streams: StreamInfo[]; -} - -export interface VideoMedia { - $type: `app.bsky.embed.video${string}`; - cid: string; - playlist: string; - thumbnail: string; - aspectRatio: { - width: number; - height: number; - } -} - -export async function processVideoEmbed(source?: VideoMedia | undefined) { - const videoUrl = source?.playlist as string | undefined; - - if (!videoUrl) { - return; - } - - const contents = await fetch(videoUrl).then((res) => res.text()); - - const initalUrl = removeLastPathSegment(videoUrl); - - const { streams } = await parseM3U8(initalUrl, contents); - - return streams; -} - -async function parseM3U8( - initalUrl: string, - contents: string -): Promise { - const lines = contents - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0); - let version = 0; - const streams: StreamInfo[] = []; - - for (const line of lines) { - if (line.startsWith("#EXT-X-VERSION:")) { - version = Number(line.split(":")[1]); - } else if (line.startsWith("#EXT-X-STREAM-INF:")) { - const attribs = line.split(":")[1].split(","); - const sInfo: StreamInfo = { - bandwidth: 0, - resolution: { - width: 0, - height: 0, - }, - codecs: "", - uri: "", - masterUri: "", - }; - - for (const attrib of attribs) { - const [key, value] = attrib.split("="); - switch (key) { - case "BANDWIDTH": - sInfo.bandwidth = Number(value); - break; - case "RESOLUTION": - const [width, height] = value.split("x").map(Number); - sInfo.resolution = { width, height }; - break; - case "CODECS": - sInfo.codecs = value.replaceAll('"', ""); - break; - } - } - - streams.push(sInfo); - } else if (line.includes("m3u8")) { - const resolvedUrl = `${initalUrl}/${line}`; - - streams.at(-1)!.masterUri = resolvedUrl; - - const cont = await fetch(resolvedUrl).then((res) => res.text()); - - const parsed = await parseM3U8(removeLastPathSegment(resolvedUrl), cont); - - streams.at(-1)!.uri = parsed.streams.map((s) => s.uri as string); - } else if (line.includes(".ts")) { - streams.push({ - bandwidth: 0, - resolution: { - width: 0, - height: 0, - }, - codecs: "", - uri: `${initalUrl}/${line}`, - masterUri: initalUrl, - }); - } else { - // Discard - continue; - } - } - - return { version, streams }; -} - -function removeLastPathSegment(url: string) { - return url.slice(0, url.lastIndexOf("/")); -} diff --git a/pkgs/app/src/lib/utils.ts b/pkgs/app/src/lib/utils.ts index 2987c5a..7532d1a 100644 --- a/pkgs/app/src/lib/utils.ts +++ b/pkgs/app/src/lib/utils.ts @@ -2,20 +2,24 @@ export const concatQueryParams = (params: Record) => Object.entries(params) .map(([key, value]) => { if (Array.isArray(value)) { - return value.map((v) => `${key}=${v}`).join("&"); + return value.map((v) => `${key}=${v}`).join('&'); } return `${key}=${value}`; }) - .join("&"); + .join('&'); export const join = (t: string | string[], s: string) => Array.isArray(t) ? t.join(s) : t; -export const checkType = (t: string, o: any) => - (typeof o?.$type === "string" && o?.$type.startsWith(t)) || o?.$type === t; +export const checkType = (t: string, o: any): boolean => + typeof o?.$type === 'string' && (o?.$type === t || o?.$type.startsWith(t)); export const indent = (s: string, n: number) => s - .split("\n") - .map((l) => " ".repeat(n) + l) - .join("\n"); + .split('\n') + .map((l) => ' '.repeat(n) + l) + .join('\n'); + +export function isObj(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} diff --git a/pkgs/app/src/routes/getOEmbed.ts b/pkgs/app/src/routes/getOEmbed.ts index f531704..e18ac91 100644 --- a/pkgs/app/src/routes/getOEmbed.ts +++ b/pkgs/app/src/routes/getOEmbed.ts @@ -1,4 +1,4 @@ -import { Handler } from "hono"; +import { Handler } from 'hono'; export enum OEmbedTypes { Post = 1, @@ -6,13 +6,13 @@ export enum OEmbedTypes { Video, } -export const getOEmbed: Handler = async (c) => { - const type = +(c.req.query("type") ?? 0); - const avatar = c.req.query("avatar"); +export const getOEmbed: Handler = async (c) => { + const type = +(c.req.query('type') ?? 0); + const avatar = c.req.query('avatar'); const defaults = { - provider_name: "VixBluesky", - provider_url: "https://bskyx.app/", + provider_name: 'VixBluesky', + provider_url: 'https://bskyx.app/', thumbnail_width: 1000, thumbnail_height: 1000, }; @@ -23,12 +23,11 @@ export const getOEmbed: Handler = async (c) => { } if (type === OEmbedTypes.Post) { - const { replies, reposts, likes, videoUrl } = c.req.query(); + const { replies, reposts, likes } = c.req.query(); return c.json({ ...defaults, author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`, - provider_url: videoUrl ? videoUrl : defaults.provider_url, }); } if (type === OEmbedTypes.Profile) { diff --git a/pkgs/app/src/routes/getPost.tsx b/pkgs/app/src/routes/getPost.tsx index 907beef..bc6446a 100644 --- a/pkgs/app/src/routes/getPost.tsx +++ b/pkgs/app/src/routes/getPost.tsx @@ -1,45 +1,86 @@ -import { Handler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { fetchPost } from "../lib/fetchPostData"; -import { Post } from "../components/Post"; -import { processVideoEmbed, StreamInfo } from "../lib/processVideoEmbed"; -import { checkType } from "../lib/utils"; +import { Handler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { fetchPost } from '../lib/fetchPostData'; +import { Post } from '../components/Post'; +import { parseEmbedImages } from '../lib/parseEmbedImages'; +import { checkType } from '../lib/utils'; +import { AppBskyFeedGetPosts } from '@atcute/client/lexicons'; + +export interface VideoInfo { + url: URL; + aspectRatio: { + width: number; + height: number; + }; +} + +interface VideoEmbed { + $type: string; + cid: string; + playlist: string; + thumbnail: string; + aspectRatio: { + width: number; + height: number; + }; +} export const getPost: Handler< Env, - "/profile/:user/post/:post" | "/https://bsky.app/profile/:user/post/:post" + '/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 }); + const isDirect = c.req.query('direct'); - if (!success) { + const agent = c.get('Agent'); + try { + var { data } = await fetchPost(agent, { user, post }); + } catch (e) { throw new HTTPException(500, { - message: "Failed to fetch the post!", + message: `Failed to fetch the post!\n${e}`, }); } const fetchedPost = data.posts[0]; - let videoMetaData: StreamInfo[] | undefined; + const images = parseEmbedImages(fetchedPost); + + let videoMetaData: VideoInfo | undefined; + + const embed = fetchedPost.embed as typeof fetchedPost.embed & { media: any }; if ( - checkType("app.bsky.embed.video", fetchedPost.embed) || - checkType("app.bsky.embed.video", fetchedPost.embed?.media) + checkType('app.bsky.embed.video', embed) || + checkType('app.bsky.embed.video', embed?.media) ) { - videoMetaData = await processVideoEmbed( - // @ts-expect-error - fetchedPost.embed?.media ?? fetchedPost.embed + const videoEmbed = (embed?.media ?? fetchedPost.embed) as VideoEmbed; + videoMetaData = { + url: new URL( + `https://bsky.social/xrpc/com.atproto.sync.getBlob?cid=${videoEmbed.cid}&did=${fetchedPost.author.did}`, + ), + aspectRatio: videoEmbed.aspectRatio, + }; + } + + if (!isDirect) { + return c.html( + , ); } - return c.html( - - ); + if (Array.isArray(images) && images.length !== 0) { + const url = images[0].fullsize; + return c.redirect(url); + } + + if (videoMetaData) { + return c.redirect(videoMetaData.url.toString()); + } }; diff --git a/pkgs/app/src/routes/getPostData.ts b/pkgs/app/src/routes/getPostData.ts index b1b5212..803eea9 100644 --- a/pkgs/app/src/routes/getPostData.ts +++ b/pkgs/app/src/routes/getPostData.ts @@ -1,19 +1,21 @@ -import { Handler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { fetchPost } from "../lib/fetchPostData"; +import { Handler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { fetchPost } from '../lib/fetchPostData'; export const getPostData: Handler< Env, - | "/profile/:user/post/:post/json" - | "/https://bsky.app/profile/:user/post/:post/json" + | '/profile/:user/post/:post/json' + | '/https://bsky.app/profile/:user/post/:post/json' > = async (c) => { const { user, post } = c.req.param(); - const agent = c.get("Agent"); - const { data, success } = await fetchPost(agent, { user, post }); - if (!success) { + const agent = c.get('Agent'); + try { + var { data } = await fetchPost(agent, { user, post }); + } catch (e) { throw new HTTPException(500, { - message: "Failed to fetch the post!", + message: `Failed to fetch the post!\n${e}`, }); } + return c.json(data); }; diff --git a/pkgs/app/src/routes/getProfile.tsx b/pkgs/app/src/routes/getProfile.tsx index a531705..a9395cf 100644 --- a/pkgs/app/src/routes/getProfile.tsx +++ b/pkgs/app/src/routes/getProfile.tsx @@ -1,25 +1,27 @@ -import { Handler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { fetchProfile } from "../lib/fetchProfile"; -import { Profile } from "../components/Profile"; +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" + '/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) { + const agent = c.get('Agent'); + try { + var { data } = await fetchProfile(agent, { user }); + } catch (e) { throw new HTTPException(500, { - message: "Failed to fetch the profile!", + message: `Failed to fetch the profile!\n${e}`, }); } + return c.html( + />, ); }; diff --git a/pkgs/app/src/routes/getProfileData.ts b/pkgs/app/src/routes/getProfileData.ts index 96c2414..4989388 100644 --- a/pkgs/app/src/routes/getProfileData.ts +++ b/pkgs/app/src/routes/getProfileData.ts @@ -1,20 +1,22 @@ /** @jsx jsx */ -import { jsx } from "hono/jsx"; -import { Handler } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { fetchProfile } from "../lib/fetchProfile"; +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" + '/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) { + const agent = c.get('Agent'); + try { + var { data } = await fetchProfile(agent, { user }); + } catch (e) { throw new HTTPException(500, { - message: "Failed to fetch the profile!", + message: `Failed to fetch the profile!\n${e}`, }); } + return c.json(data); };