import { defineConfig } from 'vite' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { spawnSync } from 'child_process' import { randomBytes, createHash, scryptSync, timingSafeEqual } from 'crypto' import { svelte } from '@sveltejs/vite-plugin-svelte' import { YonoteClient } from '@yonote/js-sdk' const PROJECT_NAME = 'vipavenue' const apiPlugin = () => ({ name: 'va-file-api', configureServer(server) { const __dirname = path.dirname(fileURLToPath(import.meta.url)) const dataDir = path.resolve(__dirname, 'data') const emailGenRoot = path.resolve(__dirname, '..', 'email-gen') const emailGenApiUrl = process.env.EMAIL_GEN_API_URL || '' const configFile = path.resolve(dataDir, 'config.json') const getYonoteClient = () => { const config = readJson(configFile, {}) if (!config.yonote_token) return null return new YonoteClient({ token: config.yonote_token, baseUrl: config.yonote_base_url || 'https://app.yonote.ru', }) } const ensureDir = (dir) => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) } const readJson = (file, fallback) => { try { if (!fs.existsSync(file)) return fallback return JSON.parse(fs.readFileSync(file, 'utf-8')) } catch { return fallback } } const writeJson = (file, data) => { fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8') } const sanitizeProjectSlug = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '') const sanitizeFileId = (value) => String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '') const getProjectDir = (name) => { const dir = path.resolve(dataDir, name) if (!dir.startsWith(dataDir + path.sep)) return null return dir } const MAX_BODY_SIZE = 20 * 1024 * 1024 class BodyError extends Error { constructor(msg, status) { super(msg); this.status = status } } const readBody = (req) => new Promise((resolve, reject) => { let size = 0 let data = '' req.on('data', (chunk) => { size += chunk.length if (size <= MAX_BODY_SIZE) data += chunk }) req.on('end', () => { if (size > MAX_BODY_SIZE) return reject(new BodyError('payload_too_large', 413)) try { resolve(data ? JSON.parse(data) : {}) } catch { reject(new BodyError('invalid_json', 400)) } }) }) // --- Image uploads --- const uploadsDir = path.resolve(dataDir, 'uploads') // Render concurrency limiter let activeRenders = 0 const MAX_CONCURRENT_RENDERS = 3 // Feed cache (persists across requests + saved to disk) const feedCache = new Map() const feedPending = new Map() const FEED_CACHE_TTL = 3 * 60 * 60 * 1000 const feedCacheFile = path.resolve(dataDir, 'feed-cache.json') // Restore feed cache from disk on startup try { if (fs.existsSync(feedCacheFile)) { const saved = JSON.parse(fs.readFileSync(feedCacheFile, 'utf-8')) if (saved.url && saved.ts && saved.products && Date.now() - saved.ts < FEED_CACHE_TTL) { const products = new Map(Object.entries(saved.products)) feedCache.set(saved.url, { ts: saved.ts, products }) console.log(`[feed] Restored ${products.size} products from cache (age: ${Math.round((Date.now() - saved.ts) / 60000)}m)`) } } } catch {} function saveFeedCacheToDisk(feedUrl, ts, products) { try { const obj = {} for (const [k, v] of products) obj[k] = v fs.writeFileSync(feedCacheFile, JSON.stringify({ url: feedUrl, ts, products: obj }), 'utf-8') } catch {} } // Pug render cache by hash (LRU, max 30 entries, persisted to disk) const renderCacheFile = path.resolve(dataDir, 'render-cache.json') const renderCache = new Map() const RENDER_CACHE_MAX = 30 try { if (fs.existsSync(renderCacheFile)) { const saved = JSON.parse(fs.readFileSync(renderCacheFile, 'utf-8')) for (const [k, v] of Object.entries(saved)) renderCache.set(k, v) } } catch {} function saveRenderCacheToDisk() { const obj = {} for (const [k, v] of renderCache) obj[k] = v fs.promises.writeFile(renderCacheFile, JSON.stringify(obj), 'utf-8').catch(() => {}) } function pugHash(slug, pug, gender, genderPaths) { const extra = genderPaths ? JSON.stringify(genderPaths) : '' return createHash('md5').update(slug + '\0' + pug + '\0' + (gender || 'female') + '\0' + extra).digest('hex') } function setRenderCache(key, html) { if (renderCache.size >= RENDER_CACHE_MAX) { const oldest = renderCache.keys().next().value renderCache.delete(oldest) } renderCache.set(key, html) saveRenderCacheToDisk() } // --- Auth system --- const systemDir = path.resolve(dataDir, '_system') ensureDir(systemDir) const usersFile = path.resolve(systemDir, 'users.json') const SESSION_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days const sessionsFile = path.resolve(systemDir, 'sessions.json') function loadSessions() { const raw = readJson(sessionsFile, {}) const m = new Map(Object.entries(raw)) const now = Date.now() for (const [k, v] of m) if (v.expiresAt < now) m.delete(k) return m } function saveSessions(store) { fs.promises.writeFile(sessionsFile, JSON.stringify(Object.fromEntries(store))).catch(() => {}) } const sessionsStore = loadSessions() function hashPassword(password) { const salt = randomBytes(16).toString('hex') const hash = scryptSync(password, salt, 64).toString('hex') return `${salt}:${hash}` } function verifyPassword(password, stored) { const [salt, hash] = stored.split(':') if (!salt || !hash) return false const test = scryptSync(password, salt, 64).toString('hex') try { return timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex')) } catch { return false } } function getUsers() { return readJson(usersFile, []) } function saveUsers(users) { writeJson(usersFile, users) } function createSession(userId) { const token = randomBytes(32).toString('hex') sessionsStore.set(token, { userId, expiresAt: Date.now() + SESSION_TTL }) saveSessions(sessionsStore) return token } function getSession(token) { const s = sessionsStore.get(token) if (!s) return null if (s.expiresAt < Date.now()) { sessionsStore.delete(token); saveSessions(sessionsStore); return null } return s } function getTokenFromReq(req) { const cookie = req.headers.cookie || '' const m = cookie.match(/(?:^|;\s*)va_token=([^;]+)/) return m ? m[1] : null } function setTokenCookie(res, token) { res.setHeader('Set-Cookie', `va_token=${token}; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=${7 * 24 * 3600}`) } function clearTokenCookie(res) { res.setHeader('Set-Cookie', 'va_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0') } // Seed admin if no users exist if (getUsers().length === 0) { const tempPassword = randomBytes(8).toString('hex') saveUsers([{ id: randomBytes(8).toString('hex'), login: 'admin', passwordHash: hashPassword(tempPassword), name: 'Администратор', role: 'admin', projects: ['*'], }]) console.log(`\n[VA] Создан admin. Временный пароль: ${tempPassword}\n`) } // Brute-force protection: 5 attempts per IP per 15 min const loginAttempts = new Map() function checkBruteForce(ip) { const now = Date.now() const entry = loginAttempts.get(ip) || { count: 0, resetAt: now + 15 * 60 * 1000 } if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 15 * 60 * 1000 } if (entry.count >= 5) return false entry.count++ loginAttempts.set(ip, entry) return true } function clearBruteForce(ip) { loginAttempts.delete(ip) } setInterval(() => { const now = Date.now() for (const [ip, entry] of loginAttempts) if (now > entry.resetAt) loginAttempts.delete(ip) }, 10 * 60 * 1000) // Auth endpoints (before auth middleware) server.middlewares.use(async (req, res, next) => { // Security headers on all responses res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Frame-Options', 'SAMEORIGIN') res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') if (req.url === '/api/auth/login' && req.method === 'POST') { const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket?.remoteAddress || 'unknown' if (!checkBruteForce(ip)) return send(429, { error: 'too_many_attempts' }) const body = await readBody(req) const { login, password } = body if (!login || !password) return send(400, { error: 'missing_credentials' }) const users = getUsers() const user = users.find(u => u.login === login) if (!user || !verifyPassword(password, user.passwordHash)) return send(401, { error: 'invalid_credentials' }) clearBruteForce(ip) const token = createSession(user.id) setTokenCookie(res, token) return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects } }) } if (req.url === '/api/auth/logout' && req.method === 'POST') { const token = getTokenFromReq(req) if (token) { sessionsStore.delete(token); saveSessions(sessionsStore) } clearTokenCookie(res) return send(200, { ok: true }) } if (req.url === '/api/auth/me' && req.method === 'GET') { const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) return send(401, { error: 'not_authenticated' }) const user = getUsers().find(u => u.id === session.userId) if (!user) return send(401, { error: 'user_not_found' }) return send(200, { user: { id: user.id, login: user.login, name: user.name, role: user.role, projects: user.projects, theme: user.theme || 'light', activePage: user.activePage || null, previewZoom: user.previewZoom || null } }) } if (req.url === '/api/auth/preferences' && req.method === 'PUT') { const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) return send(401, { error: 'not_authenticated' }) const body = await readBody(req) const users = getUsers() const idx = users.findIndex(u => u.id === session.userId) if (idx === -1) return send(404, { error: 'user_not_found' }) if (body.theme === 'dark' || body.theme === 'light') users[idx].theme = body.theme if (body.activePage) users[idx].activePage = body.activePage if (typeof body.previewZoom === 'number') users[idx].previewZoom = body.previewZoom saveUsers(users) return send(200, { ok: true }) } return next() function send(status, payload) { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } }) // CSRF protection: state-changing requests must come from same host function checkCsrf(req) { if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) return true const origin = req.headers['origin'] const referer = req.headers['referer'] const host = req.headers['host'] if (!host) return true const check = origin || referer if (!check) return true // same-origin requests from browser forms don't always send origin try { return new URL(check).host === host } catch { return false } } // Auth middleware — protect all /api/ routes (except auth endpoints) server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/') || req.url.startsWith('/api/auth/')) return next() if (!checkCsrf(req)) { res.statusCode = 403; res.end(JSON.stringify({ error: 'csrf_mismatch' })); return } const token = getTokenFromReq(req) const session = token ? getSession(token) : null if (!session) { res.statusCode = 401 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'not_authenticated' })) } const user = getUsers().find(u => u.id === session.userId) if (!user) { res.statusCode = 401 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'user_not_found' })) } req.user = user return next() }) // Admin endpoints — user management server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/admin/')) return next() if (req.user?.role !== 'admin') { res.statusCode = 403 res.setHeader('Content-Type', 'application/json') return res.end(JSON.stringify({ error: 'forbidden' })) } const send = (status, payload) => { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } if (req.url === '/api/admin/users' && req.method === 'GET') { const users = getUsers().map(u => ({ id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects })) return send(200, { users }) } if (req.url === '/api/admin/users' && req.method === 'POST') { const body = await readBody(req) const { login, password, name, role, projects } = body if (!login || !password) return send(400, { error: 'missing_fields' }) if (password.length < 8) return send(400, { error: 'password_too_short' }) const users = getUsers() if (users.some(u => u.login === login)) return send(409, { error: 'login_exists' }) const newUser = { id: randomBytes(8).toString('hex'), login, passwordHash: hashPassword(password), name: name || login, role: role || 'user', projects: projects || [], } users.push(newUser) saveUsers(users) return send(200, { user: { id: newUser.id, login: newUser.login, name: newUser.name, role: newUser.role, projects: newUser.projects } }) } const userMatch = req.url.match(/^\/api\/admin\/users\/([^/]+)$/) if (userMatch) { const userId = decodeURIComponent(userMatch[1]) const users = getUsers() const idx = users.findIndex(u => u.id === userId) if (idx === -1) return send(404, { error: 'user_not_found' }) if (req.method === 'PUT') { const body = await readBody(req) if (body.name !== undefined) users[idx].name = body.name if (body.role !== undefined) users[idx].role = body.role if (body.projects !== undefined) users[idx].projects = body.projects if (body.password) users[idx].passwordHash = hashPassword(body.password) if (body.login && body.login !== users[idx].login) { if (users.some((u, i) => i !== idx && u.login === body.login)) return send(409, { error: 'login_exists' }) users[idx].login = body.login } saveUsers(users) const u = users[idx] return send(200, { user: { id: u.id, login: u.login, name: u.name, role: u.role, projects: u.projects } }) } if (req.method === 'DELETE') { if (users[idx].id === req.user.id) return send(400, { error: 'cannot_delete_self' }) users.splice(idx, 1) saveUsers(users) return send(200, { ok: true }) } } return send(404, { error: 'not_found' }) }) server.middlewares.use(async (req, res, next) => { // Serve uploaded images at /uploads/... if (req.url?.startsWith('/uploads/')) { const filePath = path.resolve(uploadsDir, decodeURIComponent(req.url.replace('/uploads/', ''))) if (!filePath.startsWith(uploadsDir)) { res.statusCode = 403; return res.end('Forbidden') } if (!fs.existsSync(filePath)) { res.statusCode = 404; return res.end('Not found') } const ext = path.extname(filePath).toLowerCase() const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream') res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') return fs.createReadStream(filePath).pipe(res) } return next() }) // (Google OAuth callback removed — Yonote uses API token) server.middlewares.use(async (req, res, next) => { if (!req.url.startsWith('/api/')) return next() ensureDir(dataDir) const send = (status, payload) => { res.statusCode = status res.setHeader('Content-Type', 'application/json') res.end(JSON.stringify(payload)) } try { return await _handleApi(req, res, next, send) } catch (e) { if (e instanceof BodyError) return send(e.status, { error: e.message }) console.error('[VA API]', e) return send(500, { error: 'internal_error' }) } }) async function _handleApi(req, res, next, send) { const userCanAccessProject = () => { return !!req.user } // Single-project: always return vipavenue if (req.method === 'GET' && req.url === '/api/projects') { return send(200, { projects: [PROJECT_NAME] }) } if (req.method === 'GET' && req.url.startsWith('/api/parts-files')) { const slug = PROJECT_NAME const partsDir = path.resolve(emailGenRoot, 'emails', slug, 'parts') if (!fs.existsSync(partsDir)) return send(200, { files: [] }) const files = [] const walk = (dir, base) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const rel = base ? `${base}/${entry.name}` : entry.name if (entry.isDirectory()) walk(path.join(dir, entry.name), rel) else if (entry.name.endsWith('.pug')) files.push(`./parts/${rel.replace(/\.pug$/, '')}`) } } walk(partsDir, '') return send(200, { files }) } // Read a parts pug file if (req.method === 'GET' && req.url.startsWith('/api/parts-file-read')) { const u = new URL(req.url, 'http://localhost') const relPath = u.searchParams.get('path') // e.g. "./parts/header/header-woman" if (!relPath) return send(400, { error: 'no path' }) const partsDir = path.resolve(emailGenRoot, 'emails', PROJECT_NAME, 'parts') const normalized = relPath.replace(/^\.\/parts\//, '').replace(/\.pug$/, '') const filePath = path.resolve(partsDir, normalized + '.pug') if (!filePath.startsWith(partsDir)) return send(403, { error: 'forbidden' }) if (!fs.existsSync(filePath)) return send(404, { error: 'not found' }) return send(200, { content: fs.readFileSync(filePath, 'utf8') }) } // Write a parts pug file if (req.method === 'POST' && req.url === '/api/parts-file-write') { if (!userCanAccessProject()) return send(403, { error: 'forbidden' }) const body = await readBody(req) const { path: relPath, content } = body if (!relPath || typeof content !== 'string') return send(400, { error: 'bad request' }) const partsDir = path.resolve(emailGenRoot, 'emails', PROJECT_NAME, 'parts') const normalized = relPath.replace(/^\.\/parts\//, '').replace(/\.pug$/, '') const filePath = path.resolve(partsDir, normalized + '.pug') if (!filePath.startsWith(partsDir)) return send(403, { error: 'forbidden' }) fs.writeFileSync(filePath, content, 'utf8') return send(200, { ok: true }) } // Project access gate const projectAccessMatch = req.url.match(/^\/api\/project\/([^/]+)/) if (projectAccessMatch) { if (!userCanAccessProject()) return send(403, { error: 'no_project_access' }) } // Helper: per-user letters directory const getUserLettersDir = (projectDir) => { const uid = req.user?.id || '_default' const d = path.resolve(projectDir, 'letters', uid) ensureDir(d) return d } const getUserLettersFile = (projectDir) => { const uid = req.user?.id || '_default' const d = path.resolve(projectDir, 'letters', uid) ensureDir(d) return path.resolve(d, '_index.json') } const lettersMatch = req.url.match(/^\/api\/project\/([^/]+)\/letters$/) if (lettersMatch) { const name = decodeURIComponent(lettersMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) const lettersFile = getUserLettersFile(dir) if (req.method === 'GET') { const letters = readJson(lettersFile, { list: [], currentId: '' }) return send(200, letters) } if (req.method === 'PUT') { const body = await readBody(req) const next = { list: Array.isArray(body.list) ? body.list : [], currentId: typeof body.currentId === 'string' ? body.currentId : '', } writeJson(lettersFile, next) return send(200, { ok: true }) } } const historyMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter\/([^/]+)\/history$/) if (historyMatch) { const name = decodeURIComponent(historyMatch[1]) const id = sanitizeFileId(historyMatch[2]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const lettersDir = getUserLettersDir(dir) const histFile = path.resolve(lettersDir, `${id}.history.json`) if (req.method === 'GET') { return send(200, { history: readJson(histFile, []) }) } if (req.method === 'PUT') { const body = await readBody(req) const snapshot = body?.snapshot if (!snapshot) return send(400, { error: 'missing_snapshot' }) const history = readJson(histFile, []) history.unshift(snapshot) if (history.length > 20) history.splice(20) writeJson(histFile, history) return send(200, { ok: true }) } } const letterMatch = req.url.match(/^\/api\/project\/([^/]+)\/letter(?:\/([^/]+))?$/) if (letterMatch) { const name = decodeURIComponent(letterMatch[1]) const id = letterMatch[2] ? sanitizeFileId(letterMatch[2]) : '' const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const lettersDir = getUserLettersDir(dir) if (req.method === 'GET' && id) { const file = path.resolve(lettersDir, `${id}.json`) const letter = readJson(file, null) return send(200, { letter }) } if (req.method === 'DELETE' && id) { const file = path.resolve(lettersDir, `${id}.json`) if (fs.existsSync(file)) fs.unlinkSync(file) return send(200, { ok: true }) } if (req.method === 'PUT') { const body = await readBody(req) const letterId = sanitizeFileId(body.id) if (!letterId) return send(400, { error: 'missing_id' }) const file = path.resolve(lettersDir, `${letterId}.json`) writeJson(file, body || {}) return send(200, { ok: true }) } } const notesMatch = req.url.match(/^\/api\/project\/([^/]+)\/notes$/) if (notesMatch) { const name = decodeURIComponent(notesMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) const notesFile = path.resolve(dir, 'notes.json') if (req.method === 'GET') { const notes = readJson(notesFile, { list: [], currentId: '' }) return send(200, notes) } if (req.method === 'PUT') { const body = await readBody(req) const next = { list: Array.isArray(body.list) ? body.list : [], currentId: typeof body.currentId === 'string' ? body.currentId : '', } writeJson(notesFile, next) return send(200, { ok: true }) } } const noteMatch = req.url.match(/^\/api\/project\/([^/]+)\/note(?:\/([^/]+))?$/) if (noteMatch) { const name = decodeURIComponent(noteMatch[1]) const id = noteMatch[2] ? sanitizeFileId(noteMatch[2]) : '' const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const notesDir = path.resolve(dir, 'notes') ensureDir(notesDir) if (req.method === 'GET' && id) { const file = path.resolve(notesDir, `${id}.json`) const note = readJson(file, null) return send(200, { note }) } if (req.method === 'DELETE' && id) { const file = path.resolve(notesDir, `${id}.json`) if (fs.existsSync(file)) fs.unlinkSync(file) return send(200, { ok: true }) } if (req.method === 'PUT') { const body = await readBody(req) const noteId = sanitizeFileId(body.id) if (!noteId) return send(400, { error: 'missing_id' }) const file = path.resolve(notesDir, `${noteId}.json`) writeJson(file, body || {}) return send(200, { ok: true }) } } const renderMatch = req.url.match(/^\/api\/project\/([^/]+)\/render-email$/) if (renderMatch && req.method === 'POST') { if (activeRenders >= MAX_CONCURRENT_RENDERS) return send(429, { error: 'Слишком много параллельных рендеров, подождите' }) activeRenders++ try { const projectName = decodeURIComponent(renderMatch[1]) const body = await readBody(req) const slug = sanitizeProjectSlug(body.projectSlug) const pug = String(body.pug || '').replace(/([#!])\{/g, '$1\\{') // escape Pug interpolation to prevent code injection const preheader = String(body.preheader || '').replace(/[\r\n`\\]/g, '').replace(/"/g, '\\"') const gender = String(body.gender || 'female') if (!slug) return send(400, { error: 'missing_project_slug', details: 'Укажи папку проекта в email-gen' }) if (!pug.trim()) return send(400, { error: 'missing_pug', details: 'PUG пустой, нечего генерировать' }) // Start feed loading in parallel with render (await before Mindbox processing) const projDir = getProjectDir(projectName) const projSettings = projDir ? readJson(path.resolve(projDir, 'settings.json'), {}) : {} const genderPaths = projSettings.genderPaths || {} const feedUrl = projSettings.feedUrl || '' const feedPromise = feedUrl ? getFeedProducts(feedUrl).catch(() => null) : null // Check render cache by Pug hash const hash = pugHash(slug, pug, gender, genderPaths) let rawHtml = renderCache.get(hash) if (!rawHtml) { if (emailGenApiUrl) { try { const forward = await fetch(`${emailGenApiUrl.replace(/\/$/, '')}/render`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectSlug: slug, pug, preheader, gender, genderPaths }), signal: AbortSignal.timeout(45000), }) const payload = await forward.json().catch(() => ({})) if (!forward.ok) { return send(forward.status, { error: payload.error || 'render_failed', details: payload.details || 'Не удалось получить ответ от email-gen api', }) } rawHtml = payload.html || '' } catch (error) { return send(500, { error: 'email_gen_api_unreachable', details: error?.message || 'email-gen api недоступен', }) } } else { // Local render fallback if (!fs.existsSync(emailGenRoot)) return send(500, { error: 'email_gen_not_found', details: 'Папка email-gen не найдена рядом с проектом' }) const emailProjectDir = path.resolve(emailGenRoot, 'emails', slug) if (!emailProjectDir.startsWith(path.resolve(emailGenRoot, 'emails'))) { return send(400, { error: 'invalid_project_slug', details: 'Некорректное имя проекта email-gen' }) } if (!fs.existsSync(emailProjectDir)) { return send(404, { error: 'email_project_not_found', details: `Проект "${slug}" не найден в email-gen/emails` }) } const lettersDir = path.resolve(emailProjectDir, 'letters') ensureDir(lettersDir) fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8') // Rewrite html.pug to point content to let.pug and inject preheader const htmlPugPath = path.resolve(emailProjectDir, 'html.pug') const projSettings = readJson(path.resolve(dataDir, PROJECT_NAME, 'settings.json'), {}) const genderPaths = projSettings.genderPaths || {} function sanitizePartPath(p, fallback) { if (!p || typeof p !== 'string') return fallback // Разрешены только пути вида ./parts/... без .. const clean = p.replace(/\\/g, '/').replace(/\/+/g, '/') if (/\.\./.test(clean) || /[\0\r\n]/.test(clean)) return fallback if (!clean.startsWith('./parts/') && !clean.startsWith('parts/')) return fallback return clean } const headerPath = gender === 'male' ? sanitizePartPath(genderPaths.headerMale, './parts/header/header-man') : sanitizePartPath(genderPaths.headerFemale, './parts/header/header-woman') const footerPath = gender === 'male' ? sanitizePartPath(genderPaths.footerMale, './parts/footer/footer-man') : sanitizePartPath(genderPaths.footerFemale, './parts/footer/footer-woman') const htmlPugContent = [ 'extends layout/layout.pug', '', 'block header', ` include ${headerPath}`, ...(preheader ? ['block preheader', ` +preheader("${preheader}")`] : ['block preheader', ' +preheader("")']), 'block content', ' include ./letters/let.pug', 'block footer', ` include ${footerPath}`, '', ].join('\n') fs.writeFileSync(htmlPugPath, htmlPugContent, 'utf-8') const renderScript = ` const path = require('path'); const fs = require('fs'); const Email = require('email-templates'); async function run() { const project = process.argv[1]; const root = process.argv[2]; const email = new Email(); const html = await email.render({ path: project + '/html', juiceResources: { preserveImportant: true, applyStyleTags: true, removeStyleTags: true, preserveMediaQueries: true, webResources: { relativeTo: path.resolve(root, 'emails', project) } }, }, { pretty: true }); const outDir = path.resolve(root, 'public'); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const outPath = path.resolve(outDir, 'index.html'); fs.writeFileSync(outPath, String(html).replace('#MAILRU_PREHEADER_TAG#', ''), 'utf-8'); } run().catch((error) => { console.error(error && error.stack ? error.stack : String(error)); process.exit(1); }); ` const run = spawnSync(process.execPath, ['-e', renderScript, slug, emailGenRoot], { cwd: emailGenRoot, encoding: 'utf-8', shell: false, timeout: 120000, }) if (run.error || run.status !== 0) { return send(500, { error: 'render_failed', details: 'Ошибка генерации. Проверьте PUG-шаблон.', }) } const previewFile = path.resolve(emailGenRoot, 'public', 'index.html') if (!fs.existsSync(previewFile)) { return send(500, { error: 'preview_not_found', details: 'email-gen не создал public/index.html' }) } rawHtml = fs.readFileSync(previewFile, 'utf-8') } // Cache the rendered HTML setRenderCache(hash, rawHtml) } // end of if (!rawHtml) — cache miss // Ensure feed is loaded before Mindbox processing if (feedPromise) await feedPromise // Process Mindbox tags server-side const mindbox = await processMindboxTags(rawHtml, feedUrl) // Wrap prepositions/conjunctions + next word in nowrap spans const nowrapHtml = applyNowrap(rawHtml) const nowrapPreview = applyNowrap(mindbox.html) const feedCacheEntry = feedUrl ? feedCache.get(feedUrl) : null return send(200, { html: nowrapHtml, previewHtml: nowrapPreview, unavailableProducts: mindbox.unavailable, feedSyncedAt: feedCacheEntry ? feedCacheEntry.ts : null, generatedAt: new Date().toISOString(), }) } finally { activeRenders-- } } if (req.url === '/api/config') { if (req.method === 'GET') { const c = readJson(configFile, {}) return send(200, { yonote_token: c.yonote_token ? '***' : '', yonote_base_url: c.yonote_base_url || '', hasYonoteToken: Boolean(c.yonote_token), upload_base_url: c.upload_base_url || '', }) } if (req.method === 'PUT') { if (req.user?.role !== 'admin') return send(403, { error: 'Только admin может менять конфигурацию' }) const body = await readBody(req) const existing = readJson(configFile, {}) const updated = { ...existing } if (body.yonote_token !== undefined) updated.yonote_token = String(body.yonote_token).trim() if (body.yonote_base_url !== undefined) updated.yonote_base_url = String(body.yonote_base_url).trim() if (body.upload_base_url !== undefined) updated.upload_base_url = String(body.upload_base_url).trim() writeJson(configFile, updated) return send(200, { ok: true }) } } if (req.method === 'POST' && req.url === '/api/upload-image') { const body = await readBody(req) if (!body.imageData) return send(400, { error: 'Нет данных изображения' }) const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/) if (!dataMatch) return send(400, { error: 'Неверный формат изображения' }) const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } const ext = TYPES[dataMatch[1]] if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` }) const buffer = Buffer.from(dataMatch[2], 'base64') if (buffer.length > 20 * 1024 * 1024) return send(400, { error: 'Файл слишком большой (макс. 20 МБ)' }) const slug = (body.projectName || 'default').replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') const key = `${slug}/${Date.now()}-${randomBytes(4).toString('hex')}.${ext}` const filePath = path.resolve(uploadsDir, key) if (!filePath.startsWith(uploadsDir + path.sep)) return send(400, { error: 'Недопустимый путь' }) try { fs.mkdirSync(path.dirname(filePath), { recursive: true }) fs.writeFileSync(filePath, buffer) const config = readJson(configFile, {}) const baseUrl = config.upload_base_url ? config.upload_base_url.replace(/\/$/, '') : '' const url = baseUrl ? `${baseUrl}/uploads/${key}` : `/uploads/${key}` return send(200, { url }) } catch (e) { return send(500, { error: e?.message || 'Ошибка сохранения файла' }) } } // --- Link checking endpoint --- if (req.method === 'POST' && req.url === '/api/check-links') { const body = await readBody(req) const urls = Array.isArray(body.urls) ? body.urls.filter(u => /^https?:\/\//i.test(u) && !/^https?:\/\/(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i.test(u)).slice(0, 50) : [] if (!urls.length) return send(400, { error: 'no_urls' }) const results = await Promise.allSettled( urls.map(async (url) => { try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 10000) const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal, headers: { 'User-Agent': 'AspekterVA-LinkChecker/1.0' }, }) clearTimeout(timeout) return { url, status: res.status, ok: res.ok, redirected: res.redirected, finalUrl: res.url } } catch (e) { return { url, status: 0, ok: false, error: e?.message || 'Ошибка соединения' } } }) ) return send(200, { results: results.map(r => r.status === 'fulfilled' ? r.value : { url: '', status: 0, ok: false, error: 'error' }) }) } // --- Product feed endpoint (cached, lookup by IDs) --- function getFeedProducts(feedUrl) { const cached = feedCache.get(feedUrl) if (cached && Date.now() - cached.ts < FEED_CACHE_TTL) return Promise.resolve(cached.products) // Deduplicate in-flight requests, but expire stale pending after 2 min if (feedPending.has(feedUrl)) { const p = feedPending.get(feedUrl) if (Date.now() - p._startedAt < 120000) return p feedPending.delete(feedUrl) } const promise = _fetchFeedProducts(feedUrl) .finally(() => feedPending.delete(feedUrl)) promise._startedAt = Date.now() feedPending.set(feedUrl, promise) return promise } function isPublicUrl(url) { if (!/^https?:\/\//i.test(url)) return false if (/^https?:\/\/(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.|0x|\[|::)/i.test(url)) return false try { const u = new URL(url); if (u.hostname === '0.0.0.0' || u.hostname.includes(':') || u.hostname.includes('[')) return false } catch { return false } return true } async function _fetchFeedProducts(feedUrl) { if (!isPublicUrl(feedUrl)) throw new Error('Feed URL must be a public HTTP(S) address') const resp = await fetch(feedUrl, { signal: AbortSignal.timeout(90000), headers: { 'User-Agent': 'AspekterVA-FeedReader/1.0' }, }) if (!resp.ok) throw new Error(`Фид вернул ${resp.status}`) // Handle windows-1251 encoding const buf = await resp.arrayBuffer() const contentType = resp.headers.get('content-type') || '' let text if (contentType.includes('1251') || contentType.includes('windows')) { const decoder = new TextDecoder('windows-1251') text = decoder.decode(buf) } else { // Try to detect from XML declaration const preview = new TextDecoder('ascii').decode(buf.slice(0, 200)) const encMatch = preview.match(/encoding=["']([^"']+)["']/i) const enc = encMatch ? encMatch[1] : 'utf-8' try { text = new TextDecoder(enc).decode(buf) } catch { text = new TextDecoder('utf-8').decode(buf) } } const products = new Map() const offerRegex = /]*)>([\s\S]*?)<\/offer>/gi let m while ((m = offerRegex.exec(text)) !== null) { const id = m[1], attrs = m[2], block = m[3] const availAttr = attrs.match(/available="([^"]*)"/i) const available = availAttr ? availAttr[1].toLowerCase() === 'true' : true const tag = (t) => { const x = block.match(new RegExp(`<${t}[^>]*>([\\s\\S]*?)<\\/${t}>`,'i')); return x ? x[1].trim() : '' } const param = (n) => { const x = block.match(new RegExp(`]*>([\\s\\S]*?)<\\/param>`,'i')); return x ? x[1].trim() : '' } const vendor = tag('vendor') const typePrefix = tag('typePrefix') const model = tag('model') const rawName = tag('name') || tag('title') // For feeds without , compose from typePrefix + vendor (без артикула model) const name = rawName || [typePrefix, vendor].filter(Boolean).join(' ') const seriesMatch = name.match(/[«""]([^»""]+)[»""]/) const gender = param('Gender') const color = param('Цвет') products.set(id, { id, available, name, price: tag('price'), oldPrice: tag('oldprice'), image: tag('picture'), url: tag('url'), description: tag('description'), categoryId: tag('categoryId'), vendor, typePrefix, model, gender, color, discountPercent: param('DiscountPercent'), series: seriesMatch ? seriesMatch[1] : '', denomination: tag('denomination'), year: tag('year'), dia: tag('dia'), material: tag('material'), country: tag('country'), condition: tag('condition'), weight: tag('Weight') || tag('weight'), assay: tag('assay'), vendorCode: tag('vendorCode'), reverseImage: tag('reversePictureUrl'), salePercent: tag('SalePercent') || tag('salepercent'), }) } const now = Date.now() feedCache.set(feedUrl, { ts: now, products }) saveFeedCacheToDisk(feedUrl, now, products) return products } // --- Server-side Mindbox tag processing --- function resolveProductProp(product, propPath) { if (!product) return '' const prop = propPath.toLowerCase() if (prop === 'name') return product.name || '' if (prop === 'vendorname' || prop === 'vendor') return product.vendor || '' if (prop === 'url') return product.url || '' if (prop === 'pictureurl' || prop === 'picture' || prop === 'imageurl') return product.image || '' if (prop === 'price') return product.price || '' if (prop === 'oldprice') return product.oldPrice || '' if (prop === 'description') return product.description || '' const cfMatch = propPath.match(/^(?:customfield|additionaldata)\.(\w+)$/i) if (cfMatch) { const cf = cfMatch[1].toLowerCase() if (cf === 'denomination' || cf === 'nominal') return product.denomination || '' if (cf === 'year' || cf === 'god') return product.year || '' if (cf === 'dia' || cf === 'diameter' || cf === 'diametr') return product.dia || '' if (cf === 'material') return product.material || '' if (cf === 'country' || cf === 'strana') return product.country || '' if (cf === 'condition' || cf === 'sohrannost' || cf === 'soxrannost') return product.condition || '' if (cf === 'weight' || cf === 'ves') return product.weight || '' if (cf === 'assay' || cf === 'proba') return product.assay || '' if (cf === 'vendorcode' || cf === 'artikul') return product.vendorCode || '' if (cf === 'reversepictureurl') return product.reverseImage || '' if (cf === 'salepercent' || cf === 'sale') return product.salePercent || '' if (cf === 'discountpercent' || cf === 'discount') return product.discountPercent || '' return product[cfMatch[1]] || product[cf] || '' } return '' } function applyNowrap(html) { function wrapShort(text) { return text.replace(/(? const result = html.replace(/(]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi, (_match, open, content, close) => { const processed = content.replace(/>([^<]+) '>' + wrapShort(t) + '<') const firstText = processed.replace(/^([^<]+)/, (m) => wrapShort(m)) return open + firstText + close }) // Replace placeholders with actual span tags (prevents double-processing) return result.replace(/\u200Bspan\u200Bnwr\u200B/g, '').replace(/\u200B\/span\u200Bnwr\u200B/g, '') } async function processMindboxTags(html, feedUrl) { const noResult = { html, unavailable: [] } if (!feedUrl) return noResult let work = html.replace(/'/g, "'").replace(/'/g, "'") // Remove @{for...}@{end for} blocks FIRST so recommendation IDs don't leak work = work.replace(/@\{\s*for\s[^}]*\}[\s\S]*?@\{\s*end\s+for\s*\}/gi, '') const idRegex = /GetByValue\(["'](\d+)["']\)/g const ids = new Set() let m while ((m = idRegex.exec(work)) !== null) ids.add(m[1]) if (!ids.size) return noResult let allProducts try { allProducts = await getFeedProducts(feedUrl) } catch (e) { return noResult } const products = {} const unavailable = [] for (const id of ids) { const p = allProducts.get(String(id)) if (p) { products[id] = p if (!p.available) unavailable.push({ id, name: p.name, price: p.price }) } } if (!Object.keys(products).length) return noResult let result = work // Collect @{ set var = value } Mindbox variables first const mbVars = {} result = result.replace(/@\{\s*set\s+(\w+)\s*=\s*([^}]*)\}/gi, (_, name, val) => { mbVars[name.toLowerCase()] = val.trim() return '' }) // Check if compound condition's simple var prerequisites are met function compoundVarsOk(condStr) { // Extract "varName > 0" parts that are NOT inside GetByValue (simple Mindbox vars) const parts = condStr.split(/\bAND\b/gi) for (const part of parts) { if (/GetByValue|Products\.|SearchInIdentity/i.test(part)) continue const m = part.match(/(\w+)\s*>\s*0/) if (m) { const v = mbVars[m[1].toLowerCase()] if (!v || Number(v) <= 0) return false } } return true } // Handle @{ if ...DiscountPercent > 0 }...@{ end if } — hide block when discount is 0 result = result.replace(/@\{\s*if\s([^}]*?GetByValue\(["'](\d+)["']\)[^}]*?DiscountPercent\s*>\s*0[^}]*)\}([\s\S]*?)@\{\s*end\s+if\s*\}/gi, (_, cond, id, content) => { if (!compoundVarsOk(cond)) return '' const p = products[id] const discount = p ? Number(p.discountPercent || 0) : 0 return discount > 0 ? content : '' }) // Handle @{ if ...OldPrice > ...Price }...@{ end if } — hide when no real discount result = result.replace(/@\{\s*if\s([^}]*?GetByValue\(["'](\d+)["']\)[^}]*?OldPrice\s*>\s*[^}]*?Price[^}]*)\}([\s\S]*?)@\{\s*end\s+if\s*\}/gi, (_, cond, id, content) => { if (!compoundVarsOk(cond)) return '' const p = products[id] if (!p) return '' return Number(p.oldPrice || 0) > Number(p.price || 0) ? content : '' }) // Handle @{ if simpleVar > 0 }...@{ end if } with proper nesting const ifOpenRe = /@\{\s*if\s/gi const endIfRe = /@\{\s*end\s+if\s*\}/gi function resolveSimpleVarBlocks(text) { // Find @{ if varName > 0 } (simple, no AND/OR/GetByValue) const simpleIfRe = /@\{\s*if\s+(\w+)\s*>\s*0\s*\}/gi let match while ((match = simpleIfRe.exec(text)) !== null) { const varName = match[1] const blockStart = match.index const contentStart = blockStart + match[0].length // Find matching @{ end if } counting nesting depth let depth = 1, pos = contentStart let innerIf, innerEnd while (depth > 0) { ifOpenRe.lastIndex = pos endIfRe.lastIndex = pos innerIf = ifOpenRe.exec(text) innerEnd = endIfRe.exec(text) if (!innerEnd) break // malformed, bail if (innerIf && innerIf.index < innerEnd.index) { depth++ pos = innerIf.index + innerIf[0].length } else { depth-- if (depth === 0) { const content = text.slice(contentStart, innerEnd.index) const blockEnd = innerEnd.index + innerEnd[0].length const v = mbVars[varName.toLowerCase()] const keep = v && Number(v) > 0 text = text.slice(0, blockStart) + (keep ? content : '') + text.slice(blockEnd) return resolveSimpleVarBlocks(text) // recurse for remaining } pos = innerEnd.index + innerEnd[0].length } } break // malformed nesting, stop } return text } result = resolveSimpleVarBlocks(result) result = result.replace(/@\{\s*if\s[^}]*\}/gi, '') result = result.replace(/@\{\s*end\s+if\s*\}/gi, '') result = result.replace(/\$\{\s*formatDecimal\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => { const p = products[id]; if (!p) return '' const val = resolveProductProp(p, propPath) return val ? Number(val).toLocaleString('ru-RU') : '' }) result = result.replace(/\$\{\s*ResizeImage\([^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)[^}]*\}/gi, (_, id, propPath) => { return resolveProductProp(products[id], propPath) }) result = result.replace(/\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}/gi, (_, id, propPath) => { return resolveProductProp(products[id], propPath) }) result = result.replace(/-\$\{\s*Products[^}]*?GetByValue\(["'](\d+)["']\)\.(\w+(?:\.\w+)*)\s*\}%/gi, (_, id, propPath) => { const val = resolveProductProp(products[id], propPath) return val && Number(val) > 0 ? `- ${val}%` : '' }) result = result.replace(/\$\{\s*category\.\w+\s*\}/gi, '') result = result.replace(/@\{[^}]*\}/g, '') result = result.replace(/\$\{\s*[^}]*(?:Products\.|ResizeImage|formatDecimal)[^}]*\}/g, '') // Add "Нет в наличии" overlay for unavailable products in preview const badge = '
Нет в наличии
' for (const item of unavailable) { const img = products[item.id]?.image if (img) { const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const imgRegex = new RegExp(`(]*src=["']${imgEsc}["'][^>]*>)`, 'i') result = result.replace(imgRegex, `
$1${badge}
`) } } return { html: result, unavailable } } // --- Feed refresh (clear cache + re-fetch, with diff) --- const feedRefreshMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-refresh$/) if (feedRefreshMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedRefreshMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'Фид не настроен' }) // Remember old IDs before refresh const oldCached = feedCache.get(feedUrl) const oldIds = oldCached ? new Set(oldCached.products.keys()) : null feedCache.delete(feedUrl) feedPending.delete(feedUrl) try { const products = await getFeedProducts(feedUrl) const newIds = new Set(products.keys()) const diff = { count: products.size } if (oldIds) { const added = [...newIds].filter(id => !oldIds.has(id)) const removed = [...oldIds].filter(id => !newIds.has(id)) diff.added = added.length diff.removed = removed.length diff.addedProducts = added.slice(0, 10).map(id => { const p = products.get(id) return { id, name: p?.name || '' } }) } return send(200, diff) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } const feedLookupMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-lookup$/) if (feedLookupMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedLookupMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'Фид не настроен' }) const body = await readBody(req) const ids = Array.isArray(body.ids) ? body.ids : [] try { const allProducts = await getFeedProducts(feedUrl) const result = {} for (const id of ids) { const p = allProducts.get(String(id)) if (p) result[id] = p } return send(200, { products: result }) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } // --- Feed suggest replacements --- const feedSuggestMatch = req.url.match(/^\/api\/project\/([^/]+)\/feed-suggest$/) if (feedSuggestMatch && req.method === 'POST') { const projectName = decodeURIComponent(feedSuggestMatch[1]) const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const feedUrl = projSettings.feedUrl if (!feedUrl) return send(400, { error: 'Фид не настроен' }) const body = await readBody(req) const productId = String(body.productId || '') const excludeIds = new Set((Array.isArray(body.excludeIds) ? body.excludeIds : []).map(String)) const search = String(body.search || '').toLowerCase().trim() if (!productId) return send(400, { error: 'missing productId' }) try { const allProducts = await getFeedProducts(feedUrl) const source = allProducts.get(productId) if (!source) return send(404, { error: 'Товар не найден в фиде' }) const candidates = [] for (const [id, p] of allProducts) { if (id === productId || excludeIds.has(id) || !p.available) continue // If search query provided, filter by name/id match if (search && !(p.name || '').toLowerCase().includes(search) && !id.includes(search)) continue let score = 0 if (!search) { // 1. Тот же тип товара (typePrefix) — базовое условие (+20) if (source.typePrefix && p.typePrefix && source.typePrefix.toLowerCase() === p.typePrefix.toLowerCase()) score += 20 // 2. Та же категория (+15) if (source.categoryId && p.categoryId === source.categoryId) score += 15 // 3. Тот же бренд/vendor (+25) if (source.vendor && p.vendor && source.vendor.toLowerCase() === p.vendor.toLowerCase()) score += 25 // 4. Тот же гендер (+10) if (source.gender && p.gender && source.gender.toLowerCase() === p.gender.toLowerCase()) score += 10 // 5. Цена — жёсткий фильтр >3x, бонус за близость (+0..+10) const srcPrice = Number(source.price) || 0 const pPrice = Number(p.price) || 0 if (srcPrice > 0 && pPrice > 0) { const ratio = Math.max(pPrice, srcPrice) / Math.min(pPrice, srcPrice) if (ratio > 3) continue // исключаем если цена отличается более чем в 3 раза if (ratio <= 1.1) score += 10 else if (ratio <= 1.3) score += 7 else if (ratio <= 2.0) score += 3 } // 6. Тот же цвет (+5) if (source.color && p.color && source.color.toLowerCase() === p.color.toLowerCase()) score += 5 // Минимальный порог — хотя бы тип или категория должны совпасть if (score === 0) continue } candidates.push({ ...p, score }) } candidates.sort((a, b) => b.score - a.score || (Number(b.price) || 0) - (Number(a.price) || 0)) const suggestions = candidates.slice(0, 20).map(p => ({ id: p.id, name: p.name, price: p.price, oldPrice: p.oldPrice, image: p.image, url: p.url, series: p.series, categoryId: p.categoryId, vendor: p.vendor, typePrefix: p.typePrefix, color: p.color, gender: p.gender, })) return send(200, { source: { id: source.id, name: source.name }, suggestions }) } catch (e) { return send(500, { error: e?.message || 'Ошибка загрузки фида' }) } } // --- FTP/SFTP endpoints --- const ftpMatch = req.url.match(/^\/api\/project\/([^/]+)\/ftp\/(test|upload|list|delete)$/) if (ftpMatch && req.method === 'POST') { const projectName = decodeURIComponent(ftpMatch[1]) const ftpAction = ftpMatch[2] const projDir = getProjectDir(projectName) if (!projDir) return send(400, { error: 'invalid_project_name' }) const projSettings = readJson(path.resolve(projDir, 'settings.json'), {}) const fc = projSettings.ftpConfig || {} if (!fc.host) return send(400, { error: 'FTP не настроен для этого проекта' }) const connectFtp = async () => { const { Client } = await import('basic-ftp') const client = new Client() client.ftp.verbose = false client.ftp.timeout = 15000 await client.access({ host: fc.host, port: parseInt(fc.port) || 21, user: fc.user, password: fc.password, secure: false, }) return client } const connectSftp = async () => { const SftpClient = (await import('ssh2-sftp-client')).default const sftp = new SftpClient() await sftp.connect({ host: fc.host, port: parseInt(fc.port) || 22, username: fc.user, password: fc.password, readyTimeout: 15000, }) return sftp } if (ftpAction === 'test') { try { if (fc.protocol === 'sftp') { const sftp = await connectSftp() const exists = await sftp.exists(fc.remotePath || '/') await sftp.end() return send(200, { ok: true, message: `Подключено. Путь ${fc.remotePath || '/'} ${exists ? 'существует' : 'не найден (будет создан)'}` }) } else { const client = await connectFtp() await client.ensureDir(fc.remotePath || '/') client.close() return send(200, { ok: true, message: 'Подключено. Путь доступен.' }) } } catch (e) { return send(500, { error: 'connection_failed', details: e?.message || 'Не удалось подключиться' }) } } if (ftpAction === 'upload') { const body = await readBody(req) if (!body.imageData) return send(400, { error: 'Нет данных изображения' }) const dataMatch = body.imageData.match(/^data:(image\/[\w+]+);base64,(.+)$/) if (!dataMatch) return send(400, { error: 'Неверный формат' }) const TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' } const ext = TYPES[dataMatch[1]] if (!ext) return send(400, { error: `Тип ${dataMatch[1]} не поддерживается` }) const buffer = Buffer.from(dataMatch[2], 'base64') const folder = String(body.folder || '').trim() const fileName = String(body.fileName || '').trim() if (!folder) return send(400, { error: 'Не указана папка (дата письма)' }) if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' }) if (!fileName) return send(400, { error: 'Не указано имя файла' }) const safeName = fileName.replace(/[^a-zA-Z0-9а-яА-ЯёЁ_-]/g, '_') const remoteDir = `${fc.remotePath}/${folder}` const remoteFile = `${remoteDir}/${safeName}.${ext}` const steps = [] try { if (fc.protocol === 'sftp') { steps.push('sftp: connecting') const sftp = await connectSftp() steps.push('sftp: mkdir ' + remoteDir) await sftp.mkdir(remoteDir, true) steps.push('sftp: uploading ' + remoteFile) await sftp.put(buffer, remoteFile) steps.push('sftp: done') await sftp.end() } else { const client = await connectFtp() await client.ensureDir(remoteDir) const tmpFile = path.resolve(dataDir, `.ftp-tmp-${Date.now()}.${ext}`) try { fs.writeFileSync(tmpFile, buffer) await client.uploadFrom(tmpFile, `${safeName}.${ext}`) } finally { try { fs.unlinkSync(tmpFile) } catch {} client.close() } } const publicUrl = `${fc.baseUrl}/${folder}/${safeName}.${ext}` return send(200, { url: publicUrl, name: `${safeName}.${ext}`, steps }) } catch (e) { return send(500, { error: 'upload_failed', details: e?.message || 'Ошибка загрузки', steps }) } } if (ftpAction === 'list') { const body = await readBody(req) const folder = String(body.folder || '').trim() if (!folder) return send(400, { error: 'Не указана папка' }) if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' }) const remoteDir = `${fc.remotePath}/${folder}` try { let files = [] if (fc.protocol === 'sftp') { const sftp = await connectSftp() const exists = await sftp.exists(remoteDir) if (exists) { const listing = await sftp.list(remoteDir) files = listing .filter(f => f.type === '-' && /\.(png|jpe?g|gif|webp)$/i.test(f.name)) .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` })) } await sftp.end() } else { const client = await connectFtp() try { const listing = await client.list(remoteDir) files = listing .filter(f => f.type === 1 && /\.(png|jpe?g|gif|webp)$/i.test(f.name)) .map(f => ({ name: f.name, size: f.size, url: `${fc.baseUrl}/${folder}/${f.name}` })) } catch { /* folder doesn't exist yet */ } client.close() } return send(200, { files, folder }) } catch (e) { return send(500, { error: 'list_failed', details: e?.message || 'Ошибка получения списка' }) } } if (ftpAction === 'delete') { const body = await readBody(req) const folder = String(body.folder || '').trim() const fileName = String(body.fileName || '').trim() if (!folder || !fileName) return send(400, { error: 'Не указана папка или файл' }) if (!/^[\w\-. а-яА-ЯёЁ]+$/i.test(fileName)) return send(400, { error: 'Недопустимое имя файла' }) if (/\.\./.test(folder) || folder.startsWith('/')) return send(400, { error: 'Недопустимый путь папки' }) const remoteDir = `${fc.remotePath}/${folder}` try { if (fc.protocol === 'sftp') { const sftp = await connectSftp() await sftp.delete(`${remoteDir}/${fileName}`) await sftp.end() } else { const client = await connectFtp() await client.ensureDir(remoteDir) await client.remove(fileName) client.close() } return send(200, { ok: true }) } catch (e) { return send(500, { error: 'delete_failed', details: e?.message || 'Ошибка удаления' }) } } } // --- Yonote API --- if (req.method === 'GET' && req.url === '/api/yonote/status') { const config = readJson(configFile, {}) if (!config.yonote_token) return send(200, { configured: false }) try { const client = getYonoteClient() await client.documents.list({ type: ['database'] }, { offset: 0, limit: 1 }) return send(200, { configured: true, connected: true }) } catch (e) { return send(200, { configured: true, connected: false, error: e?.message || 'Ошибка подключения' }) } } if (req.method === 'GET' && req.url?.startsWith('/api/yonote/databases')) { const client = getYonoteClient() if (!client) return send(401, { error: 'yonote_not_configured' }) try { const result = await client.documents.list({ type: ['database'] }) const databases = (result.data || []).map(d => ({ id: d.id, title: d.title })) return send(200, { databases }) } catch (e) { return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка Yonote API' }) } } const yonotePropMatch = req.url?.match(/^\/api\/yonote\/database\/([^/]+)\/properties$/) if (yonotePropMatch && req.method === 'GET') { const dbId = decodeURIComponent(yonotePropMatch[1]) const client = getYonoteClient() if (!client) return send(401, { error: 'yonote_not_configured' }) try { const doc = await client.documents.get({ id: dbId }) const props = doc.data.properties || {} const properties = Object.entries(props).map(([key, p]) => ({ id: p.id || key, title: p.title, type: p.type, options: (p.options || []).map(o => ({ id: o.id, label: o.label, color: o.color })), })) return send(200, { properties }) } catch (e) { return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка получения свойств' }) } } const yonoteRowsMatch = req.url?.match(/^\/api\/yonote\/database\/([^/]+)\/rows$/) if (yonoteRowsMatch && req.method === 'GET') { const dbId = decodeURIComponent(yonoteRowsMatch[1]) const client = getYonoteClient() if (!client) return send(401, { error: 'yonote_not_configured' }) try { const allRows = [] let offset = 0 const limit = 100 while (true) { const result = await client.documents.list({ parentDocumentId: dbId, type: ['row'], sort: 'updatedAt', direction: 'DESC' }, { offset, limit }) const rows = result.data || [] allRows.push(...rows) if (rows.length < limit || allRows.length >= 500) break offset += limit } return send(200, { rows: allRows.map(r => ({ id: r.id, title: r.title, values: r.values || {} })) }) } catch (e) { return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка чтения строк' }) } } if (req.method === 'POST' && req.url === '/api/yonote/row/update') { const body = await readBody(req) const { rowId, values } = body if (!rowId || !values) return send(400, { error: 'missing_params' }) const client = getYonoteClient() if (!client) return send(401, { error: 'yonote_not_configured' }) try { await client.documents.update({ id: rowId, values }) return send(200, { ok: true }) } catch (e) { return send(500, { error: 'yonote_error', details: e?.message || 'Ошибка обновления строки' }) } } // --- Stats --- const statsMatch = req.url.match(/^\/api\/project\/([^/]+)\/stats$/) if (statsMatch) { const name = decodeURIComponent(statsMatch[1]) const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) const statsPath = path.resolve(dir, 'stats.json') if (req.method === 'GET') { return send(200, { entries: readJson(statsPath, []) }) } if (req.method === 'POST') { const body = await readBody(req) const entry = body?.entry if (!entry?.letterId) return send(400, { error: 'missing letterId' }) entry.userId = req.user?.id || '' entry.userName = req.user?.name || '' const entries = readJson(statsPath, []) const idx = entries.findIndex(e => e.letterId === entry.letterId && e.userId === entry.userId) if (idx >= 0) { entries[idx] = { ...entries[idx], ...entry } } else { entries.push(entry) } fs.writeFileSync(statsPath, JSON.stringify(entries, null, 2)) return send(200, { ok: true }) } } const allStatsMatch = req.method === 'GET' && req.url === '/api/stats' if (allStatsMatch) { const HIDDEN_DIRS = new Set(['uploads', 'node_modules', '.git', '_system']) const projects = fs.readdirSync(dataDir, { withFileTypes: true }) .filter(d => d.isDirectory() && !d.name.startsWith('.') && !HIDDEN_DIRS.has(d.name)) .map(d => d.name) const all = {} for (const p of projects) { const sp = path.resolve(dataDir, p, 'stats.json') const entries = readJson(sp, []) if (entries.length) all[p] = entries } return send(200, { stats: all }) } const match = req.url.match(/^\/api\/project\/([^/]+)(\/(block|block-custom|settings|draft|presets))?$/) if (!match) return send(404, { error: 'not_found' }) const name = decodeURIComponent(match[1]) const action = match[3] const dir = getProjectDir(name) if (!dir) return send(400, { error: 'invalid_project_name' }) ensureDir(dir) if (req.method === 'GET' && !action) { const blockPath = path.resolve(dir, 'block.pug') const meta = readJson(path.resolve(dir, 'meta.json'), { sourceName: 'Block.pug' }) const settings = readJson(path.resolve(dir, 'settings.json'), { globalSpacing: 40, blocks: {} }) // Маскируем FTP-пароль if (settings.ftpConfig?.password) { settings.ftpConfig = { ...settings.ftpConfig, password: '', hasPassword: true } } const userDraftFile = path.resolve(dir, 'drafts', `${req.user?.id || '_default'}.json`) const draft = readJson(userDraftFile, []) const presets = readJson(path.resolve(dir, 'presets.json'), []) const letters = readJson(getUserLettersFile(dir), { list: [], currentId: '' }) const notes = readJson(path.resolve(dir, 'notes.json'), { list: [], currentId: '' }) const blockText = fs.existsSync(blockPath) ? fs.readFileSync(blockPath, 'utf-8') : '' return send(200, { name, meta, settings, draft, presets, letters, notes, blockText }) } if (req.method === 'PUT' && action) { const body = await readBody(req) if (action === 'block') { fs.writeFileSync(path.resolve(dir, 'block.pug'), body.blockText || '', 'utf-8') writeJson(path.resolve(dir, 'meta.json'), { sourceName: body.sourceName || 'Block.pug' }) return send(200, { ok: true }) } if (action === 'block-custom') { fs.writeFileSync(path.resolve(dir, 'block-custom.pug'), body.content || '', 'utf-8') return send(200, { ok: true }) } if (action === 'settings') { if (req.user?.role !== 'admin') return send(403, { error: 'Только admin может менять настройки проекта' }) const incoming = body || { globalSpacing: 40, blocks: {} } // Сохраняем существующий FTP-пароль если не передан новый if (incoming.ftpConfig && !incoming.ftpConfig.password) { const existing = readJson(path.resolve(dir, 'settings.json'), {}) if (existing.ftpConfig?.password) { incoming.ftpConfig.password = existing.ftpConfig.password } } writeJson(path.resolve(dir, 'settings.json'), incoming) return send(200, { ok: true }) } if (action === 'draft') { const draftsDir = path.resolve(dir, 'drafts') ensureDir(draftsDir) writeJson(path.resolve(draftsDir, `${req.user?.id || '_default'}.json`), body || []) return send(200, { ok: true }) } if (action === 'presets') { writeJson(path.resolve(dir, 'presets.json'), body || []) return send(200, { ok: true }) } } return send(405, { error: 'method_not_allowed' }) } }, }) // https://vite.dev/config/ export default defineConfig({ plugins: [svelte(), apiPlugin()], server: { allowedHosts: ['va.aspekter.ru'], watch: { usePolling: true, interval: 300, }, proxy: { '/typograf': { target: 'http://typograf.artlebedev.ru', changeOrigin: true, rewrite: (path) => path.replace(/^\/typograf/, ''), }, '/speller': { target: 'https://speller.yandex.net', changeOrigin: true, rewrite: (path) => path.replace(/^\/speller/, ''), }, }, }, })