Files
s.zotov 718821fdd6 Initial commit: ASPEKTER — визуальный конструктор email-рассылок
- z51-pug-builder: Svelte 5 SPA, визуальный редактор Pug-писем
- email-gen: Node.js рендерер Pug→HTML через email-templates + Juice
- email-gen-api: HTTP сервер рендеринга (порт 8787)
- coin-scout: сервис подбора монет из фидов
- Docker Compose для dev/prod
- Nginx конфиг с SSL для app.aspekter.ru
2026-04-13 11:36:39 +05:00

245 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const http = require('http')
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { spawnSync } = require('child_process')
const PORT = Number(process.env.PORT || 8787)
const EMAIL_GEN_ROOT = path.resolve(process.env.EMAIL_GEN_ROOT || '/workspace/email-gen')
function send(res, status, payload) {
res.statusCode = status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(payload))
}
const MAX_BODY_SIZE = 5 * 1024 * 1024
function readBody(req) {
return new Promise((resolve) => {
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 resolve({})
try {
resolve(data ? JSON.parse(data) : {})
} catch {
resolve({})
}
})
req.on('error', () => resolve({}))
})
}
function sanitizeProjectSlug(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '')
}
function validatePugSafety(pug) {
const dangerous = [
/\brequire\s*\(/i,
/\bprocess\b/,
/\bchild_process\b/,
/\bexecSync\b/,
/\bexec\s*\(/,
/\bspawn\s*\(/,
/\beval\s*\(/,
/\bFunction\s*\(/,
/\bnew\s+Function\b/,
/\bglobal\b/,
/\b__dirname\b/,
/\b__filename\b/,
/\bfs\s*\./,
/\bBuffer\b/,
]
for (const re of dangerous) {
if (re.test(pug)) {
return { safe: false, pattern: re.source }
}
}
return { safe: true }
}
function ensureBuilderPreviewTemplate(projectDir) {
const templateName = '__builder_preview__.pug'
const templatePath = path.resolve(projectDir, templateName)
const templateBody = [
'extends ./html.pug',
'',
'block content',
' include ./letters/let.pug',
'',
].join('\n')
fs.writeFileSync(templatePath, templateBody, 'utf-8')
return { templateName, templatePath }
}
function ensureNpmDeps() {
const marker = path.resolve(EMAIL_GEN_ROOT, 'node_modules')
if (fs.existsSync(marker)) return
const install = spawnSync('npm', ['install'], {
cwd: EMAIL_GEN_ROOT,
encoding: 'utf-8',
shell: false,
})
if (install.status !== 0) {
throw new Error((install.stderr || install.stdout || 'npm install failed').trim())
}
}
function renderWithNode(projectSlug, renderTemplate = 'html') {
const outFileName = `render_${crypto.randomBytes(16).toString('hex')}.html`
const script = `
const path = require('path');
const fs = require('fs');
const Email = require('email-templates');
async function run() {
const project = process.argv[1];
const renderTemplate = process.argv[2];
const root = process.argv[3];
const outFile = process.argv[4];
const email = new Email();
const html = await email.render({
path: project + '/' + renderTemplate,
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, outFile);
const normalized = String(html).replace('#MAILRU_PREHEADER_TAG#', '<vk-snippet-end/>');
fs.writeFileSync(outPath, normalized, 'utf-8');
}
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
});
`
const run = spawnSync(process.execPath, ['-e', script, projectSlug, renderTemplate, EMAIL_GEN_ROOT, outFileName], {
cwd: EMAIL_GEN_ROOT,
encoding: 'utf-8',
shell: false,
timeout: 120000,
})
if (run.error || run.status !== 0) {
throw new Error((run.stderr || run.stdout || run.error?.message || 'render failed').trim())
}
return outFileName
}
function applyNowrap(html) {
const templatePlaceholders = []
let protected_ = html.replace(/@\{[^}]*\}/g, (m) => {
templatePlaceholders.push(m)
return `\u200BTPL${templatePlaceholders.length - 1}\u200B`
})
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'
)
}
const result = protected_.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
}
)
return result
.replace(/\u200Bspan\u200Bnwr\u200B/g, '<span style="white-space:nowrap">')
.replace(/\u200B\/span\u200Bnwr\u200B/g, '</span>')
.replace(/\u200BTPL(\d+)\u200B/g, (_, i) => templatePlaceholders[parseInt(i)])
}
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/health') {
return send(res, 200, { ok: true })
}
if (req.method === 'POST' && req.url === '/render') {
const body = await readBody(req)
const projectSlug = sanitizeProjectSlug(body.projectSlug)
const pug = String(body.pug || '')
if (!projectSlug) return send(res, 400, { error: 'missing_project_slug', details: 'Project slug required' })
if (!pug.trim()) return send(res, 400, { error: 'missing_pug', details: 'PUG required' })
if (!fs.existsSync(EMAIL_GEN_ROOT)) {
return send(res, 500, { error: 'email_gen_not_found', details: 'email-gen root missing' })
}
const emailsRoot = path.resolve(EMAIL_GEN_ROOT, 'emails')
const projectDir = path.resolve(emailsRoot, projectSlug)
if (!projectDir.startsWith(emailsRoot)) {
return send(res, 400, { error: 'invalid_project_slug', details: 'Invalid project slug' })
}
if (!fs.existsSync(projectDir)) {
return send(res, 404, { error: 'email_project_not_found', details: `Project ${projectSlug} not found` })
}
const pugCheck = validatePugSafety(pug)
if (!pugCheck.safe) {
return send(res, 400, { error: 'unsafe_pug', details: `Dangerous pattern detected: ${pugCheck.pattern}` })
}
try {
ensureNpmDeps()
const lettersDir = path.resolve(projectDir, 'letters')
if (!fs.existsSync(lettersDir)) fs.mkdirSync(lettersDir, { recursive: true })
fs.writeFileSync(path.resolve(lettersDir, 'let.pug'), pug, 'utf-8')
const { templateName, templatePath } = ensureBuilderPreviewTemplate(projectDir)
let outFileName
try {
outFileName = renderWithNode(projectSlug, templateName.replace(/\.pug$/, ''))
} finally {
if (fs.existsSync(templatePath)) fs.unlinkSync(templatePath)
}
const htmlPath = path.resolve(EMAIL_GEN_ROOT, 'public', outFileName)
if (!fs.existsSync(htmlPath)) {
return send(res, 500, { error: 'preview_not_found', details: 'Rendered file not found' })
}
const rawHtml = fs.readFileSync(htmlPath, 'utf-8')
try { fs.unlinkSync(htmlPath) } catch (_) {}
const html = applyNowrap(rawHtml)
return send(res, 200, {
html,
generatedAt: new Date().toISOString(),
})
} catch (error) {
return send(res, 500, {
error: 'render_failed',
details: error?.message || 'Unknown render error',
})
}
}
return send(res, 404, { error: 'not_found' })
})
server.listen(PORT, () => {
console.log(`email-gen-api listening on :${PORT}`)
console.log(`email-gen root: ${EMAIL_GEN_ROOT}`)
})