Add local Discord owner login and XanaX dashboard
This commit is contained in:
commit
86951c6933
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
node_modules
|
||||||
4
.env.example
Normal file
4
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal 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
26
README.md
Normal 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
43
auth.test.mjs
Normal 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
20
compose.yaml
Normal 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
1
package.json
Normal 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
31
public/app.js
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
1
public/index.html
Normal file
1
public/index.html
Normal 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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1
public/style.css
Normal file
1
public/style.css
Normal 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
80
server.mjs
Normal 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}`));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user