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 <noreply@anthropic.com>
This commit is contained in:
pavlov346346-source 2026-09-13 17:08:58 +03:00
parent 5d73b2c37e
commit fe029715eb

179
ops/timeweb/api-proxy.php Normal file
View File

@ -0,0 +1,179 @@
<?php
/**
* api.caterium.ru -> 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 <img src>, 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;