all(), PSR-7 getHeaders()); the first is used. * @param int $now Current Unix time in seconds (pass time()). * @param int $tolerance Largest accepted gap between the timestamp and $now, in seconds. * @return array|null The decoded JSON on success, null on any failure. */ function tonping_verify(string $secret, string $rawBody, array $headers, int $now, int $tolerance = 300): ?array { $h = []; foreach ($headers as $name => $value) { if (is_array($value)) { $value = reset($value); } if (is_string($name) && is_string($value)) { $h[strtolower($name)] = $value; } } $ts = $h['x-tonping-timestamp'] ?? null; $sig = $h['x-tonping-signature'] ?? null; if ($ts === null || $sig === null || $secret === '') { return null; } if (!preg_match('/\A[0-9]{1,15}\z/', $ts)) { return null; } if (strncmp($sig, 'v1=', 3) !== 0) { return null; } $given = substr($sig, 3); $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret); if (!hash_equals($expected, $given)) { return null; } if (abs($now - (int) $ts) > $tolerance) { return null; } try { $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { return null; } return is_array($event) ? $event : null; } function tonping_read_ids($fp): array { $ids = []; while (($line = fgets($fp)) !== false) { $line = rtrim($line, "\r\n"); if ($line !== '') { $ids[] = $line; } } return $ids; } function tonping_check_id(string $eventId): string { $eventId = trim($eventId); if ($eventId === '' || strpbrk($eventId, "\r\n") !== false) { throw new InvalidArgumentException('bad event id'); } return $eventId; } /** * Where receive.php keeps the ids it has handled: $TONPING_SEEN_STORE, else a file beside * this script. Keep it outside the web root (or deny it in the web server) and writable * by the PHP user. */ function tonping_store_path(): string { $path = getenv('TONPING_SEEN_STORE'); return is_string($path) && $path !== '' ? $path : __DIR__ . '/tonping-seen-ids'; } /** Open the dedupe store and take a lock ($mode LOCK_SH or LOCK_EX); the caller must fclose() it. */ function tonping_open_store(string $storePath, int $mode) { $fp = @fopen($storePath, 'c+'); if ($fp === false) { throw new RuntimeException('cannot open dedupe store'); } if (!flock($fp, $mode)) { fclose($fp); throw new RuntimeException('cannot lock dedupe store'); } return $fp; } /** True if $eventId was recorded by tonping_mark_seen. The store holds one id per line. */ function tonping_is_seen(string $storePath, string $eventId): bool { $eventId = tonping_check_id($eventId); $fp = tonping_open_store($storePath, LOCK_SH); try { return in_array($eventId, tonping_read_ids($fp), true); } finally { flock($fp, LOCK_UN); fclose($fp); } } /** Record $eventId (no-op if present). Only the last $keep ids are kept. */ function tonping_mark_seen(string $storePath, string $eventId, int $keep = 10000): void { $eventId = tonping_check_id($eventId); $fp = tonping_open_store($storePath, LOCK_EX); try { $ids = tonping_read_ids($fp); if (in_array($eventId, $ids, true)) { return; } $ids[] = $eventId; if (count($ids) > $keep) { $ids = array_slice($ids, -$keep); ftruncate($fp, 0); rewind($fp); fwrite($fp, implode("\n", $ids) . "\n"); } else { fseek($fp, 0, SEEK_END); fwrite($fp, $eventId . "\n"); } fflush($fp); } finally { flock($fp, LOCK_UN); fclose($fp); } } /** * Run $handler once per event. Returns the HTTP status to answer: * 200 when handled or already recorded, 500 when the handler threw (the id is * then NOT recorded, so TonPing's retry runs the handler again). * Throws RuntimeException when the dedupe store cannot be opened or locked; answer 500 then. */ function tonping_handle_once(string $storePath, array $event, callable $handler): int { $eventId = (string) ($event['event_id'] ?? ''); if (tonping_is_seen($storePath, $eventId)) { return 200; } try { $handler($event); } catch (Throwable $e) { error_log('tonping handler failed for ' . $eventId . ': ' . get_class($e)); return 500; } tonping_mark_seen($storePath, $eventId); return 200; }