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