error = $error; } } /** @internal */ final class Wire { public const VERSION = '0.1.0'; public const REPLAY_WINDOW_SECS = 300; public const MAX_BODY_BYTES = 1048576; public const MAX_ANSWER_BYTES = 262144; public const ACTION_KEPT_SECS = 86400; public const EVENT_KEPT_SECS = 172800; public const BUSY_SECS = 60; public const FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS; // Unicode's control characters (C0, DEL and C1), as Help's server refuses them, and the two line separators. public const CONTROL = '/[\x{0000}-\x{001f}\x{007f}-\x{009f}\x{2028}\x{2029}]/u'; public static function b64url(string $raw): string { return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); } public static function json($value): string { $out = json_encode($value, self::FLAGS); if (false === $out) { throw new \RuntimeException('not_json'); } return $out; } /** Characters, not bytes, without needing mbstring; null for text that is not UTF-8. */ public static function length(string $s): ?int { $n = preg_match_all('/./su', $s); return false === $n ? null : $n; } /** @param string|string[]|null $given */ public static function secrets($given): array { $all = is_array($given) ? $given : array($given); return array_values(array_filter($all, function ($s) { return is_string($s) && '' !== $s; })); } public static function env(string $name): ?string { $v = getenv($name); if (false === $v || '' === $v) { $v = $_SERVER[$name] ?? ($_ENV[$name] ?? null); } return is_string($v) && '' !== $v ? $v : null; } } /** The token that tells Help who is logged in, signed on your server and never in the page. */ final class Identity { /** * @param array $user ['id' => your id for them, 'email' => ?string, 'facts' => ?array] * @param array $options secret (else HELP_IDENTITY_SECRET), kid, expires_in (600), once (true), now, jti */ public static function sign(array $user, array $options = array()): string { $secret = $options['secret'] ?? Wire::env('HELP_IDENTITY_SECRET'); if (!is_string($secret) || !preg_match('/^hsk_(live|test)_./', $secret)) { throw new HelpError('bad_secret', 'The secret is the one your site shows under Recognise logged-in customers, starting hsk_live_ or hsk_test_.'); } $raw = $user['id'] ?? null; $sub = is_string($raw) ? trim($raw) : (is_int($raw) ? (string) $raw : ''); $len = Wire::length($sub); if ('' === $sub || null === $len || $len > 128 || preg_match(Wire::CONTROL, $sub)) { throw new HelpError('bad_user', "The user's id is your own id for them: text or a whole number, 1 to 128 characters."); } $email = $user['email'] ?? null; if (null !== $email && !is_string($email)) { throw new HelpError('bad_email', "The user's email is an address, or nothing."); } $email = trim((string) $email); if ('' !== $email && (strlen($email) > 254 || !preg_match('/^[^\s@]+@[^\s@]+$/u', $email))) { throw new HelpError('bad_email', "The user's email is an address, or nothing."); } $facts = $user['facts'] ?? array(); if (!is_array($facts) || count($facts) > 10) { throw new HelpError('bad_facts', 'At most ten facts, as names and values.'); } foreach ($facts as $name => $value) { $text = is_string($value) ? Wire::length($value) : null; $ok = is_bool($value) || is_int($value) || (is_float($value) && is_finite($value)) || (is_string($value) && '' !== trim($value) && null !== $text && $text <= 100 && !preg_match(Wire::CONTROL, $value)); if (!preg_match('/^[a-z0-9_]{1,40}$/', (string) $name) || !$ok) { throw new HelpError('bad_facts', 'The fact "' . $name . '" is a name of lowercase letters, digits and _ (up to 40), with text up to 100 characters, a number, or true or false.'); } } $expires = $options['expires_in'] ?? 600; if (!is_int($expires) || $expires < 1 || $expires > 86400) { throw new HelpError('bad_expiry', 'A token lasts from a second to a day; ten minutes is plenty.'); } $now = isset($options['now']) ? (int) $options['now'] : time(); $jti = false === ($options['once'] ?? true) ? null : ($options['jti'] ?? Wire::b64url(random_bytes(16))); $header = '{"alg":"HS256","typ":"JWT"' . (empty($options['kid']) ? '' : ',"kid":' . Wire::json((string) $options['kid'])) . '}'; $claims = array('"sub":' . Wire::json($sub)); if ('' !== $email) { $claims[] = '"email":' . Wire::json($email); } $claims[] = '"iat":' . $now; $claims[] = '"exp":' . ($now + $expires); if (null !== $jti) { $claims[] = '"jti":' . Wire::json($jti); } if ($facts) { ksort($facts, SORT_STRING); $pairs = array(); foreach ($facts as $name => $value) { $pairs[] = Wire::json((string) $name) . ':' . Wire::json($value); } $claims[] = '"user":{' . implode(',', $pairs) . '}'; } $input = Wire::b64url($header) . '.' . Wire::b64url('{' . implode(',', $claims) . '}'); return $input . '.' . Wire::b64url(hash_hmac('sha256', $input, $secret, true)); } /** * What your identity URL answers: 200 and {"token"} for a logged-in user, 204 for anyone else, never cached. * * @return array ['status' => int, 'headers' => array, 'body' => string] */ public static function answer(?array $user, array $options = array()): array { if (null === $user) { return array('status' => 204, 'headers' => array('Cache-Control' => 'no-store'), 'body' => ''); } $body = Wire::json(array('token' => self::sign($user, $options))); return array('status' => 200, 'headers' => array('Content-Type' => 'application/json', 'Cache-Control' => 'no-store'), 'body' => $body); } } /** The signature on every call Help makes to your URL. */ final class Signature { public const HEADER = 'X-DeltaBreen-Signature'; /** * The secret a call was signed with, of those given, or null: check this before reading the body. * * @param string|string[] $secrets */ public static function verify(?string $header, string $rawBody, $secrets, ?int $now = null): ?string { if (null === $header) { return null; } $now = null === $now ? time() : $now; $t = null; $sent = array(); foreach (explode(',', $header) as $part) { $part = trim($part); if (null === $t && 0 === strpos($part, 't=')) { $t = substr($part, 2); } elseif (0 === strpos($part, 'v1=')) { $sent[] = substr($part, 3); } } if (null === $t || !preg_match('/^[0-9]+$/', $t) || abs($now - (int) $t) > Wire::REPLAY_WINDOW_SECS) { return null; } foreach (Wire::secrets($secrets) as $secret) { $want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sent as $got) { if (hash_equals($want, $got)) { return $secret; } } } return null; } /** The answer to Help's connection test: the challenge's HMAC under your secret, never the challenge itself. */ public static function handshakeAnswer(string $secret, string $challenge): string { return hash_hmac('sha256', $challenge, $secret); } } /** Where presses and events are remembered, so each is done once: give one every copy of your server shares. */ interface Store { /** @return array{0: string, 1: ?string} ['new', null], ['busy', null] or ['done', the first answer] */ public function claim(string $key, int $ttl): array; public function finish(string $key, string $result, int $ttl): void; public function release(string $key): void; } /** Remembers presses and events in files, since PHP keeps nothing between requests; one folder for every request. */ final class FileStore implements Store { /** @var string */ private $dir; public function __construct(?string $dir = null) { $this->dir = rtrim($dir ?? sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'deltabreen-help', DIRECTORY_SEPARATOR); if (!is_dir($this->dir)) { @mkdir($this->dir, 0700, true); } } private function path(string $key): string { return $this->dir . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.json'; } private function read(string $path): ?array { $text = @file_get_contents($path); $had = false === $text ? null : json_decode($text, true); return is_array($had) ? $had : null; } public function claim(string $key, int $ttl): array { $path = $this->path($key); $had = $this->read($path); if (null !== $had && ($had['expires'] ?? 0) > time()) { return null === ($had['result'] ?? null) ? array('busy', null) : array('done', (string) $had['result']); } if (null !== $had) { @unlink($path); } $f = @fopen($path, 'x'); if (false === $f) { return array('busy', null); } fwrite($f, Wire::json(array('expires' => time() + min($ttl, Wire::BUSY_SECS), 'result' => null))); fclose($f); return array('new', null); } public function finish(string $key, string $result, int $ttl): void { file_put_contents($this->path($key), Wire::json(array('expires' => time() + $ttl, 'result' => $result)), LOCK_EX); } public function release(string $key): void { @unlink($this->path($key)); } } /** Remembers presses and events in a table of your database, for several servers; `createTable()` makes it. */ final class PdoStore implements Store { /** @var \PDO */ private $pdo; /** @var string */ private $table; public function __construct(\PDO $pdo, string $table = 'deltabreen_help_seen') { if (!preg_match('/^[a-z_][a-z0-9_]{0,62}$/', $table)) { throw new HelpError('bad_table', 'A table name of lowercase letters, digits and _.'); } $this->pdo = $pdo; $this->table = $table; } public function createTable(): void { $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$this->table} (k VARCHAR(100) PRIMARY KEY, expires BIGINT NOT NULL, result TEXT)"); } public function claim(string $key, int $ttl): array { $k = hash('sha256', $key); $this->pdo->prepare("DELETE FROM {$this->table} WHERE k = ? AND expires < ?")->execute(array($k, time())); try { if ($this->pdo->prepare("INSERT INTO {$this->table} (k, expires, result) VALUES (?, ?, NULL)")->execute(array($k, time() + min($ttl, Wire::BUSY_SECS)))) { return array('new', null); } } catch (\PDOException $e) { // Another request holds it: read what it left. } $q = $this->pdo->prepare("SELECT result FROM {$this->table} WHERE k = ?"); $q->execute(array($k)); $result = $q->fetchColumn(); return is_string($result) ? array('done', $result) : array('busy', null); } public function finish(string $key, string $result, int $ttl): void { $this->pdo->prepare("UPDATE {$this->table} SET expires = ?, result = ? WHERE k = ?")->execute(array(time() + $ttl, $result, hash('sha256', $key))); } public function release(string $key): void { $this->pdo->prepare("DELETE FROM {$this->table} WHERE k = ?")->execute(array(hash('sha256', $key))); } } /** One call Help makes to your URL about a logged-in user of your site. */ final class Call { /** @var string */ public $id; /** @var string */ public $type; /** @var string "live" or "test": the key the ticket came through */ public $mode; /** @var string */ public $product; /** @var string */ public $sentAt; /** @var string the id your own server signed for them */ public $userId; /** @var array|null ['id' => string, 'number' => int] */ public $ticket; /** @var string|null */ public $agentId; /** @var string|null a button's key, for an action */ public $key; /** @var array */ public $params = array(); /** @var string|null */ public $idempotencyKey; public static function of(array $e): self { $c = new self(); $action = is_array($e['action'] ?? null) ? $e['action'] : array(); $c->id = (string) ($e['id'] ?? ''); $c->type = (string) ($e['type'] ?? ''); $c->mode = (string) ($e['mode'] ?? ''); $c->product = (string) ($e['product'] ?? ''); $c->sentAt = (string) ($e['sent_at'] ?? ''); $c->userId = (string) ($e['user']['id'] ?? ''); $c->ticket = is_array($e['ticket'] ?? null) ? $e['ticket'] : null; $c->agentId = isset($e['agent']['id']) ? (string) $e['agent']['id'] : null; $c->key = isset($action['key']) ? (string) $action['key'] : null; $c->params = is_array($action['params'] ?? null) ? $action['params'] : array(); $c->idempotencyKey = isset($action['idempotency_key']) ? (string) $action['idempotency_key'] : null; return $c; } } /** * Answers Help at your URL: checks the signature, answers the connection test, and calls `hydrate` for the side * panel, `action` for a button (once per press), and `event` for a webhook's event (once per event). */ final class Handler { /** @var string[] */ private $secrets; /** @var array */ private $o; /** @var Store */ private $store; /** * @param array $options secrets (else HELP_CONNECTOR_SECRET and HELP_WEBHOOK_SECRET), hydrate, action, event * (callables), mode ('live' or 'test'), store, now and log */ public function __construct(array $options = array()) { $this->secrets = Wire::secrets($options['secrets'] ?? array(Wire::env('HELP_CONNECTOR_SECRET'), Wire::env('HELP_WEBHOOK_SECRET'))); if (!$this->secrets) { throw new HelpError('no_secret', "Give the integration's secret (hcs_…) or the webhook's (whs_…), or set HELP_CONNECTOR_SECRET."); } $this->o = $options; $this->store = $options['store'] ?? new FileStore(); } private static function answer(int $status, $value): array { return array('status' => $status, 'headers' => array('Content-Type' => 'application/json'), 'body' => Wire::json($value)); } private function log(string $line): void { if (isset($this->o['log'])) { ($this->o['log'])($line); } else { error_log($line); } } /** An answer of the side panel's or a button's, as an object and within Help's 256 KB. */ private function fit($value): array { if (is_object($value)) { $value = (array) $value; } if (!is_array($value) || ($value && array_keys($value) === range(0, count($value) - 1))) { return self::answer(500, array('error' => 'answer_not_an_object')); } try { $body = $value ? Wire::json($value) : '{}'; } catch (\RuntimeException $e) { return self::answer(500, array('error' => 'answer_not_json')); } if (strlen($body) > Wire::MAX_ANSWER_BYTES) { $this->log('[help] an answer was over 256 KB, which Help does not take; send less in the side panel.'); return self::answer(500, array('error' => 'answer_too_large')); } return array('status' => 200, 'headers' => array('Content-Type' => 'application/json'), 'body' => $body); } /** Once per key: a repeat is answered as the first was, and what threw is not kept, so a press can be tried again. */ private function once(string $key, int $ttl, callable $run): array { list($state, $result) = $this->store->claim($key, $ttl); if ('done' === $state && null !== $result) { return array('status' => 200, 'headers' => array('Content-Type' => 'application/json'), 'body' => $result); } if ('busy' === $state) { return self::answer(409, array('error' => 'in_progress')); } try { $a = $run(); } catch (\Throwable $e) { $this->store->release($key); $this->log('[help] ' . strstr($key, ':', true) . ' failed: ' . get_class($e)); return self::answer(500, array('error' => 'failed')); } $this->store->finish($key, $a['body'], $ttl); return $a; } private static function header(array $headers, string $name): ?string { $want = strtolower($name); foreach ($headers as $k => $v) { $k = strtolower((string) $k); if ($k === $want || $k === 'http_' . str_replace('-', '_', $want)) { return is_array($v) ? implode(',', $v) : (string) $v; } } return null; } /** * The raw body and the request's headers in; what to answer out. * * @return array ['status' => int, 'headers' => array, 'body' => string] */ public function handle(string $rawBody, array $headers): array { if (strlen($rawBody) > Wire::MAX_BODY_BYTES) { return self::answer(413, array('error' => 'too_large')); } $now = isset($this->o['now']) ? (int) ($this->o['now'])() : time(); $secret = Signature::verify(self::header($headers, Signature::HEADER), $rawBody, $this->secrets, $now); if (null === $secret) { return self::answer(401, array('error' => 'bad_signature')); } $e = json_decode($rawBody, true); if (!is_array($e) || ($e && array_keys($e) === range(0, count($e) - 1))) { return self::answer(400, array('error' => 'bad_json')); } $type = $e['type'] ?? null; $other = isset($this->o['mode']) && ($e['mode'] ?? null) !== $this->o['mode']; if ('handshake' === $type) { if (!is_string($e['challenge'] ?? null)) { return self::answer(400, array('error' => 'no_challenge')); } return self::answer(200, array('challenge_response' => Signature::handshakeAnswer($secret, $e['challenge']))); } if ('hydrate' === $type) { if (!isset($this->o['hydrate'])) { return self::answer(400, array('error' => 'not_handled')); } $shown = $other ? null : ($this->o['hydrate'])(Call::of($e)); return null === $shown ? self::answer(404, array('error' => 'not_found')) : $this->fit($shown); } if ('action' === $type) { if (!isset($this->o['action'])) { return self::answer(400, array('error' => 'not_handled')); } $key = $e['action']['idempotency_key'] ?? null; if (!is_string($key) || !preg_match('/^[A-Za-z0-9_-]{16,64}$/', $key)) { return self::answer(400, array('error' => 'bad_idempotency_key')); } if ($other) { return self::answer(200, array('ok' => false, 'message' => 'This is a ' . $e['mode'] . ' ticket; this URL acts for ' . $this->o['mode'] . ' ones only.')); } $act = $this->o['action']; return $this->once('action:' . $key, Wire::ACTION_KEPT_SECS, function () use ($act, $e) { $r = $act(Call::of($e)); $r = is_object($r) ? (array) $r : (is_array($r) ? $r : array()); $out = array('ok' => true === ($r['ok'] ?? null)); foreach (array('message', 'reference') as $k) { if (is_string($r[$k] ?? null) && '' !== $r[$k]) { $out[$k] = $r[$k]; } } return $this->fit($out); }); } if ('ping' === $type) { return self::answer(200, array('ok' => true)); } if (is_string($type) && 0 === strpos($type, 'ticket.')) { if (!isset($this->o['event'])) { return self::answer(400, array('error' => 'not_handled')); } if (!is_string($e['id'] ?? null) || '' === $e['id']) { return self::answer(400, array('error' => 'no_id')); } $event = $this->o['event']; return $this->once('event:' . $e['id'], Wire::EVENT_KEPT_SECS, function () use ($event, $e) { $event($e); return self::answer(200, array('ok' => true)); }); } return self::answer(400, array('error' => 'unknown_type')); } /** Reads this request as PHP has it, answers it and sends the answer. */ public function respond(): void { $headers = function_exists('getallheaders') ? (getallheaders() ?: array()) : array(); if (!$headers) { foreach ($_SERVER as $k => $v) { if (0 === strpos($k, 'HTTP_')) { $headers[$k] = $v; } } } self::send($this->handle((string) file_get_contents('php://input'), $headers)); } public static function send(array $answer): void { http_response_code($answer['status']); foreach ($answer['headers'] as $name => $value) { header($name . ': ' . $value); } echo $answer['body']; } }