feat: users, administrator role, selective access to server, login screen, admin dashboard with user and access management
This commit is contained in:
20 files changed
+762
-72
No files matched your search
@@ -22,6 +22,19 @@ Wymagane zmienne środowiskowe (plik `.env`):
|
|||||||
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
|
Panel działa pod `http://localhost:8000` podczas uruchomienia lokalnego. W Dockerze
|
||||||
jest dostępny pod `http://localhost` po wykonaniu `docker compose up --build`.
|
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
|
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
|
osobną kolejkę, historię, bazę plików i ustawienia odtwarzania. Ustawienia, historia
|
||||||
i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym
|
i migawki kolejek przechodzą przez porty persystencji, a PostgreSQL jest obecnym
|
||||||
|
|||||||
+59
-4
@@ -9,11 +9,46 @@ import ToastHost from '@/components/ToastHost.vue'
|
|||||||
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
|
import UploadsLibraryModal from '@/components/UploadsLibraryModal.vue'
|
||||||
import UploadsPanel from '@/components/UploadsPanel.vue'
|
import UploadsPanel from '@/components/UploadsPanel.vue'
|
||||||
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
|
import VoiceChannelPanel from '@/components/VoiceChannelPanel.vue'
|
||||||
|
import AdminLayout from '@/components/AdminLayout.vue'
|
||||||
import { useBotStatus } from '@/composables/useBotStatus'
|
import { useBotStatus } from '@/composables/useBotStatus'
|
||||||
import { useUploads } from '@/composables/useUploads'
|
import { useUploads } from '@/composables/useUploads'
|
||||||
|
|
||||||
const { status, connect, selectGuild } = useBotStatus()
|
const { status, connect, disconnect, selectGuild } = useBotStatus()
|
||||||
const { refresh } = useUploads()
|
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 libraryOpen = ref(false)
|
||||||
const selectedGuildId = ref<string | null>(null)
|
const selectedGuildId = ref<string | null>(null)
|
||||||
@@ -60,13 +95,26 @@ async function changeGuild(guildId: string) {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => { void loadUser() })
|
||||||
connect()
|
|
||||||
refresh()
|
watch(user, (value) => {
|
||||||
|
if (value && !(value.is_admin && onAdminRoute)) { connect(); void refresh() }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-1" />
|
||||||
<div class="background-glow bg-glow-2" />
|
<div class="background-glow bg-glow-2" />
|
||||||
|
|
||||||
@@ -75,7 +123,9 @@ onMounted(() => {
|
|||||||
:status="status"
|
:status="status"
|
||||||
:guilds="status?.guilds ?? []"
|
:guilds="status?.guilds ?? []"
|
||||||
:selected-guild-id="selectedGuildId"
|
:selected-guild-id="selectedGuildId"
|
||||||
|
:is-admin="user.is_admin"
|
||||||
@select-guild="changeGuild"
|
@select-guild="changeGuild"
|
||||||
|
@logout="logout"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="dashboard-grid">
|
<main class="dashboard-grid">
|
||||||
@@ -103,4 +153,9 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
</template>
|
||||||
</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>
|
||||||
@@ -31,6 +31,11 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-dark);
|
background-color: var(--bg-dark);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RotateCw } from 'lucide-vue-next'
|
|
||||||
import type { BotStatus, GuildSettings } from '@/types/status'
|
import type { BotStatus, GuildSettings } from '@/types/status'
|
||||||
import { api } from '@/api/client'
|
|
||||||
import { useToasts } from '@/composables/useToasts'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: BotStatus | null
|
status: BotStatus | null
|
||||||
guilds: GuildSettings[]
|
guilds: GuildSettings[]
|
||||||
selectedGuildId: string | null
|
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)
|
const activeChannel = computed(() => props.status?.active_voice_channel ?? null)
|
||||||
|
|
||||||
@@ -25,42 +19,6 @@ const label = computed(() =>
|
|||||||
: 'Rozłączony',
|
: '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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -101,19 +59,10 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="account-actions">
|
||||||
type="button"
|
<a v-if="isAdmin" class="account-button account-button-admin" href="/admin/users">Panel admina</a>
|
||||||
class="btn-secondary btn-restart"
|
<button class="account-button" type="button" @click="$emit('logout')">Wyloguj</button>
|
||||||
:disabled="isRestarting"
|
</div>
|
||||||
:title="isRestarting ? 'Restartowanie bota...' : 'Zrestartuj bota'"
|
|
||||||
@click="handleRestart"
|
|
||||||
>
|
|
||||||
<RotateCw
|
|
||||||
:size="16"
|
|
||||||
:class="{ 'spin-icon': isRestarting }"
|
|
||||||
/>
|
|
||||||
<span>{{ isRestarting ? 'Restart...' : 'Restart' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -125,6 +74,43 @@ watch(
|
|||||||
gap: 16px;
|
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 {
|
.btn-restart {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -166,6 +152,18 @@ watch(
|
|||||||
align-items: flex-start;
|
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 {
|
.btn-restart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,16 +1,52 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
from zyrarbot.application import Application
|
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:
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
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())
|
asyncio.run(Application().run())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository
|
|||||||
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
|
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
|
||||||
from zyrarbot.storage.ports.persistence import Persistence
|
from zyrarbot.storage.ports.persistence import Persistence
|
||||||
from zyrarbot.ui.api.factory import mount_api
|
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.client import MusicBot
|
||||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||||
|
|
||||||
@@ -74,6 +75,18 @@ class Application:
|
|||||||
|
|
||||||
self.web = FastAPI(title="ŻyrarBOT")
|
self.web = FastAPI(title="ŻyrarBOT")
|
||||||
self.web.state.restart = self.schedule_restart
|
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(
|
broadcaster = mount_api(
|
||||||
self.web,
|
self.web,
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
@@ -111,6 +124,11 @@ class Application:
|
|||||||
"""Serve the SPA for a direct link to a guild view."""
|
"""Serve the SPA for a direct link to a guild view."""
|
||||||
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
|
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(
|
self.web.mount(
|
||||||
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
|
"/", 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(
|
InitialSchemaMigration = import_module(
|
||||||
".0001_initial_schema", __name__
|
".0001_initial_schema", __name__
|
||||||
).InitialSchemaMigration
|
).InitialSchemaMigration
|
||||||
|
UsersMigration = import_module(".0002_users", __name__).UsersMigration
|
||||||
|
|
||||||
MIGRATIONS = (InitialSchemaMigration(),)
|
MIGRATIONS = (InitialSchemaMigration(), UsersMigration())
|
||||||
@@ -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
|
||||||
@@ -24,11 +24,11 @@ class ConnectionManager:
|
|||||||
self._connections.pop(websocket, None)
|
self._connections.pop(websocket, None)
|
||||||
|
|
||||||
async def broadcast(
|
async def broadcast(
|
||||||
self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]]
|
self, message: dict[str, Any] | Callable[..., dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
for connection, guild_id in list(self._connections.items()):
|
for connection, guild_id in list(self._connections.items()):
|
||||||
try:
|
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)
|
await connection.send_json(payload)
|
||||||
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
|
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
|
||||||
self.disconnect(connection)
|
self.disconnect(connection)
|
||||||
@@ -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:
|
def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
|
||||||
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
|
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
|
||||||
guilds = get_guilds(request)
|
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:
|
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:
|
if guild is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Discord server")
|
raise HTTPException(status_code=404, detail="Unknown Discord server")
|
||||||
return guild
|
return guild
|
||||||
@@ -9,6 +9,7 @@ from zyrarbot.storage.ports.guild_config import GuildConfigRepository
|
|||||||
from zyrarbot.storage.ports.uploads import UploadsRepository
|
from zyrarbot.storage.ports.uploads import UploadsRepository
|
||||||
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
||||||
from zyrarbot.ui.api.routers import (
|
from zyrarbot.ui.api.routers import (
|
||||||
|
auth_router,
|
||||||
playback_router,
|
playback_router,
|
||||||
queue_router,
|
queue_router,
|
||||||
status_router,
|
status_router,
|
||||||
@@ -21,6 +22,7 @@ from zyrarbot.ui.api.status_service import StatusService
|
|||||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||||
|
|
||||||
ROUTERS = (
|
ROUTERS = (
|
||||||
|
auth_router.router,
|
||||||
status_router.router,
|
status_router.router,
|
||||||
playback_router.router,
|
playback_router.router,
|
||||||
queue_router.router,
|
queue_router.router,
|
||||||
|
|||||||
@@ -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,16 +8,22 @@ from fastapi import APIRouter, Depends, Request
|
|||||||
from zyrarbot.music.music_player import MusicPlayer
|
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.dependencies import get_active_guild, get_player, get_status_service
|
||||||
from zyrarbot.ui.api.status_service import StatusService
|
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 = APIRouter(prefix="/api", tags=["status"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def get_status(
|
async def get_status(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
guild: discord.Guild = Depends(get_active_guild),
|
guild: discord.Guild = Depends(get_active_guild),
|
||||||
status: StatusService = Depends(get_status_service),
|
status: StatusService = Depends(get_status_service),
|
||||||
) -> dict[str, Any]:
|
) -> 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")
|
@router.get("/health")
|
||||||
@@ -26,7 +32,7 @@ async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/restart")
|
@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)
|
restart_action = getattr(request.app.state, "restart", None)
|
||||||
if restart_action:
|
if restart_action:
|
||||||
restart_action()
|
restart_action()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import discord
|
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.music_player import MusicPlayer
|
||||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
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.connect_request import ConnectRequest
|
||||||
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
|
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
|
||||||
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
||||||
|
from zyrarbot.ui.api.auth import current_user
|
||||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["voice"])
|
router = APIRouter(prefix="/api", tags=["voice"])
|
||||||
@@ -50,6 +51,8 @@ async def set_notification_channel(
|
|||||||
@router.post("/connect")
|
@router.post("/connect")
|
||||||
async def connect(
|
async def connect(
|
||||||
request: ConnectRequest,
|
request: ConnectRequest,
|
||||||
|
http_request: Request,
|
||||||
|
user: dict = Depends(current_user),
|
||||||
guilds: GuildResolver = Depends(get_guilds),
|
guilds: GuildResolver = Depends(get_guilds),
|
||||||
player: MusicPlayer = Depends(get_player),
|
player: MusicPlayer = Depends(get_player),
|
||||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||||
@@ -62,6 +65,11 @@ async def connect(
|
|||||||
channel = guilds.channel(channel_id)
|
channel = guilds.channel(channel_id)
|
||||||
if not isinstance(channel, discord.VoiceChannel):
|
if not isinstance(channel, discord.VoiceChannel):
|
||||||
raise HTTPException(status_code=404, detail="Voice channel not found")
|
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:
|
if channel.guild.me and not channel.permissions_for(channel.guild.me).connect:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -20,9 +20,22 @@ async def websocket_endpoint(
|
|||||||
status: StatusService = Depends(get_socket_status_service),
|
status: StatusService = Depends(get_socket_status_service),
|
||||||
guild_id: int | None = Query(default=None),
|
guild_id: int | None = Query(default=None),
|
||||||
) -> 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)
|
await connections.connect(websocket, guild_id)
|
||||||
try:
|
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:
|
while True:
|
||||||
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
|
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
|
|||||||
@@ -24,10 +24,21 @@ class StatusBroadcaster:
|
|||||||
if not self._connections.has_listeners:
|
if not self._connections.has_listeners:
|
||||||
return
|
return
|
||||||
try:
|
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
|
except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli
|
||||||
logger.error("Błąd rozgłaszania stanu: %s", error)
|
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:
|
def start(self) -> None:
|
||||||
if self._task is None or self._task.done():
|
if self._task is None or self._task.done():
|
||||||
self._task = asyncio.create_task(self._loop())
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
|||||||
Reference in new issue
Block a user