Zoo

← back to the zoo

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 0c1c124a02 (live). 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];