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}`), ); }