1interface Grant {
2 password: string;
3 start: string;
4 end: string | null;
5 group?: string;
6}
7
8let hardcoded: {
9 monthlyPasswords: readonly Grant[];
10 getForFile?: (file: string) => string[];
11} = {
12 monthlyPasswords: [],
13};
14try {
15 hardcoded = { ...await import("./friends/hardcoded-password.ts") };
16} catch {}
17hardcoded.monthlyPasswords ??= [];
18
19export const app = new Hono();
20
21const cookieAge = 60 * 60 * 24 * 30; // 1 month
22
23export const getForFile = hardcoded.getForFile ?? (() => []);
24
25function getFriendsCookiePassword(c: Context, passwords: readonly string[]) {
26 const cookie = c.req.header("Cookie");
27 if (!cookie) return null;
28 const cookies = cookie.split("; ").map((x) => x.split("="));
29 for (const kv of cookies) {
30 const password = kv[1]?.trim();
31 if (kv[0]!.trim() === "friends_password" && password && passwords.includes(password)) {
32 return password;
33 }
34 }
35 return null;
36}
37
38export function requireFriendAuth(
39 c: Context,
40 passwords: readonly string[] = [],
41) {
42 if (passwords.length === 0) {
43 return serveAsset(c, "/friends/misconfigure", 501);
44 }
45 const k = c.req.query("password") || c.req.query("k");
46 if (k) {
47 if (passwords.includes(k)) {
48 return c.body(null, 303, {
49 Location: c.req.path,
50 "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
51 });
52 } else {
53 return c.body(null, 303, {
54 Location: c.req.path,
55 });
56 }
57 }
58 if (getFriendsCookiePassword(c, passwords)) {
59 return undefined;
60 } else {
61 return serveAsset(c, "/friends/auth", 403);
62 }
63}
64
65let incorrectMap: Record<string, boolean> = {};
66
67app.use(friendAuthMiddleware);
68
69app.get("/friends", (c) => {
70 const grant = getGrant(c, getForRoute(c.req.path));
71 return view.serveUncached(c, "friends/index", {
72 minimumDate: grant?.start ?? null,
73 maximumDate: grant?.end ?? null,
74 group: grant?.group ?? "default",
75 });
76});
77
78app.post("/friends/logout", (c) => {
79 return c.body(null, 303, {
80 Location: "/friends",
81 "Set-Cookie": "friends_password=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
82 });
83});
84
85app.get("/friends/:year/:entry", (c) => {
86 const entry = getRouteEntry(c.req.path);
87 if (!entry) return c.notFound();
88 const grant = getGrant(c, getForRoute(c.req.path));
89 return view.serveUncached(c, `friends/entries/${entry.year}/${entry.name}` as view.Key, {
90 minimumDate: grant?.start ?? null,
91 maximumDate: grant?.end ?? null,
92 group: grant?.group ?? "default",
93 });
94});
95
96async function friendAuthMiddleware(c: Context, next: Next) {
97 if (isFriendAuthPage(c.req.path)) return next();
98 if (!isFriendRoute(c.req.path)) return next();
99
100 const passwords = getForRoute(c.req.path);
101 if (c.req.method !== "POST") {
102 const friendAuthChallenge = requireFriendAuth(c, passwords);
103 if (friendAuthChallenge) return friendAuthChallenge;
104 return next();
105 }
106
107 const ip = c.header("X-Forwarded-For") ?? "unknown";
108 if (incorrectMap[ip]) {
109 return serveAsset(c, "/friends/auth/fail", 403);
110 }
111 const data = await c.req.formData();
112 const k = data.get("password");
113 if (typeof k === "string" && passwords.includes(k)) {
114 return c.body(null, 303, {
115 Location: c.req.path,
116 "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`,
117 });
118 }
119 incorrectMap[ip] = true;
120 await setTimeout(2500);
121 incorrectMap[ip] = false;
122 return serveAsset(c, "/friends/auth/fail", 403);
123}
124
125function getForRoute(route: string) {
126 const passwords: string[] = [];
127 const month = getRouteMonth(route);
128 if (route === "/friends") {
129 passwords.push(...hardcoded.monthlyPasswords.map((grant) => grant.password));
130 return passwords;
131 }
132 if (!month) return passwords;
133 for (const grant of hardcoded.monthlyPasswords) {
134 if (month >= grant.start && (grant.end == null || month <= grant.end)) {
135 passwords.push(grant.password);
136 }
137 }
138 return passwords;
139}
140
141function getGrant(c: Context, passwords: readonly string[]) {
142 const password = getFriendsCookiePassword(c, passwords);
143 if (!password || !passwords.includes(password)) return null;
144 return hardcoded.monthlyPasswords.find((grant) => grant.password === password) ?? null;
145}
146
147function getRouteMonth(route: string) {
148 const entry = getRouteEntry(route);
149 if (!entry) return null;
150 return `20${entry.year}-${entry.name.slice(0, 2)}`;
151}
152
153function getRouteEntry(route: string) {
154 const [, year, name] = route.match(/^(?:\/friends)?\/(\d\d)\/(\d\d-[a-z]{3})$/) ?? [];
155 return year && name ? { year, name } : null;
156}
157
158function isFriendAuthPage(route: string) {
159 return route === "/friends/auth"
160 || route === "/friends/auth/fail"
161 || route === "/friends/misconfigure";
162}
163
164function isFriendRoute(route: string) {
165 return route === "/friends" || getRouteMonth(route) !== null;
166}
167
168import { serveAsset } from "#sitegen/assets";
169import * as view from "#sitegen/view";
170import { type Context, Hono, type Next } from "hono";
171import { setTimeout } from "node:timers/promises";