ssr for video viewer

this lets us send opengraph tags in the response :D
might make the player also be server-side rendered as well
This commit is contained in:
Charlotte Som 2026-03-10 23:13:48 +00:00
parent e8f3e99f88
commit 89ec573591
8 changed files with 169 additions and 46 deletions

View file

@ -19,13 +19,34 @@ CREATE TABLE IF NOT EXISTS videos (
) STRICT;
`);
conn.exec(`
CREATE TABLE IF NOT EXISTS records (
repo TEXT NOT NULL, -- did
rkey TEXT NOT NULL,
record TEXT NOT NULL, -- JSON
fetched_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (repo, rkey)
) STRICT;
`);
const selectAllowlist = conn.prepare("SELECT 1 FROM allowlist WHERE repo = ?");
const selectVideo = conn.prepare("SELECT filename FROM videos WHERE repo = ? AND cid = ?");
const insertVideo = conn.prepare("INSERT INTO videos (repo, cid, filename) VALUES (?, ?, ?)");
const selectRecord = conn.prepare("SELECT record FROM records WHERE repo = ? AND rkey = ?");
const insertRecord = conn.prepare(
"INSERT OR REPLACE INTO records (repo, rkey, record) VALUES (?, ?, ?)",
);
export const db = {
db: conn,
inAllowlist: (did: string) => (selectAllowlist.value<[boolean]>(did)?.[0] && true) ?? false,
getVideo: (did: string, cid: string) => selectVideo.value<[string]>(did, cid)?.[0],
addVideo: (did: string, cid: string, filename: string) => insertVideo.run(did, cid, filename),
getRecord: <T>(did: string, rkey: string): T | undefined => {
const row = selectRecord.value<[string]>(did, rkey);
return row ? (JSON.parse(row[0]) as T) : undefined;
},
addRecord: (did: string, rkey: string, record: unknown) => {
insertRecord.run(did, rkey, JSON.stringify(record));
},
};

View file

@ -2,10 +2,48 @@ import { ensureDir } from "@std/fs";
import { serveDir, serveFile } from "@std/http/file-server";
import * as z from "@zod/mini";
import vento from "ventojs";
import { db } from "./db.ts";
import { resolveDid } from "../common/identity.ts";
import { getPdsUrl, resolveHandle } from "../common/identity.ts";
import type { VideoRecord } from "../common/lexicons.ts";
import { VIDEO_PATTERN } from "../common/routes.ts";
import { getVideoRecord } from "../common/video.ts";
const vto = vento({ includes: "./web" });
async function getOrFetchVideo(did: string, blobCid: string): Promise<string> {
const cached = db.getVideo(did, blobCid);
if (cached) return cached;
const pdsUrl = await getPdsUrl(did);
if (!pdsUrl) throw new Error("Could not resolve PDS");
let filename: string = crypto.randomUUID();
const getBlobURL = new URL("/xrpc/com.atproto.sync.getBlob", pdsUrl);
getBlobURL.searchParams.set("did", did);
getBlobURL.searchParams.set("cid", blobCid);
const blobResponse = await fetch(getBlobURL, {
headers: { "user-agent": "video.cerulea.app" },
});
const contentType = blobResponse.headers.get("content-type");
if (contentType === "video/mp4") filename += ".mp4";
if (contentType === "video/webm") filename += ".webm";
await ensureDir("./data/videos");
using file = await Deno.open("./data/videos/" + filename, {
write: true,
createNew: true,
});
await blobResponse.body?.pipeTo(file.writable);
db.addVideo(did, blobCid, filename);
return filename;
}
async function fetchVideo(req: Request): Promise<Response> {
if (req.method === "OPTIONS") {
@ -26,16 +64,6 @@ async function fetchVideo(req: Request): Promise<Response> {
});
const body = BodySchema.parse(await req.json());
const existingVideo = db.getVideo(body.repo, body.blob);
if (existingVideo !== undefined) {
return new Response(JSON.stringify({ filename: existingVideo }), {
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
});
}
if (!db.inAllowlist(body.repo)) {
return new Response(
JSON.stringify({
@ -52,36 +80,7 @@ async function fetchVideo(req: Request): Promise<Response> {
);
}
const doc = await resolveDid(body.repo);
const pdsBaseURL = doc.service?.find(it => it.id === "#atproto_pds")?.serviceEndpoint;
if (!pdsBaseURL || typeof pdsBaseURL !== "string") return new Response(null, { status: 400 });
let filename: string = crypto.randomUUID();
{
const getBlobURL = new URL("/xrpc/com.atproto.sync.getBlob", pdsBaseURL);
getBlobURL.searchParams.set("did", body.repo);
getBlobURL.searchParams.set("cid", body.blob);
const blobResponse = await fetch(getBlobURL, {
headers: { "user-agent": "video.cerulea.app" },
});
const contentType = blobResponse.headers.get("content-type");
if (contentType === "video/mp4") filename += ".mp4";
if (contentType === "video/webm") filename += ".webm";
await ensureDir("./data/videos");
using file = await Deno.open("./data/videos/" + filename, {
write: true,
createNew: true,
});
await blobResponse.body?.pipeTo(file.writable);
db.addVideo(body.repo, body.blob, filename);
}
const filename = await getOrFetchVideo(body.repo, body.blob);
return new Response(JSON.stringify({ filename }), {
headers: {
"content-type": "application/json",
@ -108,7 +107,39 @@ export default {
const videoRoute = VIDEO_PATTERN.exec(req.url);
if (videoRoute) {
return await serveFile(req, "./web/viewer.html");
const { repo, rkey } = videoRoute.pathname.groups as Record<"repo" | "rkey", string>;
const did = repo.startsWith("did:") ? repo : await resolveHandle(repo);
if (!db.inAllowlist(did)) {
return await serveFile(req, "./web/viewer.html");
}
let record = db.getRecord<VideoRecord>(did, rkey);
if (!record) {
record = await getVideoRecord(did, rkey);
db.addRecord(did, rkey, record);
}
let videoUrl: string | undefined;
let videoType: string | undefined;
if ("$type" in record.video && record.video?.$type === "blob") {
const blobCid = record.video.ref.$link;
const filename = await getOrFetchVideo(did, blobCid);
videoUrl = `${new URL(req.url).origin}/user-content/${filename}`;
videoType = record.video.mimeType;
}
const result = await vto.run("viewer.vto", {
title: record.title,
description: record.description,
pageUrl: req.url,
videoUrl,
videoType,
});
return new Response(result.content, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return await serveDir(req, { fsRoot: "./web", quiet: true });

View file

@ -24,3 +24,9 @@ export async function resolveDid(did: string): Promise<DidDocument> {
throw new Error("unsupported did type: " + did);
}
export async function getPdsUrl(did: string): Promise<string | null> {
const doc = await resolveDid(did);
const url = doc.service?.find((it) => it.id === "#atproto_pds")?.serviceEndpoint;
return typeof url === "string" ? url : null;
}

View file

@ -1,4 +1,4 @@
import type { MakeLexiconUniverse } from "@char/lexicon.ts";
import type { Infer, MakeLexiconUniverse } from "@char/lexicon.ts";
export const videoLexicon = {
lexicon: 1,
@ -57,3 +57,4 @@ export const fetchVideoLexicon = {
export type VideoLexiconUniverse = MakeLexiconUniverse<
[typeof videoLexicon, typeof fetchVideoLexicon]
>;
export type VideoRecord = Infer<VideoLexiconUniverse, "blue.cerulea.video.video">;

18
common/video.ts Normal file
View file

@ -0,0 +1,18 @@
import { getPdsUrl } from "./identity.ts";
import type { VideoRecord } from "./lexicons.ts";
export async function getVideoRecord(did: string, rkey: string): Promise<VideoRecord> {
const pdsUrl = await getPdsUrl(did);
if (!pdsUrl) throw new Error("Could not resolve PDS");
const url = new URL("/xrpc/com.atproto.repo.getRecord", pdsUrl);
url.searchParams.set("repo", did);
url.searchParams.set("collection", "blue.cerulea.video.video");
url.searchParams.set("rkey", rkey);
const res = await fetch(url, { headers: { "user-agent": "video.cerulea.app" } });
if (!res.ok) throw new Error(`Failed to fetch record: ${res.status}`);
const record = await res.json();
return record.value as VideoRecord;
}

View file

@ -17,7 +17,8 @@
"@zod/mini": "npm:@zod/mini@^4.0.0-beta.20250505T195954",
"@char/lexicon.ts": "./vendor/lexicon.ts/lib/mod.ts",
"@char/lexicon.ts/atproto": "./vendor/lexicon.ts/pkg/atproto-lexica/mod.ts",
"@char/lexicon.ts/rpc": "./vendor/lexicon.ts/pkg/rpc/rpc.ts"
"@char/lexicon.ts/rpc": "./vendor/lexicon.ts/pkg/rpc/rpc.ts",
"ventojs": "npm:ventojs@^2.3.1"
},
"compilerOptions": {
"lib": ["deno.window", "dom"],

View file

@ -38,7 +38,8 @@
"npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3",
"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"
"npm:urlpattern-polyfill@10": "10.0.0",
"npm:ventojs@^2.3.1": "2.3.1"
},
"jsr": {
"@char/aftercare@0.4.2": {
@ -415,6 +416,9 @@
},
"urlpattern-polyfill@10.0.0": {
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="
},
"ventojs@2.3.1": {
"integrity": "sha512-saGBMwQF+TZZJLTixSzwbZl+O5cNdBhMXNYAwegQ+/EsXSllSzrzgrZN7MU07ZOea3M+I/xBTwEFVb0HU6moUw=="
}
},
"workspace": {
@ -428,7 +432,8 @@
"npm:@atcute/identity-resolver@^1.1.3",
"npm:@atcute/identity@^1.0.2",
"npm:@atcute/oauth-browser-client@^2.0.3",
"npm:@zod/mini@^4.0.0-beta.20250505T195954"
"npm:@zod/mini@^4.0.0-beta.20250505T195954",
"npm:ventojs@^2.3.1"
]
}
}

40
web/viewer.vto Normal file
View file

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ title ?? "(untitled)" }} | video.cerulea.blue</title>
<link rel="stylesheet" href="/css/styles.css" />
<meta property="og:title" content="{{ title ?? "(untitled)" }}" />
{{ if description }}
<meta property="og:description" content="{{ description }}" />
{{ /if }}
<meta property="og:type" content="video.other" />
<meta property="og:url" content="{{ pageUrl }}" />
{{ if videoUrl }}
<meta property="og:video" content="{{ videoUrl }}" />
<meta property="og:video:url" content="{{ videoUrl }}" />
<meta property="og:video:type" content="{{ videoType ?? "video/mp4" }}" />
{{ /if }}
<meta name="twitter:card" content="player" />
<meta name="twitter:title" content="{{ title ?? "(untitled)" }}" />
{{ if description }}
<meta name="twitter:description" content="{{ description }}" />
{{ /if }}
{{ if videoUrl }}
<meta name="twitter:player" content="{{ pageUrl }}" />
<meta name="twitter:player:stream" content="{{ videoUrl }}" />
<meta name="twitter:player:stream:content_type" content="{{ videoType ?? "video/mp4" }}" />
{{ /if }}
</head>
<body>
<main>
<div id="player"></div>
</main>
<script src="/dist/viewer.js" type="module"></script>
</body>
</html>