server.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. <?php
  2. /**
  3. * Statum frontend demo backend.
  4. *
  5. * Run from this directory with PHP's built-in server. Because the realtime
  6. * channel is a long-lived Server-Sent Events connection, you MUST start the
  7. * server with several workers so an open SSE stream does not block other
  8. * requests:
  9. *
  10. * PHP_CLI_SERVER_WORKERS=5 php -S localhost:8000 server.php
  11. *
  12. * Then open http://localhost:8000/ in your browser (and a second tab to see
  13. * realtime pushes).
  14. *
  15. * This is a DEMO only: there is no real signing or encryption. Snapshots are
  16. * wrapped in trivial frames, and per-item action context is carried in the
  17. * action's `private` blob as plain base64. State lives in the PHP session so
  18. * that actions are stateful across requests. Realtime subscriptions are shared
  19. * between workers through small files in the system temp dir.
  20. */
  21. session_start();
  22. // Static product catalog used by the demo.
  23. $CATALOG = [
  24. ['id' => 1, 'name' => 'Widget', 'price' => 9.99, 'featured' => true, 'inStock' => true],
  25. ['id' => 2, 'name' => 'Gadget', 'price' => 19.99, 'featured' => false, 'inStock' => true],
  26. ['id' => 3, 'name' => 'Sprocket', 'price' => 4.50, 'featured' => false, 'inStock' => false],
  27. ['id' => 4, 'name' => 'Gizmo', 'price' => 29.99, 'featured' => true, 'inStock' => true],
  28. ];
  29. $TYPES = ['counter', 'products', 'cart', 'user', 'notice', 'markdown', 'profile'];
  30. // ---------------------------------------------------------------------------
  31. // Helpers
  32. // ---------------------------------------------------------------------------
  33. /** Send a JSON array of Statum directives and stop. */
  34. function statum_respond(array $directives): void
  35. {
  36. header('Content-Type: application/vnd.statum+json');
  37. header('Cache-Control: no-store');
  38. echo json_encode($directives, JSON_PRETTY_PRINT);
  39. exit;
  40. }
  41. /** Wrap a snapshot in a trivial (unsigned) frame. The signature is a SHA256
  42. * hash of the content so that identical snapshots share a signature (used by
  43. * the client as an idempotency key) while distinct ones do not. */
  44. function frame(array $snapshot): array
  45. {
  46. $content = json_encode($snapshot);
  47. return [
  48. 'content' => $content,
  49. 'signer' => 'demo',
  50. 'signature' => base64_encode(hash('sha256', $content, true)),
  51. ];
  52. }
  53. /** Stable per-type slot key, kept in the session. */
  54. function key_for(string $type): string
  55. {
  56. if (!isset($_SESSION['keys'][$type])) {
  57. $_SESSION['keys'][$type] = 'k_' . $type;
  58. }
  59. return $_SESSION['keys'][$type];
  60. }
  61. /**
  62. * Build a snapshot for a type. Options:
  63. * - `always`: transmit_after == as_at, so the client sends the base64 frame on
  64. * every request (the always-send path).
  65. * - `stale`: transmit_after is set 10s in the future (with a far-future
  66. * invalid_after). The slot is fresh for 10s, after which the client must
  67. * pre-flight via /_statum/slots before each request. Load the page, wait ~10s,
  68. * then trigger an action to see the pre-flight request.
  69. */
  70. function make_snapshot(string $type, string $scope, array $public, array $opts = [], ?string $key = null): array
  71. {
  72. $now = time();
  73. $asAt = gmdate('Y-m-d\TH:i:s\Z', $now);
  74. if (!empty($opts['always'])) {
  75. $transmit = $asAt;
  76. } elseif (!empty($opts['stale'])) {
  77. $transmit = gmdate('Y-m-d\TH:i:s\Z', $now + 10);
  78. } else {
  79. $transmit = gmdate('Y-m-d\TH:i:s\Z', $now + 60);
  80. }
  81. return [
  82. 'as_at' => $asAt,
  83. 'slot' => ['key' => $key ?? key_for($type), 'scope' => $scope, 'type' => $type],
  84. 'transmit_after' => $transmit,
  85. 'invalid_after' => gmdate('Y-m-d\TH:i:s\Z', $now + 600),
  86. 'private' => $opts['private'] ?? '',
  87. 'public' => $public,
  88. ];
  89. }
  90. function set_dir(array $snapshot): array
  91. {
  92. return ['type' => 'set', 'snapshot_frame' => frame($snapshot)];
  93. }
  94. function clear_dir(string $key): array
  95. {
  96. return ['type' => 'clear', 'key' => $key];
  97. }
  98. function product_by_id($id): ?array
  99. {
  100. global $CATALOG;
  101. foreach ($CATALOG as $p) {
  102. if ((string)$p['id'] === (string)$id) return $p;
  103. }
  104. return null;
  105. }
  106. // ---------------------------------------------------------------------------
  107. // Public-data builders
  108. // ---------------------------------------------------------------------------
  109. function build_counter(): array
  110. {
  111. $v = $_SESSION['counter'] ?? 0;
  112. return [
  113. 'value' => $v,
  114. 'high' => $v > 5,
  115. 'increment' => ['uri' => '/api/counter/inc', 'method' => 'POST'],
  116. 'decrement' => ['uri' => '/api/counter/dec', 'method' => 'POST'],
  117. 'reset' => ['uri' => '/api/counter/reset', 'method' => 'POST'],
  118. 'add' => ['uri' => '/api/counter/add', 'method' => 'POST'],
  119. ];
  120. }
  121. function build_products(): array
  122. {
  123. global $CATALOG;
  124. $items = [];
  125. foreach ($CATALOG as $p) {
  126. $pid = (string)$p['id'];
  127. $items[] = [
  128. 'id' => $pid,
  129. 'name' => $p['name'],
  130. 'price' => $p['price'],
  131. 'featured' => $p['featured'],
  132. 'inStock' => $p['inStock'],
  133. // Per-item context rides in `private`; the client echoes it back
  134. // via the X-Statum-Private header (no stm-data expression needed).
  135. 'addToCartAction' => [
  136. 'uri' => '/api/cart/add',
  137. 'method' => 'POST',
  138. 'private' => base64_encode('id=' . $pid),
  139. ],
  140. ];
  141. }
  142. return ['items' => $items];
  143. }
  144. function build_cart(): array
  145. {
  146. $cart = $_SESSION['cart'] ?? [];
  147. $count = 0;
  148. $total = 0.0;
  149. $lines = [];
  150. foreach ($cart as $id => $qty) {
  151. $p = product_by_id($id);
  152. if (!$p) continue;
  153. $count += $qty;
  154. $total += $p['price'] * $qty;
  155. $lines[] = ['id' => (string)$id, 'name' => $p['name'], 'qty' => $qty, 'price' => $p['price']];
  156. }
  157. return [
  158. 'count' => $count,
  159. 'total' => round($total, 2),
  160. 'empty' => $count === 0,
  161. 'items' => $lines,
  162. 'checkoutAction' => ['uri' => '/api/cart/checkout', 'method' => 'POST'],
  163. 'clearAction' => ['uri' => '/api/cart/clear', 'method' => 'POST'],
  164. ];
  165. }
  166. function build_user(): array
  167. {
  168. $role = $_SESSION['role'] ?? 'guest';
  169. $names = ['guest' => 'Guest', 'member' => 'Member Mike', 'admin' => 'Admin Alice'];
  170. return [
  171. 'role' => $role,
  172. 'name' => $names[$role] ?? 'Guest',
  173. 'toggleAction' => ['uri' => '/api/user/toggle', 'method' => 'POST'],
  174. 'resetAllAction' => ['uri' => '/api/reset-all', 'method' => 'POST'],
  175. ];
  176. }
  177. function build_notice(): array
  178. {
  179. return [
  180. 'html' => $_SESSION['notice'] ?? '<em>No notice set.</em>',
  181. 'setAction' => ['uri' => '/api/notice/set', 'method' => 'POST'],
  182. 'failAction' => ['uri' => '/api/notice/fail', 'method' => 'POST'],
  183. ];
  184. }
  185. /**
  186. * Markdown sample for the stm-markdown section. Nowdoc (<<<'MD') so the
  187. * markdown is passed through verbatim: no PHP interpolation, no escaping.
  188. * Deliberately exercises the patterns the renderer is regression-tested for:
  189. * emphasis across a line wrap, soft line breaks inside a paragraph, a wrapped
  190. * list item, a fenced code block, reference + autolinks, and raw HTML that
  191. * must come out as inert text.
  192. */
  193. function build_markdown(): array
  194. {
  195. $body = <<<'MD'
  196. **This bold span wraps
  197. onto a second line** without breaking.
  198. These lines form a single
  199. paragraph: one newline is a soft break, not a new paragraph.
  200. - A bullet whose text
  201. continues on the next line.
  202. - A second bullet with `inline code`.
  203. ```vala
  204. var page = new Html.page ();
  205. ```
  206. A [reference link][docs] and an autolink <https://commonmark.org>.
  207. [docs]: https://commonmark.org "CommonMark"
  208. Raw HTML stays inert: <script>alert(1)</script> and [not a js link](javascript:alert(1)).
  209. MD;
  210. return ['body' => $body];
  211. }
  212. /**
  213. * Profile/settings form state for the stm-prefill section. Every control of
  214. * the type matrix (text, select, checkbox, radio group) round-trips through
  215. * the session; `saveAction` is the form's stm-action and the same snapshot
  216. * re-sets after the save so the prefill bindings and the echo update.
  217. */
  218. function build_profile(): array
  219. {
  220. $p = $_SESSION['profile'] ?? [];
  221. return [
  222. 'name' => (string)($p['name'] ?? 'Ada Lovelace'),
  223. 'theme' => (string)($p['theme'] ?? 'light'),
  224. 'news' => (bool)($p['news'] ?? true),
  225. 'cadence' => (string)($p['cadence'] ?? 'weekly'),
  226. 'saveAction' => ['uri' => '/api/profile/save', 'method' => 'POST'],
  227. 'failAction' => ['uri' => '/api/profile/fail', 'method' => 'POST'],
  228. ];
  229. }
  230. /** Build the snapshot for a type with its demo scope/options. */
  231. function snap(string $type): ?array
  232. {
  233. switch ($type) {
  234. case 'counter': return make_snapshot('counter', 'session', build_counter());
  235. case 'products': return make_snapshot('products', 'session', build_products());
  236. case 'cart': return make_snapshot('cart', 'session', build_cart(), ['always' => true]);
  237. case 'user': return make_snapshot('user', 'session', build_user(), ['stale' => true]);
  238. case 'notice': return make_snapshot('notice', 'flow', build_notice());
  239. case 'markdown': return make_snapshot('markdown', 'session', build_markdown());
  240. case 'profile': return make_snapshot('profile', 'session', build_profile());
  241. }
  242. return null;
  243. }
  244. function all_directives(): array
  245. {
  246. global $TYPES;
  247. $dirs = [];
  248. foreach ($TYPES as $t) {
  249. $s = snap($t);
  250. if ($s) $dirs[] = set_dir($s);
  251. }
  252. return $dirs;
  253. }
  254. // ---------------------------------------------------------------------------
  255. // Realtime channel helpers (demo pub/sub via the filesystem)
  256. // ---------------------------------------------------------------------------
  257. /** A `set` directive wrapping an already-framed snapshot. */
  258. function set_dir_frame(array $frame): array
  259. {
  260. return ['type' => 'set', 'snapshot_frame' => $frame];
  261. }
  262. /** Directory holding per-channel subscription and event-log files. */
  263. function channel_dir(): string
  264. {
  265. $dir = rtrim(sys_get_temp_dir(), '/') . '/statum_demo';
  266. if (!is_dir($dir)) @mkdir($dir, 0777, true);
  267. return $dir;
  268. }
  269. function channel_subs_path(string $id): string { return channel_dir() . '/ch_' . $id . '.subs'; }
  270. function channel_events_path(string $id): string { return channel_dir() . '/ch_' . $id . '.events'; }
  271. function gen_channel_id(): string { return bin2hex(random_bytes(8)); }
  272. /** Read a channel's subscribed slot keys (empty array if unknown). */
  273. function read_channel_subs(string $id): array
  274. {
  275. $f = channel_subs_path($id);
  276. if (!is_file($f)) return [];
  277. $arr = json_decode((string)@file_get_contents($f), true);
  278. return is_array($arr) ? array_values($arr) : [];
  279. }
  280. /** Overwrite a channel's subscribed slot keys. */
  281. function set_channel_subs(string $id, array $subs): void
  282. {
  283. @file_put_contents(channel_subs_path($id), json_encode(array_values(array_unique($subs))), LOCK_EX);
  284. }
  285. /** Append one event to a channel's event log (a JSON line). */
  286. function append_channel_event(string $id, string $type, $data): void
  287. {
  288. $line = json_encode(['t' => $type, 'd' => $data]) . "\n";
  289. @file_put_contents(channel_events_path($id), $line, FILE_APPEND | LOCK_EX);
  290. }
  291. /** Channel ids currently subscribed to a given slot key. */
  292. function channels_subscribed_to(string $key): array
  293. {
  294. $ids = [];
  295. foreach ((array)glob(channel_dir() . '/ch_*.subs') as $f) {
  296. if (!is_file($f)) continue;
  297. $id = substr(basename($f), 3, -5); // ch_{ID}.subs
  298. if (in_array($key, read_channel_subs($id), true)) $ids[] = $id;
  299. }
  300. return $ids;
  301. }
  302. /** Push a `set` frame to every channel subscribed to a slot key. */
  303. function fanout_set(string $key, array $frame): void
  304. {
  305. foreach (channels_subscribed_to($key) as $id) append_channel_event($id, 'set', $frame);
  306. }
  307. /** Shared "live" broadcast value, stored on disk so all workers agree. */
  308. function live_path(): string { return channel_dir() . '/live.json'; }
  309. function live_read(): array
  310. {
  311. if (!is_file(live_path())) return ['text' => 'No live messages yet.', 'at' => ''];
  312. $d = json_decode((string)@file_get_contents(live_path()), true);
  313. return is_array($d) ? $d : ['text' => 'No live messages yet.', 'at' => ''];
  314. }
  315. /** Snapshot for the shared `live` slot (transient scope, fixed key `k_live`). */
  316. function snap_live(): array
  317. {
  318. $d = live_read();
  319. return make_snapshot('live', 'transient', [
  320. 'text' => $d['text'],
  321. 'at' => $d['at'],
  322. 'sendAction' => ['uri' => '/api/live/send', 'method' => 'POST'],
  323. 'muteAction' => ['uri' => '/api/live/mute', 'method' => 'POST'],
  324. 'resumeAction' => ['uri' => '/api/live/resume', 'method' => 'POST'],
  325. ], [], 'k_live');
  326. }
  327. /** The initial frame to push to a channel when it subscribes to a key. */
  328. function initial_frame_for_key(string $key): ?array
  329. {
  330. if ($key === 'k_live') return frame(snap_live());
  331. return null;
  332. }
  333. // ---------------------------------------------------------------------------
  334. // Router
  335. // ---------------------------------------------------------------------------
  336. $method = $_SERVER['REQUEST_METHOD'];
  337. $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
  338. // Static assets: the Statum client modules, in load order (core first — the
  339. // later modules register into the seams it installs).
  340. if (preg_match('#^/(statum-(core|document|markdown|binding))\.js$#', $path, $m)) {
  341. header('Content-Type: application/javascript');
  342. readfile(__DIR__ . '/../js/' . $m[1] . '.js');
  343. exit;
  344. }
  345. if ($path === '/_statum/worker.js') {
  346. header('Content-Type: application/javascript');
  347. readfile(__DIR__ . '/../js/statum-worker.js');
  348. exit;
  349. }
  350. // The page itself.
  351. if ($path === '/' || $path === '/index.html') {
  352. header('Content-Type: text/html; charset=utf-8');
  353. readfile(__DIR__ . '/index.html');
  354. exit;
  355. }
  356. // Landing page after a (post) checkout navigation.
  357. if ($path === '/api/checkout-done') {
  358. header('Content-Type: text/html; charset=utf-8');
  359. $order = htmlspecialchars($_GET['order'] ?? 'UNKNOWN');
  360. echo "<!doctype html><meta charset=utf-8><title>Checkout</title>" .
  361. "<body style='font-family:sans-serif;padding:2rem'>" .
  362. "<h1>Order $order placed</h1><p>This page was reached via a statum <code>post</code> directive.</p>" .
  363. "<p><a href='/'>Back to the demo</a></p>";
  364. exit;
  365. }
  366. // --- Statum endpoints -------------------------------------------------------
  367. if ($path === '/_statum/entrypoint') {
  368. $dirs = all_directives();
  369. $dirs[] = set_dir(snap_live()); // current broadcast + actions
  370. $dirs[] = ['type' => 'subscribe', 'key' => 'k_live']; // begin realtime delivery
  371. statum_respond($dirs);
  372. }
  373. if ($path === '/_statum/slots') {
  374. // Pre-flight target: refresh each type whose frame the client sent back.
  375. $body = json_decode(file_get_contents('php://input'), true);
  376. $dirs = [];
  377. if (is_array($body)) {
  378. foreach ($body as $fr) {
  379. $snap = json_decode($fr['content'] ?? 'null', true);
  380. $type = $snap['slot']['type'] ?? null;
  381. if ($type) {
  382. $s = snap($type);
  383. if ($s) $dirs[] = set_dir($s);
  384. }
  385. }
  386. }
  387. statum_respond($dirs);
  388. }
  389. // --- Realtime channel -------------------------------------------------------
  390. // Open the SSE stream. The first event carries the channel id; later events
  391. // are `set`/`clear` for the slots this channel has subscribed to (via PATCH).
  392. if ($path === '/_statum/channel' && $method === 'GET') {
  393. // Long-lived connection: release the session lock immediately so other
  394. // requests (served by other workers) for the same session are not blocked.
  395. session_write_close();
  396. @set_time_limit(0);
  397. @ini_set('zlib.output_compression', '0');
  398. while (ob_get_level() > 0) { @ob_end_flush(); }
  399. header('Content-Type: text/event-stream');
  400. header('Cache-Control: no-cache');
  401. header('X-Accel-Buffering: no');
  402. $id = gen_channel_id();
  403. set_channel_subs($id, []);
  404. @file_put_contents(channel_events_path($id), '');
  405. echo "event: channel\ndata: {$id}\n\n";
  406. flush();
  407. $offset = 0;
  408. $eventsFile = channel_events_path($id);
  409. while (!connection_aborted()) {
  410. clearstatcache(true, $eventsFile);
  411. $size = is_file($eventsFile) ? filesize($eventsFile) : 0;
  412. if ($size > $offset) {
  413. $fp = @fopen($eventsFile, 'rb');
  414. if ($fp) {
  415. fseek($fp, $offset);
  416. $chunk = (string)stream_get_contents($fp);
  417. $offset = (int)ftell($fp);
  418. fclose($fp);
  419. foreach (preg_split('/\r?\n/', trim($chunk)) as $line) {
  420. if ($line === '') continue;
  421. $ev = json_decode($line, true);
  422. if (!is_array($ev)) continue;
  423. if (($ev['t'] ?? '') === 'set') {
  424. echo 'event: set' . "\ndata: " . json_encode($ev['d']) . "\n\n";
  425. } elseif (($ev['t'] ?? '') === 'clear') {
  426. echo 'event: clear' . "\ndata: " . $ev['d'] . "\n\n";
  427. }
  428. }
  429. flush();
  430. }
  431. }
  432. echo ": ping\n\n"; // heartbeat keeps the stream alive
  433. flush();
  434. usleep(1000000);
  435. }
  436. // Client went away: drop this channel's files.
  437. @unlink(channel_subs_path($id));
  438. @unlink($eventsFile);
  439. exit;
  440. }
  441. // Manage a channel's subscriptions: {subscribe:[keys], unsubscribe:[keys]}.
  442. if (preg_match('#^/_statum/channel/([^/]+)$#', $path, $m) && $method === 'PATCH') {
  443. $id = $m[1];
  444. $body = json_decode((string)file_get_contents('php://input'), true);
  445. if (!is_array($body)) $body = [];
  446. $add = $body['subscribe'] ?? [];
  447. $rem = $body['unsubscribe'] ?? [];
  448. $subs = read_channel_subs($id);
  449. foreach ($add as $k) {
  450. if (!in_array($k, $subs, true)) $subs[] = $k;
  451. }
  452. $subs = array_values(array_diff($subs, $rem));
  453. set_channel_subs($id, $subs);
  454. // Push the current value of any newly-subscribed slot so a fresh client
  455. // sees the latest state right away.
  456. foreach ($add as $k) {
  457. $fr = initial_frame_for_key((string)$k);
  458. if ($fr !== null) append_channel_event($id, 'set', $fr);
  459. }
  460. http_response_code(204);
  461. exit;
  462. }
  463. // --- Actions ----------------------------------------------------------------
  464. if ($path === '/api/counter/inc' && $method === 'POST') {
  465. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + 1;
  466. statum_respond([set_dir(snap('counter'))]);
  467. }
  468. if ($path === '/api/counter/dec' && $method === 'POST') {
  469. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) - 1;
  470. statum_respond([set_dir(snap('counter'))]);
  471. }
  472. if ($path === '/api/counter/reset' && $method === 'POST') {
  473. $_SESSION['counter'] = 0;
  474. statum_respond([set_dir(snap('counter'))]);
  475. }
  476. if ($path === '/api/counter/add' && $method === 'POST') {
  477. $amount = intval($_POST['amount'] ?? 1);
  478. $_SESSION['counter'] = ($_SESSION['counter'] ?? 0) + $amount;
  479. statum_respond([set_dir(snap('counter'))]);
  480. }
  481. if ($path === '/api/cart/add' && $method === 'POST') {
  482. $priv = $_SERVER['HTTP_X_STATUM_PRIVATE'] ?? '';
  483. $raw = base64_decode($priv, true) ?: '';
  484. if (preg_match('/id=([0-9]+)/', $raw, $m)) {
  485. $id = $m[1];
  486. $_SESSION['cart'][$id] = ($_SESSION['cart'][$id] ?? 0) + 1;
  487. }
  488. statum_respond([set_dir(snap('cart'))]);
  489. }
  490. if ($path === '/api/cart/clear' && $method === 'POST') {
  491. $_SESSION['cart'] = [];
  492. statum_respond([set_dir(snap('cart'))]);
  493. }
  494. if ($path === '/api/cart/checkout' && $method === 'POST') {
  495. // A `post` directive performs a full-page navigating form POST last.
  496. statum_respond([['type' => 'post', 'uri' => '/api/checkout-done', 'data' => ['order' => 'ORD-' . strtoupper(bin2hex(random_bytes(3)))]]]);
  497. }
  498. if ($path === '/api/user/toggle' && $method === 'POST') {
  499. $order = ['guest', 'member', 'admin'];
  500. $cur = array_search($_SESSION['role'] ?? 'guest', $order);
  501. $_SESSION['role'] = $order[($cur + 1) % count($order)];
  502. statum_respond([set_dir(snap('user'))]);
  503. }
  504. // The stm-prefill demo form's save. The form's controls carry the session
  505. // values via stm-prefill; this action stores the submitted edits and re-sets
  506. // the profile snapshot (which also re-runs the prefill bindings — on the
  507. // non-dirty controls — and the stm-text echo below the form).
  508. if ($path === '/api/profile/save' && $method === 'POST') {
  509. $theme = (string)($_POST['theme'] ?? 'light');
  510. $cadence = (string)($_POST['cadence'] ?? 'weekly');
  511. $allowedThemes = ['light', 'dark', 'auto'];
  512. $allowedCadences = ['daily', 'weekly', 'never'];
  513. $_SESSION['profile'] = [
  514. 'name' => trim((string)($_POST['name'] ?? '')),
  515. 'theme' => in_array($theme, $allowedThemes, true) ? $theme : 'light',
  516. 'news' => ($_POST['news'] ?? '') !== '',
  517. 'cadence' => in_array($cadence, $allowedCadences, true) ? $cadence : 'weekly',
  518. ];
  519. statum_respond([
  520. set_dir(snap('profile')),
  521. ['type' => 'notify', 'kind' => 'success', 'message' => 'Profile saved'],
  522. ]);
  523. }
  524. // The profile fail action: always answers with an error directive, so the
  525. // demo can show stm-on-error (and, on the second form, stm-clear-on-error)
  526. // against a real Statum-shaped failure.
  527. if ($path === '/api/profile/fail' && $method === 'POST') {
  528. statum_respond([
  529. ['type' => 'error', 'message' => 'The profile could not be saved (by design)'],
  530. ]);
  531. }
  532. if ($path === '/api/notice/set' && $method === 'POST') {
  533. $_SESSION['notice'] = '<div class="ok"><strong>Notice:</strong> updated at ' . date('H:i:s') . '</div>';
  534. statum_respond([set_dir(snap('notice'))]);
  535. }
  536. if ($path === '/api/notice/fail' && $method === 'POST') {
  537. statum_respond([['type' => 'error', 'message' => 'Intentional failure.', 'code' => 'DEMO_FAIL', 'data' => ['at' => date('c')]]]);
  538. }
  539. if ($path === '/api/reset-all' && $method === 'POST') {
  540. $dirs = [];
  541. foreach ($TYPES as $t) {
  542. if (isset($_SESSION['keys'][$t])) $dirs[] = clear_dir($_SESSION['keys'][$t]);
  543. }
  544. session_unset();
  545. $dirs[] = ['type' => 'navigate', 'uri' => '/'];
  546. statum_respond($dirs);
  547. }
  548. // --- Live channel actions ---------------------------------------------------
  549. if ($path === '/api/live/send' && $method === 'POST') {
  550. $msg = trim((string)($_POST['msg'] ?? ''));
  551. if ($msg === '') $msg = '(empty message)';
  552. @file_put_contents(live_path(), json_encode(['text' => $msg, 'at' => date('H:i:s')]), LOCK_EX);
  553. $fr = frame(snap_live());
  554. fanout_set('k_live', $fr); // push to every subscribed tab via the channel
  555. statum_respond([
  556. set_dir_frame($fr), // immediate feedback (idempotent vs the push)
  557. ['type' => 'notify', 'kind' => 'success', 'message' => 'Broadcast to all tabs'],
  558. ]);
  559. }
  560. // Unsubscribing the only slot closes the realtime connection for this client.
  561. if ($path === '/api/live/mute' && $method === 'POST') {
  562. statum_respond([['type' => 'unsubscribe', 'key' => 'k_live']]);
  563. }
  564. // Re-subscribing reopens the connection; the PATCH initial-push re-sends the
  565. // current broadcast.
  566. if ($path === '/api/live/resume' && $method === 'POST') {
  567. statum_respond([['type' => 'subscribe', 'key' => 'k_live']]);
  568. }
  569. // Fallback 404.
  570. http_response_code(404);
  571. header('Content-Type: text/plain');
  572. echo "404 Not Found: $path";