feat: users, administrator role, selective access to server, login screen, admin dashboard with user and access management

This commit is contained in:
mmacierzanka committed 2026-09-30 19:29:38 +02:00
1 parent 1584b5fbd0
commit 6e39bfab12
20 files changed
+762 -72

No files matched your search

+13
View File
@@ -22,6 +22,19 @@ Wymagane zmienne środowiskowe (plik `.env`):
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
### Konta i dostępy panelu
Panel wymaga logowania. Utwórz pierwszego administratora w działającym kontenerze:
```bash
docker compose exec discord-bot python -m zyrarbot create-admin admin
```
Komenda poprosi o hasło (minimum 10 znaków). Administrator ma dostęp do wszystkich
serwerów i strony `/admin`, gdzie może tworzyć konta użytkowników, przypisywać im
dostęp do serwerów oraz restartować bota. Zwykły użytkownik widzi wyłącznie
przypisane mu serwery. Konta i sesje są przechowywane w PostgreSQL.
Panel obsługuje wiele serwerów Discorda. Wybór serwera w nagłówku przełącza jego
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia
i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym
+59 -4
View File
@@ -9,11 +9,46 @@ import ToastHost from '@/components/ToastHost.vue'
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
import UploadsPanel from '@/components/UploadsPanel.vue'
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
import AdminLayout from '@/components/AdminLayout.vue'
import { useBotStatus } from '@/composables/useBotStatus'
import { useUploads } from '@/composables/useUploads'
const { status, connect, selectGuild } = useBotStatus()
const { status, connect, disconnect, selectGuild } = useBotStatus()
const { refresh } = useUploads()
const user = ref<{ id: number; username: string; is_admin: boolean } | null>(null)
const authLoading = ref(true)
const loginName = ref('')
const loginPassword = ref('')
const loginError = ref('')
const onAdminRoute = window.location.pathname.startsWith('/admin')
async function loadUser() {
try {
const response = await fetch('/api/auth/me')
if (response.ok) user.value = await response.json()
} catch {
user.value = null
} finally {
authLoading.value = false
}
}
async function login() {
loginError.value = ''
const response = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: loginName.value, password: loginPassword.value }),
})
if (!response.ok) { loginError.value = 'Nieprawidłowa nazwa użytkownika lub hasło'; return }
const loggedInUser = await response.json() as { id: number; username: string; is_admin: boolean }
user.value = loggedInUser
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' })
disconnect()
user.value = null
}
const libraryOpen = ref(false)
const selectedGuildId = ref<string | null>(null)
@@ -60,13 +95,26 @@ async function changeGuild(guildId: string) {
await refresh()
}
onMounted(() => {
connect()
refresh()
onMounted(() => { void loadUser() })
watch(user, (value) => {
if (value && !(value.is_admin && onAdminRoute)) { connect(); void refresh() }
})
</script>
<template>
<main v-if="authLoading" class="auth-loading" aria-live="polite">Ładowanie sesji…</main>
<main v-else-if="!user" class="auth-page">
<form class="auth-card" @submit.prevent="login">
<h1>ŻyrarBOT</h1>
<label>Nazwa użytkownika<input v-model="loginName" autocomplete="username" required></label>
<label>Hasło<input v-model="loginPassword" type="password" autocomplete="current-password" required></label>
<p v-if="loginError" class="auth-error">{{ loginError }}</p>
<button class="login-button" type="submit">Zaloguj</button>
</form>
</main>
<AdminLayout v-else-if="user.is_admin && onAdminRoute" :username="user.username" :user-id="user.id" />
<template v-else-if="user">
<div class="background-glow bg-glow-1" />
<div class="background-glow bg-glow-2" />
@@ -75,7 +123,9 @@ onMounted(() => {
:status="status"
:guilds="status?.guilds ?? []"
:selected-guild-id="selectedGuildId"
:is-admin="user.is_admin"
@select-guild="changeGuild"
@logout="logout"
/>
<main class="dashboard-grid">
@@ -103,4 +153,9 @@ onMounted(() => {
/>
<ToastHost />
</template>
</template>
<style scoped>
.auth-loading{min-height:60vh;display:grid;place-items:center;color:#a9b1c4;font-size:14px}.auth-page{width:520px;max-width:calc(100vw - 40px);margin:8vh auto;padding:24px}.auth-card{display:grid;gap:16px;padding:30px;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 20px 60px #0006}.auth-card h1{margin:0}.auth-card label{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.auth-card input{width:100%;height:44px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit}.auth-card input:focus{outline:2px solid #9b83ff66;border-color:#9b83ff}.login-button{height:44px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;font:600 14px Inter,ui-sans-serif,system-ui,sans-serif;cursor:pointer}.login-button:hover{background:#886df1}.auth-error{color:#ff4d5e;font-weight:600}.admin-shortcut{display:flex;justify-content:flex-end;padding:0 0 12px}.admin-shortcut a{color:#c8baff}
</style>
+5
View File
@@ -31,6 +31,11 @@
font-family: var(--font-sans);
}
#app {
width: 100%;
min-width: 0;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
type PanelUser = { id: number; username: string; is_admin: boolean; guild_ids: string[] }
type Guild = { guild_id: string; guild_name: string }
const users = ref<PanelUser[]>([])
const guilds = ref<Guild[]>([])
const loading = ref(true)
const saving = ref(false)
const draftAccess = ref<Record<number, string[]>>({})
const error = ref('')
const panelUsers = computed(() => users.value.filter((user) => !user.is_admin))
const dirtyUsers = computed(() => panelUsers.value.filter((user) => {
const saved = [...user.guild_ids].sort()
const draft = [...(draftAccess.value[user.id] ?? user.guild_ids)].sort()
return saved.length !== draft.length || saved.some((id, index) => id !== draft[index])
}))
async function load() {
loading.value = true
error.value = ''
try {
const [usersResponse, guildsResponse] = await Promise.all([
fetch('/api/auth/users'), fetch('/api/auth/guilds'),
])
if (!usersResponse.ok || !guildsResponse.ok) throw new Error('Nie udało się pobrać listy użytkowników i serwerów.')
;[users.value, guilds.value] = await Promise.all([usersResponse.json(), guildsResponse.json()])
draftAccess.value = Object.fromEntries(users.value.map((user) => [user.id, [...user.guild_ids]]))
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Wystąpił błąd.'
} finally {
loading.value = false
}
}
function updateAccess(account: PanelUser, guildId: string, checked: boolean) {
const current = draftAccess.value[account.id] ?? [...account.guild_ids]
const updated = checked
? [...new Set([...current, guildId])]
: current.filter((id) => id !== guildId)
draftAccess.value = { ...draftAccess.value, [account.id]: updated }
error.value = ''
}
async function saveChanges() {
if (!dirtyUsers.value.length || saving.value) return
saving.value = true
error.value = ''
const results = await Promise.allSettled(dirtyUsers.value.map(async (account) => {
const guildIds = [...(draftAccess.value[account.id] ?? [])]
const response = await fetch(`/api/auth/users/${account.id}/guilds`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
// Keep Discord snowflakes as decimal strings; JavaScript numbers lose precision.
body: JSON.stringify({ guild_ids: guildIds }),
})
if (!response.ok) throw new Error(`${account.username}: nie udało się zapisać uprawnień.`)
account.guild_ids = guildIds
}))
const failed = results.filter((result) => result.status === 'rejected')
if (failed.length) {
error.value = failed.map((result) => result.status === 'rejected' && result.reason instanceof Error
? result.reason.message : 'Nie udało się zapisać uprawnień.').join(' ')
}
saving.value = false
}
onMounted(() => { void load() })
</script>
<template>
<div class="admin-page-content">
<div class="admin-page-heading">
<div><p class="admin-eyebrow">UPRAWNIENIA</p><h2>Dostęp do serwerów</h2><p>Zaznacz serwery dostępne dla każdego użytkownika.</p></div>
<div class="matrix-actions">
<button class="matrix-refresh" type="button" :disabled="loading" @click="load">Odśwież</button>
<button class="matrix-save" type="button" :disabled="loading || saving || !dirtyUsers.length" @click="saveChanges">{{ saving ? 'Zapisywanie…' : 'Zapisz zmiany' }}<span v-if="dirtyUsers.length" class="pending-count">{{ dirtyUsers.length }}</span></button>
</div>
</div>
<p v-if="error" class="matrix-message matrix-error">{{ error }}</p>
<section class="matrix-panel">
<div v-if="loading" class="matrix-state">Ładowanie uprawnień…</div>
<div v-else-if="!guilds.length" class="matrix-state">Bot nie jest obecnie połączony z żadnym serwerem.</div>
<div v-else-if="!panelUsers.length" class="matrix-state">Nie ma jeszcze zwykłych użytkowników. Utwórz konto na stronie Użytkownicy.</div>
<div v-else class="matrix-scroll">
<table class="access-table">
<thead><tr><th class="server-heading">Serwer</th><th v-for="account in panelUsers" :key="account.id">{{ account.username }}</th></tr></thead>
<tbody>
<tr v-for="guild in guilds" :key="guild.guild_id">
<th class="server-name"><span class="server-mark">#</span>{{ guild.guild_name }}</th>
<td v-for="account in panelUsers" :key="account.id">
<label class="access-check" :aria-label="`${account.username}: ${guild.guild_name}`">
<input type="checkbox" :checked="(draftAccess[account.id] ?? account.guild_ids).includes(guild.guild_id)" :disabled="saving" @change="updateAccess(account, guild.guild_id, ($event.target as HTMLInputElement).checked)">
<span class="checkmark" />
</label>
</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && guilds.length && panelUsers.length" class="matrix-footer">{{ dirtyUsers.length ? `Niezapisane zmiany: ${dirtyUsers.length} użytk.` : 'Wprowadź zmiany i wybierz „Zapisz zmiany”.' }}</footer>
</section>
</div>
</template>
<style scoped>
.admin-page-content{display:grid;gap:18px}.admin-page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child{color:#a9b1c4;margin:0}.admin-eyebrow{font-size:11px;letter-spacing:.14em;color:#9c88ff;font-weight:700;margin:0}.matrix-refresh{height:38px;padding:0 14px;border-radius:9px;border:1px solid #41465c;background:#252a3c;color:#eceeff;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer}.matrix-refresh:hover:not(:disabled){background:#30364b}.matrix-refresh:disabled{opacity:.5;cursor:wait}.matrix-message{margin:0;font-size:13px}.matrix-error{color:#ff8d9b}.matrix-panel{overflow:hidden;border:1px solid #303449;border-radius:16px;background:#171a29;box-shadow:0 12px 36px #0003}.matrix-scroll{overflow:auto;max-height:calc(100vh - 280px)}.access-table{width:100%;border-collapse:separate;border-spacing:0;min-width:max-content}.access-table th,.access-table td{height:54px;padding:10px 16px;text-align:center;border-bottom:1px solid #2b2f42;border-right:1px solid #2b2f42}.access-table thead th{position:sticky;top:0;z-index:2;background:#202337;color:#c8cce0;font-size:12px;font-weight:700;min-width:132px}.access-table thead .server-heading{left:0;z-index:3;text-align:left;min-width:240px}.access-table tbody th.server-name{position:sticky;left:0;z-index:1;background:#191c2c;text-align:left;color:#eef0f8;font-weight:600;min-width:240px}.access-table tr:last-child th,.access-table tr:last-child td{border-bottom:0}.access-table th:last-child,.access-table td:last-child{border-right:0}.server-mark{display:inline-grid;place-items:center;width:25px;height:25px;margin-right:10px;border-radius:8px;background:#30294e;color:#beadff}.access-check{position:relative;display:inline-flex;width:22px;height:22px;cursor:pointer}.access-check input{position:absolute;width:1px;height:1px;opacity:0}.checkmark{width:22px;height:22px;border:1px solid #555c72;border-radius:6px;background:#101321;transition:all .14s}.access-check input:checked+.checkmark{border-color:#9678ff;background:#7959e8;box-shadow:0 0 0 3px #8c6cf126}.access-check input:checked+.checkmark:after{content:'';position:absolute;left:8px;top:4px;width:5px;height:9px;border:solid white;border-width:0 2px 2px 0;transform:rotate(45deg)}.access-check input:focus-visible+.checkmark{outline:2px solid #c4b5fd;outline-offset:2px}.access-check input:disabled+.checkmark{opacity:.45;cursor:wait}.matrix-footer{padding:13px 16px;color:#969db1;font-size:12px;border-top:1px solid #2b2f42}.matrix-state{padding:36px 22px;text-align:center;color:#abb1c2}.admin-page-content :deep(*){box-sizing:border-box}@media(max-width:700px){.admin-page-heading{align-items:flex-start}.admin-page-heading h2{font-size:23px}.access-table thead .server-heading,.access-table tbody th.server-name{min-width:180px}}
.matrix-actions{display:flex;align-items:center;gap:9px}.matrix-save{display:inline-flex;align-items:center;justify-content:center;gap:9px;min-height:38px;padding:0 14px;border:1px solid #8c70f2;border-radius:9px;background:#7657e8;color:#fff;font-family:inherit;font-size:13px;font-weight:650;cursor:pointer;transition:background .15s,border-color .15s}.matrix-save:hover:not(:disabled){background:#886df1;border-color:#a28cff}.matrix-save:disabled{opacity:.48;cursor:not-allowed}.pending-count{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:999px;background:#ffffff2a;font-size:11px}@media(max-width:520px){.admin-page-heading{flex-direction:column}.matrix-actions{width:100%}.matrix-actions button{flex:1}}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import AdminAccessPage from '@/components/AdminAccessPage.vue'
import AdminUsersPage from '@/components/AdminUsersPage.vue'
defineProps<{ username: string; userId: number }>()
const currentPath = ref(window.location.pathname)
const activePage = computed(() => currentPath.value.endsWith('/access') ? 'access' : 'users')
function navigate(path: string) {
window.history.pushState({}, '', path)
currentPath.value = path
}
function onPopState() { currentPath.value = window.location.pathname }
onMounted(() => {
if (window.location.pathname === '/admin') navigate('/admin/users')
window.addEventListener('popstate', onPopState)
})
async function restartBot() {
if (!window.confirm('Czy na pewno zrestartować bota?')) return
const response = await fetch('/api/restart', { method: 'POST' })
if (!response.ok) window.alert('Nie udało się zrestartować bota.')
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.assign('/')
}
</script>
<template>
<main class="admin-shell">
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin/users"><img src="/icon.jpeg" alt=""><span><strong>ŻyrarBOT</strong><small>Panel administracyjny</small></span></a>
<div class="sidebar-caption">ZARZĄDZANIE</div>
<nav class="sidebar-nav" aria-label="Menu administratora">
<a href="/admin/users" title="Użytkownicy" :class="{ active: activePage === 'users' }" @click.prevent="navigate('/admin/users')"><span class="nav-icon">♙</span>Użytkownicy</a>
<a href="/admin/access" title="Dostęp do serwerów" :class="{ active: activePage === 'access' }" @click.prevent="navigate('/admin/access')"><span class="nav-icon">▦</span>Dostęp do serwerów</a>
</nav>
<div class="sidebar-bottom"><div class="admin-identity"><span class="identity-dot"/><span><small>Zalogowano jako</small><strong>{{ username }}</strong></span></div><button class="sidebar-logout" type="button" @click="logout">Wyloguj</button></div>
</aside>
<section class="admin-main">
<header class="admin-topbar"><div><span class="topbar-label">ADMINISTRACJA</span><span class="topbar-separator">/</span><span>{{ activePage === 'access' ? 'Dostęp do serwerów' : 'Użytkownicy' }}</span></div><div class="topbar-actions"><a class="topbar-link" href="/">Wróć do panelu</a><button class="admin-button admin-button-danger" type="button" @click="restartBot">Restart bota</button></div></header>
<div class="admin-content"><AdminUsersPage v-if="activePage === 'users'" :current-user-id="userId"/><AdminAccessPage v-else/></div>
</section>
</main>
</template>
<style scoped>
.admin-shell{width:100%;min-height:calc(100vh - 40px);margin:0 auto;display:grid;grid-template-columns:248px minmax(0,1fr);overflow:hidden;border:1px solid #292d40;border-radius:20px;background:#101320;color:#f4f5fb;box-shadow:0 24px 70px #0006;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.admin-sidebar{position:relative;display:flex;flex-direction:column;padding:22px 14px 16px;background:#151827;border-right:1px solid #292d40}.admin-brand{display:flex;align-items:center;gap:11px;padding:3px 9px 27px;color:inherit;text-decoration:none}.admin-brand img{width:39px;height:39px;object-fit:cover;border-radius:12px}.admin-brand span,.admin-identity span{display:grid;gap:3px}.admin-brand strong{font-size:14px}.admin-brand small{font-size:11px;color:#8e95aa}.sidebar-caption{padding:12px 11px 8px;color:#747c93;font-size:10px;font-weight:800;letter-spacing:.12em}.sidebar-nav{display:grid;gap:5px}.sidebar-nav a{display:flex;align-items:center;gap:11px;min-height:41px;padding:0 11px;border:1px solid transparent;border-radius:9px;color:#aeb4c6;text-decoration:none;font-size:12px;font-weight:600;transition:background .15s,color .15s}.sidebar-nav a:hover{background:#202438;color:#fff}.sidebar-nav a.active{background:#2b2645;border-color:#3e3560;color:#d8ceff}.nav-icon{display:grid;place-items:center;width:19px;font-size:18px;color:#9d89ec}.sidebar-bottom{display:grid;gap:12px;margin-top:auto;padding:14px 7px 0;border-top:1px solid #2a2e40}.admin-identity{display:flex;align-items:center;gap:10px}.identity-dot{width:8px;height:8px;border-radius:50%;background:#53d49f;box-shadow:0 0 9px #53d49f88}.admin-identity small{color:#858da2;font-size:10px}.admin-identity strong{font-size:12px;overflow-wrap:anywhere}.sidebar-logout{height:34px;border:1px solid #353a4f;border-radius:8px;background:#202436;color:#cbd0df;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.sidebar-logout:hover{background:#2a3045;color:#fff}.admin-main{min-width:0;display:flex;flex-direction:column;background:radial-gradient(ellipse at 86% 0%,#28213c44,transparent 38%),#101320}.admin-topbar{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:15px;padding:0 26px;border-bottom:1px solid #292d40;color:#b9bfd0;font-size:12px}.topbar-label{color:#858da3;font-size:10px;font-weight:800;letter-spacing:.1em}.topbar-separator{margin:0 10px;color:#51586d}.topbar-actions{display:flex;align-items:center;gap:16px}.topbar-link{color:#c8c4df;text-decoration:none}.topbar-link:hover{color:#fff}.admin-content{width:100%;max-width:none;margin:0;padding:32px 34px 48px}.admin-button{min-height:36px;padding:0 13px;border-radius:8px;font-family:inherit;font-size:12px;font-weight:650;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.admin-button-danger{border:1px solid #613743;background:#38242d;color:#ffc1c6}.admin-button-danger:hover{border-color:#a54e5b;background:#4b2934;transform:translateY(-1px)}@media(max-width:760px){.admin-shell{min-height:calc(100vh - 20px);grid-template-columns:68px minmax(0,1fr);border-radius:14px}.admin-sidebar{padding:17px 8px 12px}.admin-brand{justify-content:center;padding:0 0 22px}.admin-brand img{width:38px;height:38px}.admin-brand span,.sidebar-caption,.sidebar-nav a:not(.active),.sidebar-nav a.active{font-size:0}.sidebar-nav a{justify-content:center;padding:0}.sidebar-nav a .nav-icon{font-size:19px}.sidebar-nav a{position:relative}.sidebar-nav a:not(:hover)::after{display:none}.admin-identity{justify-content:center}.admin-identity span{display:none}.sidebar-logout{font-size:0}.sidebar-logout:after{content:'↪';font-size:16px}.admin-topbar{padding:0 15px;min-height:58px}.topbar-actions{gap:9px}.topbar-link{font-size:0}.topbar-link:after{content:'Panel';font-size:11px}.admin-button{padding:0 9px;font-size:11px}.admin-content{padding:22px 14px 30px}}@media(max-width:480px){.admin-shell{grid-template-columns:56px minmax(0,1fr)}.admin-content{padding:18px 10px}.admin-topbar{padding:0 10px}.topbar-label,.topbar-separator{display:none}}
.admin-brand span{min-width:0}@media(max-width:760px){.admin-brand span{display:none}}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
type PanelUser = { id: number; username: string; is_admin: boolean }
const props = defineProps<{ currentUserId: number }>()
const users = ref<PanelUser[]>([])
const username = ref('')
const password = ref('')
const busy = ref(false)
const roleBusy = ref(new Set<number>())
const error = ref('')
async function loadUsers() {
const response = await fetch('/api/auth/users')
if (response.ok) users.value = await response.json()
}
async function createUser() {
busy.value = true
error.value = ''
try {
const response = await fetch('/api/auth/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value.trim(), password: password.value }),
})
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się utworzyć konta.')
username.value = ''
password.value = ''
await loadUsers()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Nie udało się utworzyć konta.'
} finally {
busy.value = false
}
}
async function setAdminRole(account: PanelUser) {
const isAdmin = !account.is_admin
roleBusy.value = new Set(roleBusy.value).add(account.id)
error.value = ''
try {
const response = await fetch(`/api/auth/users/${account.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: isAdmin }),
})
const payload = await response.json().catch(() => null)
if (!response.ok) throw new Error(payload?.detail ?? 'Nie udało się zmienić roli użytkownika.')
await loadUsers()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : 'Nie udało się zmienić roli użytkownika.'
} finally {
const pending = new Set(roleBusy.value)
pending.delete(account.id)
roleBusy.value = pending
}
}
onMounted(() => { void loadUsers() })
</script>
<template>
<div class="admin-page-content">
<div class="admin-page-heading">
<div><p class="admin-eyebrow">KONTA</p><h2>Użytkownicy</h2><p>Twórz konta i zarządzaj ich danymi logowania.</p></div>
<span class="admin-count">{{ users.length }} kont</span>
</div>
<section class="admin-panel">
<h3>Utwórz użytkownika</h3>
<form class="admin-user-form" @submit.prevent="createUser">
<label class="admin-field"><span>Nazwa użytkownika</span><input v-model="username" autocomplete="off" maxlength="80" required placeholder="np. jan.kowalski"></label>
<label class="admin-field"><span>Hasło</span><input v-model="password" type="password" autocomplete="new-password" required placeholder="Wpisz hasło"></label>
<button class="admin-button admin-button-primary" type="submit" :disabled="busy">{{ busy ? 'Tworzenie…' : 'Utwórz konto' }}</button>
</form>
<p v-if="error" class="admin-feedback admin-feedback-error">{{ error }}</p>
</section>
<section class="admin-panel">
<div class="admin-section-heading"><h3>Istniejące konta</h3><span>Nazwę lub hasło nowego konta ustawisz podczas tworzenia.</span></div>
<div v-if="users.length" class="admin-user-list">
<div v-for="account in users" :key="account.id" class="admin-user-row">
<span class="admin-avatar">{{ account.username.slice(0, 1).toUpperCase() }}</span>
<span class="admin-user-name">{{ account.username }}</span>
<label class="admin-role-toggle" :title="account.id === props.currentUserId && account.is_admin ? 'Nie można odebrać roli własnemu kontu' : undefined">
<input
type="checkbox"
:checked="account.is_admin"
:disabled="roleBusy.has(account.id) || (account.id === props.currentUserId && account.is_admin)"
@change="setAdminRole(account)"
>
<span>Administrator</span>
</label>
</div>
</div>
<p v-else class="admin-empty">Brak kont.</p>
</section>
</div>
</template>
<style scoped>
.admin-page-content{display:grid;gap:22px}.admin-page-heading,.admin-section-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.admin-page-heading h2{margin:2px 0 6px;font-size:28px}.admin-page-heading p:last-child,.admin-section-heading span{color:#a9b1c4;margin:0}.admin-eyebrow{font-size:11px;letter-spacing:.14em;color:#9c88ff;font-weight:700;margin:0}.admin-count{padding:8px 12px;border-radius:999px;background:#25263b;color:#ded9ff;font-size:13px}.admin-panel{padding:24px;border:1px solid #303449;background:#171a29;border-radius:16px;box-shadow:0 12px 36px #0003}.admin-panel h3{font-size:16px;margin:0 0 18px}.admin-user-form{display:grid;grid-template-columns:1fr 1fr auto;align-items:end;gap:14px}.admin-field{display:grid;gap:7px;color:#d9dcec;font-size:13px;font-weight:600}.admin-field input{width:100%;height:42px;padding:0 12px;border-radius:9px;border:1px solid #42475e;background:#101321;color:#f6f7fc;font:inherit;outline:none}.admin-field input:focus{border-color:#9b83ff;box-shadow:0 0 0 3px #9175ff24}.admin-field input::placeholder{color:#777f95;font-weight:400}.admin-button{min-height:42px;padding:0 17px;border-radius:9px;border:1px solid transparent;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,transform .15s}.admin-button-primary{color:white;background:#7657e8;border-color:#8c70f2}.admin-button-primary:hover:not(:disabled){background:#886df1;transform:translateY(-1px)}.admin-button:disabled{opacity:.55;cursor:wait}.admin-feedback{margin:13px 0 0;font-size:13px}.admin-feedback-error{color:#ff8d9b}.admin-section-heading{margin-bottom:8px}.admin-section-heading h3{margin:0}.admin-section-heading span{font-size:12px}.admin-user-list{display:grid}.admin-user-row{display:flex;align-items:center;gap:12px;min-height:58px;border-top:1px solid #2b2f42}.admin-avatar{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;background:#332a55;color:#c9baff;font-weight:700}.admin-user-name{flex:1;color:#f3f4f8;font-weight:600}.admin-role-toggle{display:inline-flex;align-items:center;gap:8px;color:#c7cadd;font-size:12px;white-space:nowrap;cursor:pointer}.admin-role-toggle input{width:16px;height:16px;margin:0;accent-color:#886df1;cursor:pointer}.admin-role-toggle input:disabled{opacity:.45;cursor:not-allowed}.admin-empty{color:#9ca3b5}.admin-page-content :deep(*){box-sizing:border-box}@media(max-width:760px){.admin-user-form{grid-template-columns:1fr}.admin-user-form .admin-button{width:100%}.admin-section-heading{align-items:flex-start;flex-direction:column}.admin-user-row{flex-wrap:wrap;padding:10px 0}.admin-user-name{min-width:calc(100% - 48px)}.admin-role-toggle{margin-left:auto}}
</style>
+56 -58
View File
@@ -1,21 +1,15 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RotateCw } from 'lucide-vue-next'
import { computed } from 'vue'
import type { BotStatus, GuildSettings } from '@/types/status'
import { api } from '@/api/client'
import { useToasts } from '@/composables/useToasts'
const props = defineProps<{
status: BotStatus | null
guilds: GuildSettings[]
selectedGuildId: string | null
isAdmin: boolean
}>()
defineEmits<{ selectGuild: [guildId: string] }>()
defineEmits<{ selectGuild: [guildId: string]; logout: [] }>()
const { notify } = useToasts()
const isRestarting = ref(false)
const restartInitiatedAt = ref<number | null>(null)
let restartTimer: number | null = null
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
@@ -25,42 +19,6 @@ const label = computed(() =>
: 'Rozłączony',
)
async function handleRestart() {
if (isRestarting.value) return
if (!window.confirm('Czy na pewno chcesz zrestartować bota?')) return
isRestarting.value = true
restartInitiatedAt.value = Date.now()
notify('Wysyłanie polecenia restartu...', 'info')
try {
await api.restart()
notify('Wysłano polecenie. Restartowanie bota...', 'info')
} catch {
notify('Wysłano polecenie restartu.', 'info')
}
if (restartTimer !== null) clearTimeout(restartTimer)
restartTimer = window.setTimeout(() => {
isRestarting.value = false
restartInitiatedAt.value = null
}, 15000)
}
watch(
() => props.status,
() => {
if (isRestarting.value && restartInitiatedAt.value && Date.now() - restartInitiatedAt.value > 3000) {
isRestarting.value = false
restartInitiatedAt.value = null
if (restartTimer !== null) {
clearTimeout(restartTimer)
restartTimer = null
}
notify('Bot został uruchomiony ponownie!', 'success')
}
},
)
</script>
<template>
@@ -101,19 +59,10 @@ watch(
/>
<span>{{ label }}</span>
</div>
<button
type="button"
class="btn-secondary btn-restart"
:disabled="isRestarting"
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
@click="handleRestart"
>
<RotateCw
:size="16"
:class="{ 'spin-icon': isRestarting }"
/>
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
</button>
<div class="account-actions">
<a v-if="isAdmin" class="account-button account-button-admin" href="/admin/users">Panel admina</a>
<button class="account-button" type="button" @click="$emit('logout')">Wyloguj</button>
</div>
</div>
</header>
</template>
@@ -125,6 +74,43 @@ watch(
gap: 16px;
}
.account-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
padding-left: 16px;
border-left: 1px solid var(--border-glass);
}
.account-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 13px;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
font: 600 0.8rem var(--font-sans);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--transition-smooth);
}
.account-button:hover {
background: rgba(255, 255, 255, 0.13);
border-color: rgba(255, 255, 255, 0.24);
}
.account-button-admin {
border-color: var(--border-active);
background: rgba(139, 92, 246, 0.12);
color: #d8c9ff;
}
.btn-restart {
display: inline-flex;
align-items: center;
@@ -166,6 +152,18 @@ watch(
align-items: flex-start;
}
.account-actions {
width: 100%;
margin: 0;
padding: 12px 0 0;
border-top: 1px solid var(--border-glass);
border-left: 0;
}
.account-button {
flex: 1;
}
.btn-restart {
width: 100%;
justify-content: center;
+36
View File
@@ -1,16 +1,52 @@
from __future__ import annotations
import asyncio
import os
import logging
import argparse
import getpass
import psycopg
from zyrarbot.application import Application
from zyrarbot.ui.api.auth import hash_password
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
def create_admin(username: str, password: str) -> None:
database = PostgresDatabase(
host=os.environ.get("POSTGRES_HOST", "localhost"),
port=int(os.environ.get("POSTGRES_PORT", "5432")),
database=os.environ.get("POSTGRES_DB", "zyrarbot"),
user=os.environ.get("POSTGRES_USER", "zyrarbot"),
password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"),
)
with database.connect() as connection:
connection.execute("INSERT INTO users(username,password_hash,is_admin) VALUES (%s,%s,TRUE)",
(username, hash_password(password)))
print(f"Utworzono administratora: {username}")
def main() -> None:
parser = argparse.ArgumentParser(description="ŻyrarBOT")
subparsers = parser.add_subparsers(dest="command")
sub = subparsers.add_parser("create-admin", help="Utwórz konto administratora")
sub.add_argument("username")
sub.add_argument("--password")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
if args.command == "create-admin":
password = args.password if args.password is not None else getpass.getpass("Hasło administratora: ")
if not password:
parser.error("Hasło nie może być puste")
try:
create_admin(args.username, password)
except psycopg.errors.UniqueViolation:
parser.error("Użytkownik o tej nazwie już istnieje")
return
asyncio.run(Application().run())
+18
View File
@@ -23,6 +23,7 @@ from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
from zyrarbot.storage.ports.persistence import Persistence
from zyrarbot.ui.api.factory import mount_api
from zyrarbot.ui.api.auth import AuthRepository
from zyrarbot.ui.bot.client import MusicBot
from zyrarbot.ui.bot.guild_resolver import GuildResolver
@@ -74,6 +75,18 @@ class Application:
self.web = FastAPI(title="ŻyrarBOT")
self.web.state.restart = self.schedule_restart
self.web.state.persistence = self.persistence
self.web.state.auth = AuthRepository(self.persistence.database)
@self.web.middleware("http")
async def require_panel_login(request, call_next):
if request.url.path.startswith("/api/") and request.url.path not in {
"/api/health", "/api/auth/login"
}:
if self.web.state.auth.user_for_token(request.cookies.get("zyrarbot_session")) is None:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Wymagane logowanie"}, status_code=401)
return await call_next(request)
broadcaster = mount_api(
self.web,
settings=self.settings,
@@ -111,6 +124,11 @@ class Application:
"""Serve the SPA for a direct link to a guild view."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
@self.web.get("/admin/{route_path:path}", include_in_schema=False)
async def frontend_route(route_path: str) -> FileResponse:
"""Serve the SPA for nested frontend routes such as admin pages."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
self.web.mount(
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
)
@@ -0,0 +1,38 @@
from __future__ import annotations
import psycopg
class UsersMigration:
version = 2
name = "users_and_access"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute("""
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
connection.execute("""
CREATE TABLE user_sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
)
""")
connection.execute("""
CREATE TABLE user_guild_access (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guild_id BIGINT NOT NULL,
PRIMARY KEY (user_id, guild_id)
)
""")
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("DROP TABLE user_guild_access")
connection.execute("DROP TABLE user_sessions")
connection.execute("DROP TABLE users")
@@ -5,5 +5,6 @@ from importlib import import_module
InitialSchemaMigration = import_module(
".0001_initial_schema", __name__
).InitialSchemaMigration
UsersMigration = import_module(".0002_users", __name__).UsersMigration
MIGRATIONS = (InitialSchemaMigration(),)
MIGRATIONS = (InitialSchemaMigration(), UsersMigration())
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, Request
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
SESSION_DAYS = 14
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 310_000)
return f"{salt.hex()}:{digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
salt, expected = encoded.split(":", 1)
actual = hash_password(password, bytes.fromhex(salt)).split(":", 1)[1]
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
class AuthRepository:
def __init__(self, database: PostgresDatabase):
self.database = database
def login(self, username: str, password: str) -> tuple[str, dict] | None:
with self.database.connect() as connection:
row = connection.execute(
"SELECT id, username, password_hash, is_admin FROM users WHERE username=%s",
(username,),
).fetchone()
if not row or not verify_password(password, row[2]):
return None
token = secrets.token_urlsafe(32)
connection.execute(
"INSERT INTO user_sessions(token_hash,user_id,expires_at) VALUES (%s,%s,%s)",
(hashlib.sha256(token.encode()).hexdigest(), row[0],
datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)),
)
return token, {"id": row[0], "username": row[1], "is_admin": row[3]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
return None
with self.database.connect() as connection:
row = connection.execute("""
SELECT u.id, u.username, u.is_admin FROM user_sessions s
JOIN users u ON u.id=s.user_id
WHERE s.token_hash=%s AND s.expires_at > CURRENT_TIMESTAMP
""", (hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
return {"id": row[0], "username": row[1], "is_admin": row[2]} if row else None
def logout(self, token: str | None) -> None:
if token:
with self.database.connect() as connection:
connection.execute("DELETE FROM user_sessions WHERE token_hash=%s",
(hashlib.sha256(token.encode()).hexdigest(),))
def guild_ids(self, user: dict, available: list[int]) -> list[int]:
if user["is_admin"]:
return available
with self.database.connect() as connection:
rows = connection.execute("SELECT guild_id FROM user_guild_access WHERE user_id=%s",
(user["id"],)).fetchall()
allowed = {row[0] for row in rows}
return [guild_id for guild_id in available if guild_id in allowed]
def current_user(request: Request) -> dict:
user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
if user is None:
raise HTTPException(status_code=401, detail="Wymagane logowanie")
return user
def require_admin(request: Request) -> dict:
user = current_user(request)
if not user["is_admin"]:
raise HTTPException(status_code=403, detail="Wymagane uprawnienia administratora")
return user
+2 -2
View File
@@ -24,11 +24,11 @@ class ConnectionManager:
self._connections.pop(websocket, None)
async def broadcast(
self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]]
self, message: dict[str, Any] | Callable[..., dict[str, Any]]
) -> None:
for connection, guild_id in list(self._connections.items()):
try:
payload = message(guild_id) if callable(message) else message
payload = message(connection, guild_id) if callable(message) else message
await connection.send_json(payload)
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
self.disconnect(connection)
+9 -2
View File
@@ -59,9 +59,16 @@ def get_socket_status_service(websocket: WebSocket) -> StatusService:
def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
guilds = get_guilds(request)
guild = next((item for item in guilds.guilds() if item.id == guild_id), None)
user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
permitted = request.app.state.auth.guild_ids(user, [item.id for item in guilds.guilds()]) if user else []
if guild_id is not None and guild_id not in permitted:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
guild = next((item for item in guilds.guilds() if item.id == guild_id and item.id in permitted), None)
if guild_id is None:
guild = guilds.active_guild()
active = guilds.active_guild()
guild = active if active and active.id in permitted else next(
(item for item in guilds.guilds() if item.id in permitted), None
)
if guild is None:
raise HTTPException(status_code=404, detail="Unknown Discord server")
return guild
+2
View File
@@ -9,6 +9,7 @@ from zyrarbot.storage.ports.guild_config import GuildConfigRepository
from zyrarbot.storage.ports.uploads import UploadsRepository
from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.routers import (
auth_router,
playback_router,
queue_router,
status_router,
@@ -21,6 +22,7 @@ from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.bot.guild_resolver import GuildResolver
ROUTERS = (
auth_router.router,
status_router.router,
playback_router.router,
queue_router.router,
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
class Credentials(BaseModel):
username: str = Field(min_length=1, max_length=80)
password: str = Field(min_length=1)
class UserCreate(Credentials):
guild_ids: list[int] = Field(default_factory=list)
class UserUpdate(BaseModel):
guild_ids: list[int]
class UserRoleUpdate(BaseModel):
is_admin: bool
@router.post("/login")
async def login(body: Credentials, request: Request, response: Response) -> dict:
result = request.app.state.auth.login(body.username, body.password)
if result is None:
raise HTTPException(status_code=401, detail="Nieprawidłowa nazwa użytkownika lub hasło")
token, user = result
response.set_cookie("zyrarbot_session", token, httponly=True, samesite="lax",
secure=request.url.scheme == "https", max_age=14 * 86400, path="/")
return user
@router.post("/logout")
async def logout(request: Request, response: Response) -> dict:
request.app.state.auth.logout(request.cookies.get("zyrarbot_session"))
response.delete_cookie("zyrarbot_session", path="/")
return {"status": "ok"}
@router.get("/me")
async def me(user: dict = Depends(current_user)) -> dict:
return user
def _available_guilds(request: Request) -> list[dict]:
return [{"guild_id": str(g.id), "guild_name": g.name} for g in request.app.state.guilds.guilds()]
@router.get("/users")
async def list_users(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
with request.app.state.persistence.database.connect() as connection:
rows = connection.execute("SELECT id,username,is_admin FROM users ORDER BY username").fetchall()
assignments = connection.execute("SELECT user_id,guild_id FROM user_guild_access").fetchall()
by_user: dict[int, list[str]] = {}
for user_id, guild_id in assignments:
by_user.setdefault(user_id, []).append(str(guild_id))
return [{"id": row[0], "username": row[1], "is_admin": row[2],
"guild_ids": by_user.get(row[0], [])} for row in rows]
@router.get("/guilds")
async def list_guilds(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
return _available_guilds(request)
@router.post("/users")
async def create_user(body: UserCreate, request: Request, _: dict = Depends(require_admin)) -> dict:
from zyrarbot.ui.api.auth import hash_password
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
try:
with request.app.state.persistence.database.connect() as connection:
row = connection.execute("INSERT INTO users(username,password_hash) VALUES (%s,%s) RETURNING id",
(body.username, hash_password(body.password))).fetchone()
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(row[0], guild_id))
except Exception as error:
if getattr(error, "sqlstate", None) == "23505":
raise HTTPException(status_code=409, detail="Taka nazwa użytkownika już istnieje") from error
raise
return {"id": row[0], "username": body.username, "is_admin": False,
"guild_ids": [str(g) for g in set(body.guild_ids)]}
@router.put("/users/{user_id}/guilds")
async def update_access(user_id: int, body: UserUpdate, request: Request,
_: dict = Depends(require_admin)) -> dict:
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
with request.app.state.persistence.database.connect() as connection:
exists = connection.execute("SELECT 1 FROM users WHERE id=%s AND is_admin=FALSE",
(user_id,)).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
connection.execute("DELETE FROM user_guild_access WHERE user_id=%s", (user_id,))
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(user_id, guild_id))
return {"status": "ok"}
@router.put("/users/{user_id}/role")
async def update_role(user_id: int, body: UserRoleUpdate, request: Request,
admin: dict = Depends(require_admin)) -> dict:
if user_id == admin["id"] and not body.is_admin:
raise HTTPException(status_code=400, detail="Nie możesz odebrać uprawnień administratora własnemu kontu")
with request.app.state.persistence.database.connect() as connection:
row = connection.execute(
"UPDATE users SET is_admin=%s WHERE id=%s RETURNING id,username,is_admin",
(body.is_admin, user_id),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
return {"id": row[0], "username": row[1], "is_admin": row[2]}
+8 -2
View File
@@ -8,16 +8,22 @@ from fastapi import APIRouter, Depends, Request
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status")
async def get_status(
request: Request,
user: dict = Depends(current_user),
guild: discord.Guild = Depends(get_active_guild),
status: StatusService = Depends(get_status_service),
) -> dict[str, Any]:
return status.snapshot(guild.id)
snapshot = status.snapshot(guild.id)
permitted = request.app.state.auth.guild_ids(user, [g.id for g in request.app.state.guilds.guilds()])
snapshot["guilds"] = [item for item in snapshot["guilds"] if int(item["guild_id"]) in permitted]
return snapshot
@router.get("/health")
@@ -26,7 +32,7 @@ async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any
@router.post("/restart")
async def restart_bot(request: Request) -> dict[str, Any]:
async def restart_bot(request: Request, _: dict = Depends(require_admin)) -> dict[str, Any]:
restart_action = getattr(request.app.state, "restart", None)
if restart_action:
restart_action()
+9 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import discord
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
@@ -16,6 +16,7 @@ from zyrarbot.ui.api.dependencies import (
from zyrarbot.ui.api.requests.connect_request import ConnectRequest
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.api.auth import current_user
from zyrarbot.ui.bot.guild_resolver import GuildResolver
router = APIRouter(prefix="/api", tags=["voice"])
@@ -50,6 +51,8 @@ async def set_notification_channel(
@router.post("/connect")
async def connect(
request: ConnectRequest,
http_request: Request,
user: dict = Depends(current_user),
guilds: GuildResolver = Depends(get_guilds),
player: MusicPlayer = Depends(get_player),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
@@ -62,6 +65,11 @@ async def connect(
channel = guilds.channel(channel_id)
if not isinstance(channel, discord.VoiceChannel):
raise HTTPException(status_code=404, detail="Voice channel not found")
allowed = http_request.app.state.auth.guild_ids(
user, [item.id for item in guilds.guilds()]
)
if channel.guild.id not in allowed:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
if channel.guild.me and not channel.permissions_for(channel.guild.me).connect:
raise HTTPException(
+14 -1
View File
@@ -20,9 +20,22 @@ async def websocket_endpoint(
status: StatusService = Depends(get_socket_status_service),
guild_id: int | None = Query(default=None),
) -> None:
user = websocket.app.state.auth.user_for_token(websocket.cookies.get("zyrarbot_session"))
if user is None:
await websocket.close(code=4401)
return
available = [g.id for g in websocket.app.state.guilds.guilds()]
permitted = websocket.app.state.auth.guild_ids(user, available)
if guild_id is not None and guild_id not in permitted:
await websocket.close(code=4403)
return
if guild_id is None:
guild_id = permitted[0] if permitted else None
await connections.connect(websocket, guild_id)
try:
await websocket.send_json(status.snapshot(guild_id))
initial = status.snapshot(guild_id)
initial["guilds"] = [item for item in initial["guilds"] if int(item["guild_id"]) in permitted]
await websocket.send_json(initial)
while True:
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
await websocket.receive_text()
+12 -1
View File
@@ -24,10 +24,21 @@ class StatusBroadcaster:
if not self._connections.has_listeners:
return
try:
await self._connections.broadcast(self._status.snapshot)
await self._connections.broadcast(self._snapshot_for_connection)
except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli
logger.error("Błąd rozgłaszania stanu: %s", error)
def _snapshot_for_connection(self, connection, guild_id: int | None) -> dict:
payload = self._status.snapshot(guild_id)
user = connection.app.state.auth.user_for_token(
connection.cookies.get("zyrarbot_session")
)
allowed = connection.app.state.auth.guild_ids(
user, [guild.id for guild in connection.app.state.guilds.guilds()]
) if user else []
payload["guilds"] = [item for item in payload["guilds"] if int(item["guild_id"]) in allowed]
return payload
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop())