Add local Discord owner login and XanaX dashboard

This commit is contained in:
Bxio 2026-10-10 18:23:43 +01:00
commit 86951c6933
14 changed files with 224 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
.git
.env
data
node_modules

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
APP_URL=http://192.168.88.99:3080
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
OWNER_DISCORD_ID=376768118535749633

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
data/
node_modules/
*.log

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM node:22-bookworm-slim
WORKDIR /app
COPY --chown=node:node package.json server.mjs ./
COPY --chown=node:node public ./public
RUN mkdir /app/data && chown node:node /app/data
USER node
ENV PORT=3080 DATA_DIR=/app/data
EXPOSE 3080
CMD ["npm", "start"]

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# TS6 Botflow
Primeira experiência local: login Discord, conta do dono, sessão persistente e painel XanaX. Logótipos copiados sem alterações do projeto existente. As pastas de origem permanecem intactas.
O editor de flows e o motor de bots ainda não estão implementados nesta versão. Nenhuma ligação ao TeamSpeak é feita.
## Docker / Portainer
Usar `compose.yaml` a partir deste repositório. O serviço é construído pelo Dockerfile, porta 3080, volume persistente `botflow-data`.
Configurar as quatro variáveis nas Environment variables da stack (ou copiar `.env.example` para `.env` quando executado por Docker Compose):
- `APP_URL`: endereço usado no navegador, por exemplo `http://192.168.88.99:3080`.
- `DISCORD_CLIENT_ID`: ID da aplicação Discord.
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação, nunca o token de um bot.
- `OWNER_DISCORD_ID`: ID **numérico** da tua conta Discord, não o nome `bxio`.
No Discord Developer Portal, OAuth2 → Redirects, adicionar exatamente `http://192.168.88.99:3080/auth/discord/callback` (ajustar se APP_URL mudar). É possível aproveitar a aplicação Discord existente adicionando esse redirect; manter os redirects antigos.
Para copiar o ID: Discord → Definições → Avançadas → Modo de programador; clicar com o botão direito na tua conta → Copiar ID do utilizador.
O navegador e o container precisam de Internet para o OAuth Discord. O painel não precisa de estar publicado na Internet. A rede local precisa de conseguir aceder à porta 3080. Uma conta diferente do OWNER_DISCORD_ID é recusada. Esta versão não tem gestão de outros perfis.
Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`.
Sessões HttpOnly/SameSite, estado OAuth de uso único, credenciais no ambiente e armazenamento SQLite no volume. Em HTTP local os cookies não têm Secure; usar HTTPS quando houver publicação. Não adicionar `.env` nem dados ao Git.

43
auth.test.mjs Normal file
View File

@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from './server.mjs';
test('OAuth rejects strangers, invalid state and replay; owner session requires authentication', async () => {
const directory = mkdtempSync(join(tmpdir(), 'botflow-test-'));
let profileId = '999999999999999999';
let requests = 0;
const server = createApp({ env: { DATA_DIR: directory, APP_URL: 'http://localhost:3080', DISCORD_CLIENT_ID: 'client', DISCORD_CLIENT_SECRET: 'secret', OWNER_DISCORD_ID: '111111111111111111' }, discordFetch: async (url) => {
requests++;
return { ok: true, json: async () => url.endsWith('/token') ? { access_token: 'temporary' } : { id: profileId, username: 'bxio' } };
} });
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const request = (path, options = {}) => fetch(base + path, { redirect: 'manual', ...options });
const begin = async () => { const response = await request('/auth/discord'); const state = new URL(response.headers.get('location')).searchParams.get('state'); return { state, cookie: `oauth_state=${state}` }; };
try {
assert.equal((await request('/')).status, 200);
assert.equal((await request('/api/dashboard')).status, 401);
assert.equal((await request('/auth/discord/callback?state=fake&code=code', { headers: { Cookie: 'oauth_state=fake' } })).headers.get('location'), '/?error=authentication');
assert.equal(requests, 0);
const outsider = await begin();
const denied = await request(`/auth/discord/callback?state=${outsider.state}&code=code`, { headers: { Cookie: outsider.cookie } });
assert.equal(denied.headers.get('location'), '/?error=denied');
profileId = '111111111111111111';
const owner = await begin();
const callback = `/auth/discord/callback?state=${owner.state}&code=code`;
const login = await request(callback, { headers: { Cookie: owner.cookie } });
assert.equal(login.headers.get('location'), '/');
const sessionCookie = login.headers.getSetCookie().find(v => v.startsWith('session=')).split(';')[0];
assert.equal((await request('/api/dashboard', { headers: { Cookie: sessionCookie } })).status, 200);
assert.equal((await request(callback, { headers: { Cookie: owner.cookie } })).headers.get('location'), '/?error=authentication');
assert.equal((await request('/auth/logout', { method: 'POST', headers: { Cookie: sessionCookie, Origin: 'http://evil.example' } })).status, 403);
assert.equal((await request('/auth/logout', { method: 'POST', headers: { Cookie: sessionCookie, Origin: 'http://localhost:3080' } })).status, 200);
assert.equal((await request('/api/dashboard', { headers: { Cookie: sessionCookie } })).status, 401);
} finally {
await new Promise(resolve => server.close(resolve));
rmSync(directory, { recursive: true, force: true });
}
});

20
compose.yaml Normal file
View File

@ -0,0 +1,20 @@
services:
ts6-botflow:
build: .
ports:
- "3080:3080"
environment:
APP_URL: ${APP_URL:-http://192.168.88.99:3080}
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?Define DISCORD_CLIENT_ID}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?Define DISCORD_CLIENT_SECRET}
OWNER_DISCORD_ID: ${OWNER_DISCORD_ID:-376768118535749633}
volumes:
- botflow-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
volumes:
botflow-data:

1
package.json Normal file
View File

@ -0,0 +1 @@
{"name":"ts6-botflow","version":"0.1.0","private":true,"type":"module","scripts":{"start":"node --experimental-sqlite server.mjs","test":"node --experimental-sqlite --test"},"engines":{"node":">=22.15"}}

31
public/app.js Normal file
View File

@ -0,0 +1,31 @@
const app = document.querySelector('#app');
const errors = { denied: 'Esta conta ainda não tem acesso. Nesta fase, só o dono pode entrar.', authentication: 'O login expirou ou foi cancelado. Tenta novamente.', configuration: 'Falta configurar o Discord e o ID numérico do dono no Docker.' };
async function boot() {
const response = await fetch('/api/me');
if (!response.ok) throw new Error();
const { user, configured } = await response.json();
if (!user) {
app.innerHTML = `<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><h1>O teu centro de bots.</h1><p>Configura, acompanha e organiza as automações do teu TeamSpeak num só lugar.</p><div id="notice"></div><a class="button" href="/auth/discord">Entrar com Discord →</a><p class="muted">Acesso reservado · TS6 Botflow</p></div></section>`;
const message = errors[new URLSearchParams(location.search).get('error')] || (!configured && errors.configuration);
if (message) { const notice = document.querySelector('#notice'); notice.className = 'notice'; notice.textContent = message; }
return;
}
const data = await fetch('/api/dashboard'); if (!data.ok) throw new Error();
const dashboard = await data.json();
app.innerHTML = `<div class="shell"><aside class="sidebar"><img class="logo" src="/logo.png" alt="XanaX"><div class="eyebrow">Botflow</div><nav><button class="nav active" data-page="dashboard">◈ Dashboard</button><button class="nav" data-page="bots">◇ Bots e flows</button><button class="nav" data-page="access">♙ Acesso</button></nav></aside><section class="workspace"><header class="topbar"><span>TS6 Botflow <span class="badge">Local</span></span><span><span id="username"></span> <button class="quiet" id="logout">Sair</button></span></header><div id="content"></div><footer class="footer">XanaX · O teu centro de automações</footer></section></div>`;
document.querySelector('#username').textContent = user.name;
const content = document.querySelector('#content');
function page(name) {
document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name));
if (name === 'access') {
content.innerHTML = '<h1 class="title">Acesso ao painel</h1><p>A tua conta está autorizada como dono.</p><div class="card details"><div><span>Perfil</span><strong>Dono</strong></div><div><span>ID Discord</span><strong id="discord-id"></strong></div><div><span>Novos utilizadores</span><strong>Sem acesso</strong></div></div>';
document.querySelector('#discord-id').textContent = user.id; return;
}
if (name === 'bots') { content.innerHTML = '<h1 class="title">Bots e flows</h1><p>A primeira experiência valida o painel e o acesso.</p><div class="card empty"><span class="empty-symbol">◇</span>Ainda não existem bots.<p>O editor de flows e a ligação ao TeamSpeak serão a próxima etapa.</p></div>'; return; }
content.innerHTML = `<h1 class="title">Dashboard</h1><p>Bem-vindo ao teu centro de automações.</p><div class="stats">${[['Bots',dashboard.bots.length],['Flows',dashboard.flows.length],['Execuções',dashboard.executions.length],['Servidores',dashboard.connections.length]].map(([label,value])=>`<div class="card"><span>${label}</span><strong class="stat-value">${value}</strong></div>`).join('')}</div><div class="split"><div class="card"><h2>Atividade recente</h2><div class="empty"><span class="empty-symbol">◷</span>Ainda sem execuções.<p>A atividade dos bots aparecerá aqui.</p></div></div><div class="card"><h2>O teu espaço</h2><div class="details"><div><span>Acesso</span><span class="badge">Dono</span></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Ligação TeamSpeak</span><strong>Por configurar</strong></div></div><p>Começamos por testar o login. Depois ligamos os bots e construímos os flows.</p></div></div>`;
}
document.querySelectorAll('.nav').forEach(b => b.onclick = () => page(b.dataset.page));
document.querySelector('#logout').onclick = async () => { const r = await fetch('/auth/logout', { method: 'POST' }); if (r.ok) location.assign('/'); };
page('dashboard');
}
boot().catch(() => { app.textContent = 'Não foi possível carregar o painel. Atualiza a página para tentar novamente.'; });

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

1
public/index.html Normal file
View File

@ -0,0 +1 @@
<!doctype html><html lang="pt-PT"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>XanaX · Botflow</title><link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/style.css"></head><body><main id="app"><p>A carregar…</p></main><script type="module" src="/app.js"></script></body></html>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

1
public/style.css Normal file
View File

@ -0,0 +1 @@
:root{color-scheme:dark;font-family:'Public Sans',system-ui,sans-serif;background:#25293c;color:#cfcde4;--surface:#2f3349;--purple:#7367f0}*{box-sizing:border-box}body{margin:0}button,a{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:10px;border:0;border-radius:6px;padding:12px 20px;background:var(--purple);color:white;text-decoration:none;cursor:pointer}button:hover,.button:hover{background:#8479f5}button:focus-visible,a:focus-visible{outline:2px solid white;outline-offset:4px}.login{min-height:100vh;display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:40px;background:var(--surface);border-radius:10px;box-shadow:0 8px 32px #13162440}.logo{width:190px;height:auto;object-fit:contain}.login h1{font-size:24px;margin-top:34px;color:#e6e5f2}p{line-height:1.7;color:#aaa8bf}.login .button{width:100%;margin-top:16px}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:1.4px;color:#9591ab}.notice{padding:14px;background:#ff9f4318;border:1px solid #ff9f4340;border-radius:6px;font-size:13px;color:#ffd5aa}.shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh}.sidebar{background:var(--surface);padding:30px 18px}.sidebar .logo{width:180px;margin-bottom:40px}.nav{display:block;width:100%;text-align:left;padding:13px 16px;margin:8px 0;background:transparent;color:#cfcde4}.nav.active{background:var(--purple)}.workspace{padding:28px 36px;max-width:1500px;width:100%;margin:auto}.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--surface);padding:18px 24px;border-radius:8px;margin-bottom:36px}.muted{font-size:13px;color:#aaa8bf}.quiet{background:#7367f019;color:#b5adff;padding:8px 12px}.title{font-size:27px;color:#e6e5f2;margin-bottom:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;margin:28px 0}.card{background:var(--surface);padding:24px;border-radius:8px}.stat-value{display:block;font-size:30px;color:#e6e5f2;margin-top:10px}.split{display:grid;grid-template-columns:1.6fr 1fr;gap:22px}h2{font-size:18px;font-weight:600;margin:0 0 18px}.empty{text-align:center;padding:42px 20px;color:#aaa8bf}.empty-symbol{display:block;font-size:36px;color:#7367f0;margin-bottom:15px}.badge{padding:5px 9px;border-radius:4px;background:#28c76f18;color:#50d895;font-size:12px}.footer{margin-top:32px;font-size:12px;color:#9591ab}.details{display:grid;gap:18px;font-size:14px}.details div{display:flex;justify-content:space-between;gap:16px}@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{padding:18px}.sidebar .logo{margin-bottom:12px}.sidebar nav{display:flex;flex-wrap:wrap}.nav{width:auto}.workspace{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}}@media(max-width:480px){.login-card{padding:26px}.topbar{padding:14px;gap:12px}.stats{gap:12px}.card{padding:18px}}

80
server.mjs Normal file
View File

@ -0,0 +1,80 @@
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}`));
}