diff --git a/appview/db.ts b/appview/db.ts index 6493cd7..33b7d93 100644 --- a/appview/db.ts +++ b/appview/db.ts @@ -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: (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)); + }, }; diff --git a/appview/main.ts b/appview/main.ts index 37ca29b..90c9cd5 100644 --- a/appview/main.ts +++ b/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 { + 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 { if (req.method === "OPTIONS") { @@ -26,16 +64,6 @@ async function fetchVideo(req: Request): Promise { }); 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 { ); } - 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(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 }); diff --git a/common/identity.ts b/common/identity.ts index 66ce50b..4f196ad 100644 --- a/common/identity.ts +++ b/common/identity.ts @@ -24,3 +24,9 @@ export async function resolveDid(did: string): Promise { throw new Error("unsupported did type: " + did); } + +export async function getPdsUrl(did: string): Promise { + const doc = await resolveDid(did); + const url = doc.service?.find((it) => it.id === "#atproto_pds")?.serviceEndpoint; + return typeof url === "string" ? url : null; +} diff --git a/common/lexicons.ts b/common/lexicons.ts index fb9e114..c577699 100644 --- a/common/lexicons.ts +++ b/common/lexicons.ts @@ -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; diff --git a/common/video.ts b/common/video.ts new file mode 100644 index 0000000..53b666c --- /dev/null +++ b/common/video.ts @@ -0,0 +1,18 @@ +import { getPdsUrl } from "./identity.ts"; +import type { VideoRecord } from "./lexicons.ts"; + +export async function getVideoRecord(did: string, rkey: string): Promise { + 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; +} diff --git a/deno.json b/deno.json index b10af53..0678993 100644 --- a/deno.json +++ b/deno.json @@ -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"], diff --git a/deno.lock b/deno.lock index d607407..bd1eceb 100644 --- a/deno.lock +++ b/deno.lock @@ -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" ] } } diff --git a/web/viewer.vto b/web/viewer.vto new file mode 100644 index 0000000..46f58b6 --- /dev/null +++ b/web/viewer.vto @@ -0,0 +1,40 @@ + + + + + + {{ title ?? "(untitled)" }} | video.cerulea.blue + + + + {{ if description }} + + {{ /if }} + + + {{ if videoUrl }} + + + + {{ /if }} + + + + {{ if description }} + + {{ /if }} + {{ if videoUrl }} + + + + {{ /if }} + + + +
+
+
+ + + +