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;1213export const MACHINES_LISTED = 20;14const ATTEMPTS_PER_ADDRESS = 10;15const ATTEMPT_WINDOW_MS = 3600000;161718const NO_CODE = '{"error":"no_code","message":"no code"}';1920let attempts: { at: number; address: string }[] = [];2122const attemptKey = randomBytes(32);23const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");242526export function connectKeys(27 config: Config,28): { accessKeyId: string; secretAccessKey: string } | null {29 return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null;30}313233function machineName(value: unknown): string | null {34 if (typeof value !== "string") return null;35 const characters = [...value];36 if (characters.length === 0 || characters.length > 64) return null;37 38 39 if (/[\p{C}\p{Zl}\p{Zp}]/u.test(value)) return null;40 return value;41}424344export function noCode(c: Context): Response {45 return c.body(NO_CODE, 404, {46 "Content-Type": "application/json; charset=UTF-8",47 "Cache-Control": "no-store",48 });49}505152export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {53 app.get("/connect", (c) => {54 55 if (!connectKeys(config)) return c.notFound();56 c.header("Cache-Control", "no-store");57 const machine = machineName(c.req.query("machine"));58 if (!signedIn(ctx, c)) {59 const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect";60 return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302);61 }62 if (!machine)63 return c.text("a machine name of 64 printable characters or fewer is required", 400);64 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b> to your bucket?</h1>65<p class="muted">That machine is handed the bucket key, once.</p>66<form class="issues-form" method="post" action="/connect">67<input type="hidden" name="machine" value="${machine}">68<input type="hidden" name="form" value="${formToken(c)}">69<button class="issues-submit" type="submit">connect</button>70</form>`;71 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));72 });7374 app.post("/connect", async (c) => {75 if (!connectKeys(config)) return c.notFound();76 c.header("Cache-Control", "no-store");77 78 if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");79 const refusal = await adminPostRefusal(ctx, c);80 if (refusal) return adminRefusal(c, ctx, 403, refusal);81 const machine = machineName((await c.req.parseBody()).machine);82 if (!machine)83 return c.text("a machine name of 64 printable characters or fewer is required", 400);84 const now = Math.floor(Date.now() / 1000);85 ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);86 87 const code = Array.from(88 { length: 8 },89 () => ALPHABET[randomBytes(1)[0] % ALPHABET.length],90 ).join("");91 ctx.kepr92 .query(93 "INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",94 )95 .run(sha256(code), machine, now, now + CODE_SECONDS);96 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b></h1>97<p>Type this code on that machine, within ten minutes. It is shown once.</p>98<p class="mono code">${code.slice(0, 4)}-${code.slice(4)}</p>`;99 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));100 });101102 app.post("/api/connect", async (c) => {103 if (!connectKeys(config)) return c.notFound();104 c.header("Cache-Control", "no-store");105 const now = Date.now();106 107 108 attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS);109 const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");110 const used = attempts.filter((attempt) => attempt.address === address).length;111 112 113 if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c);114 attempts.push({ at: now, address });115 let code = "";116 try {117 const fields = await c.req.parseBody();118 code = typeof fields.code === "string" ? fields.code : "";119 } catch {120 return noCode(c);121 }122 const submitted = code.trim().replaceAll("-", "").toUpperCase();123 if (submitted.length !== 8 || [...submitted].some((char) => !ALPHABET.includes(char)))124 return noCode(c);125 const at = Math.floor(now / 1000);126 127 const redeemed = ctx.kepr128 .query(129 `UPDATE connect_codes SET used_at = ?130 WHERE code_hash = ?131 AND used_at IS NULL132 AND expires_at > ?`,133 )134 .run(at, sha256(submitted), at);135 if (redeemed.changes !== 1) return noCode(c);136 ctx.kepr137 .query(138 `DELETE FROM connect_codes139 WHERE used_at IS NOT NULL140 AND code_hash NOT IN (141 SELECT code_hash FROM connect_codes142 WHERE used_at IS NOT NULL143 ORDER BY used_at DESC, rowid DESC144 LIMIT ?145 )`,146 )147 .run(MACHINES_LISTED);148 149 const bucket = config.bucket.kind === "s3" ? config.bucket : null;150 if (!bucket?.write) return noCode(c);151 const write = bucket.write;152 return c.json({153 endpoint: bucket.endpoint,154 bucket: bucket.name,155 region: bucket.region,156 access_key_id: write.accessKeyId,157 secret_access_key: write.secretAccessKey,158 });159 });160}