From 6e39bfab1257196e5236cc06df7e8263e7518ac8 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Wed, 30 Sep 2026 19:29:38 +0200 Subject: [PATCH] feat: users, administrator role, selective access to server, login screen, admin dashboard with user and access management --- README.md | 13 ++ frontend/src/App.vue | 63 ++++++++- frontend/src/assets/style.css | 5 + frontend/src/components/AdminAccessPage.vue | 108 +++++++++++++++ frontend/src/components/AdminLayout.vue | 55 ++++++++ frontend/src/components/AdminUsersPage.vue | 105 +++++++++++++++ frontend/src/components/AppHeader.vue | 114 ++++++++-------- zyrarbot/__main__.py | 36 +++++ zyrarbot/application.py | 18 +++ .../postgres/migrations/0002_users.py | 38 ++++++ .../adapters/postgres/migrations/__init__.py | 3 +- zyrarbot/ui/api/auth.py | 88 +++++++++++++ zyrarbot/ui/api/connection_manager.py | 4 +- zyrarbot/ui/api/dependencies.py | 11 +- zyrarbot/ui/api/factory.py | 2 + zyrarbot/ui/api/routers/auth_router.py | 123 ++++++++++++++++++ zyrarbot/ui/api/routers/status_router.py | 10 +- zyrarbot/ui/api/routers/voice_router.py | 10 +- zyrarbot/ui/api/routers/websocket_router.py | 15 ++- zyrarbot/ui/api/status_broadcaster.py | 13 +- 20 files changed, 762 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/AdminAccessPage.vue create mode 100644 frontend/src/components/AdminLayout.vue create mode 100644 frontend/src/components/AdminUsersPage.vue create mode 100644 zyrarbot/storage/adapters/postgres/migrations/0002_users.py create mode 100644 zyrarbot/ui/api/auth.py create mode 100644 zyrarbot/ui/api/routers/auth_router.py diff --git a/README.md b/README.md index 44b8d91..c8720fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 030a0e3..f3a05ec 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(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() } }) + + diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index d2260f4..6f85ecb 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -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); diff --git a/frontend/src/components/AdminAccessPage.vue b/frontend/src/components/AdminAccessPage.vue new file mode 100644 index 0000000..1a6ebc7 --- /dev/null +++ b/frontend/src/components/AdminAccessPage.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/components/AdminLayout.vue b/frontend/src/components/AdminLayout.vue new file mode 100644 index 0000000..efecf2d --- /dev/null +++ b/frontend/src/components/AdminLayout.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/components/AdminUsersPage.vue b/frontend/src/components/AdminUsersPage.vue new file mode 100644 index 0000000..b69b91b --- /dev/null +++ b/frontend/src/components/AdminUsersPage.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 735cc3c..6ad4e31 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,21 +1,15 @@ @@ -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; diff --git a/zyrarbot/__main__.py b/zyrarbot/__main__.py index 9d30673..29f4fea 100644 --- a/zyrarbot/__main__.py +++ b/zyrarbot/__main__.py @@ -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()) diff --git a/zyrarbot/application.py b/zyrarbot/application.py index db98363..e6cb3bc 100644 --- a/zyrarbot/application.py +++ b/zyrarbot/application.py @@ -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" ) diff --git a/zyrarbot/storage/adapters/postgres/migrations/0002_users.py b/zyrarbot/storage/adapters/postgres/migrations/0002_users.py new file mode 100644 index 0000000..92c023f --- /dev/null +++ b/zyrarbot/storage/adapters/postgres/migrations/0002_users.py @@ -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") diff --git a/zyrarbot/storage/adapters/postgres/migrations/__init__.py b/zyrarbot/storage/adapters/postgres/migrations/__init__.py index 6254bee..fc8f71c 100644 --- a/zyrarbot/storage/adapters/postgres/migrations/__init__.py +++ b/zyrarbot/storage/adapters/postgres/migrations/__init__.py @@ -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()) diff --git a/zyrarbot/ui/api/auth.py b/zyrarbot/ui/api/auth.py new file mode 100644 index 0000000..9fed760 --- /dev/null +++ b/zyrarbot/ui/api/auth.py @@ -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 diff --git a/zyrarbot/ui/api/connection_manager.py b/zyrarbot/ui/api/connection_manager.py index ff4871d..931b91a 100644 --- a/zyrarbot/ui/api/connection_manager.py +++ b/zyrarbot/ui/api/connection_manager.py @@ -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) diff --git a/zyrarbot/ui/api/dependencies.py b/zyrarbot/ui/api/dependencies.py index d9f0f03..433d983 100644 --- a/zyrarbot/ui/api/dependencies.py +++ b/zyrarbot/ui/api/dependencies.py @@ -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 diff --git a/zyrarbot/ui/api/factory.py b/zyrarbot/ui/api/factory.py index 3acbf42..5d6df11 100644 --- a/zyrarbot/ui/api/factory.py +++ b/zyrarbot/ui/api/factory.py @@ -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, diff --git a/zyrarbot/ui/api/routers/auth_router.py b/zyrarbot/ui/api/routers/auth_router.py new file mode 100644 index 0000000..3a34948 --- /dev/null +++ b/zyrarbot/ui/api/routers/auth_router.py @@ -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]} diff --git a/zyrarbot/ui/api/routers/status_router.py b/zyrarbot/ui/api/routers/status_router.py index 0ca4477..39a7f61 100644 --- a/zyrarbot/ui/api/routers/status_router.py +++ b/zyrarbot/ui/api/routers/status_router.py @@ -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() diff --git a/zyrarbot/ui/api/routers/voice_router.py b/zyrarbot/ui/api/routers/voice_router.py index 31b8e65..678056e 100644 --- a/zyrarbot/ui/api/routers/voice_router.py +++ b/zyrarbot/ui/api/routers/voice_router.py @@ -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( diff --git a/zyrarbot/ui/api/routers/websocket_router.py b/zyrarbot/ui/api/routers/websocket_router.py index 05e92c6..2834b2e 100644 --- a/zyrarbot/ui/api/routers/websocket_router.py +++ b/zyrarbot/ui/api/routers/websocket_router.py @@ -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() diff --git a/zyrarbot/ui/api/status_broadcaster.py b/zyrarbot/ui/api/status_broadcaster.py index 448ffd6..12a9ef8 100644 --- a/zyrarbot/ui/api/status_broadcaster.py +++ b/zyrarbot/ui/api/status_broadcaster.py @@ -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())