Route cloud access through Caterium and vendor login SDK
This commit is contained in:
parent
ff7fe90ccf
commit
71bc56b0cd
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Preserve the published SDK bytes, including whitespace inside template strings.
|
||||||
|
public/vendor/supabase-2.112.4.min.js -text -diff
|
||||||
1
.github/workflows/qa.yml
vendored
1
.github/workflows/qa.yml
vendored
@ -16,5 +16,6 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm audit --audit-level=high
|
- run: npm audit --audit-level=high
|
||||||
- run: npm run check:deploy
|
- 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: npx playwright install --with-deps chromium webkit
|
||||||
- run: npm run test:e2e
|
- run: npm run test:e2e
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"serverReady": true,
|
"serverReady": true,
|
||||||
"workspaceAutoDiscovery": true,
|
"workspaceAutoDiscovery": true,
|
||||||
"invitesTemporarilyDisabled": false,
|
"invitesTemporarilyDisabled": false,
|
||||||
"pwaCache": "v103-20260918-help-center",
|
"pwaCache": "v104-20260918-russia-proxy",
|
||||||
"fullOfferDescriptions": true,
|
"fullOfferDescriptions": true,
|
||||||
"dynamicOfferRows": true,
|
"dynamicOfferRows": true,
|
||||||
"pdfOfferDescriptionFix": true,
|
"pdfOfferDescriptionFix": true,
|
||||||
|
|||||||
@ -1,5 +1,35 @@
|
|||||||
# Timeweb fallback auto-deploy
|
# 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.
|
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
|
## Production target: app.caterium.ru
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* api.caterium.ru -> 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.
|
* 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).
|
* Upstream host is a fixed constant - never derived from request input (no open-proxy risk).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const UPSTREAM = 'https://usfjwhztqoopzzfmfbis.supabase.co';
|
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/'];
|
const ALLOWED_PREFIXES = ['/rest/v1/', '/auth/v1/', '/storage/v1/', '/functions/v1/'];
|
||||||
|
|
||||||
// Defense in depth: this script lives inside a document root shared with
|
// The same source is deployed to the dedicated API host and app /api/index.php.
|
||||||
// caterium.ru (2-site plan limit). The root .htaccess only rewrites into
|
// Never accept another Host or derive the upstream host from browser input.
|
||||||
// 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]);
|
$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);
|
http_response_code(404);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@ -34,7 +37,7 @@ const FORWARD_REQUEST_HEADERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const STRIP_RESPONSE_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
|
function send_cors_headers(): void
|
||||||
@ -72,15 +75,14 @@ function request_headers(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
send_cors_headers();
|
send_cors_headers();
|
||||||
|
header('Cache-Control: private, no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
|
||||||
$query = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_QUERY);
|
|
||||||
|
|
||||||
$allowed = false;
|
$allowed = false;
|
||||||
foreach (ALLOWED_PREFIXES as $prefix) {
|
foreach (ALLOWED_PREFIXES as $prefix) {
|
||||||
if (strpos($path, $prefix) === 0) {
|
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);
|
http_response_code(404);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
|
echo json_encode(['error' => 'not_found', 'message' => 'Path not proxied.']);
|
||||||
@ -125,6 +127,7 @@ curl_setopt_array($ch, [
|
|||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_SSL_VERIFYPEER => true,
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
CURLOPT_SSL_VERIFYHOST => 2,
|
CURLOPT_SSL_VERIFYHOST => 2,
|
||||||
|
CURLOPT_NOBODY => $method === 'HEAD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($body !== null && $body !== '') {
|
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) {
|
if (in_array($headerName, STRIP_RESPONSE_HEADERS, true) || strpos($headerName, 'access-control-') === 0) {
|
||||||
continue;
|
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);
|
header($line, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +179,7 @@ foreach (preg_split('/\r\n/', $rawHeaders) as $line) {
|
|||||||
// request (an <img src>, a download link, ...) is proxied too, not sent to
|
// request (an <img src>, a download link, ...) is proxied too, not sent to
|
||||||
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
|
// *.supabase.co directly. Only touch text/JSON bodies - never binary payloads.
|
||||||
if (stripos($responseContentType, 'application/json') !== false || stripos($responseContentType, 'text/') !== false) {
|
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;
|
echo $respBody;
|
||||||
|
|||||||
185
public/api/index.php
Normal file
185
public/api/index.php
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Caterium same-origin /api and api.caterium.ru -> 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 <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, 'https://api.caterium.ru', $respBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $respBody;
|
||||||
@ -2158,7 +2158,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
const DB_NAME = 'SunCloudV2';
|
const DB_NAME = 'SunCloudV2';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const BUCKET = 'sun-media';
|
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 MEDIA_PREFIX = '__sun_media__:';
|
||||||
const SYNC_DEBOUNCE = 1800;
|
const SYNC_DEBOUNCE = 1800;
|
||||||
|
|
||||||
@ -2175,7 +2175,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
|
const DEFAULT_SUPABASE_KEY = 'sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
|
||||||
const SUPABASE_API_PROXY = 'https://api.caterium.ru';
|
const SUPABASE_API_PROXY = 'https://api.caterium.ru';
|
||||||
const PROXY_FETCH_TIMEOUT_MS = 12000;
|
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 config = loadConfig();
|
||||||
let client = null;
|
let client = null;
|
||||||
@ -2189,7 +2189,8 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
let membershipsLoaded = false;
|
let membershipsLoaded = false;
|
||||||
let membershipError = '';
|
let membershipError = '';
|
||||||
let membershipLoad = null;
|
let membershipLoad = null;
|
||||||
let realtimeChannel = null;
|
let remotePollTimer = null;
|
||||||
|
let remotePollBusy = false;
|
||||||
let syncTimer = null;
|
let syncTimer = null;
|
||||||
let networkRetryTimer = null;
|
let networkRetryTimer = null;
|
||||||
let isSyncing = false;
|
let isSyncing = false;
|
||||||
@ -3353,14 +3354,27 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
catch(error){handleError(error,'Не удалось создать приглашение.');}
|
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(){
|
function subscribeRealtime(){
|
||||||
unsubscribeRealtime();if(!client||!session||!workspace?.id)return;
|
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=>{
|
remotePollTimer=setInterval(pollRemoteChanges,20000);
|
||||||
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 не подключился. Обычная синхронизация продолжит работать.');});
|
|
||||||
}
|
}
|
||||||
|
document.addEventListener('visibilitychange',()=>{if(remotePollTimer&&!document.hidden)pollRemoteChanges();});
|
||||||
|
|
||||||
function handleError(error, message) {
|
function handleError(error, message) {
|
||||||
if(signOutInProgress)return;
|
if(signOutInProgress)return;
|
||||||
@ -5513,15 +5527,12 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
let messages = [];
|
let messages = [];
|
||||||
let activeThread = null;
|
let activeThread = null;
|
||||||
let workspaceChannel = null;
|
let workspaceChannel = null;
|
||||||
let threadChannel = null;
|
|
||||||
let workspaceChannelId = '';
|
let workspaceChannelId = '';
|
||||||
let threadChannelId = '';
|
let threadChannelId = '';
|
||||||
let online = new Set();
|
let online = new Set();
|
||||||
let pendingFiles = [];
|
let pendingFiles = [];
|
||||||
let loadingThreads = false;
|
let loadingThreads = false;
|
||||||
let loadingMessages = false;
|
let loadingMessages = false;
|
||||||
let typingStopTimer = null;
|
|
||||||
let lastTypingSent = 0;
|
|
||||||
let typingUsers = new Map();
|
let typingUsers = new Map();
|
||||||
let sidebarMode = 'general';
|
let sidebarMode = 'general';
|
||||||
let chatDock = 'global';
|
let chatDock = 'global';
|
||||||
@ -5643,7 +5654,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
function activeSubtitle(t){
|
function activeSubtitle(t){
|
||||||
if(!t)return '';
|
if(!t)return '';
|
||||||
if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'<span class="sun-chat-online-text">онлайн</span>':esc(ROLE_LABELS[m?.role]||'сотрудник');}
|
if(t.kind==='direct'){const m=memberById(t.other_user_id);return online.has(String(t.other_user_id))?'<span class="sun-chat-online-text">онлайн</span>':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 'Обсуждение заказа';
|
return 'Обсуждение заказа';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5676,7 +5687,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
async function hydrateAttachments(){
|
async function hydrateAttachments(){
|
||||||
const c=client();if(!c)return;
|
const c=client();if(!c)return;
|
||||||
const links=qa('[data-chat-attachment]',$('sunChatBodyV29'));
|
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=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}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=`<img class="sun-chat-file-img" alt="Вложение" src="${esc(url)}">`;}}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?'печатают':'печатает'}…`:'';}
|
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}={}){
|
async function loadThreads({quiet=false}={}){
|
||||||
if(!available()||loadingThreads)return;
|
if(!available()||loadingThreads)return;
|
||||||
|
const startedWorkspace=workspace().id,startedUser=me();
|
||||||
|
const current=()=>available()&&workspace().id===startedWorkspace&&me()===startedUser;
|
||||||
loadingThreads=true;
|
loadingThreads=true;
|
||||||
try{
|
try{
|
||||||
const ws=workspace();await rpc('sun_chat_get_company_thread_v29',{p_workspace:ws.id});
|
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})]);
|
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:[];
|
threads=Array.isArray(t)?t:[];members=Array.isArray(m)?m:[];
|
||||||
if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};}
|
if(activeThread){const fresh=threadById(activeThread.thread_id);if(fresh)activeThread={...activeThread,...fresh};}
|
||||||
renderSidebar();renderHead();updateUnreadBadges();
|
renderSidebar();renderHead();updateUnreadBadges();
|
||||||
@ -5697,11 +5712,16 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
|
|
||||||
async function loadMessages({mark=true,quiet=false}={}){
|
async function loadMessages({mark=true,quiet=false}={}){
|
||||||
if(!activeThread||!available()||loadingMessages)return;
|
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{
|
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});}}
|
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){
|
async function openThread(t){
|
||||||
@ -5776,34 +5796,26 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureWorkspaceChannel(){
|
async function ensureWorkspaceChannel(){
|
||||||
if(!available())return unsubscribeWorkspace();const c=client(),ws=workspace(),ss=session();if(!c||!ws||!ss)return;
|
if(!available())return unsubscribeWorkspace();
|
||||||
if(workspaceChannel&&workspaceChannelId===ws.id)return;
|
const identity=workspace().id+':'+me();
|
||||||
unsubscribeWorkspace();workspaceChannelId=ws.id;
|
if(workspaceChannel&&workspaceChannelId===identity)return;
|
||||||
try{if(c.realtime?.setAuth)await c.realtime.setAuth(ss.access_token);}catch(_){}
|
if(workspaceChannelId&&workspaceChannelId!==identity){activeThread=null;threads=[];members=[];messages=[];pendingFiles=[];renderMessages();renderPending();}
|
||||||
const ch=c.channel(`sun-chat-workspace:${ws.id}`,{config:{private:true,presence:{key:me()},broadcast:{ack:true}}});workspaceChannel=ch;
|
unsubscribeWorkspace();workspaceChannelId=identity;
|
||||||
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();}})
|
workspaceChannel=setInterval(async()=>{
|
||||||
.on('presence',{event:'sync'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
|
if(document.hidden||!navigator.onLine||!available()||workspaceChannelId!==identity)return;
|
||||||
.on('presence',{event:'join'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
|
await loadThreads({quiet:true});
|
||||||
.on('presence',{event:'leave'},()=>{online=new Set(Object.keys(ch.presenceState()||{}));renderSidebar();renderHead();})
|
if(workspaceChannelId!==identity)return;
|
||||||
.subscribe(async(status)=>{if(status==='SUBSCRIBED'){try{await ch.track({user_id:me(),name:meName(),at:new Date().toISOString()})}catch(_){}await loadThreads({quiet:true});}});
|
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){
|
async function subscribeThread(id){
|
||||||
if(!available()||!id)return;const c=client(),ss=session();if(threadChannel&&threadChannelId===id)return;unsubscribeThread();threadChannelId=id;
|
if(!available()||!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);
|
|
||||||
}
|
}
|
||||||
|
function unsubscribeThread(){threadChannelId='';typingUsers.clear();renderTyping();}
|
||||||
|
function sendTyping(){/* Typing/presence require a WebSocket-capable proxy. */}
|
||||||
|
|
||||||
function updateOrderChatBadge(){
|
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();
|
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-permissions-changed',()=>setTimeout(safeStateChanged,60));
|
||||||
window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60));
|
window.addEventListener('sun:cloud-state-applied',()=>setTimeout(safeStateChanged,60));
|
||||||
window.addEventListener('online',()=>{if(available())ensureWorkspaceChannel()});
|
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);
|
let tries=0;bootRetryTimer=setInterval(()=>{tries++;safeStateChanged();if(available()||tries>=24){clearInterval(bootRetryTimer);bootRetryTimer=null;}},750);setTimeout(safeStateChanged,250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 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 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||'Неизвестная ошибка');
|
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;
|
const origin=new URL(upstream).origin;
|
||||||
let directUntil=0;
|
proxy=proxy.replace(/\/$/,'');fallbackProxy=fallbackProxy.replace(/\/$/,'');
|
||||||
|
let fallbackUntil=0;
|
||||||
return async function(input,init={}){
|
return async function(input,init={}){
|
||||||
const original=new Request(input,init),url=new URL(original.url),isBackend=url.origin===origin;
|
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
|
// 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 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 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 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/'))));
|
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){
|
async function attempt(target,limit){
|
||||||
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
|
const controller=new AbortController(),abort=()=>controller.abort(original.signal.reason);
|
||||||
@ -31,18 +43,18 @@
|
|||||||
return response;
|
return response;
|
||||||
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
|
}finally{clearTimeout(timer);original.signal.removeEventListener('abort',abort)}
|
||||||
}
|
}
|
||||||
const directFirst=isBackend&&Date.now()<directUntil;
|
const fallbackFirst=isBackend&&Date.now()<fallbackUntil;
|
||||||
const first=directFirst?original.url:isBackend?proxy+url.pathname+url.search:original.url;
|
const first=isBackend?routeUrl(fallbackFirst?fallbackProxy:proxy,url):original.url;
|
||||||
const second=directFirst?proxy+url.pathname+url.search:original.url;
|
const second=routeUrl(fallbackFirst?proxy:fallbackProxy,url);
|
||||||
try{
|
try{
|
||||||
const response=await attempt(first,safeFallback?(directFirst?fallbackTimeout:timeout):writeTimeout);
|
const response=await attempt(first,safeFallback?(fallbackFirst?fallbackTimeout:timeout):writeTimeout);
|
||||||
if(!safeFallback||response.status<500)return response;
|
if(!safeFallback||response.status<500)return response;
|
||||||
}catch(error){if(!safeFallback||original.signal.aborted)throw error}
|
}catch(error){if(!safeFallback||original.signal.aborted)throw error}
|
||||||
// Same backend, same authorization, one SDK session. Never replay writes.
|
// Same backend, same authorization, one SDK session. Never replay writes.
|
||||||
const response=await attempt(second,directFirst?timeout:fallbackTimeout);
|
const response=await attempt(second,fallbackFirst?timeout:fallbackTimeout);
|
||||||
if(response.ok)directUntil=directFirst?0:Date.now()+cooldown;
|
if(response.ok)fallbackUntil=fallbackFirst?0:Date.now()+cooldown;
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage});
|
window.CateriumCloudTransport=Object.freeze({create,isTransient,errorMessage,mediaUrl});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
(()=>{
|
(()=>{
|
||||||
'use strict';
|
'use strict';
|
||||||
const VERSION='17.7.3';
|
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}};
|
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(){
|
function installAuthBoot(){
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,7 @@
|
|||||||
const CACHE='sun-catering-pwa-v103-20260918-help-center';
|
const CACHE='sun-catering-pwa-v104-20260918-russia-proxy';
|
||||||
const VERSION='20260918-help-center';
|
const VERSION='20260918-russia-proxy';
|
||||||
const CORE=[
|
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}`,
|
`./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}`,
|
'./','./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}`,
|
`./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}`,
|
||||||
|
|||||||
12
public/vendor/README.md
vendored
Normal file
12
public/vendor/README.md
vendored
Normal file
@ -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.
|
||||||
BIN
public/vendor/supabase-2.112.4.min.js
vendored
Normal file
BIN
public/vendor/supabase-2.112.4.min.js
vendored
Normal file
Binary file not shown.
21
public/vendor/supabase-LICENSE.txt
vendored
Normal file
21
public/vendor/supabase-LICENSE.txt
vendored
Normal file
@ -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.
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import {createHash} from 'node:crypto';
|
||||||
const source=fs.readFileSync('public/app-runtime.js','utf8');
|
const source=fs.readFileSync('public/app-runtime.js','utf8');
|
||||||
const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {'));
|
const code=source.slice(source.indexOf(' function loadConfig() {'),source.indexOf(' function saveConfig() {'));
|
||||||
const url='https://usfjwhztqoopzzfmfbis.supabase.co',key='sb_publishable_CAxfhMKrduJjuk_5ybCQLg_TqSGWGoy';
|
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'));}
|
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');
|
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(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');
|
console.log('PASS backend cutover: retired config upgraded, old local data preserved separately, fresh config stable');
|
||||||
|
|||||||
@ -1,6 +1,42 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import {test,expect} from '@playwright/test';
|
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:'<html><body></body></html>'}));
|
||||||
|
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})=>{
|
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:'<html><body></body></html>'}));
|
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));
|
||||||
await page.goto('/index.html');await page.addScriptTag({url:'/core/cloud-transport.js'});
|
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)}});
|
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=[];
|
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'}})};
|
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'});
|
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/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});
|
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 upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid',calls=[];
|
||||||
const ok=()=>new Response('{}',{headers:{'content-type':'application/json'}});
|
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())};
|
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_my_workspaces',{method:'POST',body:'{}'});
|
||||||
await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'});
|
await send(upstream+'/rest/v1/rpc/sun_fetch_app_state',{method:'POST',body:'{}'});
|
||||||
const routes=calls.splice(0);
|
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})})};
|
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 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);
|
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='';
|
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}
|
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};
|
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.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);
|
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:'<html><body></body></html>'}));await page.goto('/index.html');
|
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<html><body></body></html>'}));await page.goto('/index.html');
|
||||||
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
|
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'));
|
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});
|
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 result=await page.evaluate(async()=>{
|
||||||
const calls=[],upstream='https://backend.example.invalid',proxy='https://proxy.example.invalid';let scenario='read';
|
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'}})};
|
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 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);
|
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);
|
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);
|
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};
|
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.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.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);
|
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();
|
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 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 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 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 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':'*'};
|
const headers={'access-control-allow-origin':'*'};
|
||||||
if(request.method()==='OPTIONS')return route.fulfill({status:204,headers});
|
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;
|
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};
|
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(url.pathname==='/auth/v1/user')body=user;
|
else if(path==='/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(path.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;
|
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});
|
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.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 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();
|
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(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);
|
await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.getItem('sunOrders')||'[]').map(o=>o.id))).toContain('proxy-order');
|
||||||
expect(seen.some(s=>s.endsWith('.supabase.co/rest/v1/rpc/sun_my_workspaces'))).toBe(true);
|
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);
|
expect(warnings.some(s=>s.includes('Multiple GoTrueClient'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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((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(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([...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(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-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(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('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("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');
|
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(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.version===`v${pkg.version}`,'release manifest version matches package.json');
|
||||||
check(releaseManifest.channel==='production','release manifest channel is production');
|
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(['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('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');
|
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(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(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(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(index.includes('20260918-russia-proxy'),'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(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(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(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');
|
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
||||||
|
|||||||
@ -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');
|
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));
|
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(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(!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-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(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('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("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');
|
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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user