#!/usr/bin/env node
'use strict';
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const childProcess = require('child_process');
const ROOT = __dirname;
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || '0.0.0.0';
const SECRET_FILE = path.join(ROOT, '.sun-sync-secret');
const DATA_FILE = path.join(ROOT, 'sun-sync-data.json');
const PHONE_LINK_FILE = path.join(ROOT, 'PHONE-LINK.txt');
const RELEASE_MANIFEST_FILE = path.join(ROOT, 'release-manifest.json');
function readReleaseVersion() {
try {
const data = JSON.parse(fs.readFileSync(RELEASE_MANIFEST_FILE, 'utf8'));
if (data && data.version) return String(data.version);
} catch (_) {}
return 'unknown';
}
const APP_VERSION = readReleaseVersion();
function getSecret() {
if (process.env.SUN_SYNC_TOKEN) return String(process.env.SUN_SYNC_TOKEN).trim();
try {
const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
if (existing) return existing;
} catch (_) {}
const token = crypto.randomBytes(24).toString('hex');
fs.writeFileSync(SECRET_FILE, token, { mode: 0o600 });
return token;
}
const TOKEN = getSecret();
function emptyDb() {
return { version: 1, workspaces: {} };
}
function loadDb() {
try {
const parsed = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.workspaces && typeof parsed.workspaces === 'object') return parsed;
} catch (_) {}
return emptyDb();
}
let db = loadDb();
let saveTimer = null;
function saveDbNow() {
const tmp = DATA_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(db));
fs.renameSync(tmp, DATA_FILE);
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { saveDbNow(); }
catch (e) { console.error('ERROR: cannot save sync data:', e.message); }
}, 40);
}
const mime = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.pdf': 'application/pdf',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8'
};
function send(res, code, body, type = 'application/json; charset=utf-8') {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
res.end(body);
}
function authorized(req) {
const h = String(req.headers['x-sun-token'] || '');
if (!h) return false;
const a = Buffer.from(h), b = Buffer.from(TOKEN);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function readBody(req, max = 80 * 1024 * 1024) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', chunk => {
size += chunk.length;
if (size > max) {
reject(new Error('too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
const SITE_CATALOG_PAGES = [
'https://solnce-keytering.ru/catalog',
'https://solnce-keytering.ru/catalog_black'
];
let siteCatalogCache = { at: 0, data: null };
const CATALOG_PHOTO_SOURCE_FILE = path.join(ROOT, 'catalog', 'photo-sources.json');
function catalogPhotoStatus() {
try {
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
const items = Array.isArray(list) ? list : [];
let ready = 0;
for (const item of items) {
const rel = String(item && item.path || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!rel) continue;
const file = path.resolve(ROOT, rel);
if (!file.startsWith(ROOT + path.sep)) continue;
try { const st = fs.statSync(file); if (st.isFile() && st.size > 1000) ready++; } catch (_) {}
}
return { ready, total: items.length, complete: items.length > 0 && ready === items.length };
} catch (_) {
return { ready: 0, total: 0, complete: false };
}
}
let catalogPhotoSourcesCache = null;
function catalogPhotoSources() {
if (catalogPhotoSourcesCache) return catalogPhotoSourcesCache;
try {
const list = JSON.parse(fs.readFileSync(CATALOG_PHOTO_SOURCE_FILE, 'utf8'));
catalogPhotoSourcesCache = Array.isArray(list) ? list : [];
} catch (_) { catalogPhotoSourcesCache = []; }
return catalogPhotoSourcesCache;
}
function imageType(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 12) return '';
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
if (buf.subarray(0,8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return 'image/png';
if (buf.toString('ascii',0,4)==='RIFF' && buf.toString('ascii',8,12)==='WEBP') return 'image/webp';
return '';
}
function fetchCatalogPhotoBuffer(target, redirects = 0, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (redirects > 4) return reject(new Error('too many redirects'));
let url; try { url = new URL(target); } catch (e) { return reject(e); }
const allowed = new Set(['static.tildacdn.com','static3.tildacdn.com','static.tildacdn.net']);
if (url.protocol !== 'https:' || !allowed.has(url.hostname)) return reject(new Error('photo host is not allowed'));
const req = https.get(url,{headers:{
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringPhotoProxy/17.5.17',
'Accept':'image/avif,image/webp,image/apng,image/jpeg,image/*,*/*;q=0.8',
'Accept-Language':'ru-RU,ru;q=0.9,en;q=0.5',
'Accept-Encoding':'identity',
'Referer':'https://solnce-keytering.ru/'
},timeout:timeoutMs},res=>{
if(res.statusCode>=300&&res.statusCode<400&&res.headers.location){res.resume();const next=new URL(res.headers.location,url).toString();return fetchCatalogPhotoBuffer(next,redirects+1,timeoutMs).then(resolve,reject);}
if(res.statusCode<200||res.statusCode>=300){res.resume();return reject(new Error('remote HTTP '+res.statusCode));}
const chunks=[];let size=0;const max=20*1024*1024;
res.on('data',chunk=>{size+=chunk.length;if(size>max){req.destroy(new Error('photo too large'));return;}chunks.push(chunk)});
res.on('end',()=>{const buf=Buffer.concat(chunks),type=imageType(buf);if(!type||buf.length<1000)return reject(new Error('invalid image'));resolve({buf,type})});
res.on('error',reject);
});
req.on('timeout',()=>req.destroy(new Error('timeout')));req.on('error',reject);
});
}
async function catalogPhotoApi(req,res,url){
const index=Number(url.searchParams.get('i')||0);
const sources=catalogPhotoSources();
if(!Number.isInteger(index)||index<1||index>sources.length)return send(res,400,JSON.stringify({ok:false,error:'bad photo index'}));
const item=sources[index-1]||{},rel=String(item.path||'').replace(/\\/g,'/').replace(/^\/+/,''),remote=String(item.url||'');
const file=path.resolve(ROOT,rel);
if(rel&&file.startsWith(ROOT+path.sep)){
try{const buf=fs.readFileSync(file),type=imageType(buf);if(type&&buf.length>1000){res.writeHead(200,{'Content-Type':type,'Content-Length':buf.length,'Cache-Control':'public, max-age=86400'});return res.end(buf)}}catch(_){}
}
try{
const out=await fetchCatalogPhotoBuffer(remote);
if(out.type==='image/jpeg'&&rel&&file.startsWith(ROOT+path.sep)){try{fs.mkdirSync(path.dirname(file),{recursive:true});const tmp=file+'.tmp-'+process.pid+'-'+Date.now();fs.writeFileSync(tmp,out.buf);fs.renameSync(tmp,file)}catch(_){} }
res.writeHead(200,{'Content-Type':out.type,'Content-Length':out.buf.length,'Cache-Control':'public, max-age=86400'});res.end(out.buf);
}catch(error){send(res,502,JSON.stringify({ok:false,error:String(error&&error.message||error)}));}
}
function fetchRemoteText(target, redirects = 0, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
if (redirects > 5) return reject(new Error('too many redirects'));
let url;
try { url = new URL(target); } catch (e) { return reject(e); }
if (url.protocol !== 'https:') return reject(new Error('https only'));
const allowed = new Set(['solnce-keytering.ru','www.solnce-keytering.ru','store.tildacdn.com','store2.tildacdn.com']);
if (!allowed.has(url.hostname)) return reject(new Error('remote host is not allowed'));
const req = https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/142 Safari/537.36 SunCateringCatalogSync/17.5.17',
'Accept': 'text/html,application/json,text/plain;q=0.9,*/*;q=0.5',
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.5',
'Accept-Encoding': 'identity'
},
timeout: timeoutMs
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
const next = new URL(res.headers.location, url).toString();
return fetchRemoteText(next, redirects + 1, timeoutMs).then(resolve, reject);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
res.resume();
return reject(new Error(`remote HTTP ${res.statusCode}`));
}
const chunks = []; let size = 0; const max = 12 * 1024 * 1024;
res.on('data', chunk => {
size += chunk.length;
if (size > max) { req.destroy(new Error('remote response too large')); return; }
chunks.push(chunk);
});
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
res.on('error', reject);
});
req.on('timeout', () => req.destroy(new Error('remote timeout')));
req.on('error', reject);
});
}
function balancedObject(text, start) {
let depth = 0, quote = null, esc = false;
for (let i = start; i < text.length; i++) {
const c = text[i];
if (quote) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'") quote = c;
else if (c === '{') depth++;
else if (c === '}' && --depth === 0) return text.slice(start, i + 1);
}
return '';
}
function discoverStoreConfigs(html) {
const out = [], seen = new Set();
const re = /t_store_init\(\s*['"]?(\d+)['"]?\s*,/g;
let m;
while ((m = re.exec(html))) {
const recid = m[1];
const brace = html.indexOf('{', re.lastIndex);
if (brace < 0 || brace - re.lastIndex > 5000) continue;
const raw = balancedObject(html, brace);
if (!raw) continue;
const sm = raw.match(/["']?storepart["']?\s*:\s*(?:["']([^"']+)["']|([\w-]+))/i);
const storepart = (sm && (sm[1] || sm[2]) || '').trim();
if (!storepart) continue;
const key = recid + '|' + storepart;
if (!seen.has(key)) { seen.add(key); out.push({ recid, storepart }); }
}
return out;
}
function extractProductUids(html) {
const set = new Set(); let m;
const patterns = [
/data-product-uid=["'](\d+)["']/gi,
/\/tproduct\/(\d+)-/gi,
/["']productuid["']\s*:\s*["']?(\d+)/gi
];
for (const re of patterns) while ((m = re.exec(html))) set.add(m[1]);
return [...set];
}
function parseStoreJson(raw) {
const text = String(raw || '').trim();
if (!text) return null;
try { return JSON.parse(text); } catch (_) {}
const a = text.indexOf('{'), b = text.lastIndexOf('}');
if (a >= 0 && b > a) { try { return JSON.parse(text.slice(a, b + 1)); } catch (_) {} }
return null;
}
async function fetchProductsForConfig(config) {
const products = [];
let slice = '';
for (let page = 0; page < 8; page++) {
const query = new URLSearchParams({ storepartuid: config.storepart, recid: config.recid, c: String(Date.now()), getparts: 'true', getoptions: 'true', size: '100' });
if (slice) query.set('slice', String(slice));
let parsed = null, lastError = null;
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductslist/?${query}`)); if (parsed) break; }
catch (e) { lastError = e; }
}
if (!parsed) throw lastError || new Error('cannot parse Tilda product list');
const list = Array.isArray(parsed.products) ? parsed.products : [];
products.push(...list);
if (!parsed.nextslice || !list.length) break;
slice = parsed.nextslice;
}
return products;
}
async function fetchProductsByUids(uids) {
const out = [];
for (const uid of uids.slice(0, 160)) {
const query = new URLSearchParams({ productsuid: uid, c: String(Date.now()) });
let parsed = null;
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
try { parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproductsbyuid/?${query}`)); if (parsed) break; }
catch (_) {}
}
if (!parsed) continue;
if (Array.isArray(parsed.products)) out.push(...parsed.products);
else if (Array.isArray(parsed)) out.push(...parsed);
else if (parsed.product) out.push(parsed.product);
}
return out;
}
function firstProductFromPayload(parsed) {
if (!parsed) return null;
if (parsed.product && typeof parsed.product === 'object') return parsed.product;
if (Array.isArray(parsed.products) && parsed.products.length) return parsed.products[0];
if (Array.isArray(parsed) && parsed.length) return parsed[0];
if (typeof parsed === 'object' && (parsed.uid || parsed.id || parsed.title || parsed.name)) return parsed;
return null;
}
async function fetchOneProduct(config, uid) {
if (!config || !uid) return null;
const query = new URLSearchParams({
storepartuid: config.storepart,
recid: config.recid,
productuid: String(uid),
c: String(Date.now())
});
let lastError = null;
for (const host of ['store.tildacdn.com','store2.tildacdn.com']) {
try {
const parsed = parseStoreJson(await fetchRemoteText(`https://${host}/api/getproduct/?${query}`, 0, 8000));
const product = firstProductFromPayload(parsed);
if (product) return product;
} catch (e) { lastError = e; }
}
if (lastError) throw lastError;
return null;
}
async function enrichProductsWithDetails(config, products, forceAll = false) {
const list = (Array.isArray(products) ? products : []).slice(0, 160);
const out = list.slice();
const indexes = [];
for (let i = 0; i < list.length; i++) {
const summary = list[i];
const uid = String(summary && (summary.uid || summary.id) || '');
if (uid && (forceAll || !extractComposition(summary || {}).length)) indexes.push(i);
}
let cursor = 0;
const worker = async () => {
while (cursor < indexes.length) {
const index = indexes[cursor++];
const summary = list[index];
const uid = String(summary && (summary.uid || summary.id) || '');
try {
const detail = await fetchOneProduct(config, uid);
if (detail) out[index] = {...summary, ...detail};
} catch (_) {}
}
};
const count = Math.min(2, indexes.length);
await Promise.all(Array.from({length: count}, () => worker()));
return out;
}
function decodeEntities(text) {
return String(text || '')
.replace(/ | /gi, ' ')
.replace(/&/gi, '&').replace(/"/gi, '"').replace(/'|'/gi, "'")
.replace(/</gi, '<').replace(/>/gi, '>')
.replace(/(\d+);/g, (_, n) => String.fromCodePoint(Number(n) || 32))
.replace(/([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16) || 32));
}
function htmlLines(value) {
let s = String(value || '');
s = s.replace(/
/gi, '\n').replace(/<\/(?:p|div|li|ul|ol|h\d)>/gi, '\n').replace(/