375 lines
11 KiB
JavaScript
375 lines
11 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 readBodyBuffer(req) {
|
||
const chunks = [];
|
||
for await (const chunk of req) {
|
||
chunks.push(chunk);
|
||
}
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
async function readBody(req) {
|
||
return (await readBodyBuffer(req)).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 saveUploadedAssetBuffer({ fileName, mimeType, bucket, fileBuffer }) {
|
||
if (typeof fileName !== "string" || !fileName || !Buffer.isBuffer(fileBuffer)) {
|
||
throw new Error("Некорректный файл загрузки");
|
||
}
|
||
|
||
const resolvedMimeType = mimeType || "application/octet-stream";
|
||
const resolvedBucket = sanitizeUploadBucket(bucket || "media");
|
||
const storedName = buildStoredFileName(fileName, resolvedMimeType);
|
||
const directory = join(uploadRoot, ...resolvedBucket.split("/"));
|
||
|
||
await mkdir(directory, { recursive: true });
|
||
await writeFile(join(directory, storedName), fileBuffer);
|
||
|
||
return {
|
||
ok: true,
|
||
url: `./assets/uploads/${resolvedBucket}/${storedName}`,
|
||
fileName: storedName,
|
||
originalFileName: fileName,
|
||
mimeType: resolvedMimeType,
|
||
bucket: resolvedBucket,
|
||
};
|
||
}
|
||
|
||
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 fileBuffer = Buffer.from(match[2], "base64");
|
||
|
||
return saveUploadedAssetBuffer({
|
||
fileName: payload.fileName,
|
||
mimeType,
|
||
bucket: payload.bucket || "media",
|
||
fileBuffer,
|
||
});
|
||
}
|
||
|
||
function isMultipartRequest(req) {
|
||
return String(req.headers["content-type"] || "").toLowerCase().startsWith("multipart/form-data");
|
||
}
|
||
|
||
function multipartBoundary(req) {
|
||
const contentType = String(req.headers["content-type"] || "");
|
||
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
||
return match?.[1] || match?.[2] || "";
|
||
}
|
||
|
||
function parseHeaderParams(value) {
|
||
const params = {};
|
||
const parts = String(value || "").split(";");
|
||
|
||
for (const part of parts.slice(1)) {
|
||
const [rawKey, ...rawValueParts] = part.trim().split("=");
|
||
if (!rawKey || !rawValueParts.length) continue;
|
||
const rawValue = rawValueParts.join("=").trim();
|
||
params[rawKey.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
|
||
}
|
||
|
||
return params;
|
||
}
|
||
|
||
function parseMultipartUpload(req, bodyBuffer) {
|
||
const boundary = multipartBoundary(req);
|
||
if (!boundary) {
|
||
throw new Error("Не найден multipart boundary");
|
||
}
|
||
|
||
const fields = {};
|
||
let filePart = null;
|
||
const body = bodyBuffer.toString("latin1");
|
||
const delimiter = `--${boundary}`;
|
||
|
||
for (const rawPart of body.split(delimiter)) {
|
||
let part = rawPart;
|
||
if (!part || part === "--" || part === "--\r\n") continue;
|
||
if (part.startsWith("\r\n")) part = part.slice(2);
|
||
if (part.endsWith("--")) part = part.slice(0, -2);
|
||
if (part.endsWith("\r\n")) part = part.slice(0, -2);
|
||
|
||
const headerEnd = part.indexOf("\r\n\r\n");
|
||
if (headerEnd < 0) continue;
|
||
|
||
const headerLines = part.slice(0, headerEnd).split("\r\n");
|
||
const headers = Object.fromEntries(
|
||
headerLines
|
||
.map((line) => {
|
||
const separator = line.indexOf(":");
|
||
if (separator < 0) return null;
|
||
return [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()];
|
||
})
|
||
.filter(Boolean),
|
||
);
|
||
const disposition = headers["content-disposition"];
|
||
const params = parseHeaderParams(disposition);
|
||
const fieldName = params.name;
|
||
const value = part.slice(headerEnd + 4);
|
||
|
||
if (!fieldName) continue;
|
||
|
||
if (Object.prototype.hasOwnProperty.call(params, "filename")) {
|
||
filePart = {
|
||
fileName: params.filename || fields.fileName || "asset.bin",
|
||
mimeType: headers["content-type"] || fields.mimeType || "application/octet-stream",
|
||
fileBuffer: Buffer.from(value, "latin1"),
|
||
};
|
||
continue;
|
||
}
|
||
|
||
fields[fieldName] = Buffer.from(value, "latin1").toString("utf8");
|
||
}
|
||
|
||
if (!filePart) {
|
||
throw new Error("Файл не найден в multipart payload");
|
||
}
|
||
|
||
return {
|
||
fileName: fields.fileName || filePart.fileName,
|
||
mimeType: fields.mimeType || filePart.mimeType,
|
||
bucket: fields.bucket || "media",
|
||
fileBuffer: filePart.fileBuffer,
|
||
};
|
||
}
|
||
|
||
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 result = isMultipartRequest(req)
|
||
? await saveUploadedAssetBuffer(parseMultipartUpload(req, await readBodyBuffer(req)))
|
||
: await saveUploadedAsset(JSON.parse(await readBody(req)));
|
||
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}/`);
|
||
});
|