#!/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(/&#x([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(/]*>/gi, '').replace(/<[^>]+>/g, ' '); s = decodeEntities(s).replace(/\r/g, ''); return s.split('\n').map(x => x.replace(/\s+/g, ' ').trim()).filter(Boolean); } function cleanCompositionLines(lines) { const out = []; for (let line of lines) { line = String(line || '').replace(/^[-–—•·*]+\s*/, '').trim(); if (!line) continue; if (/^(?:состав(?:\s+(?:бокса|набора|сета))?|купить|подробнее)\s*:?[\s]*$/i.test(line)) continue; if (/^(?:общий\s+)?вес(?:\s|:|$)|^(?:на\s+)?кол-?во\s+(?:персон|гостей)(?:\s|:|$)|^цена(?:\s|:|$)|^артикул(?:\s|:|$)|^доставка(?:\s|:|$)/i.test(line)) break; if (/\b(?:персон|гост(?:ей|я))\b/i.test(line) && !/(?:шт\.?|пор\.?|г\b|гр\.?)/i.test(line)) continue; if (/^\d[\d\s]*\s*(?:₽|р\.?|руб\.?)$/i.test(line)) continue; out.push(line); } return [...new Set(out)].slice(0, 50); } function collectProductTextValues(value, depth = 0, out = [], seen = new Set()) { if (value == null || depth > 5) return out; if (typeof value === 'string') { if (value.trim()) out.push(value); return out; } if (typeof value !== 'object') return out; if (seen.has(value)) return out; seen.add(value); if (Array.isArray(value)) { for (const item of value) collectProductTextValues(item, depth + 1, out, seen); return out; } for (const [key, item] of Object.entries(value)) { if (/^(?:photo|photos|image|images|gallery|url|sku|uid|id|price|price_old|priceold|quantity|title|name)$/i.test(key)) continue; collectProductTextValues(item, depth + 1, out, seen); } return out; } function extractComposition(product) { const priority = [product && product.text, product && product.descr, product && product.description, product && product.fulltext]; const fields = []; const seen = new Set(); for (const value of [...priority, ...collectProductTextValues(product || {})]) { const field = String(value || '').trim(); if (!field || seen.has(field)) continue; seen.add(field); fields.push(field); } let fallback = []; for (const field of fields) { let raw = String(field).replace(/(\u0421\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:)/ig, '\n$1\n'); const lines = htmlLines(raw); const start = lines.findIndex(x => /^\u0441\u043e\u0441\u0442\u0430\u0432(?:\s+(?:\u0431\u043e\u043a\u0441\u0430|\u043d\u0430\u0431\u043e\u0440\u0430|\u0441\u0435\u0442\u0430))?\s*:?$/i.test(x)); if (start >= 0) { const found = cleanCompositionLines(lines.slice(start + 1)); if (found.length) return found; } const plausible = cleanCompositionLines(lines).filter(x => /\b(?:\u0448\u0442\.?|\u043f\u043e\u0440\.?|\u043a\u0443\u0441\.?|\u0433\b|\u0433\u0440\.?)\b/i.test(x)); if (plausible.length > fallback.length) fallback = plausible; } return fallback; } function normalizeSiteProduct(product) { const title = decodeEntities(String(product && (product.title || product.name) || '')).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const composition = extractComposition(product || {}); return { uid: String(product && (product.uid || product.id) || ''), title, composition, url: String(product && product.url || '') }; } async function loadSiteCatalogCompositions() { const all = [], errors = [], configs = []; let premiumCompositionCount = 0; for (const pageUrl of SITE_CATALOG_PAGES) { try { const html = await fetchRemoteText(pageUrl); const found = discoverStoreConfigs(html); configs.push(...found.map(x => ({...x, pageUrl}))); if (found.length) { for (const config of found) { try { const summaries = await fetchProductsForConfig(config); const isPremiumPage = /\/catalog_black(?:$|[?#])/i.test(pageUrl); const detailed = await enrichProductsWithDetails(config, summaries, isPremiumPage); if (isPremiumPage) premiumCompositionCount += detailed.filter(item => extractComposition(item || {}).length).length; all.push(...detailed); } catch (e) { errors.push(`${pageUrl}: ${e.message}`); } } } else { const uids = extractProductUids(html); if (uids.length) all.push(...await fetchProductsByUids(uids)); else errors.push(`${pageUrl}: Tilda store config not found`); } } catch (e) { errors.push(`${pageUrl}: ${e.message}`); } } const byKey = new Map(); for (const raw of all) { const p = normalizeSiteProduct(raw); if (!p.title || !p.composition.length) continue; const key = p.uid || p.title.toLowerCase(); if (!byKey.has(key) || byKey.get(key).composition.length < p.composition.length) byKey.set(key, p); } return { source: 'solnce-keytering.ru', fetchedAt: new Date().toISOString(), configs: configs.length, premiumCompositionCount, products: [...byKey.values()], errors }; } async function catalogCompositionsApi(req, res) { const now = Date.now(); const requestUrl = new URL(req.url, 'http://localhost'); const force = requestUrl.searchParams.get('force') === '1'; if (!force && siteCatalogCache.data && now - siteCatalogCache.at < 30 * 60 * 1000) { return send(res, 200, JSON.stringify({...siteCatalogCache.data, cached: true})); } try { const data = await loadSiteCatalogCompositions(); if (!data.products.length) return send(res, 502, JSON.stringify({ error: 'site catalog compositions were not received', details: data.errors.slice(0, 6) })); if (Number(data.premiumCompositionCount || 0) > 0) siteCatalogCache = { at: now, data }; return send(res, 200, JSON.stringify({...data, cached: false})); } catch (e) { return send(res, 502, JSON.stringify({ error: e.message || String(e) })); } } function api(req, res, url) { if (!authorized(req)) return send(res, 401, JSON.stringify({ error: 'unauthorized' })); if (req.method === 'GET') { const workspace = String(url.searchParams.get('workspace') || 'main').slice(0, 120); const source = db.workspaces[workspace] || {}; const entries = {}; for (const [key, item] of Object.entries(source)) { if (!item) continue; entries[key] = { value: String(item.value ?? ''), updatedAt: Number(item.updatedAt || 0) }; } return send(res, 200, JSON.stringify({ workspace, entries, serverTime: Date.now() })); } if (req.method === 'POST') { return readBody(req).then(raw => { let data; try { data = JSON.parse(raw || '{}'); } catch (_) { return send(res, 400, JSON.stringify({ error: 'bad json' })); } const workspace = String(data.workspace || 'main').slice(0, 120); const changes = data.changes && typeof data.changes === 'object' ? data.changes : {}; const target = db.workspaces[workspace] || (db.workspaces[workspace] = {}); let count = 0; for (const [key, item] of Object.entries(changes)) { if (!key || key.length > 240 || !item) continue; const updatedAt = Number(item.updatedAt || Date.now()); const oldAt = Number(target[key]?.updatedAt || 0); if (updatedAt >= oldAt) { target[key] = { value: String(item.value ?? ''), updatedAt }; count++; } } scheduleSave(); return send(res, 200, JSON.stringify({ ok: true, count, serverTime: Date.now() })); }).catch(e => send(res, 500, JSON.stringify({ error: e.message }))); } return send(res, 405, JSON.stringify({ error: 'method not allowed' })); } function staticFile(req, res, url) { let pathname = decodeURIComponent(url.pathname); if (pathname === '/' || pathname === '') pathname = '/index.html'; const rel = pathname.replace(/^\/+/, ''); const base = path.basename(rel); const protectedNames = new Set(['server.js', 'prepare-catalog-photos.js', 'sun-sync-data.json', 'sun-sync-data.json.tmp', 'PHONE-LINK.txt']); if (base.startsWith('.') || /\.(?:sqlite|db)$/i.test(base) || protectedNames.has(base)) { return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); } const file = path.resolve(ROOT, rel); if (!file.startsWith(ROOT + path.sep) && file !== path.join(ROOT, 'index.html')) { return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8'); } fs.stat(file, (err, st) => { if (err || !st.isFile()) return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); const ext = path.extname(file).toLowerCase(); res.writeHead(200, { 'Content-Type': mime[ext] || 'application/octet-stream', 'Cache-Control': ['.html', '.js', '.css', '.webmanifest'].includes(ext) ? 'no-cache' : 'public, max-age=86400' }); fs.createReadStream(file).pipe(res); }); } function isPrivateIPv4(ip) { const p = ip.split('.').map(Number); return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168)); } function getLanAddresses() { const out = []; for (const list of Object.values(os.networkInterfaces())) { for (const n of list || []) { if (n.family !== 'IPv4' || n.internal) continue; if (isPrivateIPv4(n.address)) out.push(n.address); } } return [...new Set(out)]; } function linkFor(host) { return `http://${host}:${PORT}/`; } function tryOpenBrowser(url) { try { if (process.platform === 'win32') { childProcess.spawn('cmd.exe', ['/d', '/s', '/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); } } catch (_) {} } const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); if (url.pathname === '/healthz') return send(res, 200, JSON.stringify({ok:true,app:'sun-catering',mode:'local-test',version:APP_VERSION,catalogPhotos:catalogPhotoStatus(),time:new Date().toISOString()})); if (url.pathname === '/api/catalog-photo' && req.method === 'GET') return catalogPhotoApi(req, res, url); if (url.pathname === '/api/catalog-compositions' && req.method === 'GET') return catalogCompositionsApi(req, res); if (url.pathname === '/api/sync') return api(req, res, url); return staticFile(req, res, url); }); server.on('error', err => { console.error('\n============================================================'); console.error('SERVER START ERROR'); console.error(err && err.message ? err.message : String(err)); if (err && err.code === 'EADDRINUSE') console.error(`Port ${PORT} is already in use. Close another server window and try again.`); console.error('============================================================\n'); process.exitCode = 1; }); server.listen(PORT, HOST, () => { // Catalog photos prefer local files; missing originals are fetched and cached on demand without blocking startup. const computer = linkFor('localhost'); const lan = getLanAddresses().map(linkFor); const lines = [ 'SUN CATERING - MOBILE SERVER', '', 'OPEN ON THIS COMPUTER:', computer, '', 'OPEN ON IPHONE / PHONE:', ...(lan.length ? lan : ['No local Wi-Fi address found. Connect the computer to Wi-Fi and restart this file.']), '', 'The phone and computer must be connected to the same Wi-Fi network.', 'Keep this black window open while using the shared database.', '', 'The same phone link is saved in this folder as PHONE-LINK.txt.' ]; const text = lines.join('\r\n') + '\r\n'; try { fs.writeFileSync(PHONE_LINK_FILE, text, 'utf8'); } catch (e) { console.error('Cannot write PHONE-LINK.txt:', e.message); } console.log('\n============================================================'); console.log('SUN CATERING - MOBILE SERVER IS RUNNING'); console.log('============================================================\n'); console.log('OPEN ON THIS COMPUTER:'); console.log(' ' + computer + '\n'); console.log('OPEN ON IPHONE / PHONE:'); if (lan.length) lan.forEach(x => console.log(' ' + x)); else console.log(' No local Wi-Fi address found. Connect the computer to Wi-Fi and restart.'); console.log('\nThe phone link is also saved in:'); console.log(' ' + PHONE_LINK_FILE); console.log('\nKeep this window open while using the shared database.\n'); tryOpenBrowser(computer); }); function shutdown() { try { if (saveTimer) clearTimeout(saveTimer); saveDbNow(); } catch (_) {} server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 500).unref(); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);