| ... | ... | @@ -1,12 +1,12 @@ |
| 1 | 1 | let hardcoded = { |
| 2 | 2 | friendPassword: "", |
| 3 | | perPage: {} as Record<string, string>, |
| 3 | monthlyPasswords: [] as Array<{ password: string; start: string }>, |
| 4 | 4 | getForFile: (_: string) => [] as string[], |
| 5 | 5 | }; |
| 6 | 6 | try { |
| 7 | 7 | hardcoded = require("./friends/hardcoded-password.ts"); |
| 8 | 8 | } catch {} |
| 9 | | hardcoded.perPage ??= {}; |
| 9 | hardcoded.monthlyPasswords ??= []; |
| 10 | 10 | |
| 11 | 11 | export const app = new Hono(); |
| 12 | 12 | |
| ... | ... | @@ -14,16 +14,17 @@ const cookieAge = 60 * 60 * 24 * 30; // 1 month |
| 14 | 14 | |
| 15 | 15 | export const getForFile = hardcoded.getForFile ?? (() => []); |
| 16 | 16 | |
| 17 | | function checkFriendsCookie(c: Context, passwords: string[]) { |
| 17 | function getFriendsCookiePassword(c: Context, passwords: string[]) { |
| 18 | 18 | const cookie = c.req.header("Cookie"); |
| 19 | | if (!cookie) return false; |
| 19 | if (!cookie) return null; |
| 20 | 20 | 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; |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | 30 | 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 | 51 | return undefined; |
| 51 | 52 | } else { |
| 52 | 53 | return serveAsset(c, "/friends/auth", 403); |
| ... | ... | @@ -55,39 +56,81 @@ export function requireFriendAuth( |
| 55 | 56 | |
| 56 | 57 | let incorrectMap: Record<string, boolean> = {}; |
| 57 | 58 | |
| 58 | | function friendPage(route: assets.Key) { |
| 59 | | const expected = hardcoded.perPage[route] ?? hardcoded.friendPassword; |
| 59 | app.use(friendAuthMiddleware); |
| 60 | 60 | |
| 61 | | app.get(route, (c) => { |
| 62 | | const friendAuthChallenge = requireFriendAuth(c, [expected]); |
| 63 | | if (friendAuthChallenge) return friendAuthChallenge; |
| 64 | | return serveAsset(c, route, 200); |
| 61 | app.get("/friends", (c) => { |
| 62 | return view.serve(c, "friends/index", { |
| 63 | minimumDate: getMinimumDate(c), |
| 65 | 64 | }); |
| 65 | }); |
| 66 | 66 | |
| 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; |
| 67 | async 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]) { |
| 83 | 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"); |
| 88 | | friendPage("/friends/oct25"); |
| 96 | function 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 | |
| 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 | 133 | import { serveAsset } from "#sitegen/assets"; |
| 91 | | import * as assets from "#sitegen/assets"; |
| 92 | | import { type Context, Hono } from "hono"; |
| 134 | import * as view from "#sitegen/view"; |
| 135 | import { type Context, Hono, type Next } from "hono"; |
| 93 | 136 | import { setTimeout } from "node:timers/promises"; |