browser.ts

3.1 KB · raw

1import type { Database } from "bun:sqlite";2import { mkdtemp } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import type { Hono } from "hono";6import { openBucket } from "../src/bucket/reader.ts";7import { syncAll } from "../src/bucket/sync.ts";8import { createApp } from "../src/server/app.ts";9import type { Config } from "../src/server/config.ts";10import { openKeprDb } from "../src/server/db.ts";1112const ORIGIN = "https://kepr.example";13export const PASSWORD = "correct horse battery staple";14// The env Bun.serve passes: its requestIP gives the socket peer.15const peer = (address: string) => ({ requestIP: () => ({ address }) });1617/** A browser against one app: a cookie jar, and the form token of the last admin page read. */18export class Browser {19	cookies = new Map<string, string>();20	form = "";21	constructor(22		readonly app: Hono,23		readonly address = "203.0.113.5",24	) {}2526	async request(path: string, init: RequestInit = {}): Promise<Response> {27		const headers = new Headers(init.headers);28		headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "));29		const response = await this.app.request(path, { ...init, headers }, peer(this.address));30		for (const line of response.headers.getSetCookie()) {31			const [pair = "", ...attributes] = line.split(";");32			const [name = "", value = ""] = pair.split("=");33			if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") {34				this.cookies.delete(name);35			} else this.cookies.set(name, value);36		}37		return response;38	}3940	async get(path: string): Promise<Response> {41		const response = await this.request(path);42		const text = await response.clone().text();43		this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form;44		return response;45	}4647	post(path: string, fields: Record<string, string> | FormData, origin = ORIGIN) {48		const body = fields instanceof FormData ? fields : new URLSearchParams(fields);49		if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form);50		else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form);51		return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });52	}5354	async signIn(password = PASSWORD): Promise<Response> {55		await this.get("/sign-in");56		const response = await this.post("/sign-in", { password });57		if (response.status === 303) await this.get("/admin");58		return response;59	}60}6162export type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config };6364export async function instance(bucketDir: string, dataDir?: string): Promise<Instance> {65	const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-")));66	const kepr = openKeprDb(dir);67	const bucket = { kind: "folder" as const, path: bucketDir };68	await syncAll({69		bucket: openBucket(bucket),70		kepr,71		mirrorDir: join(dir, "mirror"),72	});73	const config: Config = {74		domain: "kepr.example",75		dataDir: dir,76		adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),77		bucket,78	};79	return { app: createApp(config, kepr), kepr, dataDir: dir, config };80}