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'; 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 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 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'; 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(); if (user.id !== env.OWNER_DISCORD_ID) 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: 'Dono' }), 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 user = session ? JSON.parse(session.user) : 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 (req.method === 'GET' && url.pathname === '/api/dashboard') return json(200, { bots: [], flows: [], executions: [], connections: [] }); 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'] }; 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', () => 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}`)); }