1import { createHash, randomBytes } from "node:crypto";2import type { Context, Hono } from "hono";3import { html } from "../pages/html.ts";4import { type PageContext, page } from "../pages/layout.ts";5import { clientIp } from "./client.ts";6import type { Config } from "./config.ts";7import { adminPostRefusal, adminRefusal, formToken, signedIn } from "./login.ts";8910const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";11const CODE_SECONDS = 600;12const ATTEMPTS_PER_ADDRESS = 10;13const ATTEMPT_WINDOW_MS = 3600000;141516const NO_CODE = '{"error":"no_code","message":"no code"}';1718let attempts: { at: number; address: string }[] = [];1920const attemptKey = randomBytes(32);21const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");222324export function connectKeys(25 config: Config,26): { accessKeyId: string; secretAccessKey: string } | null {27 return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null;28}293031function machineName(value: unknown): string | null {32 if (typeof value !== "string") return null;33 const characters = [...value];34 if (characters.length === 0 || characters.length > 64) return null;35 36 37 if (/[\p{C}\p{Zl}\p{Zp}]/u.test(value)) return null;38 return value;39}404142export function noCode(c: Context): Response {43 return c.body(NO_CODE, 404, {44 "Content-Type": "application/json; charset=UTF-8",45 "Cache-Control": "no-store",46 });47}484950export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {51 const bucket = config.bucket.kind === "s3" ? config.bucket : null;52 const write = bucket?.write ?? null;5354 app.get("/connect", (c) => {55 56 if (!write) return c.notFound();57 c.header("Cache-Control", "no-store");58 const machine = machineName(c.req.query("machine"));59 if (!signedIn(ctx, c)) {60 const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect";61 return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302);62 }63 if (!machine)64 return c.text("a machine name of 64 printable characters or fewer is required", 400);65 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b> to your bucket?</h1>66<p class="muted">That machine is handed the bucket key, once.</p>67<form class="issues-form" method="post" action="/connect">68<input type="hidden" name="machine" value="${machine}">69<input type="hidden" name="form" value="${formToken(c)}">70<button class="issues-submit" type="submit">connect</button>71</form>`;72 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));73 });7475 app.post("/connect", async (c) => {76 if (!write) return c.notFound();77 c.header("Cache-Control", "no-store");78 79 if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");80 const refusal = await adminPostRefusal(ctx, c);81 if (refusal) return adminRefusal(c, ctx, 403, refusal);82 const machine = machineName((await c.req.parseBody()).machine);83 if (!machine)84 return c.text("a machine name of 64 printable characters or fewer is required", 400);85 const now = Math.floor(Date.now() / 1000);86 ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);87 88 const code = Array.from(89 { length: 8 },90 () => ALPHABET[randomBytes(1)[0] % ALPHABET.length],91 ).join("");92 ctx.kepr93 .query(94 "INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",95 )96 .run(sha256(code), machine, now, now + CODE_SECONDS);97 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b></h1>98<p>Type this code on that machine, within ten minutes. It is shown once.</p>99<p class="mono code">${code.slice(0, 4)}-${code.slice(4)}</p>`;100 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));101 });102103 app.post("/api/connect", async (c) => {104 if (!write || !bucket) return c.notFound();105 c.header("Cache-Control", "no-store");106 const now = Date.now();107 108 109 attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS);110 const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");111 const used = attempts.filter((attempt) => attempt.address === address).length;112 attempts.push({ at: now, address });113 if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c);114 let code = "";115 try {116 const fields = await c.req.parseBody();117 code = typeof fields.code === "string" ? fields.code : "";118 } catch {119 return noCode(c);120 }121 const submitted = code.trim().replaceAll("-", "").toUpperCase();122 if (submitted.length !== 8 || [...submitted].some((char) => !ALPHABET.includes(char)))123 return noCode(c);124 const at = Math.floor(now / 1000);125 126 const redeemed = ctx.kepr127 .query(128 `UPDATE connect_codes SET used_at = ?129 WHERE code_hash = ?130 AND used_at IS NULL131 AND expires_at > ?`,132 )133 .run(at, sha256(submitted), at);134 if (redeemed.changes !== 1) return noCode(c);135 return c.json({136 endpoint: bucket.endpoint,137 bucket: bucket.name,138 region: bucket.region,139 access_key_id: write.accessKeyId,140 secret_access_key: write.secretAccessKey,141 });142 });143}