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

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

No files matched your search

+36
View File
@@ -1,16 +1,52 @@
from __future__ import annotations
import asyncio
import os
import logging
import argparse
import getpass
import psycopg
from zyrarbot.application import Application
from zyrarbot.ui.api.auth import hash_password
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
def create_admin(username: str, password: str) -> None:
database = PostgresDatabase(
host=os.environ.get("POSTGRES_HOST", "localhost"),
port=int(os.environ.get("POSTGRES_PORT", "5432")),
database=os.environ.get("POSTGRES_DB", "zyrarbot"),
user=os.environ.get("POSTGRES_USER", "zyrarbot"),
password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"),
)
with database.connect() as connection:
connection.execute("INSERT INTO users(username,password_hash,is_admin) VALUES (%s,%s,TRUE)",
(username, hash_password(password)))
print(f"Utworzono administratora: {username}")
def main() -> None:
parser = argparse.ArgumentParser(description="ŻyrarBOT")
subparsers = parser.add_subparsers(dest="command")
sub = subparsers.add_parser("create-admin", help="Utwórz konto administratora")
sub.add_argument("username")
sub.add_argument("--password")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
if args.command == "create-admin":
password = args.password if args.password is not None else getpass.getpass("Hasło administratora: ")
if not password:
parser.error("Hasło nie może być puste")
try:
create_admin(args.username, password)
except psycopg.errors.UniqueViolation:
parser.error("Użytkownik o tej nazwie już istnieje")
return
asyncio.run(Application().run())
+18
View File
@@ -23,6 +23,7 @@ from zyrarbot.storage.adapters.filesystem.uploads import UploadsRepository
from zyrarbot.storage.adapters.postgres.persistence import PostgresPersistence
from zyrarbot.storage.ports.persistence import Persistence
from zyrarbot.ui.api.factory import mount_api
from zyrarbot.ui.api.auth import AuthRepository
from zyrarbot.ui.bot.client import MusicBot
from zyrarbot.ui.bot.guild_resolver import GuildResolver
@@ -74,6 +75,18 @@ class Application:
self.web = FastAPI(title="ŻyrarBOT")
self.web.state.restart = self.schedule_restart
self.web.state.persistence = self.persistence
self.web.state.auth = AuthRepository(self.persistence.database)
@self.web.middleware("http")
async def require_panel_login(request, call_next):
if request.url.path.startswith("/api/") and request.url.path not in {
"/api/health", "/api/auth/login"
}:
if self.web.state.auth.user_for_token(request.cookies.get("zyrarbot_session")) is None:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Wymagane logowanie"}, status_code=401)
return await call_next(request)
broadcaster = mount_api(
self.web,
settings=self.settings,
@@ -111,6 +124,11 @@ class Application:
"""Serve the SPA for a direct link to a guild view."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
@self.web.get("/admin/{route_path:path}", include_in_schema=False)
async def frontend_route(route_path: str) -> FileResponse:
"""Serve the SPA for nested frontend routes such as admin pages."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
self.web.mount(
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
)
@@ -0,0 +1,38 @@
from __future__ import annotations
import psycopg
class UsersMigration:
version = 2
name = "users_and_access"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute("""
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
connection.execute("""
CREATE TABLE user_sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
)
""")
connection.execute("""
CREATE TABLE user_guild_access (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guild_id BIGINT NOT NULL,
PRIMARY KEY (user_id, guild_id)
)
""")
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("DROP TABLE user_guild_access")
connection.execute("DROP TABLE user_sessions")
connection.execute("DROP TABLE users")
@@ -5,5 +5,6 @@ from importlib import import_module
InitialSchemaMigration = import_module(
".0001_initial_schema", __name__
).InitialSchemaMigration
UsersMigration = import_module(".0002_users", __name__).UsersMigration
MIGRATIONS = (InitialSchemaMigration(),)
MIGRATIONS = (InitialSchemaMigration(), UsersMigration())
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, Request
from zyrarbot.storage.adapters.postgres.database import PostgresDatabase
SESSION_DAYS = 14
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 310_000)
return f"{salt.hex()}:{digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
salt, expected = encoded.split(":", 1)
actual = hash_password(password, bytes.fromhex(salt)).split(":", 1)[1]
return hmac.compare_digest(actual, expected)
except (ValueError, TypeError):
return False
class AuthRepository:
def __init__(self, database: PostgresDatabase):
self.database = database
def login(self, username: str, password: str) -> tuple[str, dict] | None:
with self.database.connect() as connection:
row = connection.execute(
"SELECT id, username, password_hash, is_admin FROM users WHERE username=%s",
(username,),
).fetchone()
if not row or not verify_password(password, row[2]):
return None
token = secrets.token_urlsafe(32)
connection.execute(
"INSERT INTO user_sessions(token_hash,user_id,expires_at) VALUES (%s,%s,%s)",
(hashlib.sha256(token.encode()).hexdigest(), row[0],
datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)),
)
return token, {"id": row[0], "username": row[1], "is_admin": row[3]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
return None
with self.database.connect() as connection:
row = connection.execute("""
SELECT u.id, u.username, u.is_admin FROM user_sessions s
JOIN users u ON u.id=s.user_id
WHERE s.token_hash=%s AND s.expires_at > CURRENT_TIMESTAMP
""", (hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
return {"id": row[0], "username": row[1], "is_admin": row[2]} if row else None
def logout(self, token: str | None) -> None:
if token:
with self.database.connect() as connection:
connection.execute("DELETE FROM user_sessions WHERE token_hash=%s",
(hashlib.sha256(token.encode()).hexdigest(),))
def guild_ids(self, user: dict, available: list[int]) -> list[int]:
if user["is_admin"]:
return available
with self.database.connect() as connection:
rows = connection.execute("SELECT guild_id FROM user_guild_access WHERE user_id=%s",
(user["id"],)).fetchall()
allowed = {row[0] for row in rows}
return [guild_id for guild_id in available if guild_id in allowed]
def current_user(request: Request) -> dict:
user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
if user is None:
raise HTTPException(status_code=401, detail="Wymagane logowanie")
return user
def require_admin(request: Request) -> dict:
user = current_user(request)
if not user["is_admin"]:
raise HTTPException(status_code=403, detail="Wymagane uprawnienia administratora")
return user
+2 -2
View File
@@ -24,11 +24,11 @@ class ConnectionManager:
self._connections.pop(websocket, None)
async def broadcast(
self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]]
self, message: dict[str, Any] | Callable[..., dict[str, Any]]
) -> None:
for connection, guild_id in list(self._connections.items()):
try:
payload = message(guild_id) if callable(message) else message
payload = message(connection, guild_id) if callable(message) else message
await connection.send_json(payload)
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
self.disconnect(connection)
+9 -2
View File
@@ -59,9 +59,16 @@ def get_socket_status_service(websocket: WebSocket) -> StatusService:
def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
guilds = get_guilds(request)
guild = next((item for item in guilds.guilds() if item.id == guild_id), None)
user = request.app.state.auth.user_for_token(request.cookies.get("zyrarbot_session"))
permitted = request.app.state.auth.guild_ids(user, [item.id for item in guilds.guilds()]) if user else []
if guild_id is not None and guild_id not in permitted:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
guild = next((item for item in guilds.guilds() if item.id == guild_id and item.id in permitted), None)
if guild_id is None:
guild = guilds.active_guild()
active = guilds.active_guild()
guild = active if active and active.id in permitted else next(
(item for item in guilds.guilds() if item.id in permitted), None
)
if guild is None:
raise HTTPException(status_code=404, detail="Unknown Discord server")
return guild
+2
View File
@@ -9,6 +9,7 @@ from zyrarbot.storage.ports.guild_config import GuildConfigRepository
from zyrarbot.storage.ports.uploads import UploadsRepository
from zyrarbot.ui.api.connection_manager import ConnectionManager
from zyrarbot.ui.api.routers import (
auth_router,
playback_router,
queue_router,
status_router,
@@ -21,6 +22,7 @@ from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.bot.guild_resolver import GuildResolver
ROUTERS = (
auth_router.router,
status_router.router,
playback_router.router,
queue_router.router,
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api/auth", tags=["auth"])
class Credentials(BaseModel):
username: str = Field(min_length=1, max_length=80)
password: str = Field(min_length=1)
class UserCreate(Credentials):
guild_ids: list[int] = Field(default_factory=list)
class UserUpdate(BaseModel):
guild_ids: list[int]
class UserRoleUpdate(BaseModel):
is_admin: bool
@router.post("/login")
async def login(body: Credentials, request: Request, response: Response) -> dict:
result = request.app.state.auth.login(body.username, body.password)
if result is None:
raise HTTPException(status_code=401, detail="Nieprawidłowa nazwa użytkownika lub hasło")
token, user = result
response.set_cookie("zyrarbot_session", token, httponly=True, samesite="lax",
secure=request.url.scheme == "https", max_age=14 * 86400, path="/")
return user
@router.post("/logout")
async def logout(request: Request, response: Response) -> dict:
request.app.state.auth.logout(request.cookies.get("zyrarbot_session"))
response.delete_cookie("zyrarbot_session", path="/")
return {"status": "ok"}
@router.get("/me")
async def me(user: dict = Depends(current_user)) -> dict:
return user
def _available_guilds(request: Request) -> list[dict]:
return [{"guild_id": str(g.id), "guild_name": g.name} for g in request.app.state.guilds.guilds()]
@router.get("/users")
async def list_users(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
with request.app.state.persistence.database.connect() as connection:
rows = connection.execute("SELECT id,username,is_admin FROM users ORDER BY username").fetchall()
assignments = connection.execute("SELECT user_id,guild_id FROM user_guild_access").fetchall()
by_user: dict[int, list[str]] = {}
for user_id, guild_id in assignments:
by_user.setdefault(user_id, []).append(str(guild_id))
return [{"id": row[0], "username": row[1], "is_admin": row[2],
"guild_ids": by_user.get(row[0], [])} for row in rows]
@router.get("/guilds")
async def list_guilds(request: Request, _: dict = Depends(require_admin)) -> list[dict]:
return _available_guilds(request)
@router.post("/users")
async def create_user(body: UserCreate, request: Request, _: dict = Depends(require_admin)) -> dict:
from zyrarbot.ui.api.auth import hash_password
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
try:
with request.app.state.persistence.database.connect() as connection:
row = connection.execute("INSERT INTO users(username,password_hash) VALUES (%s,%s) RETURNING id",
(body.username, hash_password(body.password))).fetchone()
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(row[0], guild_id))
except Exception as error:
if getattr(error, "sqlstate", None) == "23505":
raise HTTPException(status_code=409, detail="Taka nazwa użytkownika już istnieje") from error
raise
return {"id": row[0], "username": body.username, "is_admin": False,
"guild_ids": [str(g) for g in set(body.guild_ids)]}
@router.put("/users/{user_id}/guilds")
async def update_access(user_id: int, body: UserUpdate, request: Request,
_: dict = Depends(require_admin)) -> dict:
available = {g.id for g in request.app.state.guilds.guilds()}
if not set(body.guild_ids) <= available:
raise HTTPException(status_code=400, detail="Nieznany serwer")
with request.app.state.persistence.database.connect() as connection:
exists = connection.execute("SELECT 1 FROM users WHERE id=%s AND is_admin=FALSE",
(user_id,)).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
connection.execute("DELETE FROM user_guild_access WHERE user_id=%s", (user_id,))
for guild_id in set(body.guild_ids):
connection.execute("INSERT INTO user_guild_access(user_id,guild_id) VALUES (%s,%s)",
(user_id, guild_id))
return {"status": "ok"}
@router.put("/users/{user_id}/role")
async def update_role(user_id: int, body: UserRoleUpdate, request: Request,
admin: dict = Depends(require_admin)) -> dict:
if user_id == admin["id"] and not body.is_admin:
raise HTTPException(status_code=400, detail="Nie możesz odebrać uprawnień administratora własnemu kontu")
with request.app.state.persistence.database.connect() as connection:
row = connection.execute(
"UPDATE users SET is_admin=%s WHERE id=%s RETURNING id,username,is_admin",
(body.is_admin, user_id),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Nie znaleziono użytkownika")
return {"id": row[0], "username": row[1], "is_admin": row[2]}
+8 -2
View File
@@ -8,16 +8,22 @@ from fastapi import APIRouter, Depends, Request
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
from zyrarbot.ui.api.status_service import StatusService
from zyrarbot.ui.api.auth import current_user, require_admin
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status")
async def get_status(
request: Request,
user: dict = Depends(current_user),
guild: discord.Guild = Depends(get_active_guild),
status: StatusService = Depends(get_status_service),
) -> dict[str, Any]:
return status.snapshot(guild.id)
snapshot = status.snapshot(guild.id)
permitted = request.app.state.auth.guild_ids(user, [g.id for g in request.app.state.guilds.guilds()])
snapshot["guilds"] = [item for item in snapshot["guilds"] if int(item["guild_id"]) in permitted]
return snapshot
@router.get("/health")
@@ -26,7 +32,7 @@ async def get_health(player: MusicPlayer = Depends(get_player)) -> dict[str, Any
@router.post("/restart")
async def restart_bot(request: Request) -> dict[str, Any]:
async def restart_bot(request: Request, _: dict = Depends(require_admin)) -> dict[str, Any]:
restart_action = getattr(request.app.state, "restart", None)
if restart_action:
restart_action()
+9 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import discord
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
@@ -16,6 +16,7 @@ from zyrarbot.ui.api.dependencies import (
from zyrarbot.ui.api.requests.connect_request import ConnectRequest
from zyrarbot.ui.api.requests.notification_channel_request import NotificationChannelRequest
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
from zyrarbot.ui.api.auth import current_user
from zyrarbot.ui.bot.guild_resolver import GuildResolver
router = APIRouter(prefix="/api", tags=["voice"])
@@ -50,6 +51,8 @@ async def set_notification_channel(
@router.post("/connect")
async def connect(
request: ConnectRequest,
http_request: Request,
user: dict = Depends(current_user),
guilds: GuildResolver = Depends(get_guilds),
player: MusicPlayer = Depends(get_player),
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
@@ -62,6 +65,11 @@ async def connect(
channel = guilds.channel(channel_id)
if not isinstance(channel, discord.VoiceChannel):
raise HTTPException(status_code=404, detail="Voice channel not found")
allowed = http_request.app.state.auth.guild_ids(
user, [item.id for item in guilds.guilds()]
)
if channel.guild.id not in allowed:
raise HTTPException(status_code=403, detail="Brak dostępu do tego serwera")
if channel.guild.me and not channel.permissions_for(channel.guild.me).connect:
raise HTTPException(
+14 -1
View File
@@ -20,9 +20,22 @@ async def websocket_endpoint(
status: StatusService = Depends(get_socket_status_service),
guild_id: int | None = Query(default=None),
) -> None:
user = websocket.app.state.auth.user_for_token(websocket.cookies.get("zyrarbot_session"))
if user is None:
await websocket.close(code=4401)
return
available = [g.id for g in websocket.app.state.guilds.guilds()]
permitted = websocket.app.state.auth.guild_ids(user, available)
if guild_id is not None and guild_id not in permitted:
await websocket.close(code=4403)
return
if guild_id is None:
guild_id = permitted[0] if permitted else None
await connections.connect(websocket, guild_id)
try:
await websocket.send_json(status.snapshot(guild_id))
initial = status.snapshot(guild_id)
initial["guilds"] = [item for item in initial["guilds"] if int(item["guild_id"]) in permitted]
await websocket.send_json(initial)
while True:
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
await websocket.receive_text()
+12 -1
View File
@@ -24,10 +24,21 @@ class StatusBroadcaster:
if not self._connections.has_listeners:
return
try:
await self._connections.broadcast(self._status.snapshot)
await self._connections.broadcast(self._snapshot_for_connection)
except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli
logger.error("Błąd rozgłaszania stanu: %s", error)
def _snapshot_for_connection(self, connection, guild_id: int | None) -> dict:
payload = self._status.snapshot(guild_id)
user = connection.app.state.auth.user_for_token(
connection.cookies.get("zyrarbot_session")
)
allowed = connection.app.state.auth.guild_ids(
user, [guild.id for guild in connection.app.state.guilds.guilds()]
) if user else []
payload["guilds"] = [item for item in payload["guilds"] if int(item["guild_id"]) in allowed]
return payload
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._loop())