caterium-app/ops/server.js
2026-09-07 15:29:20 +03:00

670 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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(/&nbsp;|&#160;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, '<').replace(/&gt;/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(/<br\s*\/?\s*>/gi, '\n').replace(/<\/(?:p|div|li|ul|ol|h\d)>/gi, '\n').replace(/<li[^>]*>/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);