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;141516export function oneTimeCode(): string {17 18 return Array.from({ length: 8 }, () => ALPHABET[randomBytes(1)[0] % ALPHABET.length]).join("");19}202122export function typedCode(typed: string): string | null {23 const code = typed.trim().replaceAll("-", "").toUpperCase();24 return code.length === 8 && [...code].every((char) => ALPHABET.includes(char)) ? code : null;25}26const ATTEMPTS_PER_ADDRESS = 10;27const ATTEMPT_WINDOW_MS = 3600000;282930const NO_CODE = '{"error":"no_code","message":"no code"}';3132let attempts: { at: number; address: string }[] = [];3334const attemptKey = randomBytes(32);35const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");363738export function connectKeys(39 config: Config,40): { accessKeyId: string; secretAccessKey: string } | null {41 return config.bucket.kind === "s3" ? config.bucket : null;42}434445function machineName(value: unknown): string | null {46 if (typeof value !== "string") return null;47 const characters = [...value];48 if (characters.length === 0 || characters.length > 64) return null;49 50 51 if (/[\p{C}\p{Zl}\p{Zp}]/u.test(value)) return null;52 return value;53}545556export function noCode(c: Context): Response {57 return c.body(NO_CODE, 404, {58 "Content-Type": "application/json; charset=UTF-8",59 "Cache-Control": "no-store",60 });61}626364export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {65 app.get("/connect", (c) => {66 67 if (!connectKeys(config)) return c.notFound();68 c.header("Cache-Control", "no-store");69 const machine = machineName(c.req.query("machine"));70 if (!signedIn(ctx, c)) {71 const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect";72 return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302);73 }74 if (!machine)75 return c.text("a machine name of 64 printable characters or fewer is required", 400);76 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b> to your bucket?</h1>77<p class="muted">That machine is handed the bucket key, once.</p>78<form class="issues-form" method="post" action="/connect">79<input type="hidden" name="machine" value="${machine}">80<input type="hidden" name="form" value="${formToken(c)}">81<button class="issues-submit" type="submit">connect</button>82</form>`;83 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));84 });8586 app.post("/connect", async (c) => {87 if (!connectKeys(config)) return c.notFound();88 c.header("Cache-Control", "no-store");89 90 if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");91 const refusal = await adminPostRefusal(ctx, c);92 if (refusal) return adminRefusal(c, ctx, 403, refusal);93 const machine = machineName((await c.req.parseBody()).machine);94 if (!machine)95 return c.text("a machine name of 64 printable characters or fewer is required", 400);96 const now = Math.floor(Date.now() / 1000);97 ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);98 const code = oneTimeCode();99 ctx.kepr100 .query(101 "INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",102 )103 .run(sha256(code), machine, now, now + CODE_SECONDS);104 const body = html`<h1 class="file-page-title">Connect <b>${machine}</b></h1>105<p>Type this code on that machine, within ten minutes. It is shown once.</p>106<p class="mono code">${code.slice(0, 4)}-${code.slice(4)}</p>`;107 return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));108 });109110 app.post("/api/connect", async (c) => {111 if (!connectKeys(config)) return c.notFound();112 c.header("Cache-Control", "no-store");113 const now = Date.now();114 115 116 attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS);117 const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");118 const used = attempts.filter((attempt) => attempt.address === address).length;119 120 121 if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c);122 attempts.push({ at: now, address });123 let code = "";124 try {125 const fields = await c.req.parseBody();126 code = typeof fields.code === "string" ? fields.code : "";127 } catch {128 return noCode(c);129 }130 const submitted = typedCode(code);131 if (!submitted) return noCode(c);132 const at = Math.floor(now / 1000);133 134 const redeemed = ctx.kepr135 .query(136 `UPDATE connect_codes SET used_at = ?137 WHERE code_hash = ?138 AND used_at IS NULL139 AND expires_at > ?`,140 )141 .run(at, sha256(submitted), at);142 if (redeemed.changes !== 1) return noCode(c);143 ctx.kepr144 .query(145 `DELETE FROM connect_codes146 WHERE used_at IS NOT NULL147 AND code_hash NOT IN (148 SELECT code_hash FROM connect_codes149 WHERE used_at IS NOT NULL150 ORDER BY used_at DESC, rowid DESC151 LIMIT ?152 )`,153 )154 .run(MACHINES_LISTED);155 156 const bucket = config.bucket.kind === "s3" ? config.bucket : null;157 if (!bucket) return noCode(c);158 return c.json({159 endpoint: bucket.endpoint,160 bucket: bucket.name,161 region: bucket.region,162 access_key_id: bucket.accessKeyId,163 secret_access_key: bucket.secretAccessKey,164 });165 });166}