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:
8
deploy/email-gen-api/Dockerfile
Normal file
8
deploy/email-gen-api/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.js /app/server.js
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "/app/server.js"]
|
||||
244
deploy/email-gen-api/server.js
Normal file
244
deploy/email-gen-api/server.js
Normal 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| )+(\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
26
deploy/nginx/coins.conf
Normal 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
25
deploy/nginx/default.conf
Normal 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
37
deploy/nginx/prod.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
32
deploy/scripts/update-email-gen.sh
Executable file
32
deploy/scripts/update-email-gen.sh
Executable 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"
|
||||
Reference in New Issue
Block a user