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.
+
## 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 && (
)}
- {(shouldOverrideForVideo || !isVideo) && (
+ {!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);
};