131 lines
6.2 KiB
PHP
131 lines
6.2 KiB
PHP
<?php
|
|
/**
|
|
* Square Webhook Handler (Phase 2)
|
|
* ────────────────────────────────
|
|
* Setup (do once):
|
|
* 1. Deploy this file and note its public URL, e.g. https://app.hotlunches.net/square/webhook.php
|
|
* 2. Square Developer Dashboard → your App → Webhooks → Add Subscription:
|
|
* • Notification URL: the exact URL above (must match config 'webhook_url')
|
|
* • Events: oauth.authorization.revoked (Phase 5 will add payment.updated, refund.updated)
|
|
* 3. Copy the subscription's Signature Key → set 'webhook_signature_key' in config.php
|
|
* 4. Set 'webhook_url' in config.php to the exact Notification URL you entered.
|
|
*
|
|
* Square retries on any non-2xx for ~72h, so on transient problems we respond
|
|
* 200 (work is logged); on a FAILED signature we respond 403 (never process an
|
|
* unverified body). The raw body is read first, before anything can corrupt it.
|
|
*/
|
|
|
|
$rawBody = file_get_contents('php://input');
|
|
|
|
// Optional app logging.
|
|
ob_start();
|
|
if (file_exists(__DIR__ . '/../include/logging-functions.php')) {
|
|
require_once __DIR__ . '/../include/logging-functions.php';
|
|
if (function_exists('startLog')) startLog('square-webhook');
|
|
}
|
|
ob_end_clean();
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$config = require __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/lib/MerchantStore.php';
|
|
|
|
function sqLog(string $msg): void {
|
|
if (function_exists('writeLog')) writeLog($msg);
|
|
}
|
|
|
|
// ── Header helper (works on Apache + Nginx + PHP-FPM) ────────────────────────
|
|
function sqWebhookHeader(string $name): string {
|
|
if (function_exists('getallheaders')) {
|
|
foreach (getallheaders() as $k => $v) {
|
|
if (strcasecmp($k, $name) === 0) return $v;
|
|
}
|
|
}
|
|
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
|
return $_SERVER[$serverKey] ?? '';
|
|
}
|
|
|
|
// ── Resolve the notification URL exactly as registered (signature depends on it).
|
|
function sqNotificationUrl(array $config): string {
|
|
$u = trim($config['webhook_url'] ?? '');
|
|
if ($u !== '') return $u;
|
|
// Fallback: reconstruct from the request (works when not behind a rewriting proxy).
|
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https'
|
|
: ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https');
|
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
|
return $scheme . '://' . $host . $uri;
|
|
}
|
|
|
|
// ── Guard: signature key must be configured ──────────────────────────────────
|
|
$signatureKey = $config['webhook_signature_key'] ?? '';
|
|
if ($signatureKey === '') {
|
|
sqLog('square-webhook: webhook_signature_key not set — refusing to process unverified events');
|
|
http_response_code(500);
|
|
echo json_encode(['status' => 'signature_key_not_configured']);
|
|
exit();
|
|
}
|
|
|
|
// ── Verify HMAC-SHA256 signature ─────────────────────────────────────────────
|
|
// signature = base64( HMAC_SHA256( key, notificationUrl + rawBody ) )
|
|
$providedSig = sqWebhookHeader('x-square-hmacsha256-signature');
|
|
if ($providedSig === '') {
|
|
sqLog('square-webhook: missing x-square-hmacsha256-signature header — ignored');
|
|
http_response_code(403);
|
|
echo json_encode(['status' => 'missing_signature']);
|
|
exit();
|
|
}
|
|
|
|
$notificationUrl = sqNotificationUrl($config);
|
|
$expectedSig = base64_encode(hash_hmac('sha256', $notificationUrl . $rawBody, $signatureKey, true));
|
|
|
|
if (!hash_equals($expectedSig, $providedSig)) {
|
|
sqLog('square-webhook: signature MISMATCH (notification_url=' . $notificationUrl . ') — rejected');
|
|
http_response_code(403);
|
|
echo json_encode(['status' => 'signature_verification_failed']);
|
|
exit();
|
|
}
|
|
|
|
// ── Parse the verified event ─────────────────────────────────────────────────
|
|
$event = json_decode($rawBody, true) ?: [];
|
|
$eventType = $event['type'] ?? '';
|
|
$merchantId = $event['merchant_id'] ?? ($event['data']['object']['merchant_id'] ?? '');
|
|
|
|
sqLog('square-webhook: verified type=' . $eventType . ' event_id=' . ($event['event_id'] ?? '?') . ' merchant_id=' . ($merchantId ?: '?'));
|
|
|
|
http_response_code(200);
|
|
|
|
try {
|
|
$store = new MerchantStore($config);
|
|
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
// EVENT: oauth.authorization.revoked
|
|
// The seller removed our application from THEIR Square account. The tokens
|
|
// are already dead at Square — clear ours and flag the school so payments
|
|
// stop and staff know a reconnect is required.
|
|
// ════════════════════════════════════════════════════════════════════════
|
|
if ($eventType === 'oauth.authorization.revoked') {
|
|
if ($merchantId === '') {
|
|
sqLog('square-webhook: revoked event without merchant_id — nothing to do');
|
|
echo json_encode(['status' => 'ok', 'note' => 'no merchant_id']);
|
|
exit();
|
|
}
|
|
$school = $store->markAuthorizationRevoked($merchantId);
|
|
sqLog('square-webhook: authorization revoked for merchant_id=' . $merchantId
|
|
. ($school ? (' (school=' . $school . ')') : ' (no matching school)'));
|
|
echo json_encode(['status' => 'ok', 'school' => $school]);
|
|
exit();
|
|
}
|
|
|
|
// ── Unhandled (but verified) event — 200 so Square stops retrying ─────────
|
|
sqLog('square-webhook: unhandled event type=' . $eventType);
|
|
echo json_encode(['status' => 'ok', 'note' => 'event type not handled']);
|
|
exit();
|
|
|
|
} catch (Throwable $e) {
|
|
// Respond 200 so Square doesn't hammer us; the failure is logged for review.
|
|
sqLog('square-webhook: handler error — ' . $e->getMessage());
|
|
echo json_encode(['status' => 'ok', 'note' => 'handler error logged']);
|
|
exit();
|
|
}
|