| 1 | let hardcoded = { |
| 2 | friendPassword: "", |
| 3 | monthlyPasswords: [] as Array<{ password: string; start: string }>, |
| 4 | getForFile: (_: string) => [] as string[], |
| 5 | }; |
| 6 | try { |
| 7 | hardcoded = require("./friends/hardcoded-password.ts"); |
| 8 | } catch {} |
| 9 | hardcoded.monthlyPasswords ??= []; |
| 10 | |
| 11 | export const app = new Hono(); |
| 12 | |
| 13 | const cookieAge = 60 * 60 * 24 * 30; // 1 month |
| 14 | |
| 15 | export const getForFile = hardcoded.getForFile ?? (() => []); |
| 16 | |
| 17 | function getFriendsCookiePassword(c: Context, passwords: string[]) { |
| 18 | const cookie = c.req.header("Cookie"); |
| 19 | if (!cookie) return null; |
| 20 | const cookies = cookie.split("; ").map((x) => x.split("=")); |
| 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; |
| 28 | } |
| 29 | |
| 30 | export function requireFriendAuth( |
| 31 | c: Context, |
| 32 | passwords = [hardcoded.friendPassword], |
| 33 | ) { |
| 34 | if (passwords.length === 0 || passwords[0]!.length === 0) { |
| 35 | return serveAsset(c, "/friends/misconfigure", 501); |
| 36 | } |
| 37 | const k = c.req.query("password") || c.req.query("k"); |
| 38 | if (k) { |
| 39 | if (passwords.includes(k)) { |
| 40 | return c.body(null, 303, { |
| 41 | Location: c.req.path, |
| 42 | "Set-Cookie": `friends_password=${k}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${cookieAge}`, |
| 43 | }); |
| 44 | } else { |
| 45 | return c.body(null, 303, { |
| 46 | Location: c.req.path, |
| 47 | }); |
| 48 | } |
| 49 | } |
| 50 | if (getFriendsCookiePassword(c, passwords)) { |
| 51 | return undefined; |
| 52 | } else { |
| 53 | return serveAsset(c, "/friends/auth", 403); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | let incorrectMap: Record<string, boolean> = {}; |
| 58 | |
| 59 | app.use(friendAuthMiddleware); |
| 60 | |
| 61 | app.get("/friends", (c) => { |
| 62 | return view.serve(c, "friends/index", { |
| 63 | minimumDate: getMinimumDate(c), |
| 64 | }); |
| 65 | }); |
| 66 | |
| 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]) { |
| 80 | return serveAsset(c, "/friends/auth/fail", 403); |
| 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); |
| 94 | } |
| 95 | |
| 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 | } |
| 132 | |
| 133 | import { serveAsset } from "#sitegen/assets"; |
| 134 | import * as view from "#sitegen/view"; |
| 135 | import { type Context, Hono, type Next } from "hono"; |
| 136 | import { setTimeout } from "node:timers/promises"; |