269 lines
7.6 KiB
JavaScript
269 lines
7.6 KiB
JavaScript
import { createServer } from "node:http";
|
||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||
import { extname, join, normalize } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { spawn } from "node:child_process";
|
||
import { dirname } from "node:path";
|
||
|
||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const port = Number(process.env.PORT || 8090);
|
||
const host = process.env.HOST || "127.0.0.1";
|
||
const pagePath = join(repoRoot, "content", "pages", "home.json");
|
||
const blockTemplatesPath = join(repoRoot, "content", "block-templates", "home.json");
|
||
const uploadRoot = join(repoRoot, "assets", "uploads");
|
||
|
||
const MIME = {
|
||
".html": "text/html; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".js": "text/javascript; charset=utf-8",
|
||
".json": "application/json; charset=utf-8",
|
||
".svg": "image/svg+xml",
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".avif": "image/avif",
|
||
".webp": "image/webp",
|
||
".mp4": "video/mp4",
|
||
".webm": "video/webm",
|
||
".mov": "video/quicktime",
|
||
".m4v": "video/x-m4v",
|
||
".avi": "video/x-msvideo",
|
||
".mkv": "video/x-matroska",
|
||
".glb": "model/gltf-binary",
|
||
".gltf": "model/gltf+json",
|
||
".woff": "font/woff",
|
||
".woff2": "font/woff2",
|
||
};
|
||
|
||
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
|
||
res.writeHead(status, {
|
||
"content-type": contentType,
|
||
"cache-control": "no-store",
|
||
});
|
||
res.end(body);
|
||
}
|
||
|
||
function sendJson(res, status, payload) {
|
||
send(res, status, `${JSON.stringify(payload, null, 2)}\n`, "application/json; charset=utf-8");
|
||
}
|
||
|
||
async function readBody(req) {
|
||
const chunks = [];
|
||
for await (const chunk of req) {
|
||
chunks.push(chunk);
|
||
}
|
||
return Buffer.concat(chunks).toString("utf8");
|
||
}
|
||
|
||
function runNodeScript(scriptName) {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn(process.execPath, [join(repoRoot, "tools", scriptName)], {
|
||
cwd: repoRoot,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
|
||
let stdout = "";
|
||
let stderr = "";
|
||
child.stdout.on("data", (chunk) => {
|
||
stdout += chunk.toString();
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
stderr += chunk.toString();
|
||
});
|
||
child.on("error", reject);
|
||
child.on("close", (code) => {
|
||
if (code === 0) {
|
||
resolve({ stdout, stderr });
|
||
} else {
|
||
reject(new Error(stderr || stdout || `${scriptName} exited with ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function isUploadPayload(payload) {
|
||
return (
|
||
payload &&
|
||
typeof payload.fileName === "string" &&
|
||
typeof payload.dataUrl === "string" &&
|
||
(!payload.mimeType || typeof payload.mimeType === "string") &&
|
||
(!payload.bucket || typeof payload.bucket === "string")
|
||
);
|
||
}
|
||
|
||
function sanitizePathSegment(value) {
|
||
const safe = value
|
||
.normalize("NFKD")
|
||
.replace(/[^\w.-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.toLowerCase();
|
||
|
||
return safe || "media";
|
||
}
|
||
|
||
function sanitizeUploadBucket(value) {
|
||
const segments = String(value || "media")
|
||
.split(/[\\/]+/)
|
||
.map((segment) => sanitizePathSegment(segment))
|
||
.filter(Boolean)
|
||
.slice(0, 4);
|
||
|
||
return segments.length ? segments.join("/") : "media";
|
||
}
|
||
|
||
function extensionFromMimeType(mimeType) {
|
||
const map = {
|
||
"image/svg+xml": ".svg",
|
||
"image/png": ".png",
|
||
"image/jpeg": ".jpg",
|
||
"image/gif": ".gif",
|
||
"image/avif": ".avif",
|
||
"image/webp": ".webp",
|
||
"video/mp4": ".mp4",
|
||
"video/webm": ".webm",
|
||
"video/quicktime": ".mov",
|
||
"model/gltf-binary": ".glb",
|
||
"model/gltf+json": ".gltf",
|
||
"font/woff": ".woff",
|
||
"font/woff2": ".woff2",
|
||
};
|
||
|
||
return map[mimeType] || "";
|
||
}
|
||
|
||
function buildStoredFileName(fileName, mimeType) {
|
||
const extension = (extname(fileName) || extensionFromMimeType(mimeType) || ".bin").toLowerCase();
|
||
const base = fileName.slice(0, extension ? -extension.length : undefined);
|
||
const safeBase =
|
||
base
|
||
.normalize("NFKD")
|
||
.replace(/[^\w.-]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.toLowerCase()
|
||
.slice(0, 72) || "asset";
|
||
|
||
return `${safeBase}-${Date.now().toString(36)}${extension}`;
|
||
}
|
||
|
||
async function saveUploadedAsset(payload) {
|
||
if (!isUploadPayload(payload)) {
|
||
throw new Error("Некорректный payload загрузки");
|
||
}
|
||
|
||
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
|
||
|
||
if (!match) {
|
||
throw new Error("Файл должен прийти data-url с base64");
|
||
}
|
||
|
||
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
|
||
const bucket = sanitizeUploadBucket(payload.bucket || "media");
|
||
const storedName = buildStoredFileName(payload.fileName, mimeType);
|
||
const fileBuffer = Buffer.from(match[2], "base64");
|
||
const directory = join(uploadRoot, ...bucket.split("/"));
|
||
|
||
await mkdir(directory, { recursive: true });
|
||
await writeFile(join(directory, storedName), fileBuffer);
|
||
|
||
return {
|
||
ok: true,
|
||
url: `./assets/uploads/${bucket}/${storedName}`,
|
||
fileName: storedName,
|
||
originalFileName: payload.fileName,
|
||
mimeType,
|
||
bucket,
|
||
};
|
||
}
|
||
|
||
function safePublicPath(urlPath) {
|
||
const withoutQuery = decodeURIComponent(urlPath.split("?")[0]);
|
||
const requestPath =
|
||
withoutQuery === "/"
|
||
? "/index.html"
|
||
: withoutQuery === "/admin" || withoutQuery === "/admin/"
|
||
? "/admin/index.html"
|
||
: withoutQuery;
|
||
const normalized = normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
|
||
const absolute = join(repoRoot, normalized);
|
||
|
||
if (!absolute.startsWith(repoRoot)) {
|
||
return null;
|
||
}
|
||
|
||
return absolute;
|
||
}
|
||
|
||
async function serveStatic(req, res) {
|
||
const filePath = safePublicPath(new URL(req.url, `http://${host}:${port}`).pathname);
|
||
if (!filePath) {
|
||
send(res, 403, "Forbidden");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const fileStat = await stat(filePath);
|
||
if (!fileStat.isFile()) {
|
||
send(res, 404, "Not found");
|
||
return;
|
||
}
|
||
|
||
const ext = extname(filePath);
|
||
send(res, 200, await readFile(filePath), MIME[ext] || "application/octet-stream");
|
||
} catch {
|
||
send(res, 404, "Not found");
|
||
}
|
||
}
|
||
|
||
const server = createServer(async (req, res) => {
|
||
try {
|
||
const url = new URL(req.url, `http://${host}:${port}`);
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/page/home") {
|
||
send(res, 200, await readFile(pagePath, "utf8"), "application/json; charset=utf-8");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "GET" && url.pathname === "/api/block-templates/home") {
|
||
send(res, 200, await readFile(blockTemplatesPath, "utf8"), "application/json; charset=utf-8");
|
||
return;
|
||
}
|
||
|
||
if (req.method === "PUT" && url.pathname === "/api/page/home") {
|
||
const body = await readBody(req);
|
||
const parsed = JSON.parse(body);
|
||
await writeFile(pagePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
||
sendJson(res, 200, { ok: true });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/api/render/home") {
|
||
const result = await runNodeScript("render-home.mjs");
|
||
sendJson(res, 200, { ok: true, ...result });
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/api/upload/asset") {
|
||
const body = await readBody(req);
|
||
const result = await saveUploadedAsset(JSON.parse(body));
|
||
sendJson(res, 200, result);
|
||
return;
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/api/extract/home") {
|
||
const result = await runNodeScript("extract-home-blocks.mjs");
|
||
sendJson(res, 200, { ok: true, ...result });
|
||
return;
|
||
}
|
||
|
||
await serveStatic(req, res);
|
||
} catch (error) {
|
||
sendJson(res, 500, { ok: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
server.listen(port, host, () => {
|
||
console.log(`NODE.DC admin: http://${host}:${port}/admin/`);
|
||
console.log(`NODE.DC site: http://${host}:${port}/`);
|
||
});
|