clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

main
nzip / worker / src / db.ts
db.ts 17.4K
1import {2 formatAddress,3 GLOBAL_DEFAULT_TTL,4 SITES_PER_VAULT,5 type Target,6 VAULT_SLOTS,7 vaultSlotOf,8} from "../../shared/mod.ts";9import type {10 AppReservationInfo,11 DefaultVaults,12 PushInfo,13 SiteDetail,14 SiteInfo,15 Ttl,16 VaultInfo,17 VaultLifecycle,18} from "../../shared/mod.ts";19import { type Env, siteUrl } from "./env.ts";2021export const HISTORY_CAP = 10;2223export interface SiteRow {24 address: number;25 vault_slot: number;26 alias: string | null;27 current_manifest: string;28 created_at: number;29 updated_at: number;30 expires_at: number | null;31 password_hash: string | null;32 content_security_policy: string | null;33 auth_version: number;34 feedback: number;35 feedback_requested_at: number | null;36 feedback_submitted_at: number | null;37 feedback_callback_url: string | null;38}3940export interface VaultRow {41 slot: number;42 name: string;43 description: string | null;44 default_ttl: number | null;45 max_ttl: number | null;46 require_password: number;47 default_password_hash: string | null;48 created_at: number;49}5051export interface AppReservationRow {52 address: number;53 vault_slot: number;54 alias: string;55 created_at: number;56 retired_at: number | null;57}5859export async function getSiteByAddress(60 env: Env,61 address: number,62): Promise<SiteRow | null> {63 return await env.DB.prepare("SELECT * FROM sites WHERE address = ?")64 .bind(address).first<SiteRow>();65}6667export async function getVaultByName(68 env: Env,69 name: string,70): Promise<VaultRow | null> {71 return await env.DB.prepare("SELECT * FROM vaults WHERE name = ?").bind(name)72 .first<VaultRow>();73}7475export async function getVaultBySlot(76 env: Env,77 slot: number,78): Promise<VaultRow | null> {79 return await env.DB.prepare("SELECT * FROM vaults WHERE slot = ?").bind(slot)80 .first<VaultRow>();81}8283export async function getAppReservationByAddress(84 env: Env,85 address: number,86): Promise<AppReservationRow | null> {87 return await env.DB.prepare(88 "SELECT * FROM app_reservations WHERE address = ?",89 ).bind(address).first<AppReservationRow>();90}9192export async function getAppReservationByAlias(93 env: Env,94 vaultSlot: number,95 alias: string,96): Promise<AppReservationRow | null> {97 return await env.DB.prepare(98 "SELECT * FROM app_reservations WHERE vault_slot = ? AND alias = ?",99 ).bind(vaultSlot, alias).first<AppReservationRow>();100}101102/** Resolve an API target to an existing site row, or null if it doesn't exist. */103export async function resolveTarget(104 env: Env,105 target: Target,106): Promise<SiteRow | null> {107 if ("address" in target) return await getSiteByAddress(env, target.address);108 const vault = await getVaultByName(env, target.vault);109 if (!vault) return null;110 return await env.DB.prepare(111 "SELECT * FROM sites WHERE vault_slot = ? AND alias = ?",112 )113 .bind(vault.slot, target.alias).first<SiteRow>();114}115116/**117 * Pick a random free address within a vault. Random (not sequential) so URLs118 * don't leak site count or enumerate in order.119 */120export async function allocateAddress(env: Env, slot: number): Promise<number> {121 const taken = await env.DB.prepare(122 `SELECT address FROM sites WHERE vault_slot = ?123 UNION124 SELECT address FROM app_reservations WHERE vault_slot = ?`,125 )126 .bind(slot, slot).all<{ address: number }>();127 const used = new Set(taken.results.map((r) => r.address));128 if (used.size >= SITES_PER_VAULT) throw new Error("vault is full");129 for (let i = 0; i < 64; i++) {130 const candidate = (slot << 12) |131 (crypto.getRandomValues(new Uint32Array(1))[0] & 0xfff);132 if (!used.has(candidate)) return candidate;133 }134 // Vault nearly full and randomness unlucky: fall back to first free slot.135 for (let id = 0; id < SITES_PER_VAULT; id++) {136 const candidate = (slot << 12) | id;137 if (!used.has(candidate)) return candidate;138 }139 throw new Error("vault is full");140}141142export interface CommitParams {143 address: number;144 vaultSlot: number;145 alias: string | null;146 manifestHash: string;147 expiresAt: number | null;148 /** undefined preserves an existing password; null explicitly clears it */149 passwordHash?: string | null;150 /** undefined preserves an existing CSP; null explicitly clears it */151 contentSecurityPolicy?: string | null;152 /** undefined preserves an existing feedback setting (off for new sites) */153 feedback?: boolean;154 /** Drop stored notes and the review round, e.g. after the audience (password) changed. */155 resetFeedback?: boolean;156 isNew: boolean;157 note?: string;158}159160/** Atomic commit: upsert site, append history, prune beyond HISTORY_CAP. Returns new seq. */161export async function commitSite(env: Env, p: CommitParams): Promise<number> {162 const now = Math.floor(Date.now() / 1000);163 const seqRow = await env.DB.prepare(164 "SELECT COALESCE(MAX(seq), 0) AS s FROM pushes WHERE address = ?",165 )166 .bind(p.address).first<{ s: number }>();167 const seq = (seqRow?.s ?? 0) + 1;168169 const siteCommit = p.isNew170 ? env.DB.prepare(171 `INSERT INTO sites (172 address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at,173 password_hash, content_security_policy, feedback174 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,175 ).bind(176 p.address,177 p.vaultSlot,178 p.alias,179 p.manifestHash,180 now,181 now,182 p.expiresAt,183 p.passwordHash ?? null,184 p.contentSecurityPolicy ?? null,185 p.feedback ? 1 : 0,186 )187 : p.passwordHash === undefined && p.contentSecurityPolicy === undefined &&188 p.feedback === undefined189 ? env.DB.prepare(190 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)191 WHERE address = ?`,192 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address)193 : env.DB.prepare(194 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?,195 alias = COALESCE(?, alias),196 password_hash = CASE WHEN ? = 1 THEN ? ELSE password_hash END,197 content_security_policy = CASE WHEN ? = 1 THEN ? ELSE content_security_policy END,198 feedback = CASE WHEN ? = 1 THEN ? ELSE feedback END,199 auth_version = auth_version + ?200 WHERE address = ?`,201 ).bind(202 p.manifestHash,203 now,204 p.expiresAt,205 p.alias,206 p.passwordHash === undefined ? 0 : 1,207 p.passwordHash ?? null,208 p.contentSecurityPolicy === undefined ? 0 : 1,209 p.contentSecurityPolicy ?? null,210 p.feedback === undefined ? 0 : 1,211 p.feedback ? 1 : 0,212 p.passwordHash === undefined ? 0 : 1,213 p.address,214 );215216 const statements = [217 siteCommit,218 env.DB.prepare(219 "INSERT INTO pushes (address, seq, manifest_hash, pushed_at, note) VALUES (?, ?, ?, ?, ?)",220 ).bind(p.address, seq, p.manifestHash, now, p.note ?? null),221 env.DB.prepare(222 "DELETE FROM pushes WHERE address = ? AND seq <= ?",223 ).bind(p.address, seq - HISTORY_CAP),224 ...(p.resetFeedback ? feedbackResetStatements(env, p.address) : []),225 ];226 await env.DB.batch(statements);227 return seq;228}229230/** Statements that forget a site's notes and review round, for use inside a write batch. */231export function feedbackResetStatements(env: Env, address: number): D1PreparedStatement[] {232 return [233 env.DB.prepare("DELETE FROM site_feedback WHERE address = ?").bind(address),234 env.DB.prepare(235 `UPDATE sites SET feedback_requested_at = NULL, feedback_submitted_at = NULL,236 feedback_callback_url = NULL WHERE address = ?`,237 ).bind(address),238 ];239}240241export async function listSites(242 env: Env,243 vaultSlot?: number,244): Promise<(SiteRow & { vault_name: string })[]> {245 const base =246 `SELECT s.*, v.name AS vault_name FROM sites s JOIN vaults v ON v.slot = s.vault_slot`;247 const stmt = vaultSlot === undefined248 ? env.DB.prepare(`${base} ORDER BY s.updated_at DESC`)249 : env.DB.prepare(250 `${base} WHERE s.vault_slot = ? ORDER BY s.updated_at DESC`,251 ).bind(vaultSlot);252 const res = await stmt.all<SiteRow & { vault_name: string }>();253 return res.results;254}255256export async function siteHistory(257 env: Env,258 address: number,259): Promise<PushInfo[]> {260 const res = await env.DB.prepare(261 "SELECT seq, manifest_hash, pushed_at, note FROM pushes WHERE address = ? ORDER BY seq DESC",262 ).bind(address).all<263 {264 seq: number;265 manifest_hash: string;266 pushed_at: number;267 note: string | null;268 }269 >();270 return res.results.map((r) => ({271 seq: r.seq,272 manifestHash: r.manifest_hash,273 pushedAt: r.pushed_at,274 note: r.note,275 }));276}277278export function vaultRowToInfo(279 row: VaultRow,280 siteCount: number,281 defaultFor: VaultLifecycle[] = [],282): VaultInfo {283 const defaultTtl: Ttl | null = row.default_ttl === null284 ? null285 : row.default_ttl === 0286 ? "forever"287 : row.default_ttl;288 return {289 slot: row.slot,290 name: row.name,291 description: row.description,292 createdAt: row.created_at,293 siteCount,294 defaultTtl,295 effectiveDefaultTtl: defaultTtl ?? GLOBAL_DEFAULT_TTL,296 defaultFor,297 maxTtl: row.max_ttl ?? null,298 requirePassword: row.require_password === 1,299 hasDefaultPassword: row.default_password_hash != null,300 };301}302303export async function listVaults(env: Env): Promise<VaultInfo[]> {304 const [res, defaults] = await Promise.all([305 env.DB.prepare(306 `SELECT v.slot, v.name, v.description, v.default_ttl,307 v.max_ttl, v.require_password, v.default_password_hash,308 v.created_at, COUNT(s.address) AS site_count309 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot310 GROUP BY v.slot ORDER BY v.slot`,311 ).all<VaultRow & { site_count: number }>(),312 env.DB.prepare("SELECT lifecycle, vault_slot FROM vault_defaults")313 .all<{ lifecycle: VaultLifecycle; vault_slot: number }>(),314 ]);315 return res.results.map((r) =>316 vaultRowToInfo(317 r,318 r.site_count,319 defaults.results.filter((item) => item.vault_slot === r.slot).map((320 item,321 ) => item.lifecycle),322 )323 );324}325326export interface VaultSecurityPatch {327 maxTtl?: number | null;328 requirePassword?: boolean;329 defaultPasswordHash?: string | null;330}331332export async function createVault(333 env: Env,334 name: string,335 slot?: number,336 description: string | null = null,337 defaultTtl: Ttl | null = null,338 defaultFor: VaultLifecycle | null = null,339 security: VaultSecurityPatch = {},340): Promise<VaultRow> {341 const now = Math.floor(Date.now() / 1000);342 let chosen = slot;343 if (chosen === undefined) {344 const takenRes = await env.DB.prepare("SELECT slot FROM vaults").all<345 { slot: number }346 >();347 const taken = new Set(takenRes.results.map((r) => r.slot));348 for (let s = 0; s < VAULT_SLOTS; s++) {349 if (!taken.has(s)) {350 chosen = s;351 break;352 }353 }354 if (chosen === undefined) throw new Error("all 16 vault slots are taken");355 }356 const insert = env.DB.prepare(357 `INSERT INTO vaults358 (slot, name, description, default_ttl, created_at, max_ttl, require_password, default_password_hash)359 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,360 )361 .bind(362 chosen,363 name,364 description,365 defaultTtl === "forever" ? 0 : defaultTtl,366 now,367 security.maxTtl ?? null,368 security.requirePassword ? 1 : 0,369 security.defaultPasswordHash ?? null,370 );371 if (defaultFor === null) await insert.run();372 else {373 await env.DB.batch([374 insert,375 env.DB.prepare(376 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)377 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,378 ).bind(defaultFor, chosen),379 ]);380 }381 return {382 slot: chosen,383 name,384 description,385 default_ttl: defaultTtl === "forever" ? 0 : defaultTtl,386 max_ttl: security.maxTtl ?? null,387 require_password: security.requirePassword ? 1 : 0,388 default_password_hash: security.defaultPasswordHash ?? null,389 created_at: now,390 };391}392393export async function updateVault(394 env: Env,395 currentName: string,396 patch: {397 name?: string;398 description?: string | null;399 defaultTtl?: Ttl | null;400 defaultFor?: VaultLifecycle | null;401 } & VaultSecurityPatch,402): Promise<VaultInfo | null> {403 const current = await getVaultByName(env, currentName);404 if (!current) return null;405 const assignments: string[] = [];406 const values: unknown[] = [];407 if (patch.name !== undefined) {408 assignments.push("name = ?");409 values.push(patch.name);410 }411 if (patch.description !== undefined) {412 assignments.push("description = ?");413 values.push(patch.description);414 }415 if (patch.defaultTtl !== undefined) {416 assignments.push("default_ttl = ?");417 values.push(patch.defaultTtl === "forever" ? 0 : patch.defaultTtl);418 }419 if (patch.maxTtl !== undefined) {420 assignments.push("max_ttl = ?");421 values.push(patch.maxTtl);422 }423 if (patch.requirePassword !== undefined) {424 assignments.push("require_password = ?");425 values.push(patch.requirePassword ? 1 : 0);426 }427 if (patch.defaultPasswordHash !== undefined) {428 assignments.push("default_password_hash = ?");429 values.push(patch.defaultPasswordHash);430 }431 const statements: D1PreparedStatement[] = [];432 if (assignments.length > 0) {433 statements.push(434 env.DB.prepare(435 `UPDATE vaults SET ${assignments.join(", ")} WHERE name = ?`,436 )437 .bind(...values, currentName),438 );439 }440 if (patch.defaultFor === null) {441 statements.push(442 env.DB.prepare("DELETE FROM vault_defaults WHERE vault_slot = ?").bind(443 current.slot,444 ),445 );446 } else if (patch.defaultFor !== undefined) {447 statements.push(448 env.DB.prepare(449 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)450 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,451 ).bind(patch.defaultFor, current.slot),452 );453 }454 if (statements.length === 0) return null;455 await env.DB.batch(statements);456457 const updatedName = patch.name ?? currentName;458 const row = await getVaultByName(env, updatedName);459 if (!row) return null;460 const count = await env.DB.prepare(461 "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",462 ).bind(row.slot).first<{ n: number }>();463 const defaults = await env.DB.prepare(464 "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",465 ).bind(row.slot).all<{ lifecycle: VaultLifecycle }>();466 return vaultRowToInfo(467 row,468 count?.n ?? 0,469 defaults.results.map((r) => r.lifecycle),470 );471}472473export async function defaultVaults(env: Env): Promise<DefaultVaults> {474 const rows = await env.DB.prepare(475 `SELECT v.name, d.lifecycle476 FROM vault_defaults d JOIN vaults v ON v.slot = d.vault_slot`,477 ).all<{ name: string; lifecycle: VaultLifecycle }>();478 const result: DefaultVaults = { temporary: null, permanent: null };479 for (const row of rows.results) result[row.lifecycle] = row.name;480 return result;481}482483export async function setDefaultVault(484 env: Env,485 lifecycle: VaultLifecycle,486 name: string,487): Promise<VaultInfo | null> {488 const vault = await getVaultByName(env, name);489 if (!vault) return null;490 await env.DB.prepare(491 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)492 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,493 ).bind(lifecycle, vault.slot).run();494 const count = await env.DB.prepare(495 "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",496 ).bind(vault.slot).first<{ n: number }>();497 const defaults = await env.DB.prepare(498 "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",499 ).bind(vault.slot).all<{ lifecycle: VaultLifecycle }>();500 return vaultRowToInfo(501 vault,502 count?.n ?? 0,503 defaults.results.map((r) => r.lifecycle),504 );505}506507export async function reserveAppOrigin(508 env: Env,509 vault: VaultRow,510 alias: string,511): Promise<AppReservationRow> {512 const existing = await getAppReservationByAlias(env, vault.slot, alias);513 if (existing) return existing;514515 const site = await resolveTarget(env, { vault: vault.name, alias });516 const address = site?.address ?? await allocateAddress(env, vault.slot);517 const now = Math.floor(Date.now() / 1000);518 try {519 await env.DB.prepare(520 `INSERT INTO app_reservations521 (address, vault_slot, alias, created_at, retired_at)522 VALUES (?, ?, ?, ?, NULL)`,523 ).bind(address, vault.slot, alias, now).run();524 } catch (cause) {525 const raced = await getAppReservationByAlias(env, vault.slot, alias);526 if (raced) return raced;527 throw cause;528 }529 return {530 address,531 vault_slot: vault.slot,532 alias,533 created_at: now,534 retired_at: null,535 };536}537538export async function appReservationToInfo(539 env: Env,540 row: AppReservationRow,541 vaultName: string,542): Promise<AppReservationInfo> {543 const address = formatAddress(row.address);544 return {545 address,546 vault: vaultName,547 alias: row.alias,548 url: siteUrl(env, address),549 createdAt: row.created_at,550 deployed: (await getSiteByAddress(env, row.address)) !== null,551 };552}553554export function siteRowToInfo(555 env: Env,556 row: SiteRow,557 vaultName: string,558): SiteInfo {559 const address = formatAddress(row.address);560 return {561 address,562 vault: vaultName,563 alias: row.alias,564 manifestHash: row.current_manifest,565 createdAt: row.created_at,566 updatedAt: row.updated_at,567 expiresAt: row.expires_at,568 url: siteUrl(env, address),569 protected: row.password_hash !== null,570 feedback: row.feedback === 1,571 };572}573574export async function siteRowToDetail(575 env: Env,576 row: SiteRow,577): Promise<SiteDetail> {578 const vault = await getVaultBySlot(env, vaultSlotOf(row.address));579 return {580 ...siteRowToInfo(env, row, vault?.name ?? `slot-${row.vault_slot}`),581 history: await siteHistory(env, row.address),582 };583}