From 2bed05bdbc03b105f930feed4573a5b0768bab07 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 7 Nov 2025 00:28:14 -0800 Subject: [PATCH] feat(site): work to release october personal blog --- flake.nix | 2 + lib/progress.ts | 8 +- src/bin/download-db.ts | 26 ++++++ src/file-viewer/backend.tsx | 5 +- .../bin/{scan3.ts => file-scan.ts} | 23 ++++- src/file-viewer/bin/file-trim.ts | 18 ++++ src/file-viewer/models/MediaFile.ts | 13 ++- src/file-viewer/models/derived.ts | 18 ++++ src/file-viewer/rsync.ts | 9 +- src/friend-auth.ts | 93 ++++++++++++------- src/pages/friends/auth.fail.marko | 4 +- src/pages/friends/auth.marko | 4 +- src/pages/friends/misconfigure.marko | 10 ++ src/tags/clover-video.client.ts | 4 +- src/tags/clover-video.marko | 81 +++++++++------- src/tags/image.tsx | 6 ++ src/tags/photo-grid.css | 34 ++++--- 17 files changed, 250 insertions(+), 108 deletions(-) create mode 100644 src/bin/download-db.ts rename src/file-viewer/bin/{scan3.ts => file-scan.ts} (97%) create mode 100644 src/file-viewer/bin/file-trim.ts create mode 100644 src/pages/friends/misconfigure.marko diff --git a/flake.nix b/flake.nix index 71734588367ace33c4bdb1fc313064c9a5b14be7..6e7f32171a2221bc21f5e286310e77163856c0b6 100644 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,11 @@ pkgs.nodejs_24 # runtime pkgs.deno # formatter pkgs.python3 # for font subsetting + pkgs.python3Packages.pip # for font subsetting # paperclover.net pkgs.exiftool + pkgs.imagemagick pkgs.ffmpeg-full pkgs.rsync ]; diff --git a/lib/progress.ts b/lib/progress.ts index e1b686670f408c94e05ef11537151fbdcdb0d106..49f145f399969a046f5702be48995915168aa3d0 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -154,9 +154,9 @@ function newNode( parent?.sortChildren && parent.children.sort(parent.sortChildren); state.signal("draw"); } - const scope = log.headlessScope(() => { - console.log("TODO"); - }); + // const scope = log.headlessScope((msg) => { + // console.log("TODO"); + // }); const binding: Node = { start(text, opts) { const [child, node] = newNode(text, opts); @@ -233,7 +233,7 @@ function newNode( mutate(); } }, - log: scope, + log: log.scoped(""), end: () => void end(), [Symbol.dispose]: () => void end(), }; diff --git a/src/bin/download-db.ts b/src/bin/download-db.ts new file mode 100644 index 0000000000000000000000000000000000000000..85f0373d1cc53f8101b2271d6b013bd5958a432e --- /dev/null +++ b/src/bin/download-db.ts @@ -0,0 +1,26 @@ +export async function main() { + await rsync.spawn({ + cwd: Path.resolve(import.meta.dirname).parent!.toString(), + args: [ + "-a", + "--progress", + "clo@paperclover.net:~/paperclover.net/.clover/cache.sqlite", + ".clover/", + ], + progress: progress.start("download file cache"), + }); + await rsync.spawn({ + cwd: Path.resolve(import.meta.dirname).parent!.toString(), + args: [ + "-a", + "--progress", + "clo@paperclover.net:~/paperclover.net/.clover/questions.sqlite", + ".clover/", + ], + progress: progress.start("download questions state"), + }); +} + +import * as progress from "lib/progress.ts"; +import * as rsync from "../file-viewer/rsync.ts"; +import { Path } from "#sitegen/path"; diff --git a/src/file-viewer/backend.tsx b/src/file-viewer/backend.tsx index 1fd159b8990d635f64e710900fe20ccb9dedf5d9..324f61fd58a0f1052e6599263deee17c0fd341ad 100644 --- a/src/file-viewer/backend.tsx +++ b/src/file-viewer/backend.tsx @@ -45,7 +45,8 @@ app.get("/file/*", async (c, next) => { // The permissions system is currently binary, using the friend auth. const permissions = FilePermissions.getByPrefix(rawFilePath); if (permissions !== 0) { - const friendAuthChallenge = requireFriendAuth(c); + console.log(rawFilePath, getForFile(rawFilePath)); + const friendAuthChallenge = requireFriendAuth(c, getForFile(rawFilePath)); if (friendAuthChallenge) return friendAuthChallenge; } @@ -292,7 +293,7 @@ import { etagMatches, serveAsset } from "#sitegen/assets"; import * as view from "#sitegen/view"; import * as mime from "lib/mime.ts"; -import { requireFriendAuth } from "@/friend-auth.ts"; +import { getForFile, requireFriendAuth } from "@/friend-auth.ts"; import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts"; import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts"; import { MediaPanel } from "@/file-viewer/views/clofi.tsx"; diff --git a/src/file-viewer/bin/scan3.ts b/src/file-viewer/bin/file-scan.ts similarity index 97% rename from src/file-viewer/bin/scan3.ts rename to src/file-viewer/bin/file-scan.ts index 2269fab2d30e54e9b895c4a260531f744094ef29..80dd9e8a514784983b4fd331547f6bcbc9b5e983 100644 --- a/src/file-viewer/bin/scan3.ts +++ b/src/file-viewer/bin/file-scan.ts @@ -16,7 +16,7 @@ export async function main() { const start = performance.now(); using _ = log.startWidget({ format: (now) => - `paper clover's scan3 [${((now - start) / 1000).toFixed(1)}s]`, + `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, }); const promises = new async.PromiseAggregator(); @@ -512,6 +512,19 @@ const procDimensions: Process = { if (widthMatch && heightMatch) { dimensions = `${widthMatch[1]}x${heightMatch[1]}`; } + } else if (rules.extsImage.has(ext)) { + // Use magick to observe streams + const { stdout } = await subprocess.exec("magick", [ + "identify", + "-auto-orient", + "-format", + "%w %h", + path.toString(), + ]); + const [w, h] = stdout.split(" ").map((x) => Number(x)); + if (w && h) { + dimensions = w + "x" + h; + } } else { // Use ffprobe to observe streams const { stdout } = await subprocess.exec("ffprobe", [ @@ -522,11 +535,13 @@ const procDimensions: Process = { "-show_entries", "stream=width,height", "-of", - "csv=s=x:p=0", + "json", path.toString(), ]); - if (stdout.includes("x")) { - dimensions = stdout.trim(); + const result = JSON.parse(stdout); + const stream = result.streams[0]; + if (stream) { + dimensions = UNWRAP(stream.width) + "x" + UNWRAP(stream.height); } } diff --git a/src/file-viewer/bin/file-trim.ts b/src/file-viewer/bin/file-trim.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c31f666742571b24a43c3fe4f0ce5143f0e9b25 --- /dev/null +++ b/src/file-viewer/bin/file-trim.ts @@ -0,0 +1,18 @@ +export async function main() { + const start = performance.now(); + using _ = log.startWidget({ + format: (now) => + `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`, + }); + + const orphaned = derived.findOrphanedRoots(); + for (const root of orphaned) { + console.info("delete " + root.key); + derived.deleteRoot(root); + } + + // TODO: delete unreferenced files +} + +import * as log from "lib/log.ts"; +import * as derived from "../models/derived.ts"; diff --git a/src/file-viewer/models/MediaFile.ts b/src/file-viewer/models/MediaFile.ts index 5b4a5a4dbe3d75c99f8530c7b1321c18658a32bc..006f220c5514dc418884af23e9973362b32d4bc1 100644 --- a/src/file-viewer/models/MediaFile.ts +++ b/src/file-viewer/models/MediaFile.ts @@ -224,6 +224,7 @@ export class MediaFile { return createDirectoryQuery.getNonNull({ path: filePath, parentId, + time: Date.now(), }).id; } // walk up the path until we find a directory that exists @@ -236,6 +237,7 @@ export class MediaFile { parentId = createDirectoryQuery.getNonNull({ path: current, parentId, + time: Date.now(), }).id; } // walk back down the path, creating directories as needed @@ -245,6 +247,7 @@ export class MediaFile { parentId = createDirectoryQuery.getNonNull({ path: current, parentId, + time: Date.now(), }).id; } return parentId; @@ -314,16 +317,16 @@ export interface DirConfig { // Get a directory ID by path, creating it if it doesn't exist const createDirectoryQuery = db.prepare< - [{ path: string; parentId: number | null }], + [{ path: string; parentId: number | null; time: number }], { id: number } >( /* SQL */ ` insert into media_files ( - path, parent_id, kind, timestamp, hash, size, - duration, dimensions, contents, dirsort, processed) + path, parent_id, kind, timestamp, timestamp_updated, hash, + size, duration, dimensions, contents, dirsort, processed) values ( - $path, $parentId, ${MediaFileKind.directory}, 0, '', 0, - 0, '', '', '', 0) + $path, $parentId, ${MediaFileKind.directory}, 0, $time, '', + 0, 0, '', '', '', 0) returning id; `, ); diff --git a/src/file-viewer/models/derived.ts b/src/file-viewer/models/derived.ts index cf90ef90bda5e3e13ec7e7fc22641495913198c9..10ff11985ebf44ea5af5393f38e81837d3dab443 100644 --- a/src/file-viewer/models/derived.ts +++ b/src/file-viewer/models/derived.ts @@ -165,6 +165,14 @@ export function get(file: MediaFile, subPath: string) { return null; } +export function findOrphanedRoots() { + return findOrphanedRootsQuery.array(); +} + +export function deleteRoot(root: { id: number }) { + return deleteRootQuery.run({ root: root.id }); +} + const insertRootQuery = db.prepare< [{ key: string; date: number }], { id: number } @@ -194,6 +202,16 @@ const deleteRefQuery = db.prepare<[{ root: number; file: number }]>(/* SQL */ ` delete from derived_refs where root = $root and file = $file; `); +const findOrphanedRootsQuery = db.prepare< + [], + { id: number; key: string; date: number } +>(/* SQL */ ` + select dr.id, dr.key, dr.date + from derived_roots dr + left join derived_refs refs on refs.root = dr.id + where refs.id is null +`); + import { getDb } from "#sitegen/sqlite"; import type { MediaFile } from "./MediaFile.ts"; import { Path } from "#sitegen/path"; diff --git a/src/file-viewer/rsync.ts b/src/file-viewer/rsync.ts index 535c517dfd3083ff964ee4f4ae77e099c1dce1e9..a48b853d7ed443efd19b4fd6c25f15999ed7350d 100644 --- a/src/file-viewer/rsync.ts +++ b/src/file-viewer/rsync.ts @@ -10,7 +10,7 @@ export type Line = currentFile: string; bytesTransferred: number; percentage: number; - timeElapsed: string; + timeElapsed: string | null; transferNumber: number; filesToCheck: number; totalFiles: number; @@ -91,7 +91,7 @@ export async function spawn(options: SpawnOptions) { e.args = [rsync, ...args].join(" "); e.code = code; e.signal = signal; - return e; + throw e; } } @@ -126,9 +126,9 @@ export class Parse { return { kind: "progress", currentFile: this.lastSeenFile || "", - bytesTransferred: Number(bytesStr.replaceAll(",", "")), + bytesTransferred: Number(UNWRAP(bytesStr).replaceAll(",", "")), percentage: Number(percentageStr), - timeElapsed, + timeElapsed: timeElapsed ?? null, transferNumber: this.currentTransfer, filesToCheck: toCheckStr ? this.toCheck = Number(toCheckStr) @@ -185,3 +185,4 @@ import * as readline from "node:readline"; import events from "node:events"; import * as progress from "lib/progress.ts"; import { formatSize } from "@/file-viewer/format.ts"; +import { UNWRAP } from "lib/assert.ts"; diff --git a/src/friend-auth.ts b/src/friend-auth.ts index d65c8d62ef616bb05ae79a84647179eba445b867..2aba24f2979ea222177394bba21198b19c69255c 100644 --- a/src/friend-auth.ts +++ b/src/friend-auth.ts @@ -1,75 +1,96 @@ -let friendPassword = ""; +let hardcoded = { + friendPassword: "", + perPage: {} as Record, + getForFile: (file: string) => [] as string[], +}; try { - friendPassword = require("./friends/hardcoded-password.ts").friendPassword; + hardcoded = require("./friends/hardcoded-password.ts"); } catch {} export const app = new Hono(); const cookieAge = 60 * 60 * 24 * 30; // 1 month -function checkFriendsCookie(c: Context) { +export const getForFile = hardcoded.getForFile; + +function checkFriendsCookie(c: Context, passwords: string[]) { const cookie = c.req.header("Cookie"); if (!cookie) return false; const cookies = cookie.split("; ").map((x) => x.split("=")); return cookies.some( (kv) => - kv[0].trim() === "friends_password" && - kv[1].trim() && - kv[1].trim() === friendPassword, + kv[0]!.trim() === "friends_password" && + kv[1]!.trim() && + passwords.includes(kv[1]!.trim()), ); } -export function requireFriendAuth(c: Context) { +export function requireFriendAuth( + c: Context, + passwords = [hardcoded.friendPassword], +) { + if (passwords.length === 0 || passwords[0]!.length === 0) { + return serveAsset(c, "/friends/misconfigure", 501); + } const k = c.req.query("password") || c.req.query("k"); if (k) { - if (k === friendPassword) { + if (passwords.includes(k)) { return c.body(null, 303, { - Location: "/friends", + Location: c.req.path, "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, }); } else { return c.body(null, 303, { - Location: "/friends", + Location: c.req.path, }); } } - if (checkFriendsCookie(c)) { + if (checkFriendsCookie(c, passwords)) { return undefined; } else { return serveAsset(c, "/friends/auth", 403); } } -app.get("/friends", (c) => { - const friendAuthChallenge = requireFriendAuth(c); - if (friendAuthChallenge) return friendAuthChallenge; - return serveAsset(c, "/friends", 200); -}); - let incorrectMap: Record = {}; -app.post("/friends", async (c) => { - const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ?? - "unknown"; - if (incorrectMap[ip]) { + +function friendPage(route: assets.Key) { + const expected = hardcoded.perPage[route] ?? hardcoded.friendPassword; + + app.get(route, (c) => { + const friendAuthChallenge = requireFriendAuth(c, [expected]); + if (friendAuthChallenge) return friendAuthChallenge; + return serveAsset(c, route, 200); + }); + + app.post(route, async (c) => { + const ip = c.header("X-Forwarded-For") ?? getConnInfo(c).remote.address ?? + "unknown"; + if (incorrectMap[ip]) { + return serveAsset(c, "/friends/auth/fail", 403); + } + const data = await c.req.formData(); + const k = data.get("password"); + if (k === expected) { + return c.body(null, 303, { + Location: c.req.path, + "Set-Cookie": + `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, + }); + } + incorrectMap[ip] = true; + await setTimeout(2500); + incorrectMap[ip] = false; return serveAsset(c, "/friends/auth/fail", 403); - } - const data = await c.req.formData(); - const k = data.get("password"); - if (k === friendPassword) { - return c.body(null, 303, { - Location: "/friends", - "Set-Cookie": - `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, - }); - } - incorrectMap[ip] = true; - await setTimeout(2500); - incorrectMap[ip] = false; - return serveAsset(c, "/friends/auth/fail", 403); -}); + }); +} + +friendPage("/friends"); +friendPage("/friends/oct25"); import { type Context, Hono } from "hono"; import { serveAsset } from "#sitegen/assets"; import { setTimeout } from "node:timers/promises"; import { getConnInfo } from "#hono/conninfo"; +import * as assets from "#sitegen/assets"; diff --git a/src/pages/friends/auth.fail.marko b/src/pages/friends/auth.fail.marko index d03b9a2c284d3f4d7dd75a856981260ed697b26b..48269abe7f87b1482a3ba8a6b32ae46ac96ead8d 100644 --- a/src/pages/friends/auth.fail.marko +++ b/src/pages/friends/auth.fail.marko @@ -3,9 +3,9 @@ export const meta = { title: "password required" };

incorrect or outdated password

please contact clover

-
+

try again

- +
diff --git a/src/pages/friends/auth.marko b/src/pages/friends/auth.marko index bea6ed3a4e17af6c2cf97395eff66aaa8f11835e..ccfe2d0cb1cd9c7fcc431d68088d078e29722ae9 100644 --- a/src/pages/friends/auth.marko +++ b/src/pages/friends/auth.marko @@ -1,9 +1,9 @@ export const meta = { title: "password required" };
-
+

what's the password

- +

(clover will give the password to you if you're a friend)

diff --git a/src/pages/friends/misconfigure.marko b/src/pages/friends/misconfigure.marko new file mode 100644 index 0000000000000000000000000000000000000000..c7e09fca74a6a9f9502f8cebff50b789380daa2a --- /dev/null +++ b/src/pages/friends/misconfigure.marko @@ -0,0 +1,10 @@ +export const meta = { title: "password required" }; + +
+
+

what's the password

+ + +

this page's password is not configured

+
+
diff --git a/src/tags/clover-video.client.ts b/src/tags/clover-video.client.ts index 547e55cc5815d020ba3c35d38a34a145627b002c..7a64b19dfe52555ec6b618053c18cec056bfc659 100644 --- a/src/tags/clover-video.client.ts +++ b/src/tags/clover-video.client.ts @@ -67,7 +67,9 @@ function hydrateVideoInner( container: HTMLElement, mode: PlaybackMode, ) { - const video = container.querySelector("video")!; + const video = container instanceof HTMLVideoElement + ? container + : container.querySelector("video")!; if (video.hasAttribute("controls")) return; let src = video.getAttribute("data-src"); if (!src) return; diff --git a/src/tags/clover-video.marko b/src/tags/clover-video.marko index 2d2a4c145ad5abb1ed338871645db5910d05c7e8..81d2d442f4adafc7f0cff6fbd9e5835b27c3a3b9 100644 --- a/src/tags/clover-video.marko +++ b/src/tags/clover-video.marko @@ -5,9 +5,10 @@ export interface Input { poster?: MediaFile | string; header?: Marko.AttrTag<{ content: Marko.Body }>; minimal?: boolean; + noFigure?: boolean } - + @@ -19,40 +20,50 @@ export interface Input { - - -
- <${header.content}/> - ${file.basename} - -
- - -