Allow additional Discord reader account
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 17s

This commit is contained in:
Bxio 2026-10-10 19:06:56 +01:00
parent febc807ad9
commit e9f1a90592
5 changed files with 28 additions and 8 deletions

View File

@ -2,3 +2,4 @@ APP_URL=http://192.168.88.99:3080
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
OWNER_DISCORD_ID=376768118535749633 OWNER_DISCORD_ID=376768118535749633
ALLOWED_DISCORD_IDS=723613831196311603

View File

@ -21,7 +21,7 @@ No Discord Developer Portal, OAuth2 → Redirects, adicionar exatamente `http://
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. 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. 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. A conta OWNER_DISCORD_ID entra como Dono. O ID 723613831196311603 também está autorizado como Leitor por defeito. ALLOWED_DISCORD_IDS permite substituir a lista de leitores (IDs separados por vírgulas; valor vazio remove todos). Outras contas são recusadas. A gestão de utilizadores pelo painel ainda não está implementada.
Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`. Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`.

View File

@ -25,12 +25,21 @@ test('OAuth rejects strangers, invalid state and replay; owner session requires
const outsider = await begin(); const outsider = await begin();
const denied = await request(`/auth/discord/callback?state=${outsider.state}&code=code`, { headers: { Cookie: outsider.cookie } }); const denied = await request(`/auth/discord/callback?state=${outsider.state}&code=code`, { headers: { Cookie: outsider.cookie } });
assert.equal(denied.headers.get('location'), '/?error=denied'); assert.equal(denied.headers.get('location'), '/?error=denied');
profileId = '723613831196311603';
const reader = await begin();
const readerLogin = await request(`/auth/discord/callback?state=${reader.state}&code=code`, { headers: { Cookie: reader.cookie } });
assert.equal(readerLogin.headers.get('location'), '/');
const readerCookie = readerLogin.headers.getSetCookie().find(v => v.startsWith('session=')).split(';')[0];
const readerProfile = await (await request('/api/me', { headers: { Cookie: readerCookie } })).json();
assert.equal(readerProfile.user.role, 'Leitor');
assert.equal((await request('/api/dashboard', { headers: { Cookie: readerCookie } })).status, 200);
profileId = '111111111111111111'; profileId = '111111111111111111';
const owner = await begin(); const owner = await begin();
const callback = `/auth/discord/callback?state=${owner.state}&code=code`; const callback = `/auth/discord/callback?state=${owner.state}&code=code`;
const login = await request(callback, { headers: { Cookie: owner.cookie } }); const login = await request(callback, { headers: { Cookie: owner.cookie } });
assert.equal(login.headers.get('location'), '/'); assert.equal(login.headers.get('location'), '/');
const sessionCookie = login.headers.getSetCookie().find(v => v.startsWith('session=')).split(';')[0]; const sessionCookie = login.headers.getSetCookie().find(v => v.startsWith('session=')).split(';')[0];
assert.equal((await (await request('/api/me', { headers: { Cookie: sessionCookie } })).json()).user.role, 'Dono');
assert.equal((await request('/api/dashboard', { headers: { Cookie: sessionCookie } })).status, 200); 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(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://evil.example' } })).status, 403);

View File

@ -1,5 +1,5 @@
const app = document.querySelector('#app'); 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.' }; const errors = { denied: 'Esta conta não está autorizada a entrar no painel.', 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() { async function boot() {
const response = await fetch('/api/me'); const response = await fetch('/api/me');
if (!response.ok) throw new Error(); if (!response.ok) throw new Error();
@ -18,11 +18,13 @@ async function boot() {
function page(name) { function page(name) {
document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name)); document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name));
if (name === 'access') { 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>'; content.innerHTML = '<h1 class="title">Acesso ao painel</h1><p>A tua conta está autorizada a entrar no painel.</p><div class="card details"><div><span>Perfil</span><strong id="profile-role"></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; document.querySelector('#discord-id').textContent = user.id;
document.querySelector('#profile-role').textContent = user.role; 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; } 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>`; 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" id="dashboard-role"></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.querySelector('#dashboard-role').textContent = user.role;
} }
document.querySelectorAll('.nav').forEach(b => b.onclick = () => page(b.dataset.page)); 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('/'); }; document.querySelector('#logout').onclick = async () => { const r = await fetch('/auth/logout', { method: 'POST' }); if (r.ok) location.assign('/'); };

View File

@ -14,6 +14,11 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
const pending = new Map(); const pending = new Map();
const root = fileURLToPath(new URL('./public/', import.meta.url)); 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 configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || ''));
const roleFor = id => {
if (id === env.OWNER_DISCORD_ID) return 'Dono';
const allowed = (env.ALLOWED_DISCORD_IDS ?? '723613831196311603').split(',').map(value => value.trim()).filter(value => /^\d{17,20}$/.test(value));
return allowed.includes(id) ? 'Leitor' : null;
};
const token = () => randomBytes(32).toString('hex'); const token = () => randomBytes(32).toString('hex');
const hash = value => createHash('sha256').update(value).digest('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 cookie = (name, value, age) => `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${appUrl.protocol === 'https:' ? '; Secure' : ''}`;
@ -48,14 +53,17 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
const profile = await discordFetch('https://discord.com/api/v10/users/@me', { headers: { Authorization: `Bearer ${credentials.access_token}` }, signal: AbortSignal.timeout(15000) }); 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'); if (!profile.ok) throw new Error('profile');
const user = await profile.json(); const user = await profile.json();
if (user.id !== env.OWNER_DISCORD_ID) return redirect('/?error=denied', cookie('oauth_state', '', 0)); const role = roleFor(user.id);
if (!role) return redirect('/?error=denied', cookie('oauth_state', '', 0));
const session = token(); const session = token();
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now()); 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); 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)]); 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 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; 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 === 'GET' && url.pathname === '/api/me') return json(200, { user, configured });
if (req.method === 'POST' && url.pathname === '/auth/logout') { if (req.method === 'POST' && url.pathname === '/auth/logout') {
if (req.headers.origin !== appUrl.origin) return json(403, { error: 'Origem inválida.' }); if (req.headers.origin !== appUrl.origin) return json(403, { error: 'Origem inválida.' });