PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); $db->exec("PRAGMA busy_timeout = 8000"); $db->exec("PRAGMA journal_mode = WAL"); $db->exec(" CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT); CREATE TABLE IF NOT EXISTS guestbook (user_id INTEGER PRIMARY KEY, note TEXT NOT NULL, at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS lights (n INTEGER PRIMARY KEY, ok INTEGER NOT NULL, detail TEXT NOT NULL, data TEXT, at INTEGER NOT NULL, last_ok INTEGER); CREATE TABLE IF NOT EXISTS seen (id INTEGER PRIMARY KEY AUTOINCREMENT, n INTEGER NOT NULL, user_id INTEGER NOT NULL, at INTEGER NOT NULL);"); // Which database this is. Live and staging each make their own, so comparing them proves // the two environments share no data (exhibit 7). if (meta("instance") === null) meta_set("instance", bin2hex(random_bytes(8))); return $db; } function q(string $sql, array $args = []): PDOStatement { $s = db()->prepare($sql); $s->execute($args); return $s; } function meta(string $k): ?string { $v = q("SELECT v FROM meta WHERE k = ?", [$k])->fetchColumn(); return $v === false ? null : $v; } function meta_set(string $k, string $v): void { q("INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v", [$k, $v]); } // --- facts: what root tells the zoo, read-only, in RC_FACTS --------------------------------------- function fact(string $name): ?array { $j = json_decode((string)@file_get_contents(env("RC_FACTS") . "/$name"), true); return is_array($j) ? $j : null; } // --- exhibits ---------------------------------------------------------------------------------- // // One file per exhibit in exhibits/, returning an array: // n, wing, title, promise the exhibit as ZOO.md states it // block what the building block is and how an app uses it, in a sentence // kind "self" (the self-check proves it), "human" (a person presses and // looks) or "waiting" (its building block does not exist yet) // show(?$me): string the live part of the card, HTML // api($do, ?$me): array JSON at /api// (POSTs are CSRF-checked before this runs) // check(): [ok, detail, data] self-running exhibits: what the self-check runs // channels ["name" => fn($id, ?$me): bool], who may listen (auth channels) // files other files "how" should show besides the exhibit's own function exhibits(): array { static $all = null; if ($all !== null) return $all; $all = []; foreach (glob(ZOO_ROOT . "/exhibits/*.php") as $f) { $e = require $f; $e["file"] = "exhibits/" . basename($f); $all[$e["n"]] = $e + ["kind" => "self", "files" => [], "show" => null, "api" => null, "check" => null, "channels" => [], "try" => null]; } ksort($all); return $all; } // Run one exhibit's check and store the result as its light. The light is data: the page reads // it, it never makes live requests to draw itself. function run_check(array $e): array { $t = microtime(true); try { $r = ($e["check"])(); } catch (Throwable $x) { $r = [false, "the check itself failed: " . $x->getMessage()]; } [$ok, $detail, $data] = $r + [false, "", null]; $now = time(); q("INSERT INTO lights (n, ok, detail, data, at, last_ok) VALUES (:n, :ok, :d, :data, :at, :lo) ON CONFLICT(n) DO UPDATE SET ok = excluded.ok, detail = excluded.detail, data = excluded.data, at = excluded.at, last_ok = COALESCE(excluded.last_ok, lights.last_ok)", ["n" => $e["n"], "ok" => $ok ? 1 : 0, "d" => $detail, "data" => json_encode($data), "at" => $now, "lo" => $ok ? $now : null]); return ["n" => $e["n"], "ok" => (bool)$ok, "detail" => $detail, "ms" => (int)((microtime(true) - $t) * 1000)]; } // True for exactly one caller per $every seconds per exhibit (an upsert that only wins when the // last claim is old enough). function claim_check(int $n, int $every): bool { $now = time(); $s = q("INSERT INTO meta (k, v) VALUES (:k, :now) ON CONFLICT(k) DO UPDATE SET v = excluded.v WHERE CAST(meta.v AS INTEGER) <= :old", ["k" => "check-claim.$n", "now" => (string)$now, "old" => $now - $every]); return $s->rowCount() === 1; } function stored(int $n): ?array { $r = q("SELECT * FROM lights WHERE n = ?", [$n])->fetch(); if (!$r) return null; $r["data"] = json_decode((string)$r["data"], true); return $r; } // green / red / amber / grey, and the words that go with it. function light(array $e): array { if ($e["kind"] === "waiting") return ["color" => "grey", "text" => "not built yet: waiting on " . $e["waiting_on"]]; if ($e["kind"] === "human") { $s = q("SELECT user_id, at FROM seen WHERE n = ? ORDER BY at DESC LIMIT 1", [$e["n"]])->fetch(); if (!$s) return ["color" => "amber", "text" => "nobody has seen this work yet"]; $who = rc_users([$s["user_id"]])[$s["user_id"]]["username"] ?? "a deleted account"; $fresh = time() - $s["at"] < HUMAN_FRESH_DAYS * 86400; return ["color" => $fresh ? "green" : "amber", "text" => "last seen working by $who", "at" => (int)$s["at"]]; } $s = stored($e["n"]); if (!$s) return ["color" => "grey", "text" => "not checked yet"]; if (time() - $s["at"] > CHECK_STALE_MIN * 60) return ["color" => "red", "text" => "not checked since", "at" => (int)$s["at"]]; // A check may pass with a warning (data.amber): its detail says what is getting old. if ($s["ok"] && ($s["data"]["amber"] ?? false)) return ["color" => "amber", "text" => $s["detail"], "at" => (int)$s["at"]]; if ($s["ok"]) return ["color" => "green", "text" => "last worked", "at" => (int)$s["at"]]; return ["color" => "red", "text" => $s["detail"], "at" => $s["last_ok"] ? (int)$s["last_ok"] : null, "prefix" => "last worked"]; } // A human-only exhibit's "this worked for me". function seen(int $n, array $me): void { q("INSERT INTO seen (n, user_id, at) VALUES (?, ?, ?)", [$n, $me["id"], time()]); } // --- helpers ------------------------------------------------------------------------------------- function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8"); } function ago(?int $t): string { if (!$t) return "never"; $d = max(0, time() - $t); $s = match (true) { $d < 60 => "just now", $d < 3600 => intdiv($d, 60) . " minute" . (intdiv($d, 60) === 1 ? "" : "s") . " ago", $d < 172800 => intdiv($d, 3600) . " hour" . (intdiv($d, 3600) === 1 ? "" : "s") . " ago", default => intdiv($d, 86400) . " days ago", }; return '"; } // The other environment's address: zoo <-> zoo-staging. Derived from our own RC_HOST, the way // rc_app_url derives another app's. function other_env_host(): string { [$label, $rest] = explode(".", env("RC_HOST"), 2); $label = str_ends_with($label, "-staging") ? substr($label, 0, -8) : "$label-staging"; return "$label.$rest"; } // Publish on one of our declared channels. RC_PUBLISH is our private publish listener; rc fixes // the channel ids there as zoo..., so we can only ever reach our own streams. function publish(string $channel, string $id, array $msg): bool { $c = curl_init(env("RC_PUBLISH") . "/pub/$channel/" . rawurlencode($id)); curl_setopt_array($c, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($msg), CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_TIMEOUT => 3]); curl_exec($c); $code = curl_getinfo($c, CURLINFO_HTTP_CODE); return $code >= 200 && $code < 300; } // Call another app the way any app does: HTTPS, with an account assertion from rc_app_headers. // $user true = for the signed-in person (needs a live sign-in in this request). [status, body, content-type]. function call_app(string $app, string $path, bool $user, string $method = "GET", ?string $body = null, array $headers = [], int $timeout = 30): array { $c = curl_init(rc_app_url($app) . $path); curl_setopt_array($c, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_HTTPHEADER => [...rc_app_headers($app, $user), ...$headers]]); if ($body !== null) curl_setopt($c, CURLOPT_POSTFIELDS, $body); $out = (string)curl_exec($c); return [(int)curl_getinfo($c, CURLINFO_HTTP_CODE), $out, (string)curl_getinfo($c, CURLINFO_CONTENT_TYPE)]; } // A card's "this worked for me" button, for human-only exhibits. function seen_button(int $n, ?array $me, string $what = "This worked for me"): string { if (!$me) return '

Sign in to record that this worked for you.

'; return '"; } // One user's card: picture, name, username. Pictures are account's URLs, cached forever by // the browser, and always the current one (exhibit 4). function person(?array $u, string $class = ""): string { if (!$u) return '
?visitornot signed in
'; if (($u["picture"] ?? "") === "") return '
?' . h($u["name"]) . "no longer here
"; return '
' . h($u["name"]) . "@" . h($u["username"]) . " ยท #" . (int)$u["id"] . "
"; }