first commit

This commit is contained in:
Mateusz Gruszczyński
2026-03-04 15:21:03 +01:00
commit 5429f176c9
53 changed files with 3808 additions and 0 deletions

28
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,28 @@
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.db import get_session
from app.core.security import read_session_token, SESSION_COOKIE
from app.models.user import User
async def db_session() -> AsyncSession:
async for s in get_session():
yield s
async def get_current_user(request: Request, session: AsyncSession = Depends(db_session)) -> User:
token = request.cookies.get(SESSION_COOKIE)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
uid = read_session_token(token)
if not uid:
raise HTTPException(status_code=401, detail="Invalid session")
res = await session.execute(select(User).where(User.id == uid))
user = res.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User inactive")
return user
def require_admin(user: User) -> None:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Response, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import db_session, get_current_user
from app.core.config import settings
from app.core.security import (
verify_password, create_session_token, new_csrf_token,
SESSION_COOKIE, CSRF_COOKIE
)
from app.models.user import User
from app.schemas.user import LoginIn, UserOut
router = APIRouter()
@router.post("/login")
async def login(payload: LoginIn, response: Response, session: AsyncSession = Depends(db_session)):
res = await session.execute(select(User).where(User.email == payload.email))
user = res.scalar_one_or_none()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=401, detail="Bad credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Inactive")
token = create_session_token(user.id)
csrf = new_csrf_token()
response.set_cookie(
key=SESSION_COOKIE,
value=token,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
max_age=60 * 60 * 24 * 7,
path="/",
)
# CSRF token czytelny dla JS (nie httponly)
response.set_cookie(
key=CSRF_COOKIE,
value=csrf,
httponly=False,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,
max_age=60 * 60 * 24 * 7,
path="/",
)
return {"ok": True}
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
return {"ok": True}
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return UserOut(id=user.id, email=user.email, role=user.role)

View File

@@ -0,0 +1,70 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import db_session, get_current_user
from app.api.routes_routers import require_csrf
from app.models.user import User
from app.models.dashboard import Dashboard, DashboardPanel, Permission
from app.models.router import Router
from app.schemas.dashboard import DashboardCreate, DashboardOut, PanelCreate, PanelOut
router = APIRouter()
@router.get("", response_model=list[DashboardOut])
async def list_dashboards(user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
res = await session.execute(select(Dashboard).where(Dashboard.owner_user_id == user.id))
rows = res.scalars().all()
return [DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared) for d in rows]
@router.post("", response_model=DashboardOut)
async def create_dashboard(payload: DashboardCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
require_csrf(request)
d = Dashboard(owner_user_id=user.id, name=payload.name, is_shared=payload.is_shared)
session.add(d)
await session.commit()
await session.refresh(d)
return DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared)
@router.get("/{dashboard_id}")
async def get_dashboard(dashboard_id: int, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
res = await session.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
d = res.scalar_one_or_none()
if not d or d.owner_user_id != user.id:
raise HTTPException(status_code=404, detail="Not found")
pres = await session.execute(select(DashboardPanel).where(DashboardPanel.dashboard_id == d.id))
panels = pres.scalars().all()
out_panels = []
for p in panels:
out_panels.append(PanelOut(
id=p.id, dashboard_id=p.dashboard_id, title=p.title,
router_id=p.router_id, config=json.loads(p.config_json or "{}")
).model_dump())
return {"dashboard": DashboardOut(id=d.id, name=d.name, is_shared=d.is_shared).model_dump(), "panels": out_panels}
@router.post("/{dashboard_id}/panels", response_model=PanelOut)
async def create_panel(dashboard_id: int, payload: PanelCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
require_csrf(request)
# dashboard ownership
res = await session.execute(select(Dashboard).where(Dashboard.id == dashboard_id))
d = res.scalar_one_or_none()
if not d or d.owner_user_id != user.id:
raise HTTPException(status_code=404, detail="Not found")
# check permission to router
if user.role != "admin":
pres = await session.execute(select(Permission).where(Permission.user_id == user.id, Permission.router_id == payload.router_id, Permission.can_view == True)) # noqa
if not pres.scalar_one_or_none():
raise HTTPException(status_code=403, detail="No access to router")
p = DashboardPanel(
dashboard_id=dashboard_id,
title=payload.title,
router_id=payload.router_id,
config_json=json.dumps(payload.config or {}),
)
session.add(p)
await session.commit()
await session.refresh(p)
return PanelOut(id=p.id, dashboard_id=p.dashboard_id, title=p.title, router_id=p.router_id, config=payload.config)

View File

@@ -0,0 +1,93 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.api.deps import db_session, get_current_user, require_admin
from app.core.security import encrypt_secret, CSRF_COOKIE
from app.models.user import User
from app.models.router import Router, RouterCredential, CredentialMethod
from app.models.dashboard import Permission
from app.schemas.router import RouterCreate, RouterOut, CredentialCreate
router = APIRouter()
def require_csrf(request: Request):
# minimalny CSRF: nagłówek X-CSRF-Token musi równać się cookie mt_csrf
cookie = request.cookies.get(CSRF_COOKIE)
header = request.headers.get("X-CSRF-Token")
if not cookie or not header or cookie != header:
raise HTTPException(status_code=403, detail="CSRF check failed")
@router.get("", response_model=list[RouterOut])
async def list_routers(user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
if user.role == "admin":
res = await session.execute(select(Router))
rows = res.scalars().all()
else:
res = await session.execute(
select(Router).join(Permission, Permission.router_id == Router.id)
.where(Permission.user_id == user.id, Permission.can_view == True) # noqa
)
rows = res.scalars().all()
return [RouterOut(
id=r.id, name=r.name, host=r.host,
port_rest=r.port_rest, port_ssh=r.port_ssh, port_api=r.port_api,
verify_ssl=r.verify_ssl, preferred_method=r.preferred_method,
tags=r.tags
) for r in rows]
@router.post("", response_model=RouterOut)
async def create_router(payload: RouterCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
require_admin(user)
require_csrf(request)
r = Router(**payload.model_dump())
session.add(r)
await session.commit()
await session.refresh(r)
return RouterOut(
id=r.id, name=r.name, host=r.host,
port_rest=r.port_rest, port_ssh=r.port_ssh, port_api=r.port_api,
verify_ssl=r.verify_ssl, preferred_method=r.preferred_method, tags=r.tags
)
@router.post("/{router_id}/credentials")
async def add_credential(router_id: int, payload: CredentialCreate, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
require_admin(user)
require_csrf(request)
res = await session.execute(select(Router).where(Router.id == router_id))
r = res.scalar_one_or_none()
if not r:
raise HTTPException(status_code=404, detail="Router not found")
method = payload.method.lower()
if method not in ("rest", "ssh", "api"):
raise HTTPException(status_code=400, detail="Bad method")
c = RouterCredential(
router_id=router_id,
method=CredentialMethod(method),
username=payload.username,
secret_encrypted=encrypt_secret(payload.secret),
extra_json=json.dumps(payload.extra_json or {}),
)
session.add(c)
await session.commit()
return {"ok": True}
@router.post("/{router_id}/grant")
async def grant_router(router_id: int, target_user_id: int, can_edit: bool = False, request: Request = None,
user: User = Depends(get_current_user), session: AsyncSession = Depends(db_session)):
require_admin(user)
require_csrf(request)
# upsert permission
res = await session.execute(select(Permission).where(Permission.user_id == target_user_id, Permission.router_id == router_id))
p = res.scalar_one_or_none()
if not p:
p = Permission(user_id=target_user_id, router_id=router_id, can_view=True, can_edit=can_edit)
session.add(p)
else:
p.can_view = True
p.can_edit = bool(can_edit)
await session.commit()
return {"ok": True}

View File

@@ -0,0 +1,75 @@
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import db_session
from app.core.security import read_session_token, SESSION_COOKIE
from app.models.user import User
from app.models.dashboard import DashboardPanel, Permission, Dashboard
from app.services.streaming.hub import hub
from app.services.streaming.poller import panel_poller
router = APIRouter()
async def ws_current_user(ws: WebSocket, session: AsyncSession) -> User | None:
token = ws.cookies.get(SESSION_COOKIE)
uid = read_session_token(token) if token else None
if not uid:
return None
res = await session.execute(select(User).where(User.id == uid))
return res.scalar_one_or_none()
@router.websocket("/ws/stream")
async def ws_stream(websocket: WebSocket, session: AsyncSession = Depends(db_session)):
await websocket.accept()
user = await ws_current_user(websocket, session)
if not user or not user.is_active:
await websocket.close(code=4401)
return
subscribed_panel_id = None
try:
# pierwszy msg: {"panelId": 123}
first = await websocket.receive_text()
msg = json.loads(first)
panel_id = int(msg.get("panelId"))
subscribed_panel_id = panel_id
pres = await session.execute(select(DashboardPanel).where(DashboardPanel.id == panel_id))
panel = pres.scalar_one_or_none()
if not panel:
await websocket.close(code=4404)
return
# dashboard ownership lub (w przyszłości) shared
dres = await session.execute(select(Dashboard).where(Dashboard.id == panel.dashboard_id))
dash = dres.scalar_one_or_none()
if not dash or dash.owner_user_id != user.id:
await websocket.close(code=4403)
return
# permission do routera
if user.role != "admin":
pr = await session.execute(select(Permission).where(Permission.user_id == user.id, Permission.router_id == panel.router_id, Permission.can_view == True)) # noqa
if not pr.scalar_one_or_none():
await websocket.close(code=4403)
return
await hub.subscribe(panel_id, websocket)
await panel_poller.ensure_running(panel_id, session)
# keepalive loop: klient może wysyłać ping
while True:
_ = await websocket.receive_text()
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
if subscribed_panel_id is not None:
try:
await hub.unsubscribe(subscribed_panel_id, websocket)
except Exception:
pass