ts6-botflow/server.mjs

91 lines
7.0 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 } = {}) {
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);
const pending = new Map();
const root = fileURLToPath(new URL('./public/', import.meta.url));
const configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || ''));
const roleFor = id => {
if (id === env.OWNER_DISCORD_ID) return 'Dono';
const allowed = (env.ALLOWED_DISCORD_IDS ?? '723613831196311603').split(',').map(value => value.trim()).filter(value => /^\d{17,20}$/.test(value));
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 (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'], '/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.on('close', () => { studio.close(); db.close(); });
return server;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
createApp().listen(Number(process.env.PORT || 3080), '0.0.0.0', () => console.log(`TS6 Botflow disponível na porta ${process.env.PORT || 3080}`));
}