16. Ping me
An app can send a notification to your phone or browser.
The push app keeps each person's devices (Web Push); an app asks it to notify someone with rc_app_headers("push", true).
This is the code serving the zoo right now: read from disk for this request, from commit
77ff74344d (staging). The zoo's own self-check fetches this
page and compares it byte for byte with the file it runs.
exhibits/16-ping-me.php sha256 c074061fa7a2 · raw
1<?php 2// Exhibit 16. Two halves, both push's: 3// 1. Allowing: the card sends the person to push's own page (`/?enable=1&back=…`), which knows 4// the zoo from the Referer. Only push can subscribe a browser, so no app can plant an 5// endpoint for anyone; and no push code runs on the zoo's origin (no push.js here). 6// 2. Sending: the zoo asks push to notify the person, with an assertion that names the zoo: 7// POST rc_app_url("push") . "/?api=send" {user, title, body, url} rc_app_headers("push", true) 8// push delivers only to browsers where that person allowed the zoo (`sent: 0` otherwise). 9// "In ten seconds": the zoo takes the assertion now (it lives two minutes), answers at once, and 10// finishes the request in the background: sleep ten seconds, then send. 11// Human-only: a notification arriving on a phone cannot be seen by the zoo. The check proves the 12// send path (the robot has no browser, so `sent` may be 0); a person presses "this worked for me". 13 14function ping_send(array $me, array $headers): array { 15 $c = curl_init(rc_app_url("push") . "/?api=send"); 16 curl_setopt_array($c, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, 17 CURLOPT_HTTPHEADER => ["Content-Type: application/json", ...$headers], 18 CURLOPT_POSTFIELDS => json_encode(["user" => (int)$me["id"], "title" => "The zoo says hello", "body" => "Exhibit 16 worked. Tap to go back to the zoo.", 19 "url" => rc_app_url("zoo") . "/#e16"])]); 20 $out = (string)curl_exec($c); 21 $s = (int)curl_getinfo($c, CURLINFO_HTTP_CODE); 22 $r = json_decode($out, true); 23 return $s === 200 && isset($r["sent"]) ? $r : ["error" => "push answered $s: " . substr($out, 0, 160), "status" => 502]; 24} 25 26function ping_state(int $uid, ?array $set = null): ?array { 27 if ($set !== null) meta_set("ping16.$uid", json_encode($set + ["at" => time()])); 28 return json_decode((string)meta("ping16.$uid"), true) ?: null; 29} 30 31return [ 32 "n" => 16, "try" => "get a notification", "wing" => "Notifications", "kind" => "human", 33 "title" => "Ping me", 34 "promise" => "An app can send a notification to your phone or browser.", 35 "block" => 'The push app keeps each person\'s devices (Web Push); an app asks it to notify someone with rc_app_headers("push", true).', 36 "show" => function (?array $me): string { 37 if (!$me) return '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a> to be notified.</p>'; 38 return '<p><button id="ping16" class="primary" data-push="' . h(rc_app_url("push")) . '">Notify me in ten seconds</button> <span class="out" id="ping16-out"></span></p>' 39 . '<p class="muted">The first time, push asks you to allow the zoo on this browser. <button class="link" id="ping16-manage">Your browsers</button></p>' 40 . seen_button(16, $me, "The notification arrived and opened the zoo: this worked for me"); 41 }, 42 "api" => function (string $do, ?array $me, array $in, bool $post): ?array { 43 if (!$me) return ["error" => "sign in first", "status" => 401]; 44 if ($do === "status") return ["state" => ping_state((int)$me["id"])]; 45 if (!$post) return null; 46 if ($do === "send") return ping_send($me, rc_app_headers("push", true)); 47 if ($do !== "notify") return null; 48 $headers = rc_app_headers("push", true); // taken now: it names this person and lives two minutes 49 $uid = (int)$me["id"]; 50 ping_state($uid, ["state" => "waiting"]); 51 register_shutdown_function(function () use ($me, $headers, $uid) { 52 if (function_exists("fastcgi_finish_request")) fastcgi_finish_request(); // the browser has its answer 53 set_time_limit(40); 54 sleep(10); 55 $r = ping_send($me, $headers); 56 ping_state($uid, isset($r["error"]) ? ["state" => "error", "error" => $r["error"]] : ["state" => $r["sent"] > 0 ? "sent" : "nobody", "sent" => $r["sent"], "signed_out" => $r["signed_out"] ?? 0]); 57 }); 58 return ["scheduled" => true, "in" => 10]; 59 }, 60 // The send path, as the robot: push must accept the assertion and answer with its counts. 61 // It has no browser, so sent is expected to be 0. 62 "check" => function (): array { 63 require_once ZOO_ROOT . "/lib/robot.php"; 64 [$code, , $j] = robot_at_zoo()->post("https://" . env("RC_HOST") . "/api/16/send"); 65 if ($code !== 200 || !is_array($j)) return [false, "push's send failed ($code): " . (is_array($j) ? ($j["error"] ?? "") : substr((string)$j, 0, 120))]; 66 return [true, "push accepted the zoo's send for the robot: sent {$j['sent']}, gone {$j['gone']}, signed out {$j['signed_out']}, failed {$j['failed']}", $j]; 67 }, 68 "script" => <<<'JS' 69(() => { 70 const btn = document.getElementById("ping16"); 71 if (!btn) return; 72 const out = document.getElementById("ping16-out"); 73 const KEY = "zoo-push-allowed"; 74 const get = () => { try { return localStorage.getItem(KEY); } catch (e) { return null; } }; 75 const put = (v) => { try { v ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch (e) {} }; 76 if (/[?&]pushed=1/.test(location.search)) { put(true); history.replaceState(null, "", "/#e16"); out.textContent = "allowed: press the button again"; } 77 const push = btn.dataset.push; 78 document.getElementById("ping16-manage").addEventListener("click", () => { location.href = push + "/"; }); 79 const go = () => { location.href = push + "/?enable=1&back=" + encodeURIComponent(location.origin + "/?pushed=1#e16"); }; 80 btn.addEventListener("click", async () => { 81 if (!get()) { out.textContent = "taking you to push to allow the zoo…"; go(); return; } 82 btn.disabled = true; out.textContent = "scheduled: ten seconds"; 83 const j = await zoo.call("/api/16/notify", {}); 84 if (j.error) { out.textContent = j.error; btn.disabled = false; return; } 85 for (let i = 0; i < 20; i++) { 86 await new Promise(r => setTimeout(r, 1500)); 87 const s = (await zoo.call("/api/16/status")).state; 88 if (!s || s.state === "waiting") continue; 89 btn.disabled = false; 90 if (s.state === "sent") out.textContent = "sent to " + s.sent + " browser(s): look for it"; 91 else if (s.state === "nobody") { put(false); out.innerHTML = 'Nothing was sent: the zoo is not allowed on any of your browsers yet. <button class="link" id="ping16-allow">Allow it</button>'; document.getElementById("ping16-allow").onclick = go; } 92 else out.textContent = s.error || "failed"; 93 return; 94 } 95 btn.disabled = false; out.textContent = "no answer yet"; 96 }); 97})(); 98 99JS, 100];