authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-03 11:56:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:48-07:00
log83f7d1c67f60bc08f85e3878875374d3198e74fe
tree29dad5cf67ae6a28a9344685505de7a641c3cd12
parentb490ba62dedb3b43b9ee832e8c3630d017557b69
signature Commit is signed but in an unrecognized format.

site: finish main deploymnet


48 files changed, 787 insertions(+), 645 deletions(-)

framework/backend/entry-node.ts+5-2
...@@ -8,7 +8,9 @@ const server = serve({...@@ -8,7 +8,9 @@ const server = serve({
8 fetch: app.default.fetch,8 fetch: app.default.fetch,
9 port: Number(process.env.PORT ?? 3000),9 port: Number(process.env.PORT ?? 3000),
10}, ({ address, port }) => {10}, ({ address, port }) => {
11 if (address === "::") address = "localhost";11 if (address === "::" || address === "127.0.0.1") {
12 address = os.hostname().replace(/\.local$/, "");
13 }
12 console.info(url.format({14 console.info(url.format({
13 protocol,15 protocol,
14 hostname: address,16 hostname: address,
...@@ -36,6 +38,7 @@ declare global {...@@ -36,6 +38,7 @@ declare global {
36 var CLOVER_SERVER_ENTRY: string;38 var CLOVER_SERVER_ENTRY: string;
37}39}
3840
39import url from "node:url";41import * as os from "os";
42import * as url from "node:url";
40import { serve } from "@hono/node-server";43import { serve } from "@hono/node-server";
41import process from "node:process";44import process from "node:process";
framework/bundle.ts+14-1
...@@ -39,6 +39,18 @@ export async function bundleClientJavaScript(...@@ -39,6 +39,18 @@ export async function bundleClientJavaScript(
39 const clientPlugins: esbuild.Plugin[] = [39 const clientPlugins: esbuild.Plugin[] = [
40 projectRelativeResolution(),40 projectRelativeResolution(),
41 markoViaBuildCache(),41 markoViaBuildCache(),
42 {
43 name: "mark css external",
44 setup(b) {
45 b.onResolve({ filter: /\.css$/ }, () => ({
46 path: ".",
47 namespace: "dropped",
48 }));
49 b.onLoad({ filter: /./, namespace: "dropped" }, () => ({
50 contents: "",
51 }));
52 },
53 },
42 ];54 ];
4355
44 using _ = io.start(`bundle ${entryPoints.length} files`);56 using _ = io.start(`bundle ${entryPoints.length} files`);
...@@ -60,6 +72,7 @@ export async function bundleClientJavaScript(...@@ -60,6 +72,7 @@ export async function bundleClientJavaScript(
60 outdir: "out!",72 outdir: "out!",
61 plugins: clientPlugins,73 plugins: clientPlugins,
62 write: false,74 write: false,
75 external: ["/js/scripts/vendor/dash.js", "/js/scripts/vendor/hls.js"],
63 define: {76 define: {
64 ASSERT: "console.assert",77 ASSERT: "console.assert",
65 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),78 MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
...@@ -290,7 +303,7 @@ export async function bundleServerJavaScript({...@@ -290,7 +303,7 @@ export async function bundleServerJavaScript({
290 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),303 "globalThis.CLOVER_SERVER_ENTRY": JSON.stringify(entry),
291 },304 },
292 external: Object.keys(pkg.dependencies).filter(305 external: Object.keys(pkg.dependencies).filter(
293 (x) => !x.startsWith("@paperclover"),306 (x) => x !== "lib",
294 ),307 ),
295 });308 });
296 await trackEsbuild(io, metafile);309 await trackEsbuild(io, metafile);
framework/engine/marko-runtime.ts-1
...@@ -27,7 +27,6 @@ export const _template: typeof marko._template = (...@@ -27,7 +27,6 @@ export const _template: typeof marko._template = (
27 return markoResult.then(render.raw);27 return markoResult.then(render.raw);
28 }28 }
29 const rr = markoResult.toString();29 const rr = markoResult.toString();
30 console.log({ rr });
31 return render.raw(rr);30 return render.raw(rr);
32 }31 }
33 wrap.render = renderFn;32 wrap.render = renderFn;
framework/engine/render.ts+5-4
...@@ -6,6 +6,8 @@...@@ -6,6 +6,8 @@
6// Add-ons to the rendering engine can provide opaque data, And retrieve it6// Add-ons to the rendering engine can provide opaque data, And retrieve it
7// within component calls with 'getAddonData'. For example, 'sitegen' uses this7// within component calls with 'getAddonData'. For example, 'sitegen' uses this
8// to track needed client scripts without introducing patches to the engine.8// to track needed client scripts without introducing patches to the engine.
9//
10// This module is not production ready
9export let current: State | null = null;11export let current: State | null = null;
10export function setCurrent(r: State | null) {12export function setCurrent(r: State | null) {
11 current = r ?? null;13 current = r ?? null;
...@@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string {...@@ -212,8 +214,7 @@ export function stringifyNode(node: ResolvedNode): string {
212}214}
213function stringifyElement(element: ResolvedElement) {215function stringifyElement(element: ResolvedElement) {
214 const { 1: tag, 2: props, 3: children } = element;216 const { 1: tag, 2: props, 3: children } = element;
215 let out = "<" + tag;217 let out = "<" + tag + " ";
216 let needSpace = true;
217 for (const prop in props) {218 for (const prop in props) {
218 const value = props[prop];219 const value = props[prop];
219 if (!value || typeof value === "function") continue;220 if (!value || typeof value === "function") continue;
...@@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) {...@@ -249,10 +250,10 @@ function stringifyElement(element: ResolvedElement) {
249 case "key":250 case "key":
250 continue;251 continue;
251 }252 }
252 if (needSpace) ((out += " "), (needSpace = !attr.endsWith('"')));253 if (!out.endsWith('"')) out += " ";
253 out += attr;254 out += attr;
254 }255 }
255 out += ">";256 out = out.trimEnd() + ">";
256 if (children) out += stringifyNode(children);257 if (children) out += stringifyNode(children);
257 if (258 if (
258 tag !== "br" &&259 tag !== "br" &&
framework/generate.ts+2-4
...@@ -369,10 +369,8 @@ export async function assembleAndWritePage(...@@ -369,10 +369,8 @@ export async function assembleAndWritePage(
369369
370 pageNode.passive = false;370 pageNode.passive = false;
371371
372 const scripts = scriptIds372 const scripts = sg.joinScripts(scriptIds
373 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))373 .map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`)));
374 .map((x) => `{${x}}`)
375 .join("\n");
376374
377 const buffer = sg.wrapDocument({375 const buffer = sg.wrapDocument({
378 body: html,376 body: html,
framework/incremental.ts+1-1
...@@ -496,7 +496,7 @@ export class Io {...@@ -496,7 +496,7 @@ export class Io {
496 return resolved;496 return resolved;
497 }497 }
498 async readFile(file: string) {498 async readFile(file: string) {
499 return fs.readFile(await this.trackFile(file), "utf-8");499 return fs.readFile(await this.trackFile(file));
500 }500 }
501 async readJson<T>(file: string) {501 async readJson<T>(file: string) {
502 return JSON.parse(await this.readFile(file)) as T;502 return JSON.parse(await this.readFile(file)) as T;
framework/lib/lru.ts+2-1
...@@ -94,7 +94,8 @@ export class Lru<K, V> extends Map<K, V> {...@@ -94,7 +94,8 @@ export class Lru<K, V> extends Map<K, V> {
94 this.#used += size;94 this.#used += size;
95 try {95 try {
96 this.ensureUnusedCapacity(0);96 this.ensureUnusedCapacity(0);
97 } catch {97 } catch (err) {
98 console.error(err);
98 ASSERT(false, "capacity is already verified");99 ASSERT(false, "capacity is already verified");
99 }100 }
100 super.set(key, value);101 super.set(key, value);
framework/lib/meta.ts+1-1
...@@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string {...@@ -214,7 +214,7 @@ function renderMetadata(meta: Meta, template: Template): string {
214 if (denyRobots) {214 if (denyRobots) {
215 out += `<meta name=robots content=noindex,nofollow>`;215 out += `<meta name=robots content=noindex,nofollow>`;
216 }216 }
217 if (description) {217 if (viewport) {
218 out += `<meta name=viewport content=${attr(viewport)}>`;218 out += `<meta name=viewport content=${attr(viewport)}>`;
219 }219 }
220 if (canonical) {220 if (canonical) {
framework/lib/mime.ts+1-1
...@@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));...@@ -18,7 +18,7 @@ const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));
18export function contentTypeFor(file: string) {18export function contentTypeFor(file: string) {
19 const slash = file.indexOf("/");19 const slash = file.indexOf("/");
20 if (slash !== -1) file = file.slice(slash + 1);20 if (slash !== -1) file = file.slice(slash + 1);
21 const dot = file.indexOf(".");21 const dot = file.lastIndexOf(".");
22 if (dot === -1) file = "." + file;22 if (dot === -1) file = "." + file;
23 else if (dot > 0) {23 else if (dot > 0) {
24 let entry = fullNames.get(file);24 let entry = fullNames.get(file);
framework/lib/mime.txt+1-1
...@@ -42,7 +42,7 @@...@@ -42,7 +42,7 @@
42.jpg image/jpeg42.jpg image/jpeg
43.jpgv video/jpeg43.jpgv video/jpeg
44.jxl image/jxl44.jxl image/jxl
45.js application/javascript45.js application/javascript;charset=utf-8
46.json application/json46.json application/json
47.latex application/x-latex47.latex application/x-latex
48.list text/plain48.list text/plain
framework/lib/sitegen.ts+10-4
...@@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>;...@@ -50,9 +50,7 @@ export type FontVars = Record<string, FontVariableAxis>;
50export type FontVariableAxis = number | [min: number, max: number];50export type FontVariableAxis = number | [min: number, max: number];
51export type FontRange = string | number | { start: number; end: number };51export type FontRange = string | number | { start: number; end: number };
5252
53export const userData = render.userData<SitegenRender>(() => {53export const userData = render.userData<SitegenRender>(() => initRender());
54 throw new Error("This function can only be used in a page (static or view)");
55});
5654
57export interface SitegenRender {55export interface SitegenRender {
58 scripts: Set<string>;56 scripts: Set<string>;
...@@ -78,13 +76,21 @@ export function wrapDocument({...@@ -78,13 +76,21 @@ export function wrapDocument({
78 inlineCss: string;76 inlineCss: string;
79 scripts: string;77 scripts: string;
80}) {78}) {
81 return `<!doctype html><html lang=en><head>${head}${79 return `<!doctype html><html lang=en><head><meta charset=utf8>${head}${
82 inlineCss ? `<style>${inlineCss}</style>` : ""80 inlineCss ? `<style>${inlineCss}</style>` : ""
83 }</head><body>${body}${81 }</head><body>${body}${
84 scripts ? `<script>${scripts}</script>` : ""82 scripts ? `<script>${scripts}</script>` : ""
85 }</body></html>`;83 }</body></html>`;
86}84}
8785
86export function joinScripts(scriptSources: string[]) {
87 const { length } = scriptSources;
88 if (scriptSources[0] == null) return "";
89 if (length === 1) return UNWRAP(scriptSources[0]);
90 return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",") +
91 "].map(e=>e())";
92}
93
88import * as render from "#engine/render";94import * as render from "#engine/render";
89import type * as meta from "./meta.ts";95import type * as meta from "./meta.ts";
90import type * as css from "../css.ts";96import type * as css from "../css.ts";
framework/lib/subprocess.ts+56-52
...@@ -3,76 +3,80 @@...@@ -3,76 +3,80 @@
33
4const execFileRaw = util.promisify(child_process.execFile);4const execFileRaw = util.promisify(child_process.execFile);
5export const exec: typeof execFileRaw = ((5export const exec: typeof execFileRaw = ((
6 ...args: Parameters<typeof execFileRaw>6 ...args: Parameters<typeof execFileRaw>
7) =>7) =>
8 execFileRaw(...args).catch((e: any) => {8 execFileRaw(...args).catch((e: any) => {
9 if (e?.message?.startsWith?.("Command failed")) {9 if (e?.message?.startsWith?.("Command failed")) {
10 if (e.code > 2 ** 31) e.code |= 0;10 if (e.code > 2 ** 31) e.code |= 0;
11 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;11 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
12 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;12 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
13 }13 }
14 throw e;14 throw e;
15 })) as any;15 })) as any;
1616
17const activePids = new Set<number>();17// const activePids = new Set<number>();
1818
19export interface SpawnOptions extends child_process.SpawnOptions {19export interface SpawnOptions extends child_process.SpawnOptions {
20 cmd: string[];20 cmd: string[];
21}21}
22export function spawn({ cmd, ...rest }: SpawnOptions) {22export function spawn({ cmd, ...rest }: SpawnOptions) {
23 rest.detached ??= true;23 // rest.detached ??= true;
24 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);24 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
25 const pid = rest.detached && process.pid ? -process.pid : process.pid;25 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
26 if (pid) {26 // if (pid) {
27 activePids.add(pid);27 // activePids.add(pid);
28 process.on("exit", () => void activePids.delete(pid));28 // process.on("exit", () => void activePids.delete(pid));
29 }29 // }
30 return process;30 return process;
31}31}
3232
33export interface ForkOptions extends child_process.ForkOptions {33export interface ForkOptions extends child_process.ForkOptions {
34 cmd: string[];34 cmd: string[];
35}35}
3636
37export function fork({ cmd, ...rest }: ForkOptions) {37export function fork({ cmd, ...rest }: ForkOptions) {
38 rest.detached ??= true;38 // rest.detached ??= true;
39 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);39 const process = child_process.fork(cmd[0]!, cmd.slice(1), rest);
40 const pid = rest.detached && process.pid ? -process.pid : process.pid;40 // const pid = rest.detached && process.pid ? -process.pid : process.pid;
41 if (pid) {41 // if (pid) {
42 activePids.add(pid);42 // activePids.add(pid);
43 process.on("exit", () => void activePids.delete(pid));43 // process.on("exit", () => void activePids.delete(pid));
44 }44 // }
45 return process;45 return process;
46}46}
4747
48export async function spawnAndWait(opts: SpawnOptions) {48export async function spawnAndWait(opts: SpawnOptions) {
49 opts.stdio ??= ["ignore", "inherit", "inherit"];49 opts.stdio ??= ["ignore", "inherit", "inherit"];
50 const process = spawn(opts);50 const process = spawn(opts);
51 const output = Promise.all([51 const output = Promise.all([
52 process.stdout ? Array.fromAsync(process.stdout).then(Buffer.concat) : null,52 process.stdout
53 process.stderr ? Array.fromAsync(process.stderr).then(Buffer.concat) : null,53 ? Array.fromAsync(process.stdout).then(Buffer.concat)
54 ]);54 : null,
55 let [code, signal] = await events.EventEmitter.once(process, "exit") as [55 process.stderr
56 number | null,56 ? Array.fromAsync(process.stderr).then(Buffer.concat)
57 string | null,57 : null,
58 ];58 ]);
59 if (code !== 0) {59 let [code, signal] = (await events.EventEmitter.once(process, "exit")) as [
60 if (code !== null && code > 2 ** 31) code |= 0;60 number | null,
61 const exit = signal ? `signal ${signal}` : `code ${code}`;61 string | null,
62 throw new Error(`${opts.cmd[0]} failed with ${exit}`);62 ];
63 }63 if (code !== 0) {
64 const [stdout, stderr] = await output;64 if (code !== null && code > 2 ** 31) code |= 0;
65 return { stdout, stderr };65 const exit = signal ? `signal ${signal}` : `code ${code}`;
66 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
67 }
68 const [stdout, stderr] = await output;
69 return { stdout, stderr };
66}70}
6771
68process.on("exit", () => {72// process.on("exit", () => {
69 for (const pid of activePids) {73// for (const pid of activePids) {
70 console.info(`Kill ${Math.abs(pid)}`);74// console.info(`Kill ${Math.abs(pid)}`);
71 process.kill(pid, 9);75// process.kill(pid, 9);
72 }76// }
73});77// });
7478
75import * as process from "node:process";79// import * as process from "node:process";
76import * as events from "node:events";80import * as events from "node:events";
77import * as util from "node:util";81import * as util from "node:util";
78import * as child_process from "node:child_process";82import * as child_process from "node:child_process";
framework/lib/view.ts+1-8
...@@ -83,7 +83,7 @@ export async function renderToString<K extends Key>(...@@ -83,7 +83,7 @@ export async function renderToString<K extends Key>(
83 body,83 body,
84 head: await renderedMetaPromise,84 head: await renderedMetaPromise,
85 inlineCss,85 inlineCss,
86 scripts: joinScripts(86 scripts: sg.joinScripts(
87 Array.from(87 Array.from(
88 sitegen!.scripts,88 sitegen!.scripts,
89 (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),89 (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
...@@ -108,13 +108,6 @@ export function regenerate(tag: RegenKey) {...@@ -108,13 +108,6 @@ export function regenerate(tag: RegenKey) {
108 }108 }
109}109}
110110
111function joinScripts(scriptSources: string[]) {
112 const { length } = scriptSources;
113 if (length === 0) return "";
114 if (0 in scriptSources) return scriptSources[0];
115 return scriptSources.map((source) => `{${source}}`).join(";");
116}
117
118import * as meta from "./meta.ts";111import * as meta from "./meta.ts";
119import type * as hono from "#hono";112import type * as hono from "#hono";
120import * as render from "#engine/render";113import * as render from "#engine/render";
run.js+18-4
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1// This file allows using Node.js in combination with1// This file allows using Node.js in combination with
2// all available plugins. Usage: "node run <script>"2// all available plugins. Usage: "node run <script>"
3import * as util from "node:util";
4import * as zlib from "node:zlib";3import * as zlib from "node:zlib";
5import * as url from "node:url";4import * as url from "node:url";
5import * as fs from "node:fs";
6import * as os from "node:os";
6import process from "node:process";7import process from "node:process";
78
8// Disable experimental warnings (Type Stripping, etc)9// Disable experimental warnings (Type Stripping, etc)
...@@ -23,7 +24,7 @@ try {...@@ -23,7 +24,7 @@ try {
2324
24 // seems to fail in bun25 // seems to fail in bun
25 (await import("source-map-support")).install({ hookRequire: true });26 (await import("source-map-support")).install({ hookRequire: true });
26} catch {27} catch (e) {
27 const brand = process.versions.bun28 const brand = process.versions.bun
28 ? `bun ${process.versions.bun}`29 ? `bun ${process.versions.bun}`
29 : process.versions.deno30 : process.versions.deno
...@@ -33,8 +34,9 @@ try {...@@ -33,8 +34,9 @@ try {
33 globalThis.console.error(34 globalThis.console.error(
34 `sitegen depends on a node.js v24. your runtime is missing features.\n` +35 `sitegen depends on a node.js v24. your runtime is missing features.\n` +
35 `this is node.js version ${process.version}${brandText}\n\n` +36 `this is node.js version ${process.version}${brandText}\n\n` +
36 `get node.js --> https://nodejs.org/en/download/current`,37 `get node.js --> https://nodejs.org/en/download/current\n`,
37 );38 );
39 console.error(e);
38 process.exit(1);40 process.exit(1);
39}41}
4042
...@@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => {...@@ -53,6 +55,15 @@ process.on("uncaughtException", (error) => {
53 process.exit(1);55 process.exit(1);
54});56});
5557
58if (fs.existsSync(".env")) {
59 const env = fs.readFileSync(".env", "utf-8").split("\n").map((x) =>
60 x.trim().split("=")
61 );
62 for (const [k, v] of env) {
63 process.env[k] = v;
64 }
65}
66
56// Load with hooks67// Load with hooks
57if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {68if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
58 if (process.argv.length == 2) {69 if (process.argv.length == 2) {
...@@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {...@@ -82,9 +93,12 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
82 serve(93 serve(
83 {94 {
84 fetch: mod.default.fetch,95 fetch: mod.default.fetch,
96 port: mod.port ?? 3000,
85 },97 },
86 ({ address, port }) => {98 ({ address, port }) => {
87 if (address === "::") address = "::1";99 if (address === "::" || address === "127.0.0.1") {
100 address = os.hostname().replace(/\.local$/, "");
101 }
88 log.info(102 log.info(
89 url.format({103 url.format({
90 protocol,104 protocol,
src/admin.ts+1-3
...@@ -12,9 +12,7 @@ function compareToken(token: string) {...@@ -12,9 +12,7 @@ function compareToken(token: string) {
12}12}
1313
14export async function middleware(c: Context, next: Next) {14export async function middleware(c: Context, next: Next) {
15 if (c.req.path.startsWith("/admin")) {15 if (c.req.path.startsWith("/admin")) return adminInner(c, next);
16 return adminInner(c, next);
17 }
18 return next();16 return next();
19}17}
2018
src/backend.ts+1
...@@ -11,6 +11,7 @@ app.use(admin.middleware);...@@ -11,6 +11,7 @@ app.use(admin.middleware);
11// Backends11// Backends
12app.route("", require("./q+a/backend.ts").app);12app.route("", require("./q+a/backend.ts").app);
13app.route("", require("./file-viewer/backend.tsx").app);13app.route("", require("./file-viewer/backend.tsx").app);
14app.route("", require("./friend-auth.ts").app);
1415
15// Asset middleware has least precedence16// Asset middleware has least precedence
16app.use(assets.middleware);17app.use(assets.middleware);
src/deploy.ts created+51
...@@ -0,0 +1,51 @@
1export async function main() {
2 if (!process.argv.includes("-f")) await incr.restore();
3 await incr.compile(generate);
4
5 await rsync.spawn({
6 cwd: Path.resolve(import.meta.dirname).parent!.toString(),
7 args: [
8 "-a",
9 "-r",
10 "--progress",
11 "--delete",
12 "--exclude=node_modules",
13 "--exclude=.clover",
14 "package.json",
15 "package-lock.json",
16 ".clover/o/",
17 "paperclover.net:~/paperclover.net/",
18 ],
19 progress: progress.start("copy site files"),
20 });
21 const cache = Path.resolve(".clover/cache.sqlite").ifExistsSync();
22 if (cache) {
23 await rsync.spawn({
24 cwd: Path.resolve(import.meta.dirname).parent!.toString(),
25 args: [
26 "-a",
27 "--progress",
28 cache.toString(),
29 "paperclover.net:~/paperclover.net/.clover/cache.sqlite",
30 ],
31 progress: progress.start("copy file viewer cache"),
32 });
33 }
34 await subprocess.spawnAndWait({
35 stdio: "inherit",
36 cmd: [
37 "ssh",
38 "paperclover.net",
39 "cd paperclover.net && " +
40 "npm i &&" +
41 "pm2 restart site",
42 ],
43 });
44}
45
46import * as incr from "../framework/incremental.ts";
47import { generate } from "../framework/generate.ts";
48import * as progress from "lib/log/progress.ts";
49import * as subprocess from "#sitegen/subprocess";
50import * as rsync from "./file-viewer/rsync.ts";
51import { Path } from "#sitegen/path";
src/file-viewer/backend.tsx+19-12
...@@ -66,7 +66,10 @@ app.get("/file/*", async (c, next) => {...@@ -66,7 +66,10 @@ app.get("/file/*", async (c, next) => {
66 if (c.req.query("dl") != null) viewMode = "download";66 if (c.req.query("dl") != null) viewMode = "download";
67 if (lofi) viewMode = file.extension === ".html" ? "embed" : "download";67 if (lofi) viewMode = file.extension === ".html" ? "embed" : "download";
6868
69 if (viewMode == null && c.req.header("Accept")?.includes("text/html")) {69 if (
70 viewMode == null && !derivedKey &&
71 c.req.header("Accept")?.includes("text/html")
72 ) {
70 // cache.prefetch(file);73 // cache.prefetch(file);
71 c.res = await view.serve(c, "file-viewer/clofi", {74 c.res = await view.serve(c, "file-viewer/clofi", {
72 file,75 file,
...@@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => {...@@ -156,13 +159,14 @@ app.get("/file/*", async (c, next) => {
156159
157 const found = await cache.read({160 const found = await cache.read({
158 file,161 file,
159 hash,162 size,
160 derived: derivedKey,163 derived: derivedKey,
164 hash,
161 start,165 start,
162 end,166 end,
163 });167 });
164 if (!found) {168 if (!found) {
165 return Response.json({169 return c.json({
166 error: "localOnly is enabled and this file is not available",170 error: "localOnly is enabled and this file is not available",
167 }, 500);171 }, 500);
168 }172 }
...@@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) {...@@ -265,17 +269,20 @@ function getPartialPage(c: Context, rawFilePath: string) {
265 return c.json({ error: "File not found" }, 404);269 return c.json({ error: "File not found" }, 404);
266 }270 }
267271
268 let root = MediaPanel({272 const r = render.init(false, {});
269 file,273 let root = render.resolveNode(
270 isLast: true,274 r,
271 activeFilename: null,275 <MediaPanel
272 hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),276 file={file}
273 });277 isLast
278 activeFilename={null}
279 hasCotyledonCookie={rawFilePath === "" && checkCotyledonCookie(c)}
280 />,
281 );
274 // Remove the root element, it's created client side!282 // Remove the root element, it's created client side!
275 root = root[2].children as render.Element;283 root = (root as render.ResolvedElement)[3];
276284
277 const html = render.sync(root).text;285 return c.html(render.stringifyNode(root));
278 return c.html(html);
279}286}
280287
281import { type Context, Hono } from "hono";288import { type Context, Hono } from "hono";
src/file-viewer/bin/scan3.ts+3-2
...@@ -160,7 +160,7 @@ export async function main() {...@@ -160,7 +160,7 @@ export async function main() {
160 );160 );
161 dependantJob.needs -= 1;161 dependantJob.needs -= 1;
162 if (dependantJob.needs == 0) {162 if (dependantJob.needs == 0) {
163 queueFileProcessor(dependantJob);163 promises.push(queueFileProcessor(dependantJob));
164 }164 }
165 }165 }
166 }, (job) => ({ cores: job.processor.cores ?? 0 }));166 }, (job) => ({ cores: job.processor.cores ?? 0 }));
...@@ -281,6 +281,7 @@ export async function main() {...@@ -281,6 +281,7 @@ export async function main() {
281 using dirMetaNode = progress.start("update directory metadata");281 using dirMetaNode = progress.start("update directory metadata");
282 const dirs = MediaFile.getDirectoriesToReindex()282 const dirs = MediaFile.getDirectoriesToReindex()
283 .sort((a, b) => b.path.length - a.path.length);283 .sort((a, b) => b.path.length - a.path.length);
284
284 for (const dir of dirs) {285 for (const dir of dirs) {
285 using _ = dirMetaNode.start(dir.path);286 using _ = dirMetaNode.start(dir.path);
286 const children = dir.getChildren();287 const children = dir.getChildren();
...@@ -376,7 +377,7 @@ export async function main() {...@@ -376,7 +377,7 @@ export async function main() {
376 });377 });
377 if (sotToken) {378 if (sotToken) {
378 const res = await fetch("https://db.paperclover.net/reload", {379 const res = await fetch("https://db.paperclover.net/reload", {
379 method: "post",380 method: "POST",
380 headers: {381 headers: {
381 Authorization: sotToken,382 Authorization: sotToken,
382 },383 },
src/file-viewer/cache.ts+155-64
...@@ -33,7 +33,7 @@ export type Source =...@@ -33,7 +33,7 @@ export type Source =
33const token = process.env.CLOVER_SOT_KEY ?? null;33const token = process.env.CLOVER_SOT_KEY ?? null;
34const sotUrl = new URL(34const sotUrl = new URL(
35 process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net",35 process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net",
36);36).toString().replace(/\/+$/g, "");
37if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files");37if (!token) console.warn("Missing CLOVER_SOT_KEY, cannot host protected files");
3838
39const diskCacheRoot = openDiskCache();39const diskCacheRoot = openDiskCache();
...@@ -94,6 +94,7 @@ export interface Options {...@@ -94,6 +94,7 @@ export interface Options {
94 file: MediaFile;94 file: MediaFile;
95 /** If set, fetches the derived asset instead. */95 /** If set, fetches the derived asset instead. */
96 derived: string | null;96 derived: string | null;
97 size: number;
97 hash: string;98 hash: string;
98 start: number;99 start: number;
99 /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */100 /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */
...@@ -107,7 +108,7 @@ export interface Options {...@@ -107,7 +108,7 @@ export interface Options {
107 * `null` if a derived asset does not exist108 * `null` if a derived asset does not exist
108 */109 */
109export async function read(110export async function read(
110 { file, derived, hash, start, end, noWrite, localOnly }: Options,111 { file, derived, hash, size, start, end, noWrite, localOnly }: Options,
111): Promise<{ stream: ReadableStream; src: Source; size: number } | null> {112): Promise<{ stream: ReadableStream; src: Source; size: number } | null> {
112 const rel = derived ? `${file.path}$/${derived}` : file.path;113 const rel = derived ? `${file.path}$/${derived}` : file.path;
113114
...@@ -115,33 +116,33 @@ export async function read(...@@ -115,33 +116,33 @@ export async function read(
115 const localFile = derived116 const localFile = derived
116 ? derivedFileRoot?.join(derived)117 ? derivedFileRoot?.join(derived)
117 : rawFileRoot?.join(file.path);118 : rawFileRoot?.join(file.path);
118 if (localFile) {119 local: {
119 local: {120 if (!localFile) break local;
120 let size: number;121 if (process.env.CLOVER_SOT_FORCE) break local;
121 try {122 let size: number;
122 ({ size } = await localFile.stat());123 try {
123 } catch (err) {124 ({ size } = await localFile.stat());
124 if (error.code(err) === "ENOENT") {125 } catch (err) {
125 console.warn(`missing local file: ${localFile}`);126 if (error.code(err) === "ENOENT") {
126 break local;127 console.warn(`missing local file: ${localFile}`);
127 }128 break local;
128 throw err;
129 }129 }
130 const fileStream = await readStream(localFile, { start, end });130 throw err;
131 if (!fileStream) break local;
132 console.info(`from local: ${rel} ` + localFile);
133 return {
134 stream: stream.Readable.toWeb(fileStream),
135 size,
136 src: "local",
137 };
138 }131 }
132 const fileStream = await readStream(localFile, { start, end });
133 if (!fileStream) break local;
134 console.info(`from local: ${rel} ` + localFile);
135 return {
136 stream: stream.Readable.toWeb(fileStream),
137 size,
138 src: "local",
139 };
139 }140 }
140141
141 if (!localOnly) return null;142 if (localOnly) return null;
142143
143 // Disk cache144 // Disk cache
144 const diskPath = diskCacheRoot.join(key);145 const diskPath = diskCacheRoot.join(hash);
145 let diskFile = diskCache.get(diskPath);146 let diskFile = diskCache.get(diskPath);
146 if (diskFile) {147 if (diskFile) {
147 const lock = diskCache.lock(diskPath);148 const lock = diskCache.lock(diskPath);
...@@ -165,67 +166,154 @@ export async function read(...@@ -165,67 +166,154 @@ export async function read(
165 }166 }
166167
167 // Fetch the source of truth168 // Fetch the source of truth
168 const url = `${sotUrl}/file${file.path}`;169 const suffix = derived ? ":/" + derived.split("/").slice(1).join("/") : "";
169 let response;170 const url = `${sotUrl}/file${file.path}${suffix}`;
170 {
171 let promise = parallelRequests.get(url);
172 if (promise) {
173 console.info(`from other response: ${rel}`);
174 response = await promise;
175 } else {
176 console.info(`cache miss: ${rel}`);
177 promise = httpGet(url, {
178 headers: token ? { Authorization: token } : {},
179 });
180 parallelRequests.set(url, promise);
181 response = await promise;
182 parallelRequests.delete(url);
183 }
184 }
185 const { statusCode, headers } = response;
186 if (statusCode !== 200) {
187 const text = await new Response(response as unknown as Blob).text();
188 const msg = `Source of Truth responded with status ${statusCode}: ${text}`;
189 console.error(msg);
190 throw new Error("Source of Truth is unavailable");
191 }
192 const size = Number(headers["content-length"]);
193 ASSERT(!Number.isNaN(size) && size >= 0);
194171
195 // Do not cache large assets172 // Do not cache large assets
196 if (size > 1_000_000_000) {173 if (size > 1_000_000_000) {
197 console.info(`skipping disk cache due to large asset: ${rel}`);174 console.info(`skipping disk cache due to large asset: ${rel}`);
198 return { stream: stream.Readable.toWeb(response), size, src: "ignored" };175 return {
176 stream: fetchAndSlice(rel, url, start, end),
177 size,
178 src: "ignored",
179 };
199 }180 }
200 // Bail out of the disk cache if it is contended181 // Bail out of the disk cache if it is contended
201 if (diskCache.unlockedCapacity < size) {182 if (diskCache.unlockedCapacity < size) {
202 console.info(`skipping disk cache due to contention: ${rel}`);183 console.info(`skipping disk cache due to contention: ${rel}`);
203 return { stream: stream.Readable.toWeb(response), size, src: "contended" };184 return {
185 stream: fetchAndSlice(rel, url, start, end),
186 size,
187 src: "contended",
188 };
204 }189 }
205 // IPs which request too many cache misses are temporarily190 // IPs which request too many cache misses are temporarily
206 // banned from writing into the disk cache to avoid abuse.191 // banned from writing into the disk cache to avoid abuse.
207 if (noWrite) {192 if (noWrite) {
208 console.info(`skipping disk cache due to config: ${rel}`);193 console.info(`skipping disk cache due to config: ${rel}`);
209 return { stream: stream.Readable.toWeb(response), size, src: "ratelimit" };194 return {
195 stream: fetchAndSlice(rel, url, start, end),
196 size,
197 src: "ratelimit",
198 };
210 }199 }
211200
212 // Create a disk cache entry;201 // Create a disk cache entry;
213 // Tee the stream to both the request and the filesystem.202 // Tee the stream to both the request and the filesystem.
214 diskFile = {203 diskFile = { size, written: new async.Watch(0), memory: new Map() };
215 size,
216 written: new async.Watch(0),
217 memory: new Map(),
218 };
219 diskCache.set(diskPath, diskFile);204 diskCache.set(diskPath, diskFile);
220
221 const lock = diskCache.lock(diskPath);205 const lock = diskCache.lock(diskPath);
222 const writer = await writeStream(diskPath, response, diskFile, lock);206 return fetchAndStore(rel, url, size, diskPath, diskFile, start, end, lock);
223 writer.on("error", () => {207}
224 diskCache.delete(diskPath);208
225 diskFile.written.value = Infinity;209async function fetchAndStore(
210 rel: string,
211 url: string,
212 size: number,
213 diskPath: Path,
214 diskFile: DiskFile,
215 start: number,
216 end: number,
217 lock: Lock,
218) {
219 try {
220 let response = await fetch(rel, url);
221 const { statusCode } = response;
222 if (statusCode !== 200) {
223 const text = await new Response(response as unknown as Blob).text();
224 const msg =
225 `Source of Truth responded with status ${statusCode}: ${text}`;
226 console.error(msg);
227 throw new Error("Source of Truth is unavailable");
228 }
229 const writer = await writeStream(diskPath, response, diskFile, lock);
230 writer.on("error", () => {
231 diskCache.delete(diskPath);
232 diskFile.written.value = Infinity;
233 });
234
235 return { stream: sliceStream(response, start, end), size, src: "miss" };
236 } catch (e) {
237 lock();
238 throw e;
239 }
240}
241
242async function fetch(rel: string, url: string, headers?: {}) {
243 let promise = !headers ? parallelRequests.get(url) : null;
244 if (promise) {
245 console.info(`from other response: ${rel}`);
246 return await promise;
247 } else {
248 console.info(`cache miss: ${rel}`);
249 promise = httpGet(url, {
250 headers: {
251 ...token ? { Authorization: token } : null,
252 ...headers,
253 },
254 });
255 if (!headers) parallelRequests.set(url, promise);
256 const response = await promise;
257 if (!headers) parallelRequests.delete(url);
258 return response;
259 }
260}
261
262async function fetchAndSlice(
263 rel: string,
264 url: string,
265 start: number,
266 end: number,
267) {
268 const headers = start !== 0 || end !== Infinity
269 ? { "Clover-Start": String(start), "Clover-End": String(end) }
270 : null;
271 return stream.Readable.toWeb(await fetch(rel, url, headers));
272}
273
274export function sliceStream(
275 readable: stream.Readable,
276 start: number,
277 end: number,
278): Readable {
279 const length = end === Infinity ? Infinity : end - start;
280 let recieved = 0;
281 let sent = 0;
282
283 const transform = new stream.Transform({
284 transform(chunk: Buffer, _, callback) {
285 const chunkEnd = recieved + chunk.length;
286
287 if (chunkEnd <= start) {
288 recieved = chunkEnd;
289 callback();
290 return;
291 }
292
293 if (sent >= length) {
294 callback();
295 return;
296 }
297
298 const sliceStart = Math.max(0, start - recieved);
299 const remaining = length - sent;
300 const sliceEnd = end === Infinity
301 ? chunk.length
302 : Math.min(chunk.length, sliceStart + remaining);
303
304 const slice = chunk.slice(sliceStart, sliceEnd);
305 sent += slice.length;
306 recieved = chunkEnd;
307
308 this.push(slice);
309
310 callback();
311 },
226 });312 });
227313
228 return { stream: stream.Readable.toWeb(response), size, src: "miss" };314 readable.on("error", (err) => transform.destroy(err));
315
316 return readable.pipe(transform);
229}317}
230318
231interface DiskFile {319interface DiskFile {
...@@ -236,7 +324,10 @@ interface DiskFile {...@@ -236,7 +324,10 @@ interface DiskFile {
236324
237function httpGet(url: string, options: https.RequestOptions) {325function httpGet(url: string, options: https.RequestOptions) {
238 return new Promise<http.IncomingMessage>((resolve, reject) => {326 return new Promise<http.IncomingMessage>((resolve, reject) => {
239 const request = https.get(url, options);327 const request = (url.startsWith("https://") ? https : http).get(
328 url,
329 options,
330 );
240 request.on("response", resolve);331 request.on("response", resolve);
241 request.on("error", reject);332 request.on("error", reject);
242 });333 });
src/file-viewer/models/FilePermissions.ts+11-11
...@@ -3,9 +3,9 @@ const db = getDb("cache.sqlite");...@@ -3,9 +3,9 @@ const db = getDb("cache.sqlite");
3db.table(3db.table(
4 "permissions",4 "permissions",
5 /* SQL */ `5 /* SQL */ `
6 CREATE TABLE IF NOT EXISTS permissions (6 create table if not exists permissions (
7 prefix TEXT PRIMARY KEY,7 prefix text primary key,
8 allow INTEGER NOT NULL8 allow integer not null
9 );9 );
10`,10`,
11);11);
...@@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare<...@@ -36,24 +36,24 @@ const getByPrefixQuery = db.prepare<
36 [prefix: string],36 [prefix: string],
37 Pick<FilePermissions, "allow">37 Pick<FilePermissions, "allow">
38>(/* SQL */ `38>(/* SQL */ `
39 SELECT allow 39 select allow
40 FROM permissions 40 from permissions
41 WHERE ? GLOB prefix || '*'41 where ? glob prefix || '*'
42 ORDER BY LENGTH(prefix) DESC 42 order by length(prefix) desc
43 LIMIT 1;43 limit 1;
44`);44`);
45const getExactQuery = db.prepare<45const getExactQuery = db.prepare<
46 [file: string],46 [file: string],
47 Pick<FilePermissions, "allow">47 Pick<FilePermissions, "allow">
48>(/* SQL */ `48>(/* SQL */ `
49 SELECT allow FROM permissions WHERE ? == prefix49 select allow from permissions where ? == prefix
50`);50`);
5151
52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
53 REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);53 replace into permissions(prefix, allow) values($prefix, $allow);
54`);54`);
55const deleteQuery = db.prepare<[file: string]>(/* SQL */ `55const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
56 DELETE FROM permissions WHERE prefix = ?;56 delete from permissions where prefix = ?;
57`);57`);
5858
59import { getDb } from "#sitegen/sqlite";59import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+2-3
...@@ -113,12 +113,11 @@ export class MediaFile {...@@ -113,12 +113,11 @@ export class MediaFile {
113 return ext;113 return ext;
114 }114 }
115 getChildren() {115 getChildren() {
116 return MediaFile.getChildren(this.id)116 return MediaFile.getChildren(this.id);
117 .filter((file) => !file.basename.startsWith("."));
118 }117 }
119 getPublicChildren() {118 getPublicChildren() {
120 const children = MediaFile.getChildren(this.id).filter((x) =>119 const children = MediaFile.getChildren(this.id).filter((x) =>
121 !x.basename.startsWith("_unlisted")120 !x.basename.startsWith(".") && !x.basename.startsWith("_unlisted")
122 );121 );
123 if (FilePermissions.getByPrefix(this.path) == 0) {122 if (FilePermissions.getByPrefix(this.path) == 0) {
124 return children.filter(({ path }) => FilePermissions.getExact(path) == 0);123 return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
src/file-viewer/rules.ts+1-1
...@@ -70,7 +70,7 @@ export const extsImageLike = new Set([...@@ -70,7 +70,7 @@ export const extsImageLike = new Set([
70/** These files populate `duration` using `ffprobe` */70/** These files populate `duration` using `ffprobe` */
71export const extsDuration = new Set([...extsAudio, ...extsVideo]);71export const extsDuration = new Set([...extsAudio, ...extsVideo]);
72/** These files populate `dimensions` using `ffprobe` */72/** These files populate `dimensions` using `ffprobe` */
73export const extsDimensions = new Set([...extsImage, ...extsVideo]);73export const extsDimensions = new Set([...extsImage, ...extsVideo, ".gif"]);
7474
75/** These files read file contents into `contents`, as-is */75/** These files read file contents into `contents`, as-is */
76export const extsReadContents = new Set([".txt", ".chat", ".dirsort"]);76export const extsReadContents = new Set([".txt", ".chat", ".dirsort"]);
src/file-viewer/views/clofi.client.ts+6-1
...@@ -139,7 +139,7 @@ function ensureCanvasReady(id: string): Promise<CanvasFn> {...@@ -139,7 +139,7 @@ function ensureCanvasReady(id: string): Promise<CanvasFn> {
139 promise = new Promise<CanvasFn>((r) => resolve = r);139 promise = new Promise<CanvasFn>((r) => resolve = r);
140 fetchCanvas.set(id, promise);140 fetchCanvas.set(id, promise);
141 const script = document.createElement("script");141 const script = document.createElement("script");
142 script.src = `/js/canvas/${id}.js`;142 script.src = `/js/file-viewer/scripts/canvas_${id}.js`;
143 script.async = true;143 script.async = true;
144 script.onload = () => {144 script.onload = () => {
145 func = (globalThis as any)["canvas_" + id];145 func = (globalThis as any)["canvas_" + id];
...@@ -216,6 +216,11 @@ class Panel {...@@ -216,6 +216,11 @@ class Panel {
216 }216 }
217 }217 }
218218
219 Array.from(
220 panel.querySelectorAll<HTMLElement>(".clover-video"),
221 (container) => hydrateVideo(container),
222 );
223
219 const ul = panel.querySelector("ul");224 const ul = panel.querySelector("ul");
220 if (!ul) {225 if (!ul) {
221 this.width = 0;226 this.width = 0;
src/file-viewer/views/clofi.tsx+57-65
...@@ -70,8 +70,6 @@ export default function MediaList({...@@ -70,8 +70,6 @@ export default function MediaList({
70 file: MediaFile;70 file: MediaFile;
71 hasCotyledonCookie: boolean;71 hasCotyledonCookie: boolean;
72}) {72}) {
73 addScript("./clofi.client.ts");
74
75 const dirs: MediaFile[] = [];73 const dirs: MediaFile[] = [];
76 let dir: MediaFile | null = file;74 let dir: MediaFile | null = file;
77 assert(dir);75 assert(dir);
...@@ -115,43 +113,43 @@ export function MediaPanel({...@@ -115,43 +113,43 @@ export function MediaPanel({
115 activeFilename: string | null;113 activeFilename: string | null;
116 hasCotyledonCookie: boolean;114 hasCotyledonCookie: boolean;
117}) {115}) {
116 addScript("./clofi.client.ts");
117 addScript("../../tags/clover-video.client.ts");
118 const showsActions = file.path !== "/2024/for everyone";118 const showsActions = file.path !== "/2024/for everyone";
119 const label = file.path === "/"119 const label = file.path === "/"
120 ? (120 ? (
121 "clo's files"121 "clo's files"
122 )122 )
123 : (123 : (
124 <>124 file.kind === MediaFileKind.directory
125 {file.kind === MediaFileKind.directory125 ? (
126 ? (126 <span>
127 <span>127 {file.basename}
128 {file.basename}128 <span class="bold-slash">/</span>
129 <span class="bold-slash">/</span>129 </span>
130 </span>130 )
131 )131 : (
132 : (132 <>
133 <>133 {file.basename}
134 {file.basename}134 <div style="flex:1" />
135 <div style="flex:1" />135 {showsActions && (
136 {showsActions && (136 <div class="actions">
137 <div class="actions">137 <a
138 <a138 class="custom full-screen"
139 class="custom full-screen"139 href={`/file${escapeUri(file.path)}?view=embed`}
140 href={`/file${escapeUri(file.path)}?view=embed`}140 aria-label="full screen"
141 aria-label="full screen"141 >
142 >142 </a>
143 </a>143 <a
144 <a144 class="custom download"
145 class="custom download"145 href={`/file${escapeUri(file.path)}?dl`}
146 href={`/file${escapeUri(file.path)}?dl`}146 aria-label="download"
147 aria-label="download"147 >
148 >148 </a>
149 </a>149 </div>
150 </div>150 )}
151 )}151 </>
152 </>152 )
153 )}
154 </>
155 );153 );
156 let View = specialCaseViewers[file.path] ??154 let View = specialCaseViewers[file.path] ??
157 (extensionToViewer[file.extension.toLowerCase()] as any) ??155 (extensionToViewer[file.extension.toLowerCase()] as any) ??
...@@ -166,18 +164,16 @@ export function MediaPanel({...@@ -166,18 +164,16 @@ export function MediaPanel({
166 />164 />
167 );165 );
168 const canvases: Record<string, string> = {166 const canvases: Record<string, string> = {
169 "/2017": "2017",167 "/2017": () => addScript("../scripts/canvas_2017.client.ts"),
170 "/2018": "2018",168 "/2018": () => addScript("../scripts/canvas_2018.client.ts"),
171 "/2019": "2019",169 "/2019": () => addScript("../scripts/canvas_2019.client.ts"),
172 "/2020": "2020",170 "/2020": () => addScript("../scripts/canvas_2020.client.ts"),
173 "/2021": "2021",171 "/2021": () => addScript("../scripts/canvas_2021.client.ts"),
174 "/2022": "2022",172 "/2022": () => addScript("../scripts/canvas_2022.client.ts"),
175 "/2023": "2023",173 "/2023": () => addScript("../scripts/canvas_2023.client.ts"),
176 "/2024": "2024",174 "/2024": () => addScript("../scripts/canvas_2024.client.ts"),
177 };175 };
178 if (canvases[file.path]) {176 canvases[file.path]?.();
179 addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
180 }
181 return (177 return (
182 <div178 <div
183 class={(isLast ? "panel last" : "panel") +179 class={(isLast ? "panel last" : "panel") +
...@@ -186,7 +182,7 @@ export function MediaPanel({...@@ -186,7 +182,7 @@ export function MediaPanel({
186 {canvases[file.path] && (182 {canvases[file.path] && (
187 <canvas183 <canvas
188 class="fullscreen-canvas"184 class="fullscreen-canvas"
189 data-canvas={canvases[file.path]}185 data-canvas={file.path.slice(1).split("/")![0]}
190 >186 >
191 </canvas>187 </canvas>
192 )}188 )}
...@@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) {...@@ -680,23 +676,16 @@ function CodeView({ file }: { file: MediaFile }) {
680function VideoView({ file }: { file: MediaFile }) {676function VideoView({ file }: { file: MediaFile }) {
681 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };677 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
682 return (678 return (
683 <>679 <CloverVideo
684 <video680 minimal
685 controls681 file={file}
686 preload682 style={`background:transparent;aspect-ratio:${
687 style={`background:transparent;aspect-ratio:${683 simplifyFraction(
688 simplifyFraction(684 dimensions.width,
689 dimensions.width,685 dimensions.height,
690 dimensions.height,686 )
691 )687 }`}
692 }`}688 />
693 >
694 <source
695 src={"/file" + file.path}
696 type={mime.contentTypeFor(file.path)}
697 />
698 </video>
699 </>
700 );689 );
701}690}
702691
...@@ -748,10 +737,11 @@ function AudioView({...@@ -748,10 +737,11 @@ function AudioView({
748737
749function ImageView({ file }: { file: MediaFile }) {738function ImageView({ file }: { file: MediaFile }) {
750 return (739 return (
751 <img740 <Image
752 src={"/file" + file.path}741 src={file}
753 alt={file.basename}742 alt={file.basename}
754 style="max-width:100%;max-height:100%;object-fit:contain;margin:auto;display:block;"743 objectFit="contain"
744 style="max-width:100%;max-height:100%;margin:auto;display:block;"
755 />745 />
756 );746 );
757}747}
...@@ -935,4 +925,6 @@ import {...@@ -935,4 +925,6 @@ import {
935import { ForEveryone } from "@/file-viewer/cotyledon.tsx";925import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
936import * as mime from "#sitegen/mime";926import * as mime from "#sitegen/mime";
937import * as sort from "../sort.ts";927import * as sort from "../sort.ts";
928import CloverVideo from "@/tags/clover-video.marko";
929import Image from "@/tags/image.tsx";
938import * as render from "#engine/render";930import * as render from "#engine/render";
src/pages/in-the-summer.marko+5-2
...@@ -22,7 +22,10 @@ import "./in-the-summer.css";...@@ -22,7 +22,10 @@ import "./in-the-summer.css";
22 </p>22 </p>
23</div>23</div>
2424
25<clover-video file="/2025/in the summer/in the summer.mp4">25<clover-video
26 file="/2025/in the summer/in the summer.mp4"
27 poster="/2025/in the summer/thumbnail.jpeg"
28>
26 <@header><strong>music video</>: in the summer</>29 <@header><strong>music video</>: in the summer</>
27</>30</>
2831
...@@ -34,7 +37,7 @@ import "./in-the-summer.css";...@@ -34,7 +37,7 @@ import "./in-the-summer.css";
34 <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li>37 <li><a href="/file/2025/in%20the%20summer/fragments">fragments/</a> (behind the scenes)</li>
35 </ul>38 </ul>
36 <h2>mentions on the q&a</h2>39 <h2>mentions on the q&a</h2>
37 // <QuestionsFor artifact='in-the-summer'/>40 <questions-embed="in-the-summer" />
38</div>41</div>
39</main>42</main>
4043
src/pages/index.marko+2-5
...@@ -21,10 +21,7 @@ export const meta = {...@@ -21,10 +21,7 @@ export const meta = {
21 <p><a href="/file">file browser</a></p>21 <p><a href="/file">file browser</a></p>
22 <p><a href="/subscribe">mailing list</a></p>22 <p><a href="/subscribe">mailing list</a></p>
23 <p><a href="/donate">donate</a></p>23 <p><a href="/donate">donate</a></p>
24 <p><a href="/rss.xml">rss feed</a></p>24 <p style="position: relative; z-index: 10"><a href="/rss.xml">rss feed</a></p>
25 <p style="position: relative; z-index: 10"><a href="/discord">discord</a></p>25 <h1>paper clover</h1>
26 <h1>
27 paper clover
28 </h1>
29 </div>26 </div>
30</main>27</main>
src/pages/subscribe.client.ts deleted-97
...@@ -1,97 +0,0 @@
1// @ts-nocheck
2// manually obfuscated to make it very difficult to reverse engineer
3// if you want to decode what the email is, visit the page!
4// stops people from automatically scraping the email address
5//
6// Unfortunately this needs a rewrite to support Chrome without
7// hardware acceleration and some Linux stuff. I will probably
8// go with a proof of work alternative.
9requestAnimationFrame(() => {
10 const hash = "SHA";
11 const a = [
12 { parentElement: document.getElementById("subscribe") },
13 function (b) {
14 let c = 0, d = 0;
15 for (let i = 0; i < b.length; i++) {
16 c = (c + b[i] ^ 0xF8) % 8;
17 d = (c * b[i] ^ 0x82) % 193;
18 }
19 a[c + 1]()[c](d, b.buffer);
20 },
21 function () {
22 const i = a[4](a[3]());
23 const b = i.innerText = a.pop();
24 if (a[b.indexOf("@") / 3]) {
25 i.href = "mailto:" + b;
26 }
27 },
28 function () {
29 return a[a.length % 10];
30 },
31 function (x) {
32 return x.parentElement;
33 },
34 function (b, c) {
35 throw new Uint8Array(
36 c,
37 0,
38 64,
39 c.parentElement = this[8].call(b.call(this)).location,
40 );
41 },
42 function (b, c) {
43 this.width = 8;
44 this.height = 16;
45 b.clearColor(0.5, 0.7, 0.9, 1.0);
46 b.clear(16408 ^ this.width ^ this.height);
47 const e = new Uint8Array(4 * this.width * this.height);
48 b.readPixels(0, 0, this.width, this.height, b.RGBA, b.UNSIGNED_BYTE, e);
49 let parent = a[this.width / 2](this);
50 while (parent.tagName !== "BODY") {
51 parent = a[2 * this.height / this.width](parent);
52 }
53 try {
54 let d = [hash, e.length].join("-");
55 const b = a[0.25 * this.height](a.pop()(parent)).subtle.digest(d, e);
56 [, d] = a;
57 b.then(c.bind(a, a[this.width].bind(globalThis))).catch(d);
58 } catch (e) {
59 fetch(e).then(a[5]).catch(a[2]);
60 }
61 },
62 function (b, c) {
63 const d = a.splice(
64 9,
65 1,
66 [
67 a[3]().parentElement.id,
68 c.parentElement.hostname,
69 ].join(String.fromCharCode(b)),
70 );
71 var e = new Error();
72 Object.defineProperty(e, "stack", {
73 get() {
74 a[9] = d;
75 },
76 });
77 a[2].call(console.log(e));
78 },
79 function () {
80 return this;
81 },
82 "[failed to verify your browser]",
83 function (a) {
84 a = a.parentElement.ownerDocument.defaultView;
85 return { parentElement: a.navigator.webdriver || a.crypto };
86 },
87 ];
88 try {
89 const c = document.querySelector("canvas");
90 const g = c.getContext("webgl2") || c.getContext("webgl");
91 a[0].parentElement.innerText = "[...loading...]";
92 g.field || requestAnimationFrame(a[6].bind(c, g, a[5]));
93 } catch {
94 a.pop();
95 fetch(":").then(a[5]).catch(a[2]);
96 }
97});
src/pages/subscribe.css+1-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1#subscribe[href] {1#subscribe {
2 display: flex;2 display: flex;
3 justify-content: center;3 justify-content: center;
4 align-items: center;4 align-items: center;
...@@ -6,9 +6,6 @@...@@ -6,9 +6,6 @@
6 font-weight: bold;6 font-weight: bold;
7 text-align: center;7 text-align: center;
8}8}
9#subscribe:not([href]) {
10 opacity: 0.5;
11}
12.flex {9.flex {
13 display: flex;10 display: flex;
14 justify-content: center;11 justify-content: center;
src/pages/subscribe.marko+2-9
...@@ -4,8 +4,6 @@ export const meta = {...@@ -4,8 +4,6 @@ export const meta = {
4 description: "subscribe to the mailing list",4 description: "subscribe to the mailing list",
5};5};
66
7client import "./subscribe.client.ts";
8
9<main>7<main>
10<h1>mailing list</h1>8<h1>mailing list</h1>
11<canvas style="display: none;"></canvas>9<canvas style="display: none;"></canvas>
...@@ -17,19 +15,14 @@ client import "./subscribe.client.ts";...@@ -17,19 +15,14 @@ client import "./subscribe.client.ts";
17 the list is currently managed manually. to get added, please email anything15 the list is currently managed manually. to get added, please email anything
18 with the word "subscribe" in the subject or body to the following address:16 with the word "subscribe" in the subject or body to the following address:
19</p>17</p>
20<code class="flex">18<code id="subscribe" class="flex">
21 <a id="subscribe">19 subscribe at paper clover dot net
22 [please enable javascript, this is an anti-spam/scraping measure]
23 </a>
24</code>20</code>
25<p>21<p>
26 to unsubscribe, send an email asking to unsubscribe. for those who are close22 to unsubscribe, send an email asking to unsubscribe. for those who are close
27 friends with me, add your name and i'll add you to the 'friends' list, where i23 friends with me, add your name and i'll add you to the 'friends' list, where i
28 send life updates in addition to new content (1-2 times per month).24 send life updates in addition to new content (1-2 times per month).
29</p>25</p>
30<p>
31 alternatively, you can join <a href="/discord">the discord</a> to receive mentions in the <code>#new</code> channel.
32</p>
33<br>26<br>
34<br>27<br>
35<br>28<br>
src/pages/waterfalls.marko+3
...@@ -42,6 +42,9 @@ export const theme = {...@@ -42,6 +42,9 @@ export const theme = {
42 <br />42 <br />
43 <a href="/q+a">q&a page</a>43 <a href="/q+a">q&a page</a>
44 </p>44 </p>
45
46 <h2>mentions on the q&a</h2>
47 <questions-embed="waterfalls" />
45 </div>48 </div>
46 </main>49 </main>
47</main>50</main>
src/q+a/artifacts.ts+26-77
...@@ -3,87 +3,36 @@...@@ -3,87 +3,36 @@
3// one canonical URL, which the questions page can refer to with `@id`.3// one canonical URL, which the questions page can refer to with `@id`.
4type Artifact = [title: string, url: string, type: ArtifactType];4type Artifact = [title: string, url: string, type: ArtifactType];
5type ArtifactType = "music" | "game" | "project" | "video";5type ArtifactType = "music" | "game" | "project" | "video";
6
7// deno-fmt-ignore
6export const artifactMap: Record<string, Artifact> = {8export const artifactMap: Record<string, Artifact> = {
7 // 20259 // 2025
8 "in-the-summer": ["in the summer", "", "music"],10 "in-the-summer": ["in the summer", "/in-the-summer", "music"],
9 waterfalls: ["waterfalls", "/waterfalls", "music"],11 "waterfalls": ["waterfalls", "/waterfalls", "music"],
10 lolzip: ["lol.zip", "", "project"],12
11 "g-is-missing": ["g is missing", "", "music"],13 // cotyledon
12 "im-18-now": ["i'm 18 now", "", "music"],14 "aaaaaaaaa": ["aaaaaaaaa", "", "music"],
13 "programming-comparison": [15 "dice-roll": [ "thursday dice roll", "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4", "video"],
14 "thursday programming language comparison",
15 "",
16 "video",
17 ],
18 aaaaaaaaa: ["aaaaaaaaa", "", "music"],
19 "its-snowing": ["it's snowing", "", "video"],
20 // 2023
21 "iphone-15-review": [
22 "iphone 15 review",
23 "/file/2023/iphone%2015%20review/iphone-15-review.mp4",
24 "video",
25 ],
26 // 2022
27 mayday: ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "mystery-of-life": [
29 "mystery of life",
30 "/file/2022/mystery-of-life/mystery-of-life.mp4",
31 "music",
32 ],
33 // 2021
34 "top-10000-bread": [
35 "top 10000 bread",
36 "https://paperclover.net/file/2021/top-10000-bread/output.mp4",
37 "video",
38 ],
39 "phoenix-write-soundtrack": [
40 "Phoenix, WRITE! soundtrack",
41 "/file/2021/phoenix-write/OST",
42 "music",
43 ],
44 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
45 "money-visual-cover": [
46 "money visual cover",
47 "/file/2021/money-visual-cover/money-visual-cover.mp4",
48 "video",
49 ],
50 "i-got-this-thing": [
51 "i got this thing",
52 "/file/2021/i-got-this-thing/i-got-this-thing.mp4",
53 "video",
54 ],
55 // 2020
56 elemental4: ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
57 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],16 "elemental-4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
58 // 2019
59 "throw-soundtrack": [
60 "throw soundtrack",
61 "/file/2019/throw/soundtrack",
62 "music",
63 ],
64 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],17 "elemental-lite": ["elemental lite", "/file/2019/elemental-lite-1.7", "game"],
65 volar: [18 "elemental4": ["elemental 4", "/file/2020/elemental4-rewrite", "game"],
66 "volar visual cover",19 "g-is-missing": ["g is missing", "https://paperclover.net/file/2023/g%20is%20missing/g%20is%20missing.mp4", "music"],
67 "/file/2019/volar-visual-cover/volar.mp4",
68 "video",
69 ],
70 wpm: [
71 "how to read 500 words per minute",
72 "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4",
73 "video",
74 ],
75 "dice-roll": [
76 "thursday dice roll",
77 "/file/2019/thursday-dice-roll/thursday-dice-roll.mp4",
78 "video",
79 ],
80 "math-problem": [
81 "thursday math problem",
82 "/file/2019/thursday-math-problem/thursday-math-problem.mp4",
83 "video",
84 ],
85 // 2018
86 // 2017
87 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],20 "hatred-island": ["hatred island", "/file/2017/hatred%20island", "game"],
21 "i-got-this-thing": [ "i got this thing", "/file/2021/i-got-this-thing/i-got-this-thing.mp4", "video"],
22 "im-18-now": ["i'm 18 now", "", "music"],
23 "iphone-15-review": [ "iphone 15 review", "/file/2023/iphone%2015%20review/iphone-15-review.mp4", "video"],
24 "its-snowing": ["it's snowing", "https://paperclover.net/file/2022/its-snowing/it's%20snowing.mp4", "video"],
25 "lolzip": ["lol.zip", "", "project"],
26 "math-problem": [ "thursday math problem", "/file/2019/thursday-math-problem/thursday-math-problem.mp4", "video"],
27 "mayday": ["mayday", "/file/2022/mayday/mayday.mp4", "music"],
28 "money-visual-cover": [ "money visual cover", "/file/2021/money-visual-cover/money-visual-cover.mp4", "video"],
29 "mystery-of-life": [ "mystery of life", "/file/2022/mystery-of-life/mystery-of-life.mp4", "music"],
30 "phoenix-write": ["Pheonix, WRITE!", "/file/2021/phoenix-write", "game"],
31 "phoenix-write-soundtrack": [ "Phoenix, WRITE! soundtrack", "/file/2021/phoenix-write/OST", "music"],
32 "programming-comparison": [ "thursday programming language comparison", "", "video"],
88 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],33 "test-video-1": ["test video 1", "/file/2017/test-video1.mp4", "video"],
34 "throw-soundtrack": [ "throw soundtrack", "/file/2019/throw/soundtrack", "music"],
35 "top-10000-bread": [ "top 10000 bread", "https://paperclover.net/file/2021/top-10000-bread/output.mp4", "video"],
36 "volar": [ "volar visual cover", "/file/2019/volar-visual-cover/volar.mp4", "video" ],
37 "wpm": [ "how to read 500 words per minute", "/file/2019/how-to-read-500-words-per-minute/how-to-read-500-words-per-minute.mp4", "video"],
89};38};
src/q+a/clover-markdown.tsx+12-1
...@@ -61,7 +61,7 @@ customRules.insertBefore("paragraph", {...@@ -61,7 +61,7 @@ customRules.insertBefore("paragraph", {
61customRules.insertBefore("url", {61customRules.insertBefore("url", {
62 name: "autolink_mention",62 name: "autolink_mention",
63 match: inlineRegex(63 match: inlineRegex(
64 /^https?:\/\/(?:paper(?:dave|clover)\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/,64 /^https?:\/\/(?:paperclover\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/,
65 ),65 ),
66 parse(capture) {66 parse(capture) {
67 const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/);67 const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/);
...@@ -72,6 +72,17 @@ customRules.insertBefore("url", {...@@ -72,6 +72,17 @@ customRules.insertBefore("url", {
72 };72 };
73 }73 }
7474
75 const fileMatch = capture[1].match(/^file\/([^#?]*)/);
76 if (fileMatch) {
77 // const file = MediaFile.getByPath(decodeURIComponent(fileMatch[1]));
78 // if (file) {
79 // return {
80 // type: "file",
81 // file,
82 // };
83 // }
84 }
85
75 return {86 return {
76 type: "link",87 type: "link",
77 content: [88 content: [
src/q+a/models/Question.ts+8-1
...@@ -2,7 +2,7 @@ const db = getDb("questions.sqlite");...@@ -2,7 +2,7 @@ const db = getDb("questions.sqlite");
2db.table(2db.table(
3 "questions",3 "questions",
4 `4 `
5 create table questions (5 create table if not exists questions (
6 qmid integer not null,6 qmid integer not null,
7 type integer not null,7 type integer not null,
8 text text not null,8 text text not null,
...@@ -68,6 +68,9 @@ export class Question {...@@ -68,6 +68,9 @@ export class Question {
68 static updateByQmid(qmid: number, text: string, type: QuestionType) {68 static updateByQmid(qmid: number, text: string, type: QuestionType) {
69 return updateByQmidQuery.run({ text, type, qmid });69 return updateByQmidQuery.run({ text, type, qmid });
70 }70 }
71 static search(ref: string) {
72 return searchQuery.array(`%@${ref}%`, `%https://paperclover.net/${ref}%`);
73 }
71}74}
7275
73// Create a new question with a unique QMID76// Create a new question with a unique QMID
...@@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare<...@@ -114,5 +117,9 @@ const updateByQmidQuery = db.prepare<
114 UPDATE questions SET text = $text, type = $type WHERE qmid = $qmid117 UPDATE questions SET text = $text, type = $type WHERE qmid = $qmid
115`);118`);
116119
120const searchQuery = db.prepare<[string, string]>(
121 `select * from questions where type = ${QuestionType.normal} and (text like ? or text like ?)`,
122).as(Question);
123
117import { formatQuestionId } from "../format.ts";124import { formatQuestionId } from "../format.ts";
118import { getDb } from "#sitegen/sqlite";125import { getDb } from "#sitegen/sqlite";
src/q+a/tags/question.marko+3-1
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import "../questions.css";
2
1// Renders a `Question` entry including its markdown body.3// Renders a `Question` entry including its markdown body.
2export interface Input {4export interface Input {
3 question: Question;5 question: Question;
...@@ -12,7 +14,7 @@ export const transitionDate = 1735639200000;...@@ -12,7 +14,7 @@ export const transitionDate = 1735639200000;
12<const/{ id, date } = question/>14<const/{ id, date } = question/>
1315
14<${"e-"}16<${"e-"}
15 f=(date > transitionDate ? true : undefined)17 f=(date > transitionDate ? true : null)
16 id=admin ? `q${id}` : undefined18 id=admin ? `q${id}` : undefined
17>19>
18 <if=admin>20 <if=admin>
src/scripts/vendor/dash.client.ts created+1
...@@ -0,0 +1 @@
1export * from "dashjs";
src/scripts/vendor/hls.client.ts created+1
...@@ -0,0 +1 @@
1export * from "hls.js";
src/source-of-truth.ts+33-33
...@@ -1,24 +1,27 @@...@@ -1,24 +1,27 @@
1// The "source of truth" server is the canonical storage for1/** @module
2// paper clover's files. This is technically needed because2 * The "source of truth" server is the canonical storage for
3// the VPS she uses can only store about 20gb of content, where3 * paper clover's files. This is technically needed because
4// the contents of /file is about 48gb as of writing; only a4 * the VPS she uses can only store about 20gb of content, where
5// limited amount of data can be cached.5 * the contents of /file is about 48gb as of writing; only a
6//6 * limited amount of data can be cached.
7// What's great about this system is it also allows scaling the7 *
8// website up into multiple servers, if that is ever desired.8 * What's great about this system is it also allows scaling the
9// When that happens, mutations to "q+a" will be moved here,9 * website up into multiple servers, if that is ever desired.
10// and the SQLite synchronization mechanism will apply to both10 * When that happens, mutations to "q+a" will be moved here,
11// of those databases.11 * and the SQLite synchronization mechanism will apply to both
12//12 * of those databases.
13// An alternative to this would have been to use a SMB client,13 *
14// but I've read that the systems used for caching don't work14 * An alternative to this would have been to use a SMB client,
15// like the HTTP Cache-Control header, where you can say a file15 * but I've read that the systems used for caching don't work
16// is valid for up to a certain amount of time. If we seriously16 * like the HTTP Cache-Control header, where you can say a file
17// need cache busts (paper clover does not), the proper way17 * is valid for up to a certain amount of time. If we seriously
18// would be to push a message to all VPS nodes instead of18 * need cache busts (paper clover does not), the proper way
19// checking upstream if a file changed every time.19 * would be to push a message to all VPS nodes instead of
20 * checking upstream if a file changed every time.
21 */
20const app = new Hono();22const app = new Hono();
21export default app;23export default app;
24export const port = 4000;
2225
23const token = UNWRAP(process.env.CLOVER_SOT_KEY);26const token = UNWRAP(process.env.CLOVER_SOT_KEY);
2427
...@@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) {...@@ -32,22 +35,22 @@ if (!fs.existsSync(derivedFileRoot)) {
32// Re-use file descriptors if the same file is being read twice.35// Re-use file descriptors if the same file is being read twice.
33const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();36const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();
3437
38app.get("/", (c) => {
39 return c.text("everything you love, loves you back.");
40});
41
35app.get("/file/*", async (c) => {42app.get("/file/*", async (c) => {
36 const fullQuery = c.req.path.slice("/file".length);43 const fullQuery = c.req.path.slice("/file".length);
37 const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/");44 const [filePath, derivedAsset, ...invalid] = fullQuery.split(":/");
38 ASSERT(filePath != null);45 ASSERT(filePath != null);
39 if (invalid.length > 0) return c.notFound();46 if (invalid.length > 0) return c.notFound();
40 if (filePath.length <= 1) return c.notFound();47 if (filePath.length <= 1) return c.notFound();
41 const permissions = FilePermissions.getByPrefix(filePath);48 const permissions = FilePermissions.getByPrefix(filePath);
42 if (permissions !== 0) {49 if (permissions !== 0 && c.req.header("Authorization") !== token) {
43 if (c.req.header("Authorization") !== token) {50 return c.json({ error: "invalid authorization header" }, 401);
44 return c.json({ error: "invalid authorization header" }, 401);
45 }
46 }51 }
47 const file = MediaFile.getByPath(filePath);52 const file = MediaFile.getByPath(filePath);
48 if (!file || file.kind === MediaFileKind.directory) {53 if (!file || file.kind === MediaFileKind.directory) return c.notFound();
49 return c.notFound();
50 }
51 const fullPath = derivedAsset54 const fullPath = derivedAsset
52 ? path.join(derivedFileRoot, file.hash, derivedAsset)55 ? path.join(derivedFileRoot, file.hash, derivedAsset)
53 : path.join(rawFileRoot, file.path);56 : path.join(rawFileRoot, file.path);
...@@ -66,11 +69,10 @@ app.get("/file/*", async (c) => {...@@ -66,11 +69,10 @@ app.get("/file/*", async (c) => {
66 }69 }
67 handle.refs += 1;70 handle.refs += 1;
68 } catch (err: any) {71 } catch (err: any) {
69 if (err.code === "ENOENT") {72 if (err.code === "ENOENT") return c.notFound();
70 return c.notFound();
71 }
72 throw err;73 throw err;
73 }74 }
75 c.header("Content-Length", (await fstat(handle.fd)).size);
74 const nodeStream = fs.createReadStream(fullPath, {76 const nodeStream = fs.createReadStream(fullPath, {
75 fd: handle.fd,77 fd: handle.fd,
76 fs: {78 fs: {
...@@ -86,9 +88,6 @@ app.get("/file/*", async (c) => {...@@ -86,9 +88,6 @@ app.get("/file/*", async (c) => {
86 },88 },
87 });89 });
88 c.header("Content-Type", mime.contentTypeFor(fullPath));90 c.header("Content-Type", mime.contentTypeFor(fullPath));
89 if (!derivedAsset) {
90 c.header("Content-Length", file.size.toString());
91 }
92 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);91 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);
93});92});
9493
...@@ -102,6 +101,7 @@ app.post("/reload", async (c) => {...@@ -102,6 +101,7 @@ app.post("/reload", async (c) => {
102101
103const openFile = util.promisify(fsCallbacks.open);102const openFile = util.promisify(fsCallbacks.open);
104const closeFile = util.promisify(fsCallbacks.close);103const closeFile = util.promisify(fsCallbacks.close);
104const fstat = util.promisify(fsCallbacks.fstat);
105105
106import { Hono } from "#hono";106import { Hono } from "#hono";
107import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";107import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
src/tags/PhotoGrid.css deleted-16
...@@ -1,16 +0,0 @@
1photo-grid {
2 display: grid;
3 gap: 0.5rem;
4 div {
5 position: relative;
6 width: 100%;
7 height: 100%;
8 border: 2px solid #0009;
9 }
10 img {
11 position: absolute;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15 }
16}
src/tags/PhotoGrid.tsx deleted-135
...@@ -1,135 +0,0 @@
1import "./PhotoGrid.css";
2import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
3const { isArray } = Array;
4
5export interface PhotoGridProps {
6 style: any;
7 base: string;
8 width: number;
9 widths?: string[];
10 heights?: number[];
11 items: Item[];
12}
13
14type Item = [img: string, options?: ItemOptions];
15
16interface ItemOptions {
17 w?: number;
18 h?: number;
19 align?: keyof typeof alignments;
20}
21
22const alignments = {
23 bottom: "50% 80%",
24 "bottom-end": "50% 100%",
25 top: "50% 30%",
26 "top-end": "50% 0%",
27 left: "30% 50%",
28 "left-end": "0% 50%",
29 right: "80% 50%",
30 "right-end": "100% 50%",
31};
32
33const typicalPageWidth = 768;
34
35export function PhotoGrid({
36 style,
37 base,
38 width,
39 widths,
40 heights,
41 items,
42}: PhotoGridProps) {
43 return;
44 if (!base.endsWith("/")) base += "/";
45 let rows: boolean[][] = [];
46 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
47 const anyCollide = (x: number, y: number, w: number, h: number) => {
48 for (let yy = 0; yy < h; yy++) {
49 const r = row(y + yy);
50 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
51 }
52 return false;
53 };
54
55 const firstRowHeight = heights?.[0] ?? 325;
56
57 let x = 0;
58 let y = 0;
59 let contents = [];
60 for (const item of items) {
61 const [img, { w = 1, h = 1, align } = {}] = isArray(item) ? item : [item];
62 if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`);
63
64 while (anyCollide(x, y, w, h)) {
65 x += 1;
66 if (x > width - w) x = 0, y += 1;
67 }
68 for (let yy = 0; yy < h; yy++) {
69 const r = row(y + yy);
70 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
71 }
72
73 const info = fileInfo(base, img);
74 contents.push(
75 <div
76 style={{
77 ...x === 0 && y === 0 &&
78 {
79 "aspect-ratio": ((w / width) * typicalPageWidth) / firstRowHeight,
80 },
81 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
82 }}
83 >
84 <img
85 {...info}
86 style={{
87 "object-position": align
88 ? (alignments as Record<string, string>)[align] ?? align
89 : "50% 50%",
90 }}
91 />
92 </div>,
93 );
94 }
95
96 const height = rows.length;
97 return (
98 <photo-grid
99 style={{
100 ...style,
101 "grid-template-columns": widths
102 ? mapSizes(widths).join(" ")
103 : `repeat(${width}, 1fr)`,
104 "grid-template-rows": heights
105 ? heights.map((h) => (h / firstRowHeight) + "fr")
106 .concat(new Array(height - heights.length).fill("1fr"))
107 .join(" ")
108 : `repeat(${height}, 1fr)`,
109 }}
110 >
111 {contents}
112 </photo-grid>
113 );
114}
115
116function fileInfo(base: string, img: string) {
117 const filePath = path.join(base, img);
118 const file = MediaFile.getByPath(filePath);
119 const dimensions = file?.parseDimensions();
120 if (!dimensions) {
121 throw new Error(`File does not exist or has no dimensions: ${filePath}`);
122 }
123 const src = "/file" + base + encodeURIComponent(img);
124 const alt = path.basename(img, path.extname(img));
125 return { src, alt, width: dimensions.width, height: dimensions.height };
126}
127
128function mapSizes(sizes: Array<string | number>) {
129 return sizes.map(mapSize);
130}
131function mapSize(size: string | number) {
132 return typeof size === "number" ? size + "px" : size;
133}
134
135import * as path from "node:path";
src/tags/clover-video.client.ts+20-9
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
22
3// Polyfill for old browsers.3// Polyfill for old browsers.
4type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";4type PlaybackMode = "av1" | "hls-polyfill" | "hls-native" | "none";
5let mode: Promise<PlaybackMode> | null = null;
5let hls: Promise<typeof import("hls.js")> | null = null;6let hls: Promise<typeof import("hls.js")> | null = null;
67
7Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>8Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
...@@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>...@@ -12,11 +13,14 @@ Promise.allSettled ??= <T>(promises: Iterable<T | PromiseLike<T>>) =>
12 (reason) => ({ status: "rejected" as const, reason }),13 (reason) => ({ status: "rejected" as const, reason }),
13 )));14 )));
1415
15decidePlaybackMode().then((mode) =>16globalThis.hydrateVideo = function hydrateVideo(container) {
16 Array.from(17 mode ??= decidePlaybackMode();
17 document.querySelectorAll<HTMLElement>(".clover-video"),18 mode.then((mode) => hydrateVideoInner(container, mode));
18 (container) => hydrateVideo(container, mode),19};
19 )20
21Array.from(
22 document.querySelectorAll<HTMLElement>(".clover-video"),
23 (container) => hydrateVideo(container),
20);24);
2125
22function decidePlaybackMode(): Promise<PlaybackMode> {26function decidePlaybackMode(): Promise<PlaybackMode> {
...@@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -39,7 +43,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
39 return "hls-native";43 return "hls-native";
40 }44 }
4145
42 return (hls ??= import("hls.js"))46 return (hls ??= import("/js/scripts/vendor/hls.js"))
43 .then((hls): PlaybackMode => {47 .then((hls): PlaybackMode => {
44 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)48 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
45 // TODO: ES modules have a minimum of Chrome 63 or Firefox 6049 // TODO: ES modules have a minimum of Chrome 63 or Firefox 60
...@@ -55,8 +59,16 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -55,8 +59,16 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
55 });59 });
56}60}
5761
58function hydrateVideo(container: HTMLElement, mode: PlaybackMode) {62declare global {
63 function hydrateVideo(container: HTMLElement): void;
64}
65
66function hydrateVideoInner(
67 container: HTMLElement,
68 mode: PlaybackMode,
69) {
59 const video = container.querySelector("video")!;70 const video = container.querySelector("video")!;
71 if (video.hasAttribute("controls")) return;
60 let src = video.getAttribute("data-src");72 let src = video.getAttribute("data-src");
61 if (!src) return;73 if (!src) return;
62 src = `/file${src}`;74 src = `/file${src}`;
...@@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) {...@@ -65,8 +77,7 @@ function hydrateVideo(container: HTMLElement, mode: PlaybackMode) {
65 const hlsFile = `${src}:/master.m3u8`;77 const hlsFile = `${src}:/master.m3u8`;
6678
67 if (mode === "av1") {79 if (mode === "av1") {
68 console.info(`Playback: av1, opus, mpeg-dash`);80 import("/js/scripts/vendor/dash.js").then((dashjs) => {
69 import("dashjs").then((dashjs) => {
70 const player = dashjs.MediaPlayer().create();81 const player = dashjs.MediaPlayer().create();
71 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay82 // This proxying is for ManagedMediaSource, exposing the HLS stream for AirPlay
72 let mainSource: HTMLSourceElement | null = null;83 let mainSource: HTMLSourceElement | null = null;
src/tags/clover-video.css+5-1
...@@ -3,6 +3,9 @@...@@ -3,6 +3,9 @@
3 display: flex;3 display: flex;
4 flex-direction: column;4 flex-direction: column;
5}5}
6.clover-video.minimal {
7 border: none;
8}
6.clover-video video {9.clover-video video {
7 background-color: transparent;10 background-color: transparent;
8}11}
...@@ -11,6 +14,7 @@...@@ -11,6 +14,7 @@
11 overflow: hidden;14 overflow: hidden;
12 display: flex;15 display: flex;
13 flex-direction: column;16 flex-direction: column;
17 background-color: black;
14}18}
15.clover-video video {19.clover-video video {
16 width: 100%;20 width: 100%;
...@@ -33,7 +37,7 @@...@@ -33,7 +37,7 @@
33}37}
34.clover-video figcaption {38.clover-video figcaption {
35 background-color: var(--fg);39 background-color: var(--fg);
36 color: white;40 color: var(--bg);
37 width: 100%;41 width: 100%;
38 margin-top: -1px;42 margin-top: -1px;
39 padding-bottom: 2px;43 padding-bottom: 2px;
src/tags/clover-video.marko+10-7
...@@ -4,9 +4,10 @@ export interface Input {...@@ -4,9 +4,10 @@ export interface Input {
4 file: MediaFile | string;4 file: MediaFile | string;
5 poster?: MediaFile | string;5 poster?: MediaFile | string;
6 header?: Marko.AttrTag<{ content: Marko.Body }>;6 header?: Marko.AttrTag<{ content: Marko.Body }>;
7 minimal?: boolean;
7}8}
89
9<const/{ header } = input />10<const/{ header, minimal } = input />
10<const/file = typeof UNWRAP(input.file) === 'object'11<const/file = typeof UNWRAP(input.file) === 'object'
11 ? input.file12 ? input.file
12 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />13 : UNWRAP(MediaFile.getByPath(input.file), `No file ${JSON.stringify(input.file)}`) />
...@@ -18,12 +19,14 @@ export interface Input {...@@ -18,12 +19,14 @@ export interface Input {
1819
19<const/{ width, height } = file.parseDimensions() />20<const/{ width, height } = file.parseDimensions() />
2021
21<figure.clover-video>22<figure.clover-video class={ minimal }>
22 <figcaption>23 <if=!minimal>
23 <if=header><${header.content}/></><else>24 <figcaption>
24 ${file.basename}25 <if=header><${header.content}/></><else>
25 </else>26 ${file.basename}
26 </figcaption>27 </else>
28 </figcaption>
29 </>
27 <div.stack style=`aspect-ratio:${width}/${height}`>30 <div.stack style=`aspect-ratio:${width}/${height}`>
28 <video31 <video
29 data-src=file.path32 data-src=file.path
src/tags/image.tsx created+55
...@@ -0,0 +1,55 @@
1interface Props {
2 src: MediaFile | string;
3 alt: string;
4 displayWidthEstimate?: number | string;
5 objectFit?: string;
6
7 style?: string;
8 className?: string;
9}
10
11export default function Image(
12 { src, class: className, objectFit, style, displayWidthEstimate, ...rest }:
13 Props,
14) {
15 src = typeof src === "object"
16 ? src
17 : UNWRAP(MediaFile.getByPath(src), `No file ${JSON.stringify(src)}`);
18
19 const base = `/file${escapeUri(src.path)}`;
20 if (src.extension === ".gif") {
21 return <img src={base} class={className} style={style} {...rest} />;
22 }
23
24 const { width } = UNWRAP(src.parseDimensions());
25 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
26
27 const sizes = displayWidthEstimate
28 ? typeof displayWidthEstimate === "number"
29 ? `${displayWidthEstimate}px`
30 : displayWidthEstimate
31 : `100vw`;
32
33 return (
34 <picture class={className} style={style}>
35 {["jxl", "avif", "webp"].map((ext) => (
36 <source
37 type={`image/${ext}`}
38 srcset={targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)}
39 sizes={sizes}
40 />
41 ))}
42 <img
43 src={base}
44 {...rest}
45 style={objectFit
46 ? { "object-fit": objectFit, width: "100%", height: "100%" }
47 : null}
48 />
49 </picture>
50 );
51}
52
53import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
54import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
55import { escapeUri } from "@/file-viewer/format.ts";
src/tags/photo-grid.css created+16
...@@ -0,0 +1,16 @@
1photo-grid {
2 display: grid;
3 gap: 0.5rem;
4 div {
5 position: relative;
6 width: 100%;
7 height: 100%;
8 border: 2px solid #0009;
9 }
10 img {
11 position: absolute;
12 width: 100%;
13 height: 100%;
14 object-fit: cover;
15 }
16}
src/tags/photo-grid.tsx created+135
...@@ -0,0 +1,135 @@
1const { isArray } = Array;
2
3export interface PhotoGridProps {
4 style: any;
5 base: string;
6 width: number;
7 widths?: string[];
8 heights?: number[];
9 items: Item[];
10}
11
12type Item = [img: string, options?: ItemOptions];
13
14interface ItemOptions {
15 w?: number;
16 h?: number;
17 align?: keyof typeof alignments;
18}
19
20const alignments = {
21 bottom: "50% 80%",
22 "bottom-end": "50% 100%",
23 top: "50% 30%",
24 "top-end": "50% 0%",
25 left: "30% 50%",
26 "left-end": "0% 50%",
27 right: "80% 50%",
28 "right-end": "100% 50%",
29};
30
31const typicalPageWidth = 768;
32
33export function PhotoGrid({
34 style,
35 base,
36 width,
37 widths,
38 heights,
39 items,
40}: PhotoGridProps) {
41 if (!base.endsWith("/")) base += "/";
42 let rows: boolean[][] = [];
43 const row = (y: number) => (rows[y] ??= new Array(width).fill(false));
44 const anyCollide = (x: number, y: number, w: number, h: number) => {
45 for (let yy = 0; yy < h; yy++) {
46 const r = row(y + yy);
47 for (let xx = 0; xx < w; xx++) if (r[x + xx]) return true;
48 }
49 return false;
50 };
51
52 const firstRowHeight = heights?.[0] ?? 325;
53
54 let x = 0;
55 let y = 0;
56 let contents = [];
57 for (const item of items) {
58 const [img, { w = 1, h = 1, align } = {}] = isArray(item) ? item : [item];
59 if (w > width) throw new Error(`Item ${img} too wide (${w} > ${width})`);
60
61 while (anyCollide(x, y, w, h)) {
62 x += 1;
63 if (x > width - w) x = 0, y += 1;
64 }
65 for (let yy = 0; yy < h; yy++) {
66 const r = row(y + yy);
67 for (let xx = 0; xx < w; xx++) r[x + xx] = true;
68 }
69
70 const info = fileInfo(base, img);
71 contents.push(
72 <div
73 style={{
74 ...x === 0 && y === 0 &&
75 {
76 "aspect-ratio": ((w / width) * typicalPageWidth) / firstRowHeight,
77 },
78 "grid-area": `${y + 1} / ${x + 1} / ${1 + y + h} / ${1 + x + w}`,
79 }}
80 >
81 <Image
82 {...info}
83 displayWidthEstimate={Math.ceil((w / width) * 736)}
84 style={{
85 "object-position": align
86 ? (alignments as Record<string, string>)[align] ?? align
87 : "50% 50%",
88 }}
89 />
90 </div>,
91 );
92 }
93
94 const height = rows.length;
95 return (
96 <photo-grid
97 style={{
98 ...style,
99 "grid-template-columns": widths
100 ? mapSizes(widths).join(" ")
101 : `repeat(${width}, 1fr)`,
102 "grid-template-rows": heights
103 ? heights.map((h) => (h / firstRowHeight) + "fr")
104 .concat(new Array(height - heights.length).fill("1fr"))
105 .join(" ")
106 : `repeat(${height}, 1fr)`,
107 }}
108 >
109 {contents}
110 </photo-grid>
111 );
112}
113
114function fileInfo(base: string, img: string) {
115 const filePath = path.join(base, img);
116 const file = MediaFile.getByPath(filePath);
117 const dimensions = file?.parseDimensions();
118 if (!dimensions) {
119 throw new Error(`File does not exist or has no dimensions: ${filePath}`);
120 }
121 const alt = path.basename(img, path.extname(img));
122 return { src: file, alt, width: dimensions.width, height: dimensions.height };
123}
124
125function mapSizes(sizes: Array<string | number>) {
126 return sizes.map(mapSize);
127}
128function mapSize(size: string | number) {
129 return typeof size === "number" ? size + "px" : size;
130}
131
132import "./photo-grid.css";
133import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
134import Image from "./image.tsx";
135import * as path from "node:path";
src/tags/questions-embed.css created+9
...@@ -0,0 +1,9 @@
1.questions-embed {
2 --fg: #f0f0f0;
3 --bg: #1a1a1a;
4 --primary: #f5a9b8;
5 background-color: var(--bg);
6 margin: -1rem;
7 margin-top: 1rem;
8 padding: 1rem;
9}
src/tags/questions-embed.marko created+16
...@@ -0,0 +1,16 @@
1import "./questions-embed.css";
2
3export interface Input {
4 value: string;
5}
6
7<div.questions-embed.qa>
8 <a href="/q+a">ask your own question / view all questions</a>
9 <br />
10 <for|question| of=Question.search(input.value)>
11 <QuestionRender ...{ question } />
12 </>
13</div>
14
15import QuestionRender from "@/q+a/tags/question.marko";
16import { Question } from "@/q+a/models/Question.ts";