Files
VA.ASPEKTER/z51-pug-builder/vite.config.js
Sergey Zotov c090bfcf47 Initial commit — Aspekter VA email builder
Full project: Svelte 5 frontend, Vite 7 backend API,
Pug email templates (email-gen), Docker deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:21:00 +05:00

1601 lines
75 KiB
JavaScript
Raw 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.
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#', '<vk-snippet-end/>'), '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 = /<offer\s+id="([^"]*)"([^>]*)>([\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(`<param\\s+name=["']${n}["'][^>]*>([\\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 <name>, 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(/(?<![a-zA-Zа-яА-ЯёЁ])([a-zA-Zа-яА-ЯёЁ]{1,3})(?:\s|&nbsp;)+(\S+)/gu, '\u200Bspan\u200Bnwr\u200B$1\u00A0$2\u200B/span\u200Bnwr\u200B')
}
// Only process text blocks: h3 spans closed by </span></td>
const result = html.replace(/(<span[^>]*class="[^"]*\bh3\b[^"]*"[^>]*>)([\s\S]*?)(<\/span><\/td>)/gi, (_match, open, content, close) => {
const processed = content.replace(/>([^<]+)</g, (m, t) => '>' + 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, '<span style="white-space:nowrap">').replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
}
async function processMindboxTags(html, feedUrl) {
const noResult = { html, unavailable: [] }
if (!feedUrl) return noResult
let work = html.replace(/&#39;/g, "'").replace(/&#x27;/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 = '<div style="position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;z-index:1;font-family:sans-serif;">Нет в наличии</div>'
for (const item of unavailable) {
const img = products[item.id]?.image
if (img) {
const imgEsc = img.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const imgRegex = new RegExp(`(<img[^>]*src=["']${imgEsc}["'][^>]*>)`, 'i')
result = result.replace(imgRegex, `<div style="position:relative;display:inline-block;">$1${badge}</div>`)
}
}
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/, ''),
},
},
},
})