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:
279
aspekter_ref/editor-svelte/server/index.js
Normal file
279
aspekter_ref/editor-svelte/server/index.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import http from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
const distDir = path.resolve(__dirname, "..", "dist");
|
||||
|
||||
const emailGenRoot = path.resolve(process.env.EMAIL_GEN_ROOT || path.resolve(__dirname, "..", "..", "email-gen"));
|
||||
const emailGenProject = process.env.EMAIL_GEN_PROJECT || "vipavenue";
|
||||
const htmlRelPath = process.env.EMAIL_GEN_HTML_PATH || `emails/${emailGenProject}/html.pug`;
|
||||
const lettersRelDir = process.env.EMAIL_GEN_LETTERS_DIR || `emails/${emailGenProject}/letters`;
|
||||
const publicIndexRel = process.env.EMAIL_GEN_PUBLIC_INDEX || "public/index.html";
|
||||
const allowOrigin = process.env.API_ALLOW_ORIGIN || "*";
|
||||
|
||||
const contentTypes = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".ico": "image/x-icon",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8"
|
||||
};
|
||||
|
||||
const json = (res, status, payload) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const text = (res, status, payload) => {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end(payload);
|
||||
};
|
||||
|
||||
const safeResolve = (root, rel) => {
|
||||
const target = path.resolve(root, rel);
|
||||
const safeRoot = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
if (!target.startsWith(safeRoot)) {
|
||||
throw new Error("Path escapes root");
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const normalizeContentPath = (value = "") =>
|
||||
value
|
||||
.replace(/^(\.\/)?letters\//i, "")
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\.pug$/i, "")
|
||||
.trim();
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 5 * 1024 * 1024) {
|
||||
reject(new Error("Payload too large"));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => resolve(data));
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (pathname === "/api/save") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const raw = await readBody(req);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
json(res, 400, { ok: false, error: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
const { pugCode, htmlPug, contentPath } = payload || {};
|
||||
if (typeof pugCode !== "string" || !pugCode.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing pugCode" });
|
||||
return;
|
||||
}
|
||||
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing htmlPug" });
|
||||
return;
|
||||
}
|
||||
if (typeof contentPath !== "string" || !contentPath.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing contentPath" });
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeContentPath(contentPath);
|
||||
if (!normalizedPath || normalizedPath.includes("..")) {
|
||||
json(res, 400, { ok: false, error: "Invalid contentPath" });
|
||||
return;
|
||||
}
|
||||
|
||||
const letterRelPath = path.posix.join(lettersRelDir, `${normalizedPath}.pug`);
|
||||
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
|
||||
const letterTarget = safeResolve(emailGenRoot, letterRelPath);
|
||||
|
||||
await fs.mkdir(path.dirname(letterTarget), { recursive: true });
|
||||
await fs.writeFile(letterTarget, pugCode, "utf8");
|
||||
await fs.writeFile(htmlTarget, htmlPug, "utf8");
|
||||
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
saved: {
|
||||
letter: letterRelPath,
|
||||
html: htmlRelPath
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/save-html") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const raw = await readBody(req);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
json(res, 400, { ok: false, error: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
const { htmlPug } = payload || {};
|
||||
if (typeof htmlPug !== "string" || !htmlPug.trim()) {
|
||||
json(res, 400, { ok: false, error: "Missing htmlPug" });
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlTarget = safeResolve(emailGenRoot, htmlRelPath);
|
||||
await fs.writeFile(htmlTarget, htmlPug, "utf8");
|
||||
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
saved: {
|
||||
html: htmlRelPath
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/api/html") {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
|
||||
try {
|
||||
const data = await fs.readFile(previewPath);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
text(res, 404, "Preview not found. Run the email generator.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/preview") {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
const previewPath = safeResolve(emailGenRoot, publicIndexRel);
|
||||
try {
|
||||
const data = await fs.readFile(previewPath);
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
text(res, 404, "Preview not found. Run the email generator.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
text(res, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
text(res, 405, "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const safePath = path.resolve(distDir, "." + pathname);
|
||||
const safeRoot = distDir.endsWith(path.sep) ? distDir : distDir + path.sep;
|
||||
if (!safePath.startsWith(safeRoot)) {
|
||||
text(res, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = safePath;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Not a file");
|
||||
}
|
||||
} catch (e) {
|
||||
filePath = path.join(distDir, "index.html");
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = contentTypes[ext] || "application/octet-stream";
|
||||
let data;
|
||||
try {
|
||||
data = await fs.readFile(filePath);
|
||||
} catch (e) {
|
||||
text(res, 404, "Not Found");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
text(res, 500, "Internal Server Error");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`editor-svelte server running on http://localhost:${PORT}`);
|
||||
console.log(`email-gen root: ${emailGenRoot}`);
|
||||
});
|
||||
Reference in New Issue
Block a user