diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c0d9f9a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the published SDK bytes, including whitespace inside template strings. +public/vendor/supabase-2.112.4.min.js -text -diff diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index a7260d4..cb93866 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -16,5 +16,6 @@ jobs: - run: npm ci - run: npm audit --audit-level=high - run: npm run check:deploy + - run: php -l public/api/index.php && php -l ops/timeweb/api-proxy.php - run: npx playwright install --with-deps chromium webkit - run: npm run test:e2e diff --git a/docs/release-manifest.json b/docs/release-manifest.json index 2f838b5..78319c6 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v103-20260918-help-center", + "pwaCache": "v104-20260918-russia-proxy", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, diff --git a/ops/timeweb/README.md b/ops/timeweb/README.md index dd7dfac..b76b8f7 100644 --- a/ops/timeweb/README.md +++ b/ops/timeweb/README.md @@ -1,5 +1,35 @@ # Timeweb fallback auto-deploy +## Backend access without external browser dependencies + +The browser loads the pinned Supabase SDK from `public/vendor/`, then sends +Auth, REST, Storage and Functions traffic to the same-origin PHP entry point +`https://app.caterium.ru/api/index.php?__caterium_path=...`. +The query route is deliberate: nginx can serve static extensions before Apache, +so rewriting a storage URL ending in `.jpg` is insufficient on this hosting. + +`public/api/index.php` must be identical to `ops/timeweb/api-proxy.php`. +The app copy is deployed with the release; the dedicated API host copy is +installed separately at `/home/c/ci503744/public_html/api-proxy/index.php`. +Only `app.caterium.ru` and `api.caterium.ru` are accepted, with a fixed Supabase +upstream and the existing allowlist of backend paths. Browser authorization is +forwarded unchanged; RLS remains enforced. Responses are private/no-store, and +the service worker never caches `/api/`. + +Safe reads and password login may retry on `api.caterium.ru`. Direct Supabase +fallback is disabled. Writes and refresh-token exchanges are not replayed; +uncertain saves retain the existing read/revision recovery path. The Supabase +client still uses the project URL internally, preserving existing auth sessions. + +Updates use HTTP: a revision-only request every 20 seconds while visible, and +chat refresh every 10 seconds. Unchanged bases are not downloaded again. Requests +from the previous account are discarded. Typing/online indicators require a +future WebSocket-capable proxy and are not advertised by the HTTP mode. + +This covers application data and media. Optional map/geocoding providers remain +external and do not gate login or order loading. If the login page itself cannot +open, diagnose DNS/TLS/operator connectivity separately. + Use this only for the Year/shared-hosting fallback where system `crontab` is unavailable and scheduling is configured in the Timeweb panel. ## Production target: app.caterium.ru diff --git a/ops/timeweb/api-proxy.php b/ops/timeweb/api-proxy.php index 8190d05..11fb0e1 100644 --- a/ops/timeweb/api-proxy.php +++ b/ops/timeweb/api-proxy.php @@ -1,23 +1,26 @@ Supabase managed backend reverse proxy. + * Caterium same-origin /api and api.caterium.ru -> Supabase HTTP proxy. * Forwards only REST/Auth/Storage/Edge Functions HTTP traffic. - * Realtime/WebSocket is intentionally NOT proxied here (see ops/timeweb/README.md). + * Browser updates use authenticated HTTP polling; this is not a WebSocket proxy. * Upstream host is a fixed constant - never derived from request input (no open-proxy risk). */ const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.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. +// The same source is deployed to the dedicated API host and app /api/index.php. +// Never accept another Host or derive the upstream host from browser input. $requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); -if ($requestHost !== SERVE_HOST) { +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY); +if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) { + $path = $_GET['__caterium_path']; + $query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) { + return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path'; + })); +} elseif ($requestHost !== 'api.caterium.ru') { http_response_code(404); exit; } @@ -34,7 +37,7 @@ const FORWARD_REQUEST_HEADERS = [ ]; const STRIP_RESPONSE_HEADERS = [ - 'transfer-encoding', 'connection', 'content-encoding', 'content-length', + 'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie', ]; function send_cors_headers(): void @@ -72,15 +75,14 @@ function request_headers(): array } send_cors_headers(); +header('Cache-Control: private, no-store'); +header('X-Content-Type-Options: nosniff'); 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) { @@ -89,7 +91,7 @@ foreach (ALLOWED_PREFIXES as $prefix) { } } -if (!$allowed) { +if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']); @@ -125,6 +127,7 @@ curl_setopt_array($ch, [ CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_NOBODY => $method === 'HEAD', ]); if ($body !== null && $body !== '') { @@ -165,6 +168,9 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) { if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) { continue; } + // Backend HTTP endpoints do not require external redirects. Keep any + // upstream redirect on the dedicated API host, including signed downloads. + if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line); header($line, false); } @@ -173,7 +179,7 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) { // 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); + $respBody = str_replace(UPSTREAM, 'https://api.caterium.ru', $respBody); } echo $respBody; diff --git a/public/api/index.php b/public/api/index.php new file mode 100644 index 0000000..11fb0e1 --- /dev/null +++ b/public/api/index.php @@ -0,0 +1,185 @@ + Supabase HTTP proxy. + * Forwards only REST/Auth/Storage/Edge Functions HTTP traffic. + * Browser updates use authenticated HTTP polling; this is not a WebSocket proxy. + * Upstream host is a fixed constant - never derived from request input (no open-proxy risk). + */ + +const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co'; + +const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/']; + +// The same source is deployed to the dedicated API host and app /api/index.php. +// Never accept another Host or derive the upstream host from browser input. +$requestHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY); +if ($requestHost === 'app.caterium.ru' && $path === '/api/index.php' && is_string($_GET['__caterium_path'] ?? null)) { + $path = $_GET['__caterium_path']; + $query = implode('&', array_filter(explode('&', $query ?? ''), static function ($part) { + return urldecode(explode('=', $part, 2)[0]) !== '__caterium_path'; + })); +} elseif ($requestHost !== 'api.caterium.ru') { + 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', 'cache-control', +]; + +const STRIP_RESPONSE_HEADERS = [ + 'transfer-encoding', 'connection', 'content-encoding', 'content-length', 'cache-control', 'expires', 'pragma', 'set-cookie', +]; + +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(); +header('Cache-Control: private, no-store'); +header('X-Content-Type-Options: nosniff'); + +if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { + http_response_code(204); + exit; +} + +$allowed = false; +foreach (ALLOWED_PREFIXES as $prefix) { + if (strpos($path, $prefix) === 0) { + $allowed = true; + break; + } +} + +if (!$allowed || strpos(rawurldecode($path), '..') !== false || preg_match('/[\\\\?#\x00-\x20]/', $path)) { + 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, + CURLOPT_NOBODY => $method === 'HEAD', +]); + +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; + } + // Backend HTTP endpoints do not require external redirects. Keep any + // upstream redirect on the dedicated API host, including signed downloads. + if ($headerName === 'location') $line = str_replace(UPSTREAM, 'https://api.caterium.ru', $line); + 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, 'https://api.caterium.ru', $respBody); +} + +echo $respBody; diff --git a/public/app-runtime.js b/public/app-runtime.js index 807a7a3..853be01 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -2158,7 +2158,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const DB_NAME = 'SunCloudV2'; const DB_VERSION = 1; const BUCKET = 'sun-media'; - const SUPABASE_JS = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.112.4'; + const SUPABASE_JS = '/vendor/supabase-2.112.4.min.js'; const MEDIA_PREFIX = '__sun_media__:'; const SYNC_DEBOUNCE = 1800; @@ -2175,7 +2175,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; const SUPABASE_API_PROXY = 'https://api.caterium.ru'; const PROXY_FETCH_TIMEOUT_MS = 12000; - const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); + const supabaseProxyFetch=window.CateriumCloudTransport.create({upstream:DEFAULT_SUPABASE_URL,proxy:location.origin+'/api/index.php',fallbackProxy:SUPABASE_API_PROXY,timeout:PROXY_FETCH_TIMEOUT_MS}); let config = loadConfig(); let client = null; @@ -2189,7 +2189,8 @@ window.SUN_LEGACY_CATALOG_V175=[]; let membershipsLoaded = false; let membershipError = ''; let membershipLoad = null; - let realtimeChannel = null; + let remotePollTimer = null; + let remotePollBusy = false; let syncTimer = null; let networkRetryTimer = null; let isSyncing = false; @@ -3353,14 +3354,27 @@ window.SUN_LEGACY_CATALOG_V175=[]; catch(error){handleError(error,'Не удалось создать приглашение.');} } - function unsubscribeRealtime(){if(realtimeChannel&&client){try{client.removeChannel(realtimeChannel);}catch(_){}}realtimeChannel=null;} + function unsubscribeRealtime(){clearInterval(remotePollTimer);remotePollTimer=null;} + async function pollRemoteChanges(){ + if(remotePollBusy||document.hidden||!navigator.onLine||isSyncing||supportMode||!workspaceMigrated()||!session?.user||!workspace?.id)return; + const startedUser=session.user.id,startedWorkspace=workspace.id,startedClient=client; + const current=()=>!signOutInProgress&&client===startedClient&&session?.user?.id===startedUser&&workspace?.id===startedWorkspace; + remotePollBusy=true; + try{ + // Poll only the revision; download/merge the full base only after a change. + // The same RLS and authorization apply as to the previous socket channel. + const {data,error}=await client.from('sun_app_state').select('revision').eq('workspace_id',startedWorkspace).maybeSingle(); + if(error)throw error; + const baseline=await getBaseline(); + if(current()&&data&&Number(data.revision)!==Number(baseline?.revision))await syncNow({quiet:true}); + }catch(_){/* A later poll retries; normal saves retain their visible error/retry UI. */} + finally{remotePollBusy=false;} + } function subscribeRealtime(){ unsubscribeRealtime();if(!client||!session||!workspace?.id)return; - realtimeChannel=client.channel(`sun-state-${workspace.id}-${clientId()}`).on('postgres_changes',{event:'INSERT',schema:'public',table:'sun_sync_events',filter:`workspace_id=eq.${workspace.id}`},async payload=>{ - if(payload?.new?.client_id===clientId())return; - try{const row=await fetchRemoteRow();if(row)await handleRealtimeRow(row);}catch(error){handleError(error,'Не удалось получить изменения с другого устройства.');} - }).subscribe(status=>{if(status==='CHANNEL_ERROR')setStatus('error','Realtime не подключился. Обычная синхронизация продолжит работать.');}); + remotePollTimer=setInterval(pollRemoteChanges,20000); } + document.addEventListener('visibilitychange',()=>{if(remotePollTimer&&!document.hidden)pollRemoteChanges();}); function handleError(error, message) { if(signOutInProgress)return; @@ -5513,15 +5527,12 @@ window.SUN_LEGACY_CATALOG_V175=[]; let messages = []; let activeThread = null; let workspaceChannel = null; - let threadChannel = null; let workspaceChannelId = ''; let threadChannelId = ''; let online = new Set(); let pendingFiles = []; let loadingThreads = false; let loadingMessages = false; - let typingStopTimer = null; - let lastTypingSent = 0; let typingUsers = new Map(); let sidebarMode = 'general'; let chatDock = 'global'; @@ -5643,7 +5654,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function activeSubtitle(t){ if(!t)return ''; if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'онлайн':esc(ROLE_LABELS[m?.role]||'сотрудник');} - if(t.kind==='company'){const count=members.length,on=members.filter(m=>online.has(String(m.user_id))).length;return `${count} сотрудников · ${on} онлайн`;} + if(t.kind==='company')return `${members.length} сотрудников`; return 'Обсуждение заказа'; } @@ -5676,7 +5687,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; async function hydrateAttachments(){ const c=client();if(!c)return; const links=qa('[data-chat-attachment]',$('sunChatBodyV29')); - for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=r.data?.signedUrl;if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`Вложение`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}} + for(const a of links){const path=a.dataset.chatAttachment;if(!path)continue;try{const r=await c.storage.from('sun-chat').createSignedUrl(path,3600);if(r.error)throw r.error;const url=window.CateriumCloudTransport.mediaUrl(r.data?.signedUrl);if(!url)continue;a.href=url;const type=a.dataset.chatType||'';if(type.startsWith('image/')){const p=a.querySelector('.sun-chat-file-preview');if(p)p.innerHTML=`Вложение`;}}catch(e){a.onclick=ev=>{ev.preventDefault();toast('Не удалось открыть вложение.','error')}}} } function renderTyping(){const root=$('sunChatTypingV29');if(!root)return;const names=[...typingUsers.values()].filter(x=>x&&x.user_id!==me()&&Date.now()-x.at<3500).map(x=>x.name||'Сотрудник');root.textContent=names.length?`${names.slice(0,2).join(', ')} ${names.length>1?'печатают':'печатает'}…`:'';} @@ -5685,10 +5696,14 @@ window.SUN_LEGACY_CATALOG_V175=[]; async function loadThreads({quiet=false}={}){ if(!available()||loadingThreads)return; + const startedWorkspace=workspace().id,startedUser=me(); + const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser; loadingThreads=true; try{ const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id}); + if(!current())return; const [t,m]=await Promise.all([rpc('sun_chat_list_threads_v29',{p_workspace:ws.id}),rpc('sun_chat_list_members_v29',{p_workspace:ws.id})]); + if(!current())return; threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[]; if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};} renderSidebar();renderHead();updateUnreadBadges(); @@ -5697,11 +5712,16 @@ window.SUN_LEGACY_CATALOG_V175=[]; async function loadMessages({mark=true,quiet=false}={}){ if(!activeThread||!available()||loadingMessages)return; - loadingMessages=true;renderMessages(); + const startedThread=activeThread.thread_id,startedWorkspace=workspace().id,startedUser=me(); + const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser&&activeThread?.thread_id===startedThread; + let changed=!quiet; + loadingMessages=true;if(!quiet)renderMessages(); try{ - const data=await rpc('sun_chat_list_messages_v29',{p_thread:activeThread.thread_id,p_limit:120,p_before:null});messages=Array.isArray(data)?data:[]; + const data=await rpc('sun_chat_list_messages_v29',{p_thread:startedThread,p_limit:120,p_before:null}); + if(!current())return; + const next=Array.isArray(data)?data:[];changed=changed||JSON.stringify(next)!==JSON.stringify(messages);messages=next; if(mark){const t=threadById(activeThread.thread_id);if(Number(t?.unread_count||0)>0){await rpc('sun_chat_mark_read_v29',{p_thread:activeThread.thread_id});await loadThreads({quiet:true});}} - }catch(e){if(!quiet)toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;renderMessages();} + }catch(e){if(!quiet&¤t())toast(e.message||String(e),'error',6500)}finally{loadingMessages=false;if(current()&&changed)renderMessages();} } async function openThread(t){ @@ -5776,34 +5796,26 @@ window.SUN_LEGACY_CATALOG_V175=[]; } async function ensureWorkspaceChannel(){ - if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return; - if(workspaceChannel&&workspaceChannelId===ws.id)return; - unsubscribeWorkspace();workspaceChannelId=ws.id; - try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} - const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch; - ch.on('broadcast',{event:'chat_changed'},async()=>{await loadThreads({quiet:true});if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh&&Number(fresh.unread_count||0)>0)await loadMessages({mark:true,quiet:true});else renderMessages();}}) - .on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) - .on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) - .on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();}) - .subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}}); + if(!available())return unsubscribeWorkspace(); + const identity=workspace().id+':'+me(); + if(workspaceChannel&&workspaceChannelId===identity)return; + if(workspaceChannelId&&workspaceChannelId!==identity){activeThread=null;threads=[];members=[];messages=[];pendingFiles=[];renderMessages();renderPending();} + unsubscribeWorkspace();workspaceChannelId=identity; + workspaceChannel=setInterval(async()=>{ + if(document.hidden||!navigator.onLine||!available()||workspaceChannelId!==identity)return; + await loadThreads({quiet:true}); + if(workspaceChannelId!==identity)return; + const main=$('sunChatMainV29'); + if(activeThread&&main?.getClientRects().length)await loadMessages({mark:true,quiet:true}); + },10000); } - function unsubscribeWorkspace(){if(workspaceChannel){try{client()?.removeChannel?.(workspaceChannel)}catch(_){}workspaceChannel=null;}workspaceChannelId='';online=new Set();unsubscribeThread();} + function unsubscribeWorkspace(){clearInterval(workspaceChannel);workspaceChannel=null;workspaceChannelId='';online=new Set();unsubscribeThread();} async function subscribeThread(id){ - if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id; - try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){} - const ch=c.channel(`sun-chat-thread:${id}`,{config:{private:true,broadcast:{ack:false,self:false}}});threadChannel=ch; - ch.on('broadcast',{event:'message'},()=>loadMessages({mark:true,quiet:true})) - .on('broadcast',{event:'read'},()=>loadMessages({mark:false,quiet:true})) - .on('broadcast',{event:'typing'},payload=>{const p=payload?.payload||{};if(!p.user_id||String(p.user_id)===String(me()))return;if(p.typing)typingUsers.set(String(p.user_id),{user_id:p.user_id,name:p.name||'Сотрудник',at:Date.now()});else typingUsers.delete(String(p.user_id));renderTyping();setTimeout(()=>renderTyping(),3600)}) - .subscribe(); - } - function unsubscribeThread(){if(threadChannel){try{client()?.removeChannel?.(threadChannel)}catch(_){}threadChannel=null;}threadChannelId='';typingUsers.clear();renderTyping();} - async function sendTyping(flag){ - if(!threadChannel||!activeThread)return;const now=Date.now();if(flag&&now-lastTypingSent<700){clearTimeout(typingStopTimer);typingStopTimer=setTimeout(()=>sendTyping(false),1800);return}lastTypingSent=now; - try{await threadChannel.send({type:'broadcast',event:'typing',payload:{user_id:me(),name:meName(),typing:Boolean(flag)}})}catch(_){} - clearTimeout(typingStopTimer);if(flag)typingStopTimer=setTimeout(()=>sendTyping(false),1800); + if(!available()||!id)return;unsubscribeThread();threadChannelId=id; } + function unsubscribeThread(){threadChannelId='';typingUsers.clear();renderTyping();} + function sendTyping(){/* Typing/presence require a WebSocket-capable proxy. */} function updateOrderChatBadge(){ const b=$('sunOrderChatTabV31');if(!b)return;const id=currentOrderId(),t=id?orderThreadByOrder(id):null,n=Number(t?.unread_count||0);let badge=b.querySelector('.sun-chat-mini-badge');if(n>0){if(!badge){badge=document.createElement('span');badge.className='sun-chat-mini-badge';b.appendChild(badge);}badge.textContent=n>99?'99+':String(n);}else badge?.remove(); @@ -5851,7 +5863,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(safeStateChanged,60)); window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60)); window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()}); - window.addEventListener('beforeunload',()=>{try{workspaceChannel?.untrack?.()}catch(_){}}); + window.addEventListener('beforeunload',unsubscribeWorkspace); let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250); } diff --git a/public/core/cloud-transport.js b/public/core/cloud-transport.js index 50c7183..b5c7c19 100644 --- a/public/core/cloud-transport.js +++ b/public/core/cloud-transport.js @@ -3,9 +3,21 @@ const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']); const isTransient=error=>/TimeoutError|AbortError|CATERIUM_TIMEOUT|Failed to fetch|fetch failed|NetworkError|Load failed|network request failed|превышено время ожидания/i.test(String(error?.message||error||'')); const errorMessage=error=>isTransient(error)?'Сервер временно не отвечает. Изменения остаются на этом устройстве. Проверьте соединение и повторите загрузку.':String(error?.message||error||'Неизвестная ошибка'); - function create({upstream,proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){ + function routeUrl(base,url){ + // Explicit PHP entry point also works for storage paths ending in .jpg: + // Timeweb serves static extensions before Apache rewrite rules. + return base.endsWith('.php')?base+'?__caterium_path='+encodeURIComponent(url.pathname)+(url.search?'&'+url.search.slice(1):''):base+url.pathname+url.search; + } + function mediaUrl(value){ + if(!value)return value; + const url=new URL(value,location.href); + if(['https://usfjwhztqoopzzfmfbis.supabase.co','https://api.caterium.ru'].includes(url.origin)&&url.pathname.startsWith('/storage/v1/'))return routeUrl(location.origin+'/api/index.php',url); + return value; + } + function create({upstream,proxy,fallbackProxy=proxy,timeout=12000,fallbackTimeout=15000,writeTimeout=35000,cooldown=60000}){ const origin=new URL(upstream).origin; - let directUntil=0; + proxy=proxy.replace(/\/$/,'');fallbackProxy=fallbackProxy.replace(/\/$/,''); + let fallbackUntil=0; return async function(input,init={}){ const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin; // A Request used as RequestInit exposes its ReadableStream body. Safari @@ -14,7 +26,7 @@ const requestInit={method:original.method,headers:original.headers,body,credentials:original.credentials,mode:original.mode,cache:original.cache,redirect:original.redirect,referrer:original.referrer,referrerPolicy:original.referrerPolicy,integrity:original.integrity,keepalive:original.keepalive}; const read=original.method==='GET'||original.method==='HEAD'||(original.method==='POST'&&url.pathname.startsWith('/rest/v1/rpc/')&&READ_RPCS.has(url.pathname.slice('/rest/v1/rpc/'.length))); const passwordLogin=original.method==='POST'&&url.pathname==='/auth/v1/token'&&url.searchParams.get('grant_type')==='password'; - const safeFallback=isBackend&&(read||passwordLogin); + const safeFallback=isBackend&&proxy!==fallbackProxy&&(read||passwordLogin); const expectJson=original.method!=='HEAD'&&(passwordLogin||url.pathname.startsWith('/rest/v1/rpc/')||(read&&(url.pathname.startsWith('/rest/v1/')||url.pathname.startsWith('/auth/v1/')))); async function attempt(target,limit){ const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason); @@ -31,18 +43,18 @@ return response; }finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)} } - const directFirst=isBackend&&Date.now(){ 'use strict'; const VERSION='17.7.3'; - const RELEASE='20260918-help-center'; + const RELEASE='20260918-russia-proxy'; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; function installAuthBoot(){ diff --git a/public/index.html b/public/index.html index 5852d56..457b0f2 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index dbc92e6..9f4f081 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,7 @@ -const CACHE='sun-catering-pwa-v103-20260918-help-center'; -const VERSION='20260918-help-center'; +const CACHE='sun-catering-pwa-v104-20260918-russia-proxy'; +const VERSION='20260918-russia-proxy'; const CORE=[ + './vendor/supabase-2.112.4.min.js', `./core/help-center.js?v=${VERSION}`,`./core/help-center.css?v=${VERSION}`,`./help/knowledge-v1.json?v=${VERSION}`, './','./index.html',`./core/mobile-order.js?v=${VERSION}`,`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`, `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, diff --git a/public/vendor/README.md b/public/vendor/README.md new file mode 100644 index 0000000..6e8d148 --- /dev/null +++ b/public/vendor/README.md @@ -0,0 +1,12 @@ +# Supabase browser SDK + +`supabase-2.112.4.min.js` is the unmodified `dist/umd/supabase.js` from +the official npm package `@supabase/supabase-js@2.112.4`. + +Source: https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.4.tgz + +SHA-256: `f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c` + +MIT license: `supabase-LICENSE.txt`, from the upstream Supabase repository. +Hosting the SDK here keeps login independent of external CDNs. Keep this +version aligned with the runtime loader, PWA assets and browser tests. diff --git a/public/vendor/supabase-2.112.4.min.js b/public/vendor/supabase-2.112.4.min.js new file mode 100644 index 0000000..3783bf2 Binary files /dev/null and b/public/vendor/supabase-2.112.4.min.js differ diff --git a/public/vendor/supabase-LICENSE.txt b/public/vendor/supabase-LICENSE.txt new file mode 100644 index 0000000..ddeba6a --- /dev/null +++ b/public/vendor/supabase-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Supabase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/backend-cutover.mjs b/tests/backend-cutover.mjs index f374b21..9c76d8a 100644 --- a/tests/backend-cutover.mjs +++ b/tests/backend-cutover.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs'; import vm from 'node:vm'; import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; const source=fs.readFileSync('public/app-runtime.js','utf8'); const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {')); const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy'; @@ -8,4 +9,6 @@ const run=config=>{const storage=new Map([['sunCloudV2Config',JSON.stringify(con for(const retired of ['https://cksuehzcimitsxmeloes.supabase.co','https://api.caterium.ru']){const {result,storage}=run({url:retired,key:'old-key',workspaceId:'old-company',autoSync:false});assert.equal(result.url,url);assert.equal(result.key,key);assert.equal(result.workspaceId,'');assert.equal(result.legacyLocalWorkspaceId,'old-company');assert.equal(result.autoSync,false);assert.equal(storage.get('sunOrders'),'[{"id":"old-order"}]');assert.ok(storage.has('cateriumRetiredBackend20260917'));} const {result}=run({url,key,workspaceId:'new-company',localWorkspaceId:'new-company',tenantStorageReady:true});assert.equal(result.workspaceId,'new-company');assert.equal(result.localWorkspaceId,'new-company'); assert.equal(run({}).result.url,url); +assert.equal(fs.readFileSync('public/api/index.php','utf8').replaceAll('\r\n','\n'),fs.readFileSync('ops/timeweb/api-proxy.php','utf8').replaceAll('\r\n','\n'),'both deployed proxy entry points use the same reviewed source'); +assert.equal(createHash('sha256').update(fs.readFileSync('public/vendor/supabase-2.112.4.min.js','utf8').replaceAll('\r\n','\n')).digest('hex'),'f8ce7fab799af1916019cbd0b485b39bb80dbdbc6dc062909a751c9e5198e04c','vendored SDK matches the published package'); console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable'); diff --git a/tests/login-recovery.spec.mjs b/tests/login-recovery.spec.mjs index 4af126b..1e7da9f 100644 --- a/tests/login-recovery.spec.mjs +++ b/tests/login-recovery.spec.mjs @@ -1,6 +1,42 @@ import fs from 'node:fs'; import {test,expect} from '@playwright/test'; +test('same-origin proxy preserves encoded storage paths, tokens, filters and never falls back to Supabase',async({page})=>{ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); + await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); + const result=await page.evaluate(async()=>{ + const calls=[],upstream='https://usfjwhztqoopzzfmfbis.supabase.co',proxy=location.origin+'/api/index.php'; + const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://api.caterium.ru'}); + window.fetch=async request=>{calls.push(request.url);return new Response('unavailable',{status:503})}; + await send(upstream+'/rest/v1/sun_app_state?select=revision&workspace_id=eq.company'); + const path='/storage/v1/object/sign/sun-chat/company/photo%20one.jpg',query='?token=a%2Bb%2Fc%3D&download=photo.jpg'; + const media=CateriumCloudTransport.mediaUrl(upstream+path+query),url=new URL(media); + return {calls,media,pathname:url.pathname,path:url.searchParams.get('__caterium_path'),token:url.searchParams.get('token'),download:url.searchParams.get('download'),external:CateriumCloudTransport.mediaUrl('https://example.invalid/image.jpg')}; + }); + expect(result.calls).toHaveLength(2);expect(result.calls.every(u=>!u.includes('.supabase.co'))).toBe(true); + expect(new URL(result.calls[0]).searchParams.get('workspace_id')).toBe('eq.company'); + expect(result.pathname).toBe('/api/index.php');expect(result.path).toBe('/storage/v1/object/sign/sun-chat/company/photo%20one.jpg');expect(result.token).toBe('a+b/c=');expect(result.download).toBe('photo.jpg'); + expect(result.external).toBe('https://example.invalid/image.jpg'); +}); + +test('revision polling reads only changed bases and ignores the previous account response',async({page})=>{ + await syncHarness(page); + const result=await page.evaluate(async()=>{ + let revision=1,readCount=0,resolveRevision;const calls=[]; + const payload={format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[]}}}; + const query={select(fields){calls.push(fields);return this},eq(key,value){calls.push([key,value]);return this},async maybeSingle(){readCount++;return revision===99?new Promise(r=>resolveRevision=r):{data:{revision}}}}; + const c={from(table){calls.push(table);return query},async rpc(name){calls.push(name);return {data:[{revision,payload}]}}}; + SunCloudV2.testInit(c);await SunCloudV2.testBaseline({revision:1,payload}); + await SunCloudV2.testPoll();const unchanged=calls.splice(0); + revision=2;await SunCloudV2.testPoll();const changed=calls.splice(0); + revision=99;const pending=SunCloudV2.testPoll();await SunCloudV2.testPoll();SunCloudV2.testLeave();resolveRevision({data:{revision:99}});await pending; + return {unchanged,changed,late:calls,readCount}; + }); + expect(result.unchanged).toEqual(['sun_app_state','revision',['workspace_id','company']]); + expect(result.changed).toContain('sun_fetch_app_state'); + expect(result.late).not.toContain('sun_fetch_app_state');expect(result.readCount).toBe(3); +}); + test('Safari without streaming uploads can send login and binary bodies through fallback',async({page})=>{ await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''})); await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'}); @@ -9,7 +45,7 @@ test('Safari without streaming uploads can send login and binary bodies through window.Request=new Proxy(NativeRequest,{construct(Target,args){if(args[1]?.body instanceof ReadableStream)throw new TypeError('ReadableStream uploading is not supported');return new Target(...args)}}); const calls=[]; window.fetch=async request=>{calls.push({url:request.url,body:[...new Uint8Array(await request.arrayBuffer())],type:request.headers.get('content-type')});return request.url.includes('proxy.example')?new Response('',{status:503}):new Response('{}',{headers:{'content-type':'application/json'}})}; - const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',cooldown:0}); + const send=CateriumCloudTransport.create({upstream:'https://backend.example',proxy:'https://proxy.example',fallbackProxy:'https://secondary.example',cooldown:0}); const body=JSON.stringify({email:'test@example.invalid',password:'test-password'}); await send('https://backend.example/auth/v1/token?grant_type=password',{method:'POST',headers:{'content-type':'application/json'},body}); await send('https://backend.example/storage/v1/object/test/image',{method:'POST',headers:{'content-type':'image/png'},body:new Uint8Array([0,255,137,80]).buffer}); @@ -69,20 +105,20 @@ test('slow connections use a healthy route, allow longer saves and preserve call const upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[]; const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}}); window.fetch=(request,{signal})=>{calls.push(request.url);return request.url.startsWith(proxy)?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):Promise.resolve(ok())}; - const send=CateriumCloudTransport.create({upstream,proxy,timeout:10,fallbackTimeout:100,writeTimeout:100}); + const send=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,fallbackTimeout:100,writeTimeout:100}); await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}'}); await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'}); const routes=calls.splice(0); window.fetch=(request,{signal})=>{calls.push(request.url);return new Promise((resolve,reject)=>{const timer=setTimeout(()=>resolve(ok()),35);signal.addEventListener('abort',()=>{clearTimeout(timer);reject(signal.reason)},{once:true})})}; - const slowSave=CateriumCloudTransport.create({upstream,proxy,timeout:10,writeTimeout:100}); + const slowSave=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',timeout:10,writeTimeout:100}); const saved=(await slowSave(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})).status;const saveCalls=calls.splice(0); - const timeoutSend=CateriumCloudTransport.create({upstream,proxy,writeTimeout:5});let timeoutName=''; + const timeoutSend=CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',writeTimeout:5});let timeoutName=''; try{await timeoutSend(upstream+'/rest/v1/rpc/sun_save_app_state_v17',{method:'POST',body:'{}'})}catch(e){timeoutName=e.name}const timeoutCalls=calls.splice(0); const controller=new AbortController();controller.abort(new DOMException('Account changed','AbortError'));let cancel=''; try{await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',body:'{}',signal:controller.signal})}catch(e){cancel=e.message} return {routes,saved,saveCalls,timeoutName,timeoutCalls,cancel,cancelCalls:calls}; }); - expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','backend.example.invalid','backend.example.invalid']); + expect(result.routes.map(u=>new URL(u).host)).toEqual(['proxy.example.invalid','secondary.example.invalid','secondary.example.invalid']); expect(result.saved).toBe(200);expect(result.saveCalls).toHaveLength(1);expect(result.timeoutName).toBe('TimeoutError');expect(result.timeoutCalls).toHaveLength(1); expect(result.cancel).toBe('Account changed');expect(result.cancelCalls).toHaveLength(0); }); @@ -91,7 +127,7 @@ async function syncHarness(page){ await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:''}));await page.goto('/index.html'); await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); - source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`); + source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testPoll:pollRemoteChanges,testUploadDataUrl:uploadDataUrl,testInit:c=>{client=c;session={user:{id:'test-user'}};workspace={id:'company',role:'admin'};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'},testBaseline:setBaseline,testLeave:()=>{session=null;workspace=null},`); await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); } @@ -139,7 +175,7 @@ test('cloud reads and password login recover from empty proxy responses without const result=await page.evaluate(async()=>{ const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read'; window.fetch=async request=>{calls.push({url:request.url,body:await request.text(),auth:request.headers.get('authorization')});if(scenario==='denied')return new Response('{"error":"denied"}',{status:401});if(scenario==='write')return new Response('',{status:503});return request.url.startsWith(proxy)?new Response('',{headers:{'content-type':'text/html'}}):new Response('[{"id":"company"}]',{headers:{'content-type':'application/json'}})}; - const send=window.CateriumCloudTransport.create({upstream,proxy,cooldown:0}); + const send=window.CateriumCloudTransport.create({upstream,proxy,fallbackProxy:'https://secondary.example.invalid',cooldown:0}); const read=await (await send(upstream+'/rest/v1/rpc/sun_my_workspaces',{method:'POST',headers:{Authorization:'Bearer test-token'},body:'{}'})).json(); const readCalls=calls.splice(0); await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{"email":"test@example.invalid","password":"test"}'});const authCalls=calls.splice(0); @@ -148,7 +184,7 @@ test('cloud reads and password login recover from empty proxy responses without scenario='denied';const denied=await send(upstream+'/auth/v1/token?grant_type=password',{method:'POST',body:'{}'});const deniedCalls=calls.splice(0); return {read,readCalls,authCalls,write:write.status,writeCalls,writeError,emptyWriteCalls,denied:denied.status,deniedCalls}; }); - expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://backend.example.invalid/rest/v1/rpc/sun_my_workspaces']); + expect(result.read).toEqual([{id:'company'}]);expect(result.readCalls.map(c=>c.url)).toEqual(['https://proxy.example.invalid/rest/v1/rpc/sun_my_workspaces','https://secondary.example.invalid/rest/v1/rpc/sun_my_workspaces']); expect(result.readCalls.every(c=>c.auth==='Bearer test-token'&&c.body==='{}')).toBe(true); expect(result.authCalls).toHaveLength(2);expect(result.authCalls[0].body).toBe(result.authCalls[1].body); expect(result.write).toBe(503);expect(result.writeCalls).toHaveLength(1);expect(result.writeError).toContain('пустой');expect(result.emptyWriteCalls).toHaveLength(1); @@ -186,29 +222,35 @@ test('a failed company load replaces the stale login form with an actionable ret await expect(page.getByRole('button',{name:'Повторить загрузку',exact:true})).toBeEnabled();await expect(page.locator('body > header')).toBeHidden(); }); -test('real SDK login opens the ordinary app when the proxy returns empty successful responses',async({page})=>{ +for(const primaryFails of [false,true])test('real SDK loads orders with foreign services blocked, primary failure='+primaryFails,async({page})=>{ const userId='11111111-1111-4111-8111-111111111111',workspaceId='22222222-2222-4222-8222-222222222222',expires=Math.floor(Date.now()/1000)+3600; const user={id:userId,aud:'authenticated',role:'authenticated',email:'test@example.invalid',email_confirmed_at:new Date().toISOString(),app_metadata:{provider:'email'},user_metadata:{}}; const token=[{alg:'HS256',typ:'JWT'},{sub:userId,role:'authenticated',aud:'authenticated',exp:expires,iat:expires-3600,aal:'aal1'},'test'].map(x=>typeof x==='string'?x:Buffer.from(JSON.stringify(x)).toString('base64url')).join('.'); - const seen=[],warnings=[];page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())}); + const seen=[],foreign=[],sockets=[],warnings=[]; + page.on('console',m=>{if(m.type()==='warning')warnings.push(m.text())});page.on('websocket',ws=>sockets.push(ws.url())); + await page.route('https://**',r=>{foreign.push(r.request().url());return r.abort()}); const handle=async route=>{ - const request=route.request(),url=new URL(request.url());seen.push(url.host+url.pathname); + const request=route.request(),url=new URL(request.url()),path=url.searchParams.get('__caterium_path')||url.pathname;seen.push({host:url.host,path,method:request.method()}); const headers={'access-control-allow-origin':'*'}; if(request.method()==='OPTIONS')return route.fulfill({status:204,headers}); - if(url.host==='api.caterium.ru')return route.fulfill({status:200,contentType:'text/html',body:'',headers}); + if(primaryFails&&url.pathname==='/api/index.php')return route.fulfill({status:200,contentType:'text/html',body:'',headers}); let body=null; - if(url.pathname==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user}; - else if(url.pathname==='/auth/v1/user')body=user; - else if(url.pathname.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}]; - else if(url.pathname.endsWith('/sun_is_platform_admin'))body=true; + if(path==='/auth/v1/token')body={access_token:token,refresh_token:'test-refresh',token_type:'bearer',expires_in:3600,expires_at:expires,user}; + else if(path==='/auth/v1/user')body=user; + else if(path.endsWith('/sun_my_workspaces'))body=[{id:workspaceId,name:'Test workspace',role:'admin',is_active:true,permissions:{}}]; + else if(path.endsWith('/sun_is_platform_admin'))body=false; + else if(path.endsWith('/sun_fetch_app_state'))body=[{revision:1,payload:{format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:[{id:'proxy-order',event:'Заказ без VPN',contact:'Тестовый клиент',phone:'79990000000',address:'Тестовый адрес',total:2400,lines:[]}]},sunBoxes:{t:'j',v:[]}}}}]; + else if(path.endsWith('/sun_app_state'))body={revision:1}; return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body),headers}); }; - await page.route('**://api.caterium.ru/**',handle);await page.route('**://*.supabase.co/**',handle); + await page.route('**://api.caterium.ru/**',handle);await page.route('**/api/index.php?**',handle); await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.CateriumAuthSecurityV1774&&window.SunCloudV2?.getClient()); await page.locator('#sunGateEmailV3').fill(user.email);await page.locator('#sunGatePasswordV3').fill('test-password');await page.locator('#sunGateSubmitV3').click(); await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000});await expect(page.locator('body > header')).toBeVisible(); expect(await page.evaluate(()=>window.SunCloudV2.getWorkspace()?.id)).toBe(workspaceId); - expect(seen.some(s=>s==='api.caterium.ru/rest/v1/rpc/sun_my_workspaces')).toBe(true); - expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true); + await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.getItem('sunOrders')||'[]').map(o=>o.id))).toContain('proxy-order'); + expect(seen.some(s=>s.path==='/auth/v1/token'&&s.host!=='api.caterium.ru')).toBe(true); + expect(seen.some(s=>s.host==='api.caterium.ru')).toBe(primaryFails); + expect(foreign).toEqual([]);expect(sockets).toEqual([]); expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false); }); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index ffa8595..c642811 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v103-20260918-help-center')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); -check(index.includes('20260918-help-center')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); +check(sw.includes('v104-20260918-russia-proxy')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3'); +check(index.includes('20260918-russia-proxy')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); @@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30 check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.channel==='production','release manifest channel is production'); -check(String(releaseManifest.pwaCache||'').includes('v103-20260918-help-center'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v104-20260918-russia-proxy'),'release manifest points to current PWA cache'); check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); @@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); check(pkg.version==='17.7.3','package version is v17.7.3'); -check(index.includes('20260918-help-center'),'index cache bust is v17.7.3'); -check(sw.includes('v103-20260918-help-center')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); +check(index.includes('20260918-russia-proxy'),'index cache bust is v17.7.3'); +check(sw.includes('v104-20260918-russia-proxy')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index cbb436c..653a48c 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('20260918-help-center')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); -if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-help-center')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); +if(!sw.includes('20260918-russia-proxy')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh'); +if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-russia-proxy')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');