big rewrite (#15)

This commit is contained in:
Lexie
2024-10-11 16:02:11 +02:00
committed by GitHub
parent 4ee85a79ba
commit b4e44be4bf
22 changed files with 338 additions and 466 deletions

BIN
.github/README/raw-media.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

View File

@@ -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

49
conf/nginx.conf Normal file
View File

@@ -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;
}

5
pkgs/app/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"tabWidth": 2,
"endOfLine": "lf"
}

View File

@@ -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"
}

108
pkgs/app/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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`
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<link rel="canonical" href="${url.substring(1)}" />

View File

@@ -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 = ({
<meta property="og:video:type" content="video/mp4" />
<meta
property="og:video:width"
content={streamInfo.resolution.width.toString()}
content={streamInfo.aspectRatio.width.toString()}
/>
<meta
property="og:video:height"
content={streamInfo.resolution.height.toString()}
content={streamInfo.aspectRatio.height.toString()}
/>
<meta
property="twitter:player:width"
content={streamInfo.resolution.width.toString()}
content={streamInfo.aspectRatio.width.toString()}
/>
<meta
property="twitter:player:height"
content={streamInfo.resolution.height.toString()}
content={streamInfo.aspectRatio.height.toString()}
/>
<link
@@ -80,7 +69,7 @@ const Video = ({
}&reposts=${post.repostCount}&likes=${
post.likeCount
}&avatar=${encodeURIComponent(
post.author.avatar ?? ""
post.author.avatar ?? '',
)}&description=${encodeURIComponent(description)}`}
/>
</>
@@ -93,7 +82,7 @@ const Images = ({
images: AppBskyEmbedImages.ViewImage[] | string;
}) => (
<>
{typeof images === "string" ? (
{typeof images === 'string' ? (
<>
<meta property="og:image" content={images} />
<meta property="twitter:image" content={images} />
@@ -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 (
<Layout url={url}>
<meta name="twitter:creator" content={`@${post.author.handle}`} />
@@ -151,21 +133,19 @@ export const Post = ({
{!isAuthor && <Meta post={post} />}
{images.length !== 0 && (shouldOverrideForVideo || !isVideo) && (
<Images images={images} />
)}
{images.length !== 0 && !isVideo && <Images images={images} />}
{isVideo && streamInfo!.uri.length <= 4 && (
{isVideo && (
<Video
apiUrl={apiUrl}
streamInfo={streamInfo!}
streamInfo={videoMetadata!}
appDomain={appDomain}
description={description}
post={post}
/>
)}
{(shouldOverrideForVideo || !isVideo) && (
{!isVideo && (
<link
rel="alternate"
type="application/json+oembed"
@@ -174,10 +154,8 @@ export const Post = ({
}&reposts=${post.repostCount}&likes=${
post.likeCount
}&avatar=${encodeURIComponent(
post.author.avatar ?? ""
)}&description=${encodeURIComponent(description)}${
videoUrl ? `&videoUrl=${encodeURIComponent(videoUrl)}` : ""
}`}
post.author.avatar ?? '',
)}&description=${encodeURIComponent(description)}`}
/>
)}
</Layout>

View File

@@ -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) => (
<Layout url={url}>
<meta name="og:type" content="article" />
<meta name="twitter:creator" content={`@${profile.handle}`} />
<meta property="og:description" content={profile.description ?? ""} />
<meta property="og:description" content={profile.description ?? ''} />
<meta
property="og:title"
content={`${profile.displayName} (@${profile.handle})`}
@@ -26,7 +25,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 ?? '',
)}`}
/>
</Layout>

View File

@@ -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;
};
}
}

View File

@@ -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<Env>();
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;

View File

@@ -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}`] },
});
}

View File

@@ -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 } });
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<M3U8Data> {
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("/"));
}

View File

@@ -2,20 +2,24 @@ export const concatQueryParams = (params: Record<string, string | string[]>) =>
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<string, unknown> {
return typeof v === 'object' && v !== null;
}

View File

@@ -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<Env, "/oembed"> = async (c) => {
const type = +(c.req.query("type") ?? 0);
const avatar = c.req.query("avatar");
export const getOEmbed: Handler<Env, '/oembed'> = 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<Env, "/oembed"> = 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) {

View File

@@ -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(
<Post
post={fetchedPost}
url={c.req.path}
appDomain={c.env.VIXBLUESKY_APP_DOMAIN}
videoMetadata={videoMetaData}
apiUrl={c.env.VIXBLUESKY_API_URL}
images={images}
/>,
);
}
return c.html(
<Post
post={fetchedPost}
url={c.req.path}
appDomain={c.env.VIXBLUESKY_APP_DOMAIN}
videoMetadata={videoMetaData}
apiUrl={c.env.VIXBLUESKY_API_URL}
/>
);
if (Array.isArray(images) && images.length !== 0) {
const url = images[0].fullsize;
return c.redirect(url);
}
if (videoMetaData) {
return c.redirect(videoMetaData.url.toString());
}
};

View File

@@ -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);
};

View File

@@ -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(
<Profile
profile={data}
url={c.req.path}
appDomain={c.env.VIXBLUESKY_APP_DOMAIN}
/>
/>,
);
};

View File

@@ -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);
};