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:
parent
e8f3e99f88
commit
89ec573591
8 changed files with 169 additions and 46 deletions
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
115
appview/main.ts
115
appview/main.ts
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
18
common/video.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
40
web/viewer.vto
Normal 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>
|
||||
Loading…
Reference in a new issue