complete viewer

now that PDSls has blob uploading i don't have to make the uploader
first. yaaay
This commit is contained in:
Charlotte Som 2025-06-04 23:10:23 +01:00
parent 2ce74c6391
commit a25ec9f235
12 changed files with 411 additions and 9 deletions

12
README.md Normal file
View file

@ -0,0 +1,12 @@
# video.cerulea.blue
the application comprises a video CDN and a client that fetches a record and displays a `<video>` tag.
the appview does not ingest from any event stream, but rather fetches videos on-demand.
there is an allowlist of trusted users whose videos can be proxied,
since re-serving arbitrary user content can be a big liability.
## to-do list
- video cdn garbage collection (so that disk usage doesn't grow unbounded)
- etc

7
_client_build.ts Normal file
View file

@ -0,0 +1,7 @@
import * as esbuild from "@char/aftercare/esbuild";
esbuild.build({
in: ["./client/viewer.tsx"],
outDir: "./web/dist",
watch: Deno.args.includes("--watch"),
});

View file

@ -1,5 +1,5 @@
import { ensureDir } from "@std/fs";
import { serveDir } from "@std/http/file-server";
import { serveDir, serveFile } from "@std/http/file-server";
import * as z from "@zod/mini";
import { db } from "./db.ts";
@ -9,6 +9,7 @@ import {
PlcDidDocumentResolver,
WebDidDocumentResolver,
} from "@atcute/identity-resolver";
import { VIDEO_PATTERN } from "../common/routes.ts";
const didResolver = new CompositeDidDocumentResolver({
methods: {
@ -117,6 +118,11 @@ export default {
});
}
const videoRoute = VIDEO_PATTERN.exec(req.url);
if (videoRoute) {
return await serveFile(req, "./web/viewer.html");
}
return await serveDir(req, { fsRoot: "./web", quiet: true });
},
} satisfies Deno.ServeDefaultExport;

72
client/viewer.tsx Normal file
View file

@ -0,0 +1,72 @@
import { resolveDid, resolveHandle } from "../common/identity.ts";
import { VIDEO_PATTERN } from "../common/routes.ts";
type BlobRef = {
$type: "blob";
ref: { $link: string };
mimeType?: string;
size?: number;
};
type VideoRecord = { video: BlobRef; title?: string; description?: string };
const fetchVideoRecord = async (
did: string,
rkey: string
): Promise<VideoRecord> => {
// TODO: we do lots of casting here that we shouldn't need once we have lexicon.ts validations
const didDoc = await resolveDid(did);
const pds = didDoc.service?.find((it) => it.id === "#atproto_pds")
?.serviceEndpoint as string | undefined;
if (!pds) throw new Error("could not resolve pds for requested repo");
const getRecordURL = new URL("/xrpc/com.atproto.repo.getRecord", pds);
getRecordURL.searchParams.set("collection", "blue.cerulea.video.video");
getRecordURL.searchParams.set("repo", did);
getRecordURL.searchParams.set("rkey", rkey);
const recordResponse = await fetch(getRecordURL);
if (recordResponse.status !== 200)
throw new Error("got error fetching record");
const record = (await recordResponse.json()).value as VideoRecord;
return record;
};
const resolveVideoURL = async (did: string, blob: string): Promise<string> => {
const res = await fetch("/xrpc/blue.cerulea.video.fetchVideo", {
method: "POST",
headers: { "content-encoding": "application/json" },
body: JSON.stringify({ repo: did, blob }),
}).then((r) => r.json());
return `/user-content/${res.filename}`;
};
const main = async () => {
const player = document.querySelector("#player")! as HTMLElement;
const location = VIDEO_PATTERN.exec(globalThis.location.href);
if (!location) throw new Error("video pattern did not match url");
const { repo, rkey } = location.pathname.groups as {
repo: string;
rkey: string;
};
const did = repo.startsWith("did:") ? repo : await resolveHandle(repo);
const video = await fetchVideoRecord(did, rkey);
const videoURL = await resolveVideoURL(did, video.video.ref.$link);
player.append(
<video crossOrigin="anonymous" controls>
<source src={videoURL} />
</video>
);
if (video.title) player.append(<h1>{video.title}</h1>);
if (video.description)
player.append(<p className="description">{video.description}</p>);
};
main();

26
common/identity.ts Normal file
View file

@ -0,0 +1,26 @@
import type { DidDocument } from "@atcute/identity";
import {
CompositeDidDocumentResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
XrpcHandleResolver,
} from "@atcute/identity-resolver";
const handleResolver = new XrpcHandleResolver({
serviceUrl: "https://public.api.bsky.app",
});
const didResolver = new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
});
export function resolveHandle(handle: string): Promise<string> {
return handleResolver.resolve(handle as `${string}.${string}`);
}
export function resolveDid(did: string): Promise<DidDocument> {
return didResolver.resolve(did as `did:${"plc" | "web"}:${string}`);
}

3
common/routes.ts Normal file
View file

@ -0,0 +1,3 @@
if (!globalThis.URLPattern) await import("npm:urlpattern-polyfill@10");
export const VIDEO_PATTERN = new URLPattern({ pathname: "/:repo/video/:rkey" });

View file

@ -1,13 +1,22 @@
{
"tasks": {
"appview:start": "deno serve -A --port 4080 ./appview/main.ts"
"appview:start": "deno serve -A --port 4080 ./appview/main.ts",
"client:build": "deno run -A ./_client_build.ts",
"client:watch": "deno run -A ./_client_build.ts --watch"
},
"imports": {
"@atcute/identity": "npm:@atcute/identity@^1.0.2",
"@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3",
"@char/aftercare": "jsr:@char/aftercare@^0.4.2",
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@std/fs": "jsr:@std/fs@^1.0.17",
"@std/http": "jsr:@std/http@^1.0.17",
"@std/path": "jsr:@std/path@^1.1.0",
"@zod/mini": "npm:@zod/mini@^4.0.0-beta.20250505T195954"
},
"compilerOptions": {
"lib": ["deno.window", "dom"],
"jsx": "react-jsx",
"jsxImportSource": "@char/aftercare"
}
}

200
deno.lock
View file

@ -1,14 +1,20 @@
{
"version": "5",
"specifiers": {
"jsr:@char/aftercare@~0.4.2": "0.4.2",
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@luca/esbuild-deno-loader@0.11": "0.11.1",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0",
"jsr:@std/bytes@^1.0.2": "1.0.5",
"jsr:@std/cli@1": "1.0.18",
"jsr:@std/cli@^1.0.18": "1.0.18",
"jsr:@std/dotenv@0.225": "0.225.3",
"jsr:@std/encoding@0.221": "0.221.0",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/encoding@^1.0.5": "1.0.10",
"jsr:@std/fmt@0.221": "0.221.0",
"jsr:@std/fmt@1": "1.0.8",
"jsr:@std/fmt@^1.0.8": "1.0.8",
@ -22,13 +28,27 @@
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@0.221": "0.221.0",
"jsr:@std/path@1": "1.1.0",
"jsr:@std/path@^1.0.6": "1.1.0",
"jsr:@std/path@^1.0.9": "1.1.0",
"jsr:@std/path@^1.1.0": "1.1.0",
"jsr:@std/streams@^1.0.9": "1.0.9",
"npm:@atcute/identity-resolver@^1.1.3": "1.1.3_@atcute+identity@1.0.2",
"npm:@zod/mini@^4.0.0-beta.20250505T195954": "4.0.0-beta.20250505T195954"
"npm:@atcute/identity@^1.0.2": "1.0.2",
"npm:@zod/mini@^4.0.0-beta.20250505T195954": "4.0.0-beta.20250505T195954",
"npm:esbuild@0.24": "0.24.2",
"npm:urlpattern-polyfill@10": "10.0.0"
},
"jsr": {
"@char/aftercare@0.4.2": {
"integrity": "54aacf6911a841b5aaf3613e04df780375e7372f1da08a81883274414b8b3e99",
"dependencies": [
"jsr:@luca/esbuild-deno-loader",
"jsr:@std/cli@1",
"jsr:@std/dotenv",
"jsr:@std/path@1",
"npm:esbuild"
]
},
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
@ -54,15 +74,29 @@
"jsr:@std/path@1"
]
},
"@luca/esbuild-deno-loader@0.11.1": {
"integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267",
"dependencies": [
"jsr:@std/bytes",
"jsr:@std/encoding@^1.0.5",
"jsr:@std/path@^1.0.6"
]
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@0.221.0": {
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
},
"@std/bytes@1.0.5": {
"integrity": "4465dd739d7963d964c809202ebea6d5c6b8e3829ef25c6a224290fbb8a1021e"
},
"@std/cli@1.0.18": {
"integrity": "33846eab6a7cac52156cc105a798451df06965693606e4668adfe0436a155fd7"
},
"@std/dotenv@0.225.3": {
"integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a"
},
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
@ -94,7 +128,7 @@
"@std/http@1.0.17": {
"integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/cli@^1.0.18",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/fmt@^1.0.8",
"jsr:@std/html",
@ -161,6 +195,131 @@
"@badrap/valita@0.4.5": {
"integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ=="
},
"@esbuild/aix-ppc64@0.24.2": {
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"os": ["aix"],
"cpu": ["ppc64"]
},
"@esbuild/android-arm64@0.24.2": {
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"os": ["android"],
"cpu": ["arm64"]
},
"@esbuild/android-arm@0.24.2": {
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"os": ["android"],
"cpu": ["arm"]
},
"@esbuild/android-x64@0.24.2": {
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"os": ["android"],
"cpu": ["x64"]
},
"@esbuild/darwin-arm64@0.24.2": {
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@esbuild/darwin-x64@0.24.2": {
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@esbuild/freebsd-arm64@0.24.2": {
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
"@esbuild/freebsd-x64@0.24.2": {
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@esbuild/linux-arm64@0.24.2": {
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@esbuild/linux-arm@0.24.2": {
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"os": ["linux"],
"cpu": ["arm"]
},
"@esbuild/linux-ia32@0.24.2": {
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"os": ["linux"],
"cpu": ["ia32"]
},
"@esbuild/linux-loong64@0.24.2": {
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"os": ["linux"],
"cpu": ["loong64"]
},
"@esbuild/linux-mips64el@0.24.2": {
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"os": ["linux"],
"cpu": ["mips64el"]
},
"@esbuild/linux-ppc64@0.24.2": {
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@esbuild/linux-riscv64@0.24.2": {
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@esbuild/linux-s390x@0.24.2": {
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@esbuild/linux-x64@0.24.2": {
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"os": ["linux"],
"cpu": ["x64"]
},
"@esbuild/netbsd-arm64@0.24.2": {
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
"@esbuild/netbsd-x64@0.24.2": {
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"os": ["netbsd"],
"cpu": ["x64"]
},
"@esbuild/openbsd-arm64@0.24.2": {
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
"@esbuild/openbsd-x64@0.24.2": {
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"os": ["openbsd"],
"cpu": ["x64"]
},
"@esbuild/sunos-x64@0.24.2": {
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"os": ["sunos"],
"cpu": ["x64"]
},
"@esbuild/win32-arm64@0.24.2": {
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@esbuild/win32-ia32@0.24.2": {
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@esbuild/win32-x64@0.24.2": {
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"os": ["win32"],
"cpu": ["x64"]
},
"@zod/core@0.11.6": {
"integrity": "sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA=="
},
@ -170,17 +329,54 @@
"@zod/core"
]
},
"esbuild@0.24.2": {
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
],
"scripts": true,
"bin": true
},
"esm-env@1.2.2": {
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
},
"urlpattern-polyfill@10.0.0": {
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="
}
},
"workspace": {
"dependencies": [
"jsr:@char/aftercare@~0.4.2",
"jsr:@db/sqlite@0.12",
"jsr:@std/fs@^1.0.17",
"jsr:@std/http@^1.0.17",
"jsr:@std/path@^1.1.0",
"npm:@atcute/identity-resolver@^1.1.3",
"npm:@atcute/identity@^1.0.2",
"npm:@zod/mini@^4.0.0-beta.20250505T195954"
]
}

50
web/css/styles.css Normal file
View file

@ -0,0 +1,50 @@
* {
box-sizing: border-box;
}
:root {
color-scheme: dark;
--color-bg: 16 16 16;
--color-fg: 255 255 255;
--color-accent: 233 161 255;
--color-text: var(--color-fg);
--alpha-text: 0.9;
--font-sans:
Inter, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif;
background-color: rgb(var(--color-bg) / 1);
color: rgb(var(--color-fg) / var(--alpha-text));
font-family: var(--font-sans);
font-size: 1.125rem;
cursor: default;
}
a {
color: rgb(var(--color-accent) / var(--alpha-text));
text-decoration: none;
border-bottom: 1px solid rgb(var(--color-accent) / var(--alpha-text));
}
:is(h1, h2, h3) {
font-size: 1.25rem;
}
main {
padding-top: 2em;
max-width: 120ch;
margin: 0 auto;
}
#player {
video {
width: 100%;
}
.description {
white-space: pre-wrap;
}
}

2
web/dist/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!/.gitignore

View file

@ -2,9 +2,17 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video.cerulea.blue</title>
<link rel="stylesheet" href="/css/styles.css" />
<style>
:root {
color-scheme: dark;
}
</style>
<main>
<header>
<h1>video.cerulea.blue</h1>
<p>
super simple direct-link video on the
<a href="https://atproto.com/">AT Protocol</a>.
</p>
<p>to view a video, head to <code>/:repo/video/:rkey</code>.</p>
</header>
<div id="app"></div>
</main>

11
web/viewer.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video.cerulea.blue</title>
<link rel="stylesheet" href="/css/styles.css" />
<main>
<div id="player"></div>
</main>
<script src="/dist/viewer.js" type="module"></script>