Files
crm-embed-test/server.js

94 lines
4.0 KiB
JavaScript

// Тестовая страница встраивания в CRM Tvorymo.
// Токены не логируются; сервер ничего не хранит, кроме конфигурации.
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = Number(process.env.PORT || 3000);
const SUPABASE_URL = (process.env.SUPABASE_URL || "").replace(/\/$/, "");
const SUPABASE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY || "";
// Разрешённые origin CRM: встраивание (frame-ancestors) и приём postMessage.
const CRM_ORIGINS = (process.env.CRM_ORIGINS || "https://shotam.tvorymo.com,https://units.tvorymo.com")
.split(",").map((s) => s.trim().replace(/\/$/, "")).filter(Boolean);
const INDEX = fs.readFileSync(path.join(__dirname, "public", "index.html"), "utf8")
.replace("__CRM_ORIGINS__", JSON.stringify(CRM_ORIGINS).replace(/</g, "\\u003c"));
const BASE_HEADERS = {
"Content-Security-Policy": `frame-ancestors ${CRM_ORIGINS.join(" ")}`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
};
function send(res, status, body, type) {
res.writeHead(status, { ...BASE_HEADERS, "Content-Type": type });
res.end(body);
}
const json = (res, status, obj) => send(res, status, JSON.stringify(obj), "application/json; charset=utf-8");
function decodeJwtPayload(token) {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("not a JWT");
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
}
function readBody(req, limit = 16384) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (size > limit) { reject(new Error("too large")); req.destroy(); return; }
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
async function verify(req, res) {
let token;
try {
token = JSON.parse(await readBody(req)).access_token;
} catch {
return json(res, 400, { ok: false, error: "некорректный запрос" });
}
if (typeof token !== "string" || !token) return json(res, 400, { ok: false, error: "нет access_token" });
let claims;
try {
claims = decodeJwtPayload(token);
} catch {
return json(res, 401, { ok: false, error: "токен не является JWT" });
}
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp !== "number" || claims.exp <= now) return json(res, 401, { ok: false, error: "токен истёк (exp)" });
if (claims.aud !== "authenticated") return json(res, 401, { ok: false, error: "aud ≠ authenticated" });
let r;
try {
r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
});
} catch {
return json(res, 502, { ok: false, error: "Supabase недоступен" });
}
if (!r.ok) return json(res, 401, { ok: false, error: `Supabase отклонил токен (HTTP ${r.status})` });
const user = await r.json();
if (user.aud !== "authenticated") return json(res, 401, { ok: false, error: "aud пользователя ≠ authenticated" });
const md = user.user_metadata || {};
const name = md.name || md.full_name || md.display_name || null;
json(res, 200, { ok: true, id: user.id, email: user.email || null, name, exp: claims.exp });
}
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if ((req.method === "GET" || req.method === "HEAD") && (url.pathname === "/" || url.pathname === "/index.html"))
return send(res, 200, INDEX, "text/html; charset=utf-8");
if (req.method === "GET" && url.pathname === "/healthz") return send(res, 200, "ok", "text/plain");
if (req.method === "POST" && url.pathname === "/api/verify")
return verify(req, res).catch(() => json(res, 500, { ok: false, error: "ошибка сервера" }));
send(res, 404, "not found", "text/plain");
}).listen(PORT, () => console.log(`listening on ${PORT}`));