diff --git a/.env.example b/.env.example index cb15442..6489a18 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ APP_URL=http://192.168.88.99:3080 DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= OWNER_DISCORD_ID=376768118535749633 +ALLOWED_DISCORD_IDS=723613831196311603 diff --git a/README.md b/README.md index e2e0fea..c3c3f32 100644 --- a/README.md +++ b/README.md @@ -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. -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`. diff --git a/auth.test.mjs b/auth.test.mjs index fd87506..f09146f 100644 --- a/auth.test.mjs +++ b/auth.test.mjs @@ -25,12 +25,21 @@ test('OAuth rejects strangers, invalid state and replay; owner session requires 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 = '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'; 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 (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(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); diff --git a/public/app.js b/public/app.js index 61c4537..2603f00 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,5 @@ 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() { const response = await fetch('/api/me'); if (!response.ok) throw new Error(); @@ -18,11 +18,13 @@ async function boot() { 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; + content.innerHTML = '

Acesso ao painel

A tua conta está autorizada a entrar no painel.

Perfil
ID Discord
Novos utilizadoresSem acesso
'; + document.querySelector('#discord-id').textContent = user.id; + document.querySelector('#profile-role').textContent = user.role; 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.

`; + 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

Acesso
AutenticaçãoDiscord
Ligação TeamSpeakPor configurar

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

`; + document.querySelector('#dashboard-role').textContent = user.role; } 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('/'); }; diff --git a/server.mjs b/server.mjs index 171f018..32f07cd 100644 --- a/server.mjs +++ b/server.mjs @@ -14,6 +14,11 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) { 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 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 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' : ''}`; @@ -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) }); 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 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: '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)]); } 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 === 'POST' && url.pathname === '/auth/logout') { if (req.headers.origin !== appUrl.origin) return json(403, { error: 'Origem inválida.' });