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
This commit is contained in:
2026-04-13 11:36:39 +05:00
commit 718821fdd6
282 changed files with 64697 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js /app/server.js
EXPOSE 8787
CMD ["node", "/app/server.js"]

View File

@@ -0,0 +1,244 @@
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}`)
})

26
deploy/nginx/coins.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name coins.aspekter.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name coins.aspekter.ru;
ssl_certificate /etc/letsencrypt/live/coins.aspekter.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/coins.aspekter.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 5m;
location / {
proxy_pass http://127.0.0.1:5180;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

25
deploy/nginx/default.conf Normal file
View File

@@ -0,0 +1,25 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
client_max_body_size 20m;
location / {
proxy_pass http://builder:5173;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Vite HMR/WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}

37
deploy/nginx/prod.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name app.aspekter.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.aspekter.ru;
ssl_certificate /etc/letsencrypt/live/app.aspekter.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.aspekter.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 30m;
# Local images — static files served by nginx
location /images/ {
alias /opt/aspekter/data/images/;
expires 30d;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
# App
location / {
proxy_pass http://127.0.0.1:5175;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
EMAIL_GEN_DIR="$ROOT_DIR/email-gen"
if [[ ! -d "$EMAIL_GEN_DIR/.git" ]]; then
echo "[error] email-gen git repo not found: $EMAIL_GEN_DIR"
exit 1
fi
BRANCH="${1:-}"
cd "$EMAIL_GEN_DIR"
echo "[info] fetching email-gen..."
git fetch --all --prune
if [[ -n "$BRANCH" ]]; then
echo "[info] checking out branch: $BRANCH"
git checkout "$BRANCH"
fi
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
echo "[info] pulling branch: $CURRENT_BRANCH"
git pull --ff-only origin "$CURRENT_BRANCH"
echo "[info] rebuilding email-gen-api container..."
cd "$ROOT_DIR"
docker compose -f docker-compose.prod.yml up -d --build email-gen-api
echo "[ok] email-gen updated and email-gen-api restarted"