| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | let hardcoded = { | 1 | let 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 | }; |
| 6 | try { | 6 | try { |
| 7 | hardcoded = require("./friends/hardcoded-password.ts"); | 7 | hardcoded = require("./friends/hardcoded-password.ts"); |
| 8 | } catch {} | 8 | } catch {} |
| 9 | hardcoded.perPage ??= {}; | 9 | hardcoded.monthlyPasswords ??= []; |
| 10 | | 10 | |
| 11 | export const app = new Hono(); | 11 | export const app = new Hono(); |
| 12 | | 12 | |
| ... | @@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month | ... | @@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month |
| 14 | | 14 | |
| 15 | export const getForFile = hardcoded.getForFile ?? (() => []); | 15 | export const getForFile = hardcoded.getForFile ?? (() => []); |
| 16 | | 16 | |
| 17 | function checkFriendsCookie(c: Context, passwords: string[]) { | 17 | function 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 | } |
| 28 | | 29 | |
| 29 | export function requireFriendAuth( | 30 | export 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( |
| 55 | | 56 | |
| 56 | let incorrectMap: Record<string, boolean> = {}; | 57 | let incorrectMap: Record<string, boolean> = {}; |
| 57 | | 58 | |
| 58 | function friendPage(route: assets.Key) { | 59 | app.use(friendAuthMiddleware); |
| 59 | const expected = hardcoded.perPage[route] ?? hardcoded.friendPassword; | | |
| 60 | | 60 | |
| 61 | app.get(route, (c) => { | 61 | app.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 | }); |
| 66 | | 66 | |
| 67 | app.post(route, async (c) => { | 67 | async 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 | } |
| 86 | | 95 | |
| 87 | friendPage("/friends"); | 96 | function getForRoute(route: string) { |
| 88 | friendPage("/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 | |
| | 110 | function 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 | |
| | 117 | function 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 | |
| | 123 | function isFriendAuthPage(route: string) { |
| | 124 | return route === "/friends/auth" |
| | 125 | || route === "/friends/auth/fail" |
| | 126 | || route === "/friends/misconfigure"; |
| | 127 | } |
| | 128 | |
| | 129 | function isFriendRoute(route: string) { |
| | 130 | return route === "/friends" || getRouteMonth(route) !== null; |
| | 131 | } |
| 89 | | 132 | |
| 90 | import { serveAsset } from "#sitegen/assets"; | 133 | import { serveAsset } from "#sitegen/assets"; |
| 91 | import * as assets from "#sitegen/assets"; | 134 | import * as view from "#sitegen/view"; |
| 92 | import { type Context, Hono } from "hono"; | 135 | import { type Context, Hono, type Next } from "hono"; |
| 93 | import { setTimeout } from "node:timers/promises"; | 136 | import { setTimeout } from "node:timers/promises"; |