import { createServer } from "node:http"; import { createReadStream } from "node:fs"; 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 sendStream(req, res, status, stream, headers) { res.writeHead(status, { "cache-control": "no-store", "accept-ranges": "bytes", ...headers, }); if (req.method === "HEAD") { stream.destroy(); res.end(); return; } stream.on("error", () => { if (!res.headersSent) { send(res, 500, "Failed to read file"); return; } res.destroy(); }); stream.pipe(res); } 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); const contentType = MIME[ext] || "application/octet-stream"; const range = req.headers.range; if (range) { const match = /^bytes=(\d*)-(\d*)$/.exec(range); if (!match) { res.writeHead(416, { "cache-control": "no-store", "content-range": `bytes */${fileStat.size}`, }); res.end(); return; } const start = match[1] ? Number(match[1]) : 0; const end = match[2] ? Number(match[2]) : fileStat.size - 1; if ( !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= fileStat.size ) { res.writeHead(416, { "cache-control": "no-store", "content-range": `bytes */${fileStat.size}`, }); res.end(); return; } const cappedEnd = Math.min(end, fileStat.size - 1); sendStream(req, res, 206, createReadStream(filePath, { start, end: cappedEnd }), { "content-type": contentType, "content-length": cappedEnd - start + 1, "content-range": `bytes ${start}-${cappedEnd}/${fileStat.size}`, }); return; } sendStream(req, res, 200, createReadStream(filePath), { "content-type": contentType, "content-length": fileStat.size, }); } 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}/`); });