server.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <?php
  2. /**
  3. * Statum frontend demo backend.
  4. *
  5. * Run from this directory with PHP's built-in server:
  6. *
  7. * php -S localhost:8000 server.php
  8. *
  9. * Then open http://localhost:8000/ in your browser.
  10. *
  11. * This is a DEMO only: there is no real signing or encryption. Snapshots are
  12. * wrapped in trivial frames, and per-item action context is carried in the
  13. * action's `private` blob as plain base64. State lives in the PHP session so
  14. * that actions are stateful across requests.
  15. */
  16. session_start();
  17. // Static product catalog used by the demo.
  18. $CATALOG = [
  19. ['id' => 1, 'name' => 'Widget', 'price' => 9.99, 'featured' => true, 'inStock' => true],
  20. ['id' => 2, 'name' => 'Gadget', 'price' => 19.99, 'featured' => false, 'inStock' => true],
  21. ['id' => 3, 'name' => 'Sprocket', 'price' => 4.50, 'featured' => false, 'inStock' => false],
  22. ['id' => 4, 'name' => 'Gizmo', 'price' => 29.99, 'featured' => true, 'inStock' => true],
  23. ];
  24. $TYPES = ['counter', 'products', 'cart', 'user', 'notice'];
  25. // ---------------------------------------------------------------------------
  26. // Helpers
  27. // ---------------------------------------------------------------------------
  28. /** Send a JSON array of Statum directives and stop. */
  29. function statum_respond(array $directives): void
  30. {
  31. header('Content-Type: application/vnd.statum+json');
  32. header('Cache-Control: no-store');
  33. echo json_encode($directives, JSON_PRETTY_PRINT);
  34. exit;
  35. }
  36. /** Wrap a snapshot in a trivial (unsigned) frame. The signature is a SHA256
  37. * hash of the content so that identical snapshots share a signature (used by
  38. * the client as an idempotency key) while distinct ones do not. */
  39. function frame(array $snapshot): array
  40. {
  41. $content = json_encode($snapshot);
  42. return [
  43. 'content' => $content,
  44. 'signer' => 'demo',
  45. 'signature' => base64_encode(hash('sha256', $content, true)),
  46. ];
  47. }
  48. /** Stable per-type slot key, kept in the session. */
  49. function key_for(string $type): string
  50. {
  51. if (!isset($_SESSION['keys'][$type])) {
  52. $_SESSION['keys'][$type] = 'k_' . $type;
  53. }
  54. return $_SESSION['keys'][$type];
  55. }
  56. /**
  57. * Build a snapshot for a type. Options:
  58. * - `always`: transmit_after == as_at, so the client sends the base64 frame on
  59. * every request (the always-send path).
  60. * - `stale`: transmit_after is set 10s in the future (with a far-future
  61. * invalid_after). The slot is fresh for 10s, after which the client must
  62. * pre-flight via /_statum/slots before each request. Load the page, wait ~10s,
  63. * then trigger an action to see the pre-flight request.
  64. */
  65. function make_snapshot(string $type, string $scope, array $public, array $opts = []): array
  66. {
  67. $now = time();
  68. $asAt = gmdate('Y-m-d\TH:i:s\Z', $now);
  69. if (!empty($opts['always'])) {
  70. $transmit = $asAt;
  71. } elseif (!empty($opts['stale'])) {
  72. $transmit = gmdate('Y-m-d\TH:i:s\Z', $now + 10);
  73. } else {
  74. $transmit = gmdate('Y-m-d\TH:i:s\Z', $now + 60);
  75. }
  76. return [
  77. 'as_at' => $asAt,
  78. 'slot' => ['key' => key_for($type), 'scope' => $scope, 'type' => $type],
  79. 'transmit_after' => $transmit,
  80. 'invalid_after' => gmdate('Y-m-d\TH:i:s\Z', $now + 600),
  81. 'private' => $opts['private'] ?? '',
  82. 'public' => $public,
  83. ];
  84. }
  85. function set_dir(array $snapshot): array
  86. {
  87. return ['type' => 'set', 'snapshot_frame' => frame($snapshot)];
  88. }
  89. function clear_dir(string $key): array
  90. {
  91. return ['type' => 'clear', 'key' => $key];
  92. }
  93. function product_by_id($id): ?array
  94. {
  95. global $CATALOG;
  96. foreach ($CATALOG as $p) {
  97. if ((string)$p['id'] === (string)$id) return $p;
  98. }
  99. return null;
  100. }
  101. // ---------------------------------------------------------------------------
  102. // Public-data builders
  103. // ---------------------------------------------------------------------------
  104. function build_counter(): array
  105. {
  106. $v = $_SESSION['counter'] ?? 0;
  107. return [
  108. 'value' => $v,
  109. 'high' => $v > 5,
  110. 'increment' => ['uri' => '/api/counter/inc', 'method' => 'POST'],
  111. 'decrement' => ['uri' => '/api/counter/dec', 'method' => 'POST'],
  112. 'reset' => ['uri' => '/api/counter/reset', 'method' => 'POST'],
  113. 'add' => ['uri' => '/api/counter/add', 'method' => 'POST'],
  114. ];
  115. }
  116. function build_products(): array
  117. {
  118. global $CATALOG;
  119. $items = [];
  120. foreach ($CATALOG as $p) {
  121. $pid = (string)$p['id'];
  122. $items[] = [
  123. 'id' => $pid,
  124. 'name' => $p['name'],
  125. 'price' => $p['price'],
  126. 'featured' => $p['featured'],
  127. 'inStock' => $p['inStock'],
  128. // Per-item context rides in `private`; the client echoes it back
  129. // via the X-Statum-Private header (no stm-data expression needed).
  130. 'addToCartAction' => [
  131. 'uri' => '/api/cart/add',
  132. 'method' => 'POST',
  133. 'private' => base64_encode('id=' . $pid),
  134. ],
  135. ];
  136. }
  137. return ['items' => $items];
  138. }
  139. function build_cart(): array
  140. {
  141. $cart = $_SESSION['cart'] ?? [];
  142. $count = 0;
  143. $total = 0.0;
  144. $lines = [];
  145. foreach ($cart as $id => $qty) {
  146. $p = product_by_id($id);
  147. if (!$p) continue;
  148. $count += $qty;
  149. $total += $p['price'] * $qty;
  150. $lines[] = ['id' => (string)$id, 'name' => $p['name'], 'qty' => $qty, 'price' => $p['price']];
  151. }
  152. return [
  153. 'count' => $count,
  154. 'total' => round($total, 2),
  155. 'empty' => $count === 0,
  156. 'items' => $lines,
  157. 'checkoutAction' => ['uri' => '/api/cart/checkout', 'method' => 'POST'],
  158. 'clearAction' => ['uri' => '/api/cart/clear', 'method' => 'POST'],
  159. ];
  160. }
  161. function build_user(): array
  162. {
  163. $role = $_SESSION['role'] ?? 'guest';
  164. $names = ['guest' => 'Guest', 'member' => 'Member Mike', 'admin' => 'Admin Alice'];
  165. return [
  166. 'role' => $role,
  167. 'name' => $names[$role] ?? 'Guest',
  168. 'toggleAction' => ['uri' => '/api/user/toggle', 'method' => 'POST'],
  169. 'resetAllAction' => ['uri' => '/api/reset-all', 'method' => 'POST'],
  170. ];
  171. }
  172. function build_notice(): array
  173. {
  174. return [
  175. 'html' => $_SESSION['notice'] ?? '<em>No notice set.</em>',
  176. 'setAction' => ['uri' => '/api/notice/set', 'method' => 'POST'],
  177. 'failAction' => ['uri' => '/api/notice/fail', 'method' => 'POST'],
  178. ];
  179. }
  180. /** Build the snapshot for a type with its demo scope/options. */
  181. function snap(string $type): ?array
  182. {
  183. switch ($type) {
  184. case 'counter': return make_snapshot('counter', 'session', build_counter());
  185. case 'products': return make_snapshot('products', 'session', build_products());
  186. case 'cart': return make_snapshot('cart', 'session', build_cart(), ['always' => true]);
  187. case 'user': return make_snapshot('user', 'session', build_user(), ['stale' => true]);
  188. case 'notice': return make_snapshot('notice', 'flow', build_notice());
  189. }
  190. return null;
  191. }
  192. function all_directives(): array
  193. {
  194. global $TYPES;
  195. $dirs = [];
  196. foreach ($TYPES as $t) {
  197. $s = snap($t);
  198. if ($s) $dirs[] = set_dir($s);
  199. }
  200. return $dirs;
  201. }
  202. // ---------------------------------------------------------------------------
  203. // Router
  204. // ---------------------------------------------------------------------------
  205. $method = $_SERVER['REQUEST_METHOD'];
  206. $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
  207. // Static assets.
  208. if ($path === '/statum.js') {
  209. header('Content-Type: application/javascript');
  210. readfile(__DIR__ . '/../js/statum.js');
  211. exit;
  212. }
  213. // The page itself.
  214. if ($path === '/' || $path === '/index.html') {
  215. header('Content-Type: text/html; charset=utf-8');
  216. readfile(__DIR__ . '/index.html');
  217. exit;
  218. }
  219. // Landing page after a (post) checkout navigation.
  220. if ($path === '/api/checkout-done') {
  221. header('Content-Type: text/html; charset=utf-8');
  222. $order = htmlspecialchars($_GET['order'] ?? 'UNKNOWN');
  223. echo "<!doctype html><meta charset=utf-8><title>Checkout</title>" .
  224. "<body style='font-family:sans-serif;padding:2rem'>" .
  225. "<h1>Order $order placed</h1><p>This page was reached via a statum <code>post</code> directive.</p>" .
  226. "<p><a href='/'>Back to the demo</a></p>";
  227. exit;
  228. }
  229. // --- Statum endpoints -------------------------------------------------------
  230. if ($path === '/_statum/entrypoint') {
  231. statum_respond(all_directives());
  232. }
  233. if ($path === '/_statum/slots') {
  234. // Pre-flight target: refresh each type whose frame the client sent back.
  235. $body = json_decode(file_get_contents('php://input'), true);
  236. $dirs = [];
  237. if (is_array($body)) {
  238. foreach ($body as $fr) {
  239. $snap = json_decode($fr['content'] ?? 'null', true);
  240. $type = $snap['slot']['type'] ?? null;
  241. if ($type) {
  242. $s = snap($type);
  243. if ($s) $dirs[] = set_dir($s);
  244. }
  245. }
  246. }
  247. statum_respond($dirs);
  248. }
  249. // --- Actions ----------------------------------------------------------------
  250. if ($path === '/api/counter/inc' && $method === 'POST') {
  251. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + 1;
  252. statum_respond([set_dir(snap('counter'))]);
  253. }
  254. if ($path === '/api/counter/dec' && $method === 'POST') {
  255. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) - 1;
  256. statum_respond([set_dir(snap('counter'))]);
  257. }
  258. if ($path === '/api/counter/reset' && $method === 'POST') {
  259. $_SESSION['counter'] = 0;
  260. statum_respond([set_dir(snap('counter'))]);
  261. }
  262. if ($path === '/api/counter/add' && $method === 'POST') {
  263. $amount = intval($_POST['amount'] ?? 1);
  264. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + $amount;
  265. statum_respond([set_dir(snap('counter'))]);
  266. }
  267. if ($path === '/api/cart/add' && $method === 'POST') {
  268. $priv = $_SERVER['HTTP_X_STATUM_PRIVATE'] ?? '';
  269. $raw = base64_decode($priv, true) ?: '';
  270. if (preg_match('/id=([0-9]+)/', $raw, $m)) {
  271. $id = $m[1];
  272. $_SESSION['cart'][$id] = ($_SESSION['cart'][$id] ?? 0) + 1;
  273. }
  274. statum_respond([set_dir(snap('cart'))]);
  275. }
  276. if ($path === '/api/cart/clear' && $method === 'POST') {
  277. $_SESSION['cart'] = [];
  278. statum_respond([set_dir(snap('cart'))]);
  279. }
  280. if ($path === '/api/cart/checkout' && $method === 'POST') {
  281. // A `post` directive performs a full-page navigating form POST last.
  282. statum_respond([['type' => 'post', 'uri' => '/api/checkout-done', 'data' => ['order' => 'ORD-' . strtoupper(bin2hex(random_bytes(3)))]]]);
  283. }
  284. if ($path === '/api/user/toggle' && $method === 'POST') {
  285. $order = ['guest', 'member', 'admin'];
  286. $cur = array_search($_SESSION['role'] ?? 'guest', $order);
  287. $_SESSION['role'] = $order[($cur + 1) % count($order)];
  288. statum_respond([set_dir(snap('user'))]);
  289. }
  290. if ($path === '/api/notice/set' && $method === 'POST') {
  291. $_SESSION['notice'] = '<div class="ok"><strong>Notice:</strong> updated at ' . date('H:i:s') . '</div>';
  292. statum_respond([set_dir(snap('notice'))]);
  293. }
  294. if ($path === '/api/notice/fail' && $method === 'POST') {
  295. statum_respond([['type' => 'error', 'message' => 'Intentional failure.', 'code' => 'DEMO_FAIL', 'data' => ['at' => date('c')]]]);
  296. }
  297. if ($path === '/api/reset-all' && $method === 'POST') {
  298. $dirs = [];
  299. foreach ($TYPES as $t) {
  300. if (isset($_SESSION['keys'][$t])) $dirs[] = clear_dir($_SESSION['keys'][$t]);
  301. }
  302. session_unset();
  303. $dirs[] = ['type' => 'navigate', 'uri' => '/'];
  304. statum_respond($dirs);
  305. }
  306. // Fallback 404.
  307. http_response_code(404);
  308. header('Content-Type: text/plain');
  309. echo "404 Not Found: $path";