/square/onboard.php we carry that URL inside the OAuth // `state` (which Square echoes back verbatim), signed with our server key so it // cannot be swapped for another site: // state = <32-hex nonce>.. // Only the nonce is stored server-side (square_pending_onboarding.state), so no // schema change is needed. function squareStateKey(array $config): string { $secret = (string)(($config['token_encryption_key'] ?? '') ?: ($config['client_secret'] ?? '')); return hash('sha256', 'square-oauth-state|' . $secret, true); } // The URL of the onboarding page the admin is on right now (on their school's host). function squareCurrentOnboardUrl(): string { $scheme = 'http'; if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { $scheme = 'https'; } elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') { $scheme = 'https'; } $host = (string)($_SERVER['HTTP_HOST'] ?? ''); $path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ($_SERVER['SCRIPT_NAME'] ?? '')); if (!preg_match('/^[A-Za-z0-9.-]+(:\d{1,5})?$/', $host) || !preg_match('#^/[A-Za-z0-9._~/-]*$#', $path)) { return ''; } return $scheme . '://' . $host . $path; } function squareMakeState(array $config, string $onboardUrl): array { $nonce = bin2hex(random_bytes(16)); if ($onboardUrl === '') return [$nonce, $nonce]; $payload = rtrim(strtr(base64_encode($onboardUrl), '+/', '-_'), '='); $sig = substr(hash_hmac('sha256', $nonce . '.' . $payload, squareStateKey($config)), 0, 32); return [$nonce, $nonce . '.' . $payload . '.' . $sig]; } // Returns [nonce, verified school onboard URL or '']. function squareParseState(array $config, string $state): array { if (preg_match('/^([0-9a-f]{32})\.([A-Za-z0-9_-]{1,700})\.([0-9a-f]{32})$/', $state, $m)) { $expected = substr(hash_hmac('sha256', $m[1] . '.' . $m[2], squareStateKey($config)), 0, 32); if (!hash_equals($expected, $m[3])) return ['', '']; $url = (string)base64_decode(strtr($m[2], '-_', '+/'), true); return [$m[1], preg_match('#^https?://[A-Za-z0-9.-]+(:\d{1,5})?/[A-Za-z0-9._~/-]*$#', $url) ? $url : '']; } return [preg_match('/^[0-9a-f]{32}$/', $state) ? $state : '', '']; } // The school's home URL: the onboarding URL minus its "/square/onboard.php" tail. function squareSchoolHomeUrl(string $onboardUrl): string { $p = parse_url($onboardUrl); $base = rtrim(str_replace('\\', '/', dirname(dirname($p['path'] ?? '/'))), '/'); return $p['scheme'] . '://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $base . '/'; } // Finish the Square callback. When we know the school's site (from the signed // state), send the admin back there: // connected + payments ready → the school's home URL (button is live); // connected but Square not ready → the school's onboarding page, which explains why; // failed → the school's onboarding page with the error. // Otherwise (legacy/unsigned state) render a self-contained page: this host is // usually not the school's site, so it must not link into gated pages. function squareCallbackPage(bool $ok, string $title, string $bodyHtml, bool $ready = false): void { $GLOBALS['sqCallbackPageSent'] = true; // tells the shutdown guard a page was rendered $onboardUrl = (string)($GLOBALS['sqReturnOnboardUrl'] ?? ''); if ($onboardUrl !== '' && !headers_sent()) { if ($ok && $ready) { $to = squareSchoolHomeUrl($onboardUrl); } elseif ($ok) { $to = $onboardUrl; } else { $msg = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags(str_replace('

', ' ', $bodyHtml)), ENT_QUOTES))); $to = $onboardUrl . '?status=error&msg=' . rawurlencode($title . ': ' . $msg); } header('Location: ' . $to, true, 302); exit(); } if (!headers_sent()) header('Content-Type: text/html; charset=utf-8'); $color = $ok ? '#28a745' : '#c53030'; echo "" . "School Square Setup" . "" . "
" . "
" . ($ok ? '✓' : '!') . "
" . "

" . htmlspecialchars($title) . "

" . $bodyHtml . "

You can close this page and return to your school's HotLunches site.

" . "
"; exit(); } // The school this logged-in admin may connect/disconnect: ALWAYS the school of // their own session ($school_record from the host app) — never a school id or // name sent by the browser, or an admin of one school could attach their own // Square account to another school and receive its payments. Platform // SuperUsers working outside any school context may name the school by its // unique record_num. function squareAdminSchoolNum(): int { global $school_record; $num = (int)($school_record['record_num'] ?? 0); if ($num > 0) return $num; if (checkPermission(array('SuperUser')) == 1) return (int)($_POST['school_num'] ?? 0); return 0; } // Handle AJAX requests if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']); // Admin actions only — never on a callback-shaped request, which skipped the login gate. if (!$isSquareCallback && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { header('Content-Type: application/json'); // Guarantee a JSON body even on a FATAL error (PHP `Error`, parse/type error, // a broken DB connection inside db_query, an OOM, etc.). Without this, such // failures emit a blank 500 body because display_errors is off, and the // browser's r.json() throws "Unexpected end of JSON input" with no clue why. // We gate strictly on a fatal having occurred so a normal successful exit() // (which also triggers this shutdown handler) does NOT append a second body. register_shutdown_function(function () { $err = error_get_last(); $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; if (!$err || !in_array($err['type'], $fatalTypes, true)) { return; } $msg = 'Server error: ' . $err['message']; if (function_exists('writeLog')) { writeLog('onboard POST fatal: ' . $msg); } if (!headers_sent()) { header('Content-Type: application/json'); } echo json_encode(['success' => false, 'error' => $msg]); }); $config = require __DIR__ . '/config.php'; require_once __DIR__ . '/lib/SquareClient.php'; require_once __DIR__ . '/lib/MerchantStore.php'; try { $client = new SquareClient($config); $store = new MerchantStore($config); // ACTION: Create an OAuth authorization link and hand it to the browser. $schoolNum = squareAdminSchoolNum(); if ($schoolNum <= 0) { echo json_encode(['success' => false, 'error' => 'Could not determine your school. Please sign in to your school account and try again.']); exit(); } if ($_POST['action'] === 'connect') { // Confirm the school_num actually exists before starting OAuth, so the // callback is guaranteed a row to attach to. $resolvedName = $store->schoolNameByNum($schoolNum); if ($resolvedName === null) { echo json_encode(['success' => false, 'error' => 'School not found.']); exit(); } writeLog("connect school_num: $schoolNum ($resolvedName)"); // Cryptographically-random anti-CSRF nonce, plus this school's own // onboarding URL (signed) so the callback can send the admin back to // their school's site. The nonce keys the pending record. [$nonce, $state] = squareMakeState($config, squareCurrentOnboardUrl()); $authUrl = $client->authorizeUrl($state, squareRedirectUri($config)); writeLog("connect: authorize URL issued for school_num $schoolNum"); // Persist the pending attempt in the DB (prunes stale ones first). $store->createPending($nonce, $schoolNum); echo json_encode(['success' => true, 'redirect' => $authUrl]); exit(); } // ACTION: Re-check a connected seller's readiness (locations / card capability). if ($_POST['action'] === 'check_status') { $accessToken = $store->ensureFreshToken('', $client, ['school_num' => $schoolNum])['access_token'] ?? null; if (!$accessToken) { echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square yet']); exit(); } $sellerStatus = $client->getSellerStatus($accessToken); writeLog('Seller status [manual] school_num ' . $schoolNum . ': square_ready=' . ($sellerStatus['square_ready'] ? 'true' : 'false')); $store->mergeMetaByNum($schoolNum, [ 'square_ready' => $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']), 'active_location_id' => $sellerStatus['active_location_id'], 'merchant_name' => $sellerStatus['merchant_name'], 'currency' => $sellerStatus['currency'], 'status_issues' => $sellerStatus['issues'], 'status_checked_at' => time(), ]); echo json_encode([ 'success' => true, 'square_ready' => $sellerStatus['square_ready'], 'active_location_id' => $sellerStatus['active_location_id'], 'currency' => $sellerStatus['currency'], 'issues' => $sellerStatus['issues'], ]); exit(); } // ACTION: Disconnect a seller — revoke their tokens at Square and clear ours. if ($_POST['action'] === 'disconnect') { $record = $store->getByNum($schoolNum); if (!$record) { echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square']); exit(); } // Revoke the whole authorization at Square first (by merchant id). A // revoke Square refused is logged; we still clear our copy so the // button disappears, and the school can also remove the app in Square. try { $resp = $client->revokeToken('', (string)$record['merchant_id']); if ($resp['status'] < 200 || $resp['status'] >= 300) { writeLog('Revoke rejected for school_num ' . $schoolNum . ': HTTP ' . $resp['status'] . ' ' . ($resp['debug_id'] ?? '')); } } catch (\Throwable $e) { writeLog('Revoke failed for school_num ' . $schoolNum . ': ' . $e->getMessage()); } $store->removeConnectionByNum($schoolNum); echo json_encode(['success' => true]); exit(); } } catch (\Throwable $e) { // \Throwable (not just Exception) so PHP Errors — TypeError, a fatal from // a broken db_query, etc. — are reported as JSON instead of a blank body. if (function_exists('writeLog')) { writeLog('onboard POST error: ' . $e->getMessage()); } echo json_encode(['success' => false, 'error' => $e->getMessage()]); exit(); } } // Handle Square OAuth callback (top-level browser return from squareup.com). if ($isSquareCallback) { // Never log the query string: it carries the single-use code and state. writeLog('Square callback received' . (isset($_GET['error']) ? ' (error=' . $_GET['error'] . ')' : '')); // Never leave the seller on a blank page. Every normal path below ends in a // squareCallbackPage(); if execution instead dies first (a fatal PHP Error, or // a host db_query that exits the script on a query error), this shutdown // guard renders a readable status page instead of an empty body. register_shutdown_function(function () { // A result page was rendered (headers_sent() can't tell when output is // buffered), or real output already went out — nothing to rescue. if (!empty($GLOBALS['sqCallbackPageSent']) || headers_sent()) { return; } $err = error_get_last(); $detail = ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) ? $err['message'] : ''; if (function_exists('writeLog')) { writeLog('Square callback aborted before redirect' . ($detail !== '' ? (': ' . $detail) : ' (no PHP fatal captured — likely a DB query error)')); } $back = (string)($GLOBALS['sqReturnOnboardUrl'] ?? ''); if ($back !== '') { while (ob_get_level() > 0) ob_end_clean(); header('Location: ' . $back . '?status=error&msg=' . rawurlencode('Square authorized the account, but saving the connection failed on our side. Please click Connect Square Account again.'), true, 302); return; } http_response_code(500); header('Content-Type: text/html; charset=utf-8'); echo "" . "
" . "

Connection could not be completed

" . "

Square authorized the account, but saving the connection failed on our side. " . "Please open Square setup on your school's HotLunches site and click Connect Square Account again.

" . "
"; }); $config = require __DIR__ . '/config.php'; require_once __DIR__ . '/lib/SquareClient.php'; require_once __DIR__ . '/lib/MerchantStore.php'; $store = new MerchantStore($config); // $state = the server-side nonce; the verified school onboarding URL (if the // signature checks out) is where the admin is sent back to. [$state, $GLOBALS['sqReturnOnboardUrl']] = squareParseState($config, (string)($_GET['state'] ?? '')); $code = $_GET['code'] ?? ''; $oauthError = $_GET['error'] ?? ''; // 1) Validate state against our server-side pending store (CSRF protection). // Only an unused attempt from the last 15 minutes is accepted, and it is // burned right away, so a state value can never be replayed later to swap // another Square account onto the school. $pendingRow = $store->getPending($state); if (!$state || $pendingRow === null) { writeLog('Callback rejected: unknown, expired or already-used state'); squareCallbackPage(false, 'Link expired', '

This authorization link has expired or was already used. Please open Square setup on your school's site and click Connect again.

'); } $store->updatePending($state, 'processing', ['last_callback' => time()]); // The pending record identifies the school by its unique school_num. Resolve // the name for the name-keyed reads/embed/display below. $schoolNum = (int)($pendingRow['school_num'] ?? 0); $school = $schoolNum > 0 ? (string)($store->schoolNameByNum($schoolNum) ?? '') : ''; // 2) Seller declined or Square reported an error. if ($oauthError !== '') { $desc = $_GET['error_description'] ?? $oauthError; writeLog('OAuth error for school_num ' . $schoolNum . ': ' . $oauthError . ' — ' . $desc); $store->updatePending($state, 'declined', ['last_callback' => time()]); squareCallbackPage(false, 'Not connected', '

Square authorization was not completed: ' . htmlspecialchars((string)$desc) . '

'); } if ($code === '' || $schoolNum <= 0) { squareCallbackPage(false, 'Not connected', '

Missing authorization code. Please open Square setup on your school's site and click Connect again.

'); } try { $client = new SquareClient($config); // 3) Exchange the authorization code for tokens (codes expire in 5 minutes). $resp = $client->obtainToken($code, squareRedirectUri($config)); if ($resp['status'] < 200 || $resp['status'] >= 300 || empty($resp['body']['access_token'])) { $detail = $resp['body']['errors'][0]['detail'] ?? ('HTTP ' . $resp['status']); writeLog('Token exchange failed for ' . $school . ': ' . $detail); $store->updatePending($state, 'token_exchange_failed', ['last_callback' => time()]); squareCallbackPage(false, 'Not connected', '

Could not complete the connection with Square: ' . htmlspecialchars((string)$detail) . '

Please open Square setup on your school's site and click Connect again.

'); } $body = $resp['body']; $accessToken = $body['access_token']; $refreshToken = $body['refresh_token'] ?? ''; $merchantId = $body['merchant_id'] ?? ''; $expiresAt = $body['expires_at'] ?? null; // ISO 8601 // 4) Persist immediately (tokens encrypted at rest, in the database). The // connection MUST be recorded even if the readiness check below fails. // Keyed by school_num so duplicate school names can't misroute the write. $saved = $store->saveConnectionByNum($schoolNum, $merchantId, $accessToken, $refreshToken, $expiresAt); writeLog('Merchant saved [callback] school_num ' . $schoolNum . ' (' . $school . ') => ' . $merchantId . ' (' . ($saved ? 'ok' : 'WRITE FAILED') . ')'); if (!$saved) { squareCallbackPage(false, 'Not saved', '

Square approved the connection, but it could not be saved for ' . htmlspecialchars($school !== '' ? $school : ('school #' . $schoolNum)) . '. Please open Square setup on your school's site and click Connect again.

'); } // 5) Mark the pending attempt connected. $store->updatePending($state, 'connected', [ 'merchant_id' => $merchantId, 'connected_at' => time(), 'last_callback' => time(), ]); // 6) Record whether Square says this account can take payments right now // (an ACTIVE location). That — not merely being connected — is what // makes the "Pay with Square" button appear. Guarded so a failure never // discards the connection we just saved; if the check fails, the button // re-checks Square on its own (MerchantStore::isOnboarded). $ready = false; try { $sellerStatus = $client->getSellerStatus($accessToken); $ready = $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']); writeLog('Seller status [callback] ' . $school . ': square_ready=' . ($ready ? 'true' : 'false')); if (!empty($sellerStatus['issues'])) { writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues'])); } $store->mergeMetaByNum($schoolNum, [ 'square_ready' => $ready, 'active_location_id' => $sellerStatus['active_location_id'], 'merchant_name' => $sellerStatus['merchant_name'], 'currency' => $sellerStatus['currency'], 'country' => $sellerStatus['country'], 'status_issues' => $sellerStatus['issues'], 'status_checked_at' => time(), ]); } catch (\Throwable $e) { writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage()); // Not ready yet; a null checked-at makes the next page view re-check Square. $store->mergeMetaByNum($schoolNum, ['square_ready' => false, 'status_checked_at' => null]); } $schoolHtml = '

' . htmlspecialchars($school !== '' ? $school : ('School #' . $schoolNum)) . '

'; if ($ready) { $acct = !empty($sellerStatus['merchant_name']) ? ' (' . htmlspecialchars((string)$sellerStatus['merchant_name']) . ')' : ''; squareCallbackPage(true, 'Square account connected', $schoolHtml . '

The Pay with Square button is now live for parents. Payments go directly to this school\'s Square account' . $acct . '.

', true); } $issues = ''; foreach (($sellerStatus['issues'] ?? []) as $issue) { $issues .= '
  • ' . htmlspecialchars((string)$issue) . '
  • '; } squareCallbackPage(true, 'Connected — almost there', $schoolHtml . '

    Your Square account is connected, but Square says it can\'t take payments yet, so parents don\'t see the button. ' . 'It will appear automatically once this is fixed in your Square account.

    ' . ($issues !== '' ? '' : '')); } catch (\Throwable $e) { writeLog('Square callback error [' . $school . ']: ' . $e->getMessage()); squareCallbackPage(false, 'Not connected', '

    Something went wrong while connecting to Square. Please open Square setup on your school's site and click Connect again.

    '); } } // Current Square status of THIS admin's school, for the page below. require_once __DIR__ . '/lib/SquareClient.php'; require_once __DIR__ . '/lib/MerchantStore.php'; $pageConfig = require __DIR__ . '/config.php'; $pageStore = new MerchantStore($pageConfig); $pageSchoolNum = (int)($school_record['record_num'] ?? 0); $pageMeta = $pageSchoolNum > 0 ? $pageStore->getMetaByNum($pageSchoolNum) : []; $pageConnected = !empty($pageMeta['merchant_id']) && empty($pageMeta['authorization_revoked']); $pageLive = $pageConnected && $pageStore->isOnboarded($pageSchoolNum, new SquareClient($pageConfig)); if ($pageLive !== !empty($pageMeta['square_ready'])) { $pageMeta = $pageStore->getMetaByNum($pageSchoolNum); // isOnboarded re-checked Square } $pageIssues = is_array($pageMeta['status_issues'] ?? null) ? $pageMeta['status_issues'] : []; ?> School Square Account Setup

    🏫 School Square Setup

    Connect your school's Square account

    Connected to Square' . htmlspecialchars((string)$pageMeta['merchant_name']) . '' : ''; ?>. The Pay with Square button is live for parents.
    Connected to Square, but the account can't take payments yet, so parents don't see the button.
    0): ?>