ts6-botflow/server.mjs
Bxio 1be8a45dde
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
Expand flow editor with TeamSpeak events, permissions and execution controls
2026-10-10 20:38:31 +01:00

214 lines
8.5 KiB
JavaScript

import http from "node:http";
import { randomBytes, createHash } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { createStudio } from "./studio.mjs";
export function createApp({
env = process.env,
discordFetch = fetch,
studioOptions = {},
} = {}) {
const appUrl = new URL(env.APP_URL || "http://localhost:3080");
const dataDir = resolve(env.DATA_DIR || "data");
mkdirSync(dataDir, { recursive: true });
const db = new DatabaseSync(resolve(dataDir, "botflow.sqlite"));
db.exec(
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL)",
);
const studio = createStudio(db, dataDir, studioOptions);
const pending = new Map();
const root = fileURLToPath(new URL("./public/", import.meta.url));
const ids = (value) =>
(value || "")
.split(",")
.map((id) => id.trim())
.filter((id) => /^\d{17,20}$/.test(id));
const owners = ids(env.OWNER_DISCORD_ID);
const configured = Boolean(
env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && owners.length,
);
const roleFor = (id) => {
if (owners.includes(id)) return "Dono";
const allowed = ids(env.ALLOWED_DISCORD_IDS ?? "");
return allowed.includes(id) ? "Leitor" : null;
};
const token = () => randomBytes(32).toString("hex");
const hash = (value) => createHash("sha256").update(value).digest("hex");
const cookie = (name, value, age) =>
`${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${appUrl.protocol === "https:" ? "; Secure" : ""}`;
const server = http.createServer(async (req, res) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; img-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
);
const url = new URL(req.url, appUrl);
const cookies = Object.fromEntries(
(req.headers.cookie || "").split(";").map((v) => v.trim().split("=")),
);
const json = (status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
const redirect = (location, cookies) => {
res.writeHead(302, {
Location: location,
...(cookies ? { "Set-Cookie": cookies } : {}),
});
res.end();
};
try {
if (req.method === "GET" && url.pathname === "/health")
return json(200, { status: "ok" });
if (req.method === "GET" && url.pathname === "/auth/discord") {
if (!configured) return redirect("/?error=configuration");
for (const [key, expiry] of pending)
if (expiry < Date.now()) pending.delete(key);
if (pending.size >= 1000)
return json(429, { error: "Tenta novamente mais tarde." });
const state = token();
pending.set(state, Date.now() + 600000);
const auth = new URL("https://discord.com/oauth2/authorize");
auth.search = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
redirect_uri: `${appUrl.origin}/auth/discord/callback`,
response_type: "code",
scope: "identify",
state,
}).toString();
return redirect(auth.href, cookie("oauth_state", state, 600));
}
if (req.method === "GET" && url.pathname === "/auth/discord/callback") {
const state = url.searchParams.get("state");
const valid =
configured &&
state &&
state === cookies.oauth_state &&
pending.get(state) > Date.now();
if (state && state === cookies.oauth_state) pending.delete(state);
if (!valid || !url.searchParams.get("code"))
return redirect(
"/?error=authentication",
cookie("oauth_state", "", 0),
);
const response = await discordFetch(
"https://discord.com/api/oauth2/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code: url.searchParams.get("code"),
redirect_uri: `${appUrl.origin}/auth/discord/callback`,
}),
signal: AbortSignal.timeout(15000),
},
);
if (!response.ok) throw new Error("oauth");
const credentials = await response.json();
const profile = await discordFetch(
"https://discord.com/api/v10/users/@me",
{
headers: { Authorization: `Bearer ${credentials.access_token}` },
signal: AbortSignal.timeout(15000),
},
);
if (!profile.ok) throw new Error("profile");
const user = await profile.json();
const role = roleFor(user.id);
if (!role)
return redirect("/?error=denied", cookie("oauth_state", "", 0));
const session = token();
db.prepare("DELETE FROM sessions WHERE expires < ?").run(Date.now());
db.prepare("INSERT INTO sessions VALUES (?, ?, ?)").run(
hash(session),
JSON.stringify({
id: user.id,
name: user.global_name || user.username,
role,
}),
Date.now() + 86400000,
);
return redirect("/", [
cookie("session", session, 86400),
cookie("oauth_state", "", 0),
]);
}
const session =
cookies.session &&
db
.prepare("SELECT user FROM sessions WHERE id = ? AND expires > ?")
.get(hash(cookies.session), Date.now());
const storedUser = session ? JSON.parse(session.user) : null;
const currentRole = storedUser && roleFor(storedUser.id);
const user = currentRole ? { ...storedUser, role: currentRole } : null;
if (req.method === "GET" && url.pathname === "/api/me")
return json(200, { user, configured });
if (req.method === "POST" && url.pathname === "/auth/logout") {
if (req.headers.origin !== appUrl.origin)
return json(403, { error: "Origem inválida." });
if (cookies.session)
db.prepare("DELETE FROM sessions WHERE id = ?").run(
hash(cookies.session),
);
res.setHeader("Set-Cookie", cookie("session", "", 0));
return json(200, { ok: true });
}
if (await studio.handleWebhook(req, url, json)) return;
if (url.pathname.startsWith("/api/")) {
if (!user) return json(401, { error: "Autenticação necessária." });
if (await studio.route(req, url, user, json)) return;
return json(404, { error: "Não encontrado." });
}
const files = {
"/": ["index.html", "text/html; charset=utf-8"],
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
"/flow-editor.js": ["flow-editor.js", "text/javascript; charset=utf-8"],
"/style.css": ["style.css", "text/css"],
"/logo.png": ["logo.png", "image/png"],
"/favicon.ico": ["favicon.ico", "image/x-icon"],
"/fonts/public-sans.ttf": ["fonts/public-sans.ttf", "font/ttf"],
};
if (req.method !== "GET" || !files[url.pathname])
return json(404, { error: "Não encontrado." });
const [file, type] = files[url.pathname];
res.writeHead(200, { "Content-Type": type });
res.end(readFileSync(resolve(root, file)));
} catch {
json(502, {
error: "Não foi possível concluir o login Discord. Tenta novamente.",
});
}
});
server.once("close", () => {
const stopped = studio.close();
if (studio.running.size) stopped.finally(() => db.close());
else db.close();
});
let stopping;
server.shutdown = () =>
(stopping ||= (async () => {
await studio.close();
await new Promise((resolve) => server.close(resolve));
})());
return server;
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const server = createApp();
process.once("SIGTERM", () => server.shutdown());
process.once("SIGINT", () => server.shutdown());
server.listen(Number(process.env.PORT || 3080), "0.0.0.0", () =>
console.log(`TS6 Botflow disponível na porta ${process.env.PORT || 3080}`),
);
}