authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-01 22:32:15-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-02 20:24:40-07:00
log59f6221379cc7f174fd65267e8f58b82d63f3a17
treec8e08fe5b86237d5d627acfe9151966cac3649b8
parent35197e9d466b5eca56137fceccd0d061cf3bbb1c
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

chore: friend auth adjustments


5 files changed, 86 insertions(+), 42 deletions(-)

framework/esbuild-support.ts+1-1
......@@ -64,7 +64,7 @@ export function markoViaBuildCache(): esbuild.Plugin {
6464 name: "marko via build cache",
6565 setup(b) {
6666 b.onLoad(
67 { filter: /\.marko$/ },
67 { filter: /\.marko$|\.mdo$/ },
6868 async ({ path: file }) => {
6969 const cacheEntry = markoCache.get(file);
7070 if (!cacheEntry) {
framework/lib/view.ts+2-1
......@@ -6,7 +6,8 @@
66let codegen: Codegen;
77try {
88 codegen = require("$views");
9} catch {
9} catch (e) {
10 console.error(e);
1011 throw new Error("Can only import '#sitegen/view' in backends.");
1112}
1213
src/friend-auth.ts+81-38
......@@ -1,12 +1,12 @@
11let hardcoded = {
22 friendPassword: "",
3 perPage: {} as Record<string, string>,
3 monthlyPasswords: [] as Array<{ password: string; start: string }>,
44 getForFile: (_: string) => [] as string[],
55};
66try {
77 hardcoded = require("./friends/hardcoded-password.ts");
88} catch {}
9hardcoded.perPage ??= {};
9hardcoded.monthlyPasswords ??= [];
1010
1111export const app = new Hono();
1212
......@@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month
1414
1515export const getForFile = hardcoded.getForFile ?? (() => []);
1616
17function checkFriendsCookie(c: Context, passwords: string[]) {
17function getFriendsCookiePassword(c: Context, passwords: string[]) {
1818 const cookie = c.req.header("Cookie");
19 if (!cookie) return false;
19 if (!cookie) return null;
2020 const cookies = cookie.split("; ").map((x) => x.split("="));
21 return cookies.some(
22 (kv) =>
23 kv[0]!.trim() === "friends_password"
24 && kv[1]!.trim()
25 && passwords.includes(kv[1]!.trim()),
26 );
21 for (const kv of cookies) {
22 const password = kv[1]?.trim();
23 if (kv[0]!.trim() === "friends_password" && password && passwords.includes(password)) {
24 return password;
25 }
26 }
27 return null;
2728}
2829
2930export function requireFriendAuth(
......@@ -46,7 +47,7 @@ export function requireFriendAuth(
4647 });
4748 }
4849 }
49 if (checkFriendsCookie(c, passwords)) {
50 if (getFriendsCookiePassword(c, passwords)) {
5051 return undefined;
5152 } else {
5253 return serveAsset(c, "/friends/auth", 403);
......@@ -55,39 +56,81 @@ export function requireFriendAuth(
5556
5657let incorrectMap: Record<string, boolean> = {};
5758
58function friendPage(route: assets.Key) {
59 const expected = hardcoded.perPage[route] ?? hardcoded.friendPassword;
59app.use(friendAuthMiddleware);
6060
61 app.get(route, (c) => {
62 const friendAuthChallenge = requireFriendAuth(c, [expected]);
63 if (friendAuthChallenge) return friendAuthChallenge;
64 return serveAsset(c, route, 200);
61app.get("/friends", (c) => {
62 return view.serve(c, "friends/index", {
63 minimumDate: getMinimumDate(c),
6564 });
65});
6666
67 app.post(route, async (c) => {
68 const ip = c.header("X-Forwarded-For") ?? "unknown";
69 if (incorrectMap[ip]) {
70 return serveAsset(c, "/friends/auth/fail", 403);
71 }
72 const data = await c.req.formData();
73 const k = data.get("password");
74 if (k === expected) {
75 return c.body(null, 303, {
76 Location: c.req.path,
77 "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
78 });
79 }
80 incorrectMap[ip] = true;
81 await setTimeout(2500);
82 incorrectMap[ip] = false;
67async function friendAuthMiddleware(c: Context, next: Next) {
68 if (isFriendAuthPage(c.req.path)) return next();
69 if (!isFriendRoute(c.req.path)) return next();
70
71 const passwords = getForRoute(c.req.path);
72 if (c.req.method !== "POST") {
73 const friendAuthChallenge = requireFriendAuth(c, passwords);
74 if (friendAuthChallenge) return friendAuthChallenge;
75 return next();
76 }
77
78 const ip = c.header("X-Forwarded-For") ?? "unknown";
79 if (incorrectMap[ip]) {
8380 return serveAsset(c, "/friends/auth/fail", 403);
84 });
81 }
82 const data = await c.req.formData();
83 const k = data.get("password");
84 if (typeof k === "string" && passwords.includes(k)) {
85 return c.body(null, 303, {
86 Location: c.req.path,
87 "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
88 });
89 }
90 incorrectMap[ip] = true;
91 await setTimeout(2500);
92 incorrectMap[ip] = false;
93 return serveAsset(c, "/friends/auth/fail", 403);
8594}
8695
87friendPage("/friends");
88friendPage("/friends/oct25");
96function getForRoute(route: string) {
97 const passwords = [hardcoded.friendPassword];
98 const month = getRouteMonth(route);
99 if (route === "/friends") {
100 passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password));
101 return passwords;
102 }
103 if (!month) return passwords;
104 for (const grant of hardcoded.monthlyPasswords) {
105 if (month >= grant.start) passwords.push(grant.password);
106 }
107 return passwords;
108}
109
110function getMinimumDate(c: Context) {
111 const password = getFriendsCookiePassword(c, getForRoute(c.req.path));
112 if (!password || password === hardcoded.friendPassword) return null;
113 const grant = hardcoded.monthlyPasswords.find((grant) => grant.password === password);
114 return grant?.start ?? null;
115}
116
117function getRouteMonth(route: string) {
118 const [, year, month] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d)-/) ?? [];
119 if (!year || !month) return null;
120 return `20${year}-${month}`;
121}
122
123function isFriendAuthPage(route: string) {
124 return route === "/friends/auth"
125 || route === "/friends/auth/fail"
126 || route === "/friends/misconfigure";
127}
128
129function isFriendRoute(route: string) {
130 return route === "/friends" || getRouteMonth(route) !== null;
131}
89132
90133import { serveAsset } from "#sitegen/assets";
91import * as assets from "#sitegen/assets";
92import { type Context, Hono } from "hono";
134import * as view from "#sitegen/view";
135import { type Context, Hono, type Next } from "hono";
93136import { setTimeout } from "node:timers/promises";
src/pages/dream.mdo+1-1
......@@ -1,6 +1,6 @@
11---
22meta:
3 title: paper clover's media license
3 title: clo's dream
44---
55<div style='display:flex;align-items:center;justify-content:center;min-height:100dvh'>
66<main style='max-width:666px;line-height:1.5;text-align:justify'>
src/site.ts+1-1
......@@ -9,7 +9,7 @@ export const siteSections: sg.Section[] = [
99 { root: join(".") },
1010 { root: join("q+a/") },
1111 { root: join("file-viewer/") },
12 { root: join("friends/") },
12 { root: join("friends/"), base: "/friends" },
1313 { root: join("blog/"), base: "/blog" },
1414 // { root: join("fiction/"), pageBase: "/fiction" },
1515];