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>
1601 lines
75 KiB
JavaScript
1601 lines
75 KiB
JavaScript
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| )+(\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(/'/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 = '<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/, ''),
|
||
},
|
||
},
|
||
},
|
||
})
|