diff --git a/decnet/web/api.py b/decnet/web/api.py index 13955f2..372d830 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -3,12 +3,16 @@ from contextlib import asynccontextmanager from datetime import timedelta from typing import Any, AsyncGenerator -from fastapi import FastAPI, HTTPException, status +import jwt +from fastapi import Depends, FastAPI, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import OAuth2PasswordBearer from pydantic import BaseModel from decnet.web.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, + ALGORITHM, + SECRET_KEY, create_access_token, get_password_hash, verify_password, @@ -50,6 +54,25 @@ app.add_middleware( ) +oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> str: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_uuid: str | None = payload.get("uuid") + if user_uuid is None: + raise credentials_exception + except jwt.PyJWTError: + raise credentials_exception + return user_uuid + + class Token(BaseModel): access_token: str token_type: str @@ -60,6 +83,13 @@ class LoginRequest(BaseModel): password: str +class LogsResponse(BaseModel): + total: int + limit: int + offset: int + data: list[dict[str, Any]] + + @app.post("/api/v1/auth/login", response_model=Token) async def login(request: LoginRequest) -> dict[str, str]: user: dict[str, Any] | None = await repo.get_user_by_username(request.username) @@ -76,3 +106,20 @@ async def login(request: LoginRequest) -> dict[str, str]: data={"uuid": user["uuid"]}, expires_delta=access_token_expires ) return {"access_token": access_token, "token_type": "bearer"} + + +@app.get("/api/v1/logs", response_model=LogsResponse) +async def get_logs( + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0), + search: str | None = None, + current_user: str = Depends(get_current_user) +) -> dict[str, Any]: + logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search) + total: int = await repo.get_total_logs(search=search) + return { + "total": total, + "limit": limit, + "offset": offset, + "data": logs + } diff --git a/decnet/web/repository.py b/decnet/web/repository.py new file mode 100644 index 0000000..c8db500 --- /dev/null +++ b/decnet/web/repository.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod +from typing import Any, Optional + + +class BaseRepository(ABC): + """Abstract base class for DECNET web dashboard data storage.""" + + @abstractmethod + async def initialize(self) -> None: + """Initialize the database schema.""" + pass + + @abstractmethod + async def add_log(self, log_data: dict[str, Any]) -> None: + """Add a new log entry to the database.""" + pass + + @abstractmethod + async def get_logs( + self, + limit: int = 50, + offset: int = 0, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + """Retrieve paginated log entries.""" + pass + + @abstractmethod + async def get_total_logs(self, search: Optional[str] = None) -> int: + """Retrieve the total count of logs, optionally filtered by search.""" + pass + + @abstractmethod + async def get_stats_summary(self) -> dict[str, Any]: + """Retrieve high-level dashboard metrics.""" + pass + + @abstractmethod + async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]: + """Retrieve a user by their username.""" + pass + + @abstractmethod + async def create_user(self, user_data: dict[str, Any]) -> None: + """Create a new dashboard user.""" + pass diff --git a/decnet/web/sqlite_repository.py b/decnet/web/sqlite_repository.py new file mode 100644 index 0000000..9d8dc45 --- /dev/null +++ b/decnet/web/sqlite_repository.py @@ -0,0 +1,121 @@ +import aiosqlite +from typing import Any, Optional +from decnet.web.repository import BaseRepository + + +class SQLiteRepository(BaseRepository): + """SQLite implementation of the DECNET web repository.""" + + def __init__(self, db_path: str = "decnet.db") -> None: + self.db_path: str = db_path + + async def initialize(self) -> None: + async with aiosqlite.connect(self.db_path) as db: + # Logs table + await db.execute(""" + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + decky TEXT, + service TEXT, + event_type TEXT, + attacker_ip TEXT, + raw_line TEXT + ) + """) + # Users table (internal RBAC) + await db.execute(""" + CREATE TABLE IF NOT EXISTS users ( + uuid TEXT PRIMARY KEY, + username TEXT UNIQUE, + password_hash TEXT, + role TEXT DEFAULT 'viewer' + ) + """) + await db.commit() + + async def add_log(self, log_data: dict[str, Any]) -> None: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT INTO logs (decky, service, event_type, attacker_ip, raw_line) VALUES (?, ?, ?, ?, ?)", + ( + log_data.get("decky"), + log_data.get("service"), + log_data.get("event_type"), + log_data.get("attacker_ip"), + log_data.get("raw_line") + ) + ) + await db.commit() + + async def get_logs( + self, + limit: int = 50, + offset: int = 0, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + query: str = "SELECT * FROM logs" + params: list[Any] = [] + if search: + query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?" + like_val = f"%{search}%" + params.extend([like_val, like_val, like_val, like_val]) + + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(query, params) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + async def get_total_logs(self, search: Optional[str] = None) -> int: + query: str = "SELECT COUNT(*) as total FROM logs" + params: list[Any] = [] + if search: + query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?" + like_val = f"%{search}%" + params.extend([like_val, like_val, like_val, like_val]) + + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(query, params) as cursor: + row = await cursor.fetchone() + return row["total"] if row else 0 + + async def get_stats_summary(self) -> dict[str, Any]: + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT COUNT(*) as total_logs FROM logs") as cursor: + row = await cursor.fetchone() + total_logs: int = row["total_logs"] if row else 0 + + async with db.execute("SELECT COUNT(DISTINCT attacker_ip) as unique_attackers FROM logs") as cursor: + row = await cursor.fetchone() + unique_attackers: int = row["unique_attackers"] if row else 0 + + return { + "total_logs": total_logs, + "unique_attackers": unique_attackers + } + + async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]: + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def create_user(self, user_data: dict[str, Any]) -> None: + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + "INSERT INTO users (uuid, username, password_hash, role) VALUES (?, ?, ?, ?)", + ( + user_data["uuid"], + user_data["username"], + user_data["password_hash"], + user_data["role"] + ) + ) + await db.commit() diff --git a/tests/test_web_api.py b/tests/test_web_api.py index b22e039..fd87f21 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1,7 +1,9 @@ import os from typing import Generator + import pytest from fastapi.testclient import TestClient + from decnet.web.api import app, repo @@ -45,3 +47,28 @@ def test_login_failure() -> None: json={"username": "nonexistent", "password": "wrongpassword"} ) assert response.status_code == 401 + + +def test_get_logs_unauthorized() -> None: + with TestClient(app) as client: + response = client.get("/api/v1/logs") + assert response.status_code == 401 + + +def test_get_logs_success() -> None: + with TestClient(app) as client: + login_response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"} + ) + token = login_response.json()["access_token"] + + response = client.get( + "/api/v1/logs", + headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + data = response.json() + assert "data" in data + assert data["total"] >= 0 + assert isinstance(data["data"], list)