commit 86951c69339d7a5803ca05396dd5c65fdf8a263c Author: Bxio Date: Sat Oct 10 18:23:43 2026 +0100 Add local Discord owner login and XanaX dashboard diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6fbc9ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.env +data +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb15442 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +APP_URL=http://192.168.88.99:3080 +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +OWNER_DISCORD_ID=376768118535749633 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e1846d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/ +node_modules/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4513c4c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..da095fc --- /dev/null +++ b/README.md @@ -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. diff --git a/auth.test.mjs b/auth.test.mjs new file mode 100644 index 0000000..fd87506 --- /dev/null +++ b/auth.test.mjs @@ -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 }); + } +}); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6aa0653 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e186f2 --- /dev/null +++ b/package.json @@ -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"}} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..61c4537 --- /dev/null +++ b/public/app.js @@ -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 = `
`; + 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 = `
TS6 Botflow Local
XanaX · O teu centro de automações
`; + 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 = '

Acesso ao painel

A tua conta está autorizada como dono.

PerfilDono
ID Discord
Novos utilizadoresSem acesso
'; + document.querySelector('#discord-id').textContent = user.id; return; + } + if (name === 'bots') { content.innerHTML = '

Bots e flows

A primeira experiência valida o painel e o acesso.

◇Ainda não existem bots.

O editor de flows e a ligação ao TeamSpeak serão a próxima etapa.

'; return; } + content.innerHTML = `

Dashboard

Bem-vindo ao teu centro de automações.

${[['Bots',dashboard.bots.length],['Flows',dashboard.flows.length],['Execuções',dashboard.executions.length],['Servidores',dashboard.connections.length]].map(([label,value])=>`
${label}${value}
`).join('')}

Atividade recente

◷Ainda sem execuções.

A atividade dos bots aparecerá aqui.

O teu espaço

AcessoDono
AutenticaçãoDiscord
Ligação TeamSpeakPor configurar

Começamos por testar o login. Depois ligamos os bots e construímos os flows.

`; + } + 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.'; }); diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..3867cd2 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..706ec50 --- /dev/null +++ b/public/index.html @@ -0,0 +1 @@ +XanaX · Botflow

A carregar…

diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..5d24abf Binary files /dev/null and b/public/logo.png differ diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..55947f5 --- /dev/null +++ b/public/style.css @@ -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}} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..171f018 --- /dev/null +++ b/server.mjs @@ -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}`)); +}