From fe029715ebcbfe14bdb0c7b76be59ae2f9e524bc Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Sun, 13 Sep 2026 17:08:58 +0300 Subject: [PATCH] ops: add api.caterium.ru proxy script to repo, fix auth CORS The deployed PHP reverse proxy at api.caterium.ru only forwarded/allowed a fixed set of CORS request headers, missing x-supabase-api-version which supabase-js v2.112.4's auth client sends on every request. That made the browser reject the preflight and fail the actual login call client-side with a generic "Failed to fetch" (not a server error, so it never showed up in server logs) - every login was broken since the proxy went live. Fixed on the live server and committed the previously SCP-only script here so future edits go through git instead of being SSH-only. Co-Authored-By: Claude Sonnet 5 --- ops/timeweb/api-proxy.php | 179 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 ops/timeweb/api-proxy.php diff --git a/ops/timeweb/api-proxy.php b/ops/timeweb/api-proxy.php new file mode 100644 index 0000000..40e29ca --- /dev/null +++ b/ops/timeweb/api-proxy.php @@ -0,0 +1,179 @@ + Supabase managed backend reverse proxy. + * Forwards only REST/Auth/Storage/Edge Functions HTTP traffic. + * Realtime/WebSocket is intentionally NOT proxied here (see ops/timeweb/README.md). + * Upstream host is a fixed constant - never derived from request input (no open-proxy risk). + */ + +const UPSTREAM = 'https://cksuehzcimitsxmeloes.supabase.co'; +const PUBLIC_BASE = 'https://api.caterium.ru'; +const SERVE_HOST = 'api.caterium.ru'; + +const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/']; + +// Defense in depth: this script lives inside a document root shared with +// caterium.ru (2-site plan limit). The root .htaccess only rewrites into +// here for Host: api.caterium.ru, but a bare direct hit on this file's own +// path (under any host) must still refuse to act as a generic proxy. +$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); +if ($requestHost !== SERVE_HOST) { + http_response_code(404); + exit; +} + +const ALLOWED_ORIGINS = [ + 'https://app.caterium.ru', + 'https://caterium.ru', + 'https://www.caterium.ru', +]; + +const FORWARD_REQUEST_HEADERS = [ + 'authorization', 'apikey', 'content-type', 'prefer', 'range', + 'x-client-info', 'x-supabase-api-version', 'accept-profile', 'content-profile', 'x-upsert', +]; + +const STRIP_RESPONSE_HEADERS = [ + 'transfer-encoding', 'connection', 'content-encoding', 'content-length', +]; + +function send_cors_headers(): void +{ + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if (in_array($origin, ALLOWED_ORIGINS, true)) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: Origin'); + } + header('Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: ' . implode(', ', FORWARD_REQUEST_HEADERS)); + header('Access-Control-Max-Age: 86400'); +} + +function request_headers(): array +{ + if (function_exists('getallheaders')) { + $raw = getallheaders(); + if (is_array($raw)) { + return $raw; + } + } + // Fallback for environments without getallheaders(). + $out = []; + foreach ($_SERVER as $key => $value) { + if (strpos($key, 'HTTP_') === 0) { + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); + $out[$name] = $value; + } + } + if (isset($_SERVER['CONTENT_TYPE'])) { + $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; + } + return $out; +} + +send_cors_headers(); + +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { + http_response_code(204); + exit; +} + +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY); + +$allowed = false; +foreach (ALLOWED_PREFIXES as $prefix) { + if (strpos($path, $prefix) === 0) { + $allowed = true; + break; + } +} + +if (!$allowed) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']); + exit; +} + +$upstreamUrl = UPSTREAM . $path . ($query !== null && $query !== '' ? '?' . $query : ''); + +$incoming = request_headers(); +$incomingLower = []; +foreach ($incoming as $name => $value) { + $incomingLower[strtolower($name)] = $value; +} + +$forwardHeaders = []; +foreach (FORWARD_REQUEST_HEADERS as $name) { + if (isset($incomingLower[$name]) && $incomingLower[$name] !== '') { + $forwardHeaders[] = $name . ': ' . $incomingLower[$name]; + } +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$body = ($method === 'GET' || $method === 'HEAD') ? null : file_get_contents('php://input'); + +$ch = curl_init($upstreamUrl); +curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $forwardHeaders, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, +]); + +if ($body !== null && $body !== '') { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); +} + +$response = curl_exec($ch); + +if ($response === false) { + http_response_code(502); + header('Content-Type: application/json'); + echo json_encode(['error' => 'upstream_unreachable']); + curl_close($ch); + exit; +} + +$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); +$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$rawHeaders = substr($response, 0, $headerSize); +$respBody = substr($response, $headerSize); +curl_close($ch); + +http_response_code($statusCode); + +$responseContentType = ''; +foreach (preg_split('/\r\n/', $rawHeaders) as $line) { + if ($line === '' || stripos($line, 'HTTP/') === 0) { + continue; + } + $colon = strpos($line, ':'); + if ($colon === false) { + continue; + } + $headerName = strtolower(trim(substr($line, 0, $colon))); + if ($headerName === 'content-type') { + $responseContentType = trim(substr($line, $colon + 1)); + } + if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) { + continue; + } + header($line, false); +} + +// Supabase returns absolute upstream URLs inside some JSON bodies (e.g. Storage +// createSignedUrl). Rewrite those to our public host so the browser's follow-up +// request (an , a download link, ...) is proxied too, not sent to +// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads. +if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) { + $respBody = str_replace(UPSTREAM, PUBLIC_BASE, $respBody); +} + +echo $respBody;