initial commit: appview fetchVideo implementation

just need to make:
- viewer (very simple - fetch record, fetchVideo, display <video> tag)
- upload/management (need atcute browser oauth client, write to PDS, etcetc)
This commit is contained in:
Charlotte Som 2025-05-29 20:59:09 +01:00
commit 2ce74c6391
8 changed files with 439 additions and 0 deletions

38
appview/db.ts Normal file
View file

@ -0,0 +1,38 @@
import { Database } from "@db/sqlite";
const conn = new Database("./data/video.db");
conn.exec(`pragma journal_mode = WAL;`);
conn.exec(`
CREATE TABLE IF NOT EXISTS allowlist (
repo TEXT NOT NULL UNIQUE PRIMARY KEY -- did
) STRICT;
`);
conn.exec(`
CREATE TABLE IF NOT EXISTS videos (
id INTEGER NOT NULL PRIMARY KEY,
repo TEXT NOT NULL, -- did
cid TEXT NOT NULL, -- blob cid
fetched_at INTEGER NOT NULL DEFAULT (unixepoch()), -- datetime
filename TEXT NOT NULL
) 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 (?, ?, ?)"
);
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),
};

122
appview/main.ts Normal file
View file

@ -0,0 +1,122 @@
import { ensureDir } from "@std/fs";
import { serveDir } from "@std/http/file-server";
import * as z from "@zod/mini";
import { db } from "./db.ts";
import {
CompositeDidDocumentResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
} from "@atcute/identity-resolver";
const didResolver = new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
});
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());
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({ error: "repo is not allowlisted on AppView" }),
{
headers: {
"content-type": "application/json",
"access-control-allow-origin": "*",
},
}
);
}
const doc = await didResolver.resolve(
body.repo as `did:${"plc" | "web"}:${string}`
);
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);
}
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",
});
}
return await serveDir(req, { fsRoot: "./web", quiet: true });
},
} satisfies Deno.ServeDefaultExport;

2
data/.gitignore vendored Normal file
View file

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

13
deno.json Normal file
View file

@ -0,0 +1,13 @@
{
"tasks": {
"appview:start": "deno serve -A --port 4080 ./appview/main.ts"
},
"imports": {
"@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3",
"@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"
}
}

187
deno.lock Normal file
View file

@ -0,0 +1,187 @@
{
"version": "5",
"specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0",
"jsr:@std/cli@^1.0.18": "1.0.18",
"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/fmt@0.221": "0.221.0",
"jsr:@std/fmt@1": "1.0.8",
"jsr:@std/fmt@^1.0.8": "1.0.8",
"jsr:@std/fs@0.221": "0.221.0",
"jsr:@std/fs@1": "1.0.17",
"jsr:@std/fs@^1.0.17": "1.0.17",
"jsr:@std/html@^1.0.4": "1.0.4",
"jsr:@std/http@^1.0.17": "1.0.17",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.4": "1.0.4",
"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.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"
},
"jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.0.6": {
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
"dependencies": [
"jsr:@std/encoding@0.221",
"jsr:@std/fmt@0.221",
"jsr:@std/fs@0.221",
"jsr:@std/path@0.221"
]
},
"@denosaurs/plug@1.1.0": {
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
"dependencies": [
"jsr:@std/encoding@1",
"jsr:@std/fmt@1",
"jsr:@std/fs@1",
"jsr:@std/path@1"
]
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@0.221.0": {
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
},
"@std/cli@1.0.18": {
"integrity": "33846eab6a7cac52156cc105a798451df06965693606e4668adfe0436a155fd7"
},
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@0.221.0": {
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@0.221.0": {
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
"dependencies": [
"jsr:@std/assert@0.221",
"jsr:@std/path@0.221"
]
},
"@std/fs@1.0.17": {
"integrity": "1c00c632677c1158988ef7a004cb16137f870aafdb8163b9dce86ec652f3952b",
"dependencies": [
"jsr:@std/path@^1.0.9"
]
},
"@std/html@1.0.4": {
"integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e"
},
"@std/http@1.0.17": {
"integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/fmt@^1.0.8",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path@^1.1.0",
"jsr:@std/streams"
]
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/net@1.0.4": {
"integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852"
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@0.217"
]
},
"@std/path@0.221.0": {
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
"dependencies": [
"jsr:@std/assert@0.221"
]
},
"@std/path@1.1.0": {
"integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886"
},
"@std/streams@1.0.9": {
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
}
},
"npm": {
"@atcute/identity-resolver@1.1.3_@atcute+identity@1.0.2": {
"integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons",
"@atcute/util-fetch",
"@badrap/valita"
]
},
"@atcute/identity@1.0.2": {
"integrity": "sha512-SrDPHuEarEHj9bx7NfYn7DYG6kIgJIMRU581iOCIaVaiZ1WhE9D8QxTxeYG/rbGNSa85E891ECp1sQcKiBN0kg==",
"dependencies": [
"@atcute/lexicons",
"@badrap/valita"
]
},
"@atcute/lexicons@1.0.4": {
"integrity": "sha512-VyGJuGKAIeE+71UT9aSMJJdvfxfXsdsGMG9acv9rnGT7enVy4TD5XoYQy7TCHZ4YpxXzuHkqjyAqBz95c4WkRg==",
"dependencies": [
"esm-env"
]
},
"@atcute/util-fetch@1.0.1": {
"integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==",
"dependencies": [
"@badrap/valita"
]
},
"@badrap/valita@0.4.5": {
"integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ=="
},
"@zod/core@0.11.6": {
"integrity": "sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA=="
},
"@zod/mini@4.0.0-beta.20250505T195954": {
"integrity": "sha512-ioybPtU4w4TqwHvJv0gkAiYNaBkZ/BaGHBpK7viCIRSE8BiiZucVZ8vS0YE04Qy1R120nAnFy1d+tD9ByMO0yw==",
"dependencies": [
"@zod/core"
]
},
"esm-env@1.2.2": {
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
}
},
"workspace": {
"dependencies": [
"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:@zod/mini@^4.0.0-beta.20250505T195954"
]
}
}

38
lexicon/fetchVideo.json Normal file
View file

@ -0,0 +1,38 @@
{
"lexicon": 1,
"id": "blue.cerulea.video.fetchVideo",
"defs": {
"main": {
"type": "procedure",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["repo", "blob"],
"properties": {
"repo": {
"type": "string",
"format": "did"
},
"blob": {
"type": "string",
"format": "cid"
}
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["filename"],
"properties": {
"filename": {
"type": "string"
}
}
}
}
}
}
}

29
lexicon/video.json Normal file
View file

@ -0,0 +1,29 @@
{
"lexicon": 1,
"id": "blue.cerulea.video.video",
"defs": {
"main": {
"type": "record",
"key": "any",
"record": {
"type": "object",
"required": ["video"],
"properties": {
"video": {
"type": "blob",
"accept": ["video/mp4", "video/webm"],
"maxSize": 536870912
},
"title": {
"type": "string",
"maxGraphemes": 300,
"maxLength": 3000
},
"description": {
"type": "string"
}
}
}
}
}
}

10
web/index.html Normal file
View file

@ -0,0 +1,10 @@
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video.cerulea.blue</title>
<style>
:root {
color-scheme: dark;
}
</style>