video.cerulea.blue/appview/main.ts
Charlotte Som 89ec573591 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
2026-03-10 23:39:04 +00:00

147 lines
4.2 KiB
TypeScript

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 { 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") {
return new Response(null, {
headers: {
"access-control-allow-origin": "*",
},
});
}
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const BodySchema = z.object({
repo: z.string().check(z.startsWith("did:")),
blob: z.string(),
});
const body = BodySchema.parse(await req.json());
if (!db.inAllowlist(body.repo)) {
return new Response(
JSON.stringify({
error: "Denied",
message: "repo is not allowlisted on AppView",
}),
{
status: 400,
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
},
);
}
const filename = await getOrFetchVideo(body.repo, body.blob);
return new Response(JSON.stringify({ filename }), {
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
});
}
export default {
async fetch(req: Request, _info): Promise<Response> {
const pathname = new URL(req.url).pathname;
if (pathname === "/xrpc/blue.cerulea.video.fetchVideo") {
return await fetchVideo(req);
}
if (pathname.startsWith("/user-content/")) {
return await serveDir(req, {
fsRoot: "./data/videos",
quiet: true,
enableCors: true,
urlRoot: "user-content",
});
}
const videoRoute = VIDEO_PATTERN.exec(req.url);
if (videoRoute) {
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 });
},
} satisfies Deno.ServeDefaultExport;