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 {...@@ -64,7 +64,7 @@ export function markoViaBuildCache(): esbuild.Plugin {
64 name: "marko via build cache",64 name: "marko via build cache",
65 setup(b) {65 setup(b) {
66 b.onLoad(66 b.onLoad(
67 { filter: /\.marko$/ },67 { filter: /\.marko$|\.mdo$/ },
68 async ({ path: file }) => {68 async ({ path: file }) => {
69 const cacheEntry = markoCache.get(file);69 const cacheEntry = markoCache.get(file);
70 if (!cacheEntry) {70 if (!cacheEntry) {
framework/lib/view.ts+2-1
...@@ -6,7 +6,8 @@...@@ -6,7 +6,8 @@
6let codegen: Codegen;6let codegen: Codegen;
7try {7try {
8 codegen = require("$views");8 codegen = require("$views");
9} catch {9} catch (e) {
10 console.error(e);
10 throw new Error("Can only import '#sitegen/view' in backends.");11 throw new Error("Can only import '#sitegen/view' in backends.");
11}12}
1213
src/friend-auth.ts+81-38
...@@ -1,12 +1,12 @@...@@ -1,12 +1,12 @@
1let hardcoded = {1let hardcoded = {
2 friendPassword: "",2 friendPassword: "",
3 perPage: {} as Record<string, string>,3 monthlyPasswords: [] as Array<{ password: string; start: string }>,
4 getForFile: (_: string) => [] as string[],4 getForFile: (_: string) => [] as string[],
5};5};
6try {6try {
7 hardcoded = require("./friends/hardcoded-password.ts");7 hardcoded = require("./friends/hardcoded-password.ts");
8} catch {}8} catch {}
9hardcoded.perPage ??= {};9hardcoded.monthlyPasswords ??= [];
1010
11export const app = new Hono();11export const app = new Hono();
1212
...@@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month...@@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month
1414
15export const getForFile = hardcoded.getForFile ?? (() => []);15export const getForFile = hardcoded.getForFile ?? (() => []);
1616
17function checkFriendsCookie(c: Context, passwords: string[]) {17function getFriendsCookiePassword(c: Context, passwords: string[]) {
18 const cookie = c.req.header("Cookie");18 const cookie = c.req.header("Cookie");
19 if (!cookie) return false;19 if (!cookie) return null;
20 const cookies = cookie.split("; ").map((x) => x.split("="));20 const cookies = cookie.split("; ").map((x) => x.split("="));
21 return cookies.some(21 for (const kv of cookies) {
22 (kv) =>22 const password = kv[1]?.trim();
23 kv[0]!.trim() === "friends_password"23 if (kv[0]!.trim() === "friends_password" && password && passwords.includes(password)) {
24 && kv[1]!.trim()24 return password;
25 && passwords.includes(kv[1]!.trim()),25 }
26 );26 }
27 return null;
27}28}
2829
29export function requireFriendAuth(30export function requireFriendAuth(
...@@ -46,7 +47,7 @@ export function requireFriendAuth(...@@ -46,7 +47,7 @@ export function requireFriendAuth(
46 });47 });
47 }48 }
48 }49 }
49 if (checkFriendsCookie(c, passwords)) {50 if (getFriendsCookiePassword(c, passwords)) {
50 return undefined;51 return undefined;
51 } else {52 } else {
52 return serveAsset(c, "/friends/auth", 403);53 return serveAsset(c, "/friends/auth", 403);
...@@ -55,39 +56,81 @@ export function requireFriendAuth(...@@ -55,39 +56,81 @@ export function requireFriendAuth(
5556
56let incorrectMap: Record<string, boolean> = {};57let incorrectMap: Record<string, boolean> = {};
5758
58function friendPage(route: assets.Key) {59app.use(friendAuthMiddleware);
59 const expected = hardcoded.perPage[route] ?? hardcoded.friendPassword;
6060
61 app.get(route, (c) => {61app.get("/friends", (c) => {
62 const friendAuthChallenge = requireFriendAuth(c, [expected]);62 return view.serve(c, "friends/index", {
63 if (friendAuthChallenge) return friendAuthChallenge;63 minimumDate: getMinimumDate(c),
64 return serveAsset(c, route, 200);
65 });64 });
65});
6666
67 app.post(route, async (c) => {67async function friendAuthMiddleware(c: Context, next: Next) {
68 const ip = c.header("X-Forwarded-For") ?? "unknown";68 if (isFriendAuthPage(c.req.path)) return next();
69 if (incorrectMap[ip]) {69 if (!isFriendRoute(c.req.path)) return next();
70 return serveAsset(c, "/friends/auth/fail", 403);70
71 }71 const passwords = getForRoute(c.req.path);
72 const data = await c.req.formData();72 if (c.req.method !== "POST") {
73 const k = data.get("password");73 const friendAuthChallenge = requireFriendAuth(c, passwords);
74 if (k === expected) {74 if (friendAuthChallenge) return friendAuthChallenge;
75 return c.body(null, 303, {75 return next();
76 Location: c.req.path,76 }
77 "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,77
78 });78 const ip = c.header("X-Forwarded-For") ?? "unknown";
79 }79 if (incorrectMap[ip]) {
80 incorrectMap[ip] = true;
81 await setTimeout(2500);
82 incorrectMap[ip] = false;
83 return serveAsset(c, "/friends/auth/fail", 403);80 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);
85}94}
8695
87friendPage("/friends");96function getForRoute(route: string) {
88friendPage("/friends/oct25");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
90import { serveAsset } from "#sitegen/assets";133import { serveAsset } from "#sitegen/assets";
91import * as assets from "#sitegen/assets";134import * as view from "#sitegen/view";
92import { type Context, Hono } from "hono";135import { type Context, Hono, type Next } from "hono";
93import { setTimeout } from "node:timers/promises";136import { setTimeout } from "node:timers/promises";
src/pages/dream.mdo+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1---1---
2meta:2meta:
3 title: paper clover's media license3 title: clo's dream
4---4---
5<div style='display:flex;align-items:center;justify-content:center;min-height:100dvh'>5<div style='display:flex;align-items:center;justify-content:center;min-height:100dvh'>
6<main style='max-width:666px;line-height:1.5;text-align:justify'>6<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[] = [...@@ -9,7 +9,7 @@ export const siteSections: sg.Section[] = [
9 { root: join(".") },9 { root: join(".") },
10 { root: join("q+a/") },10 { root: join("q+a/") },
11 { root: join("file-viewer/") },11 { root: join("file-viewer/") },
12 { root: join("friends/") },12 { root: join("friends/"), base: "/friends" },
13 { root: join("blog/"), base: "/blog" },13 { root: join("blog/"), base: "/blog" },
14 // { root: join("fiction/"), pageBase: "/fiction" },14 // { root: join("fiction/"), pageBase: "/fiction" },
15];15];