All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
214 lines
8.5 KiB
JavaScript
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}`),
|
|
);
|
|
}
|