diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e68a33c..241bf84 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -13,15 +13,20 @@ jobs: cd /root/projects/team-board/web-client git pull origin main - - name: Install and build + - name: Install and build frontend run: | cd /root/projects/team-board/web-client npm install --production=false npm run build - - name: Restart service + - name: Install BFF dependencies + run: | + /opt/team-board-bff-venv/bin/pip install -r /root/projects/team-board/web-client/bff/requirements.txt -q + + - name: Restart services run: | systemctl restart team-board-web + systemctl restart team-board-bff - name: Health check run: | diff --git a/bff/__pycache__/app.cpython-312.pyc b/bff/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..5854bc2 Binary files /dev/null and b/bff/__pycache__/app.cpython-312.pyc differ diff --git a/bff/__pycache__/auth.cpython-312.pyc b/bff/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..e14dcc9 Binary files /dev/null and b/bff/__pycache__/auth.cpython-312.pyc differ diff --git a/bff/__pycache__/config.cpython-312.pyc b/bff/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..7ba7028 Binary files /dev/null and b/bff/__pycache__/config.cpython-312.pyc differ diff --git a/bff/__pycache__/tracker_client.cpython-312.pyc b/bff/__pycache__/tracker_client.cpython-312.pyc new file mode 100644 index 0000000..f04adb9 Binary files /dev/null and b/bff/__pycache__/tracker_client.cpython-312.pyc differ diff --git a/bff/__pycache__/ws_proxy.cpython-312.pyc b/bff/__pycache__/ws_proxy.cpython-312.pyc new file mode 100644 index 0000000..6cc4dc2 Binary files /dev/null and b/bff/__pycache__/ws_proxy.cpython-312.pyc differ diff --git a/bff/app.py b/bff/app.py new file mode 100644 index 0000000..50a5dbf --- /dev/null +++ b/bff/app.py @@ -0,0 +1,175 @@ +"""BFF — Backend for Frontend. Proxies to Tracker with user auth.""" + +import logging +import time + +from fastapi import FastAPI, Request, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from auth import create_token, get_current_user +from config import AUTH_USER, AUTH_PASS, ENV +from tracker_client import tracker_get, tracker_post, tracker_patch, tracker_delete, close_client +from ws_proxy import router as ws_router + +logging.basicConfig( + level=logging.DEBUG if ENV == "dev" else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger("bff") + +app = FastAPI(title="Team Board BFF", version="0.1.0") + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["https://team.uix.su", "http://localhost:3100"], + allow_methods=["*"], + allow_headers=["*"], +) + + +# --- Request logging --- + +@app.middleware("http") +async def log_requests(request: Request, call_next): + start = time.time() + logger.info("[REQ] %s %s", request.method, request.url.path) + try: + response = await call_next(request) + elapsed = (time.time() - start) * 1000 + logger.info("[RES] %s %s → %d (%.0fms)", request.method, request.url.path, response.status_code, elapsed) + return response + except Exception as e: + logger.error("[ERR] %s %s → %s", request.method, request.url.path, str(e)) + return JSONResponse({"error": str(e)}, status_code=500) + + +# --- Auth --- + +class LoginRequest(BaseModel): + username: str + password: str + + +@app.post("/api/auth/login") +async def login(data: LoginRequest): + if data.username == AUTH_USER and data.password == AUTH_PASS: + token = create_token(data.username) + return {"token": token, "user": {"name": data.username, "provider": "local"}} + return JSONResponse({"error": "Invalid credentials"}, status_code=401) + + +@app.get("/api/auth/me") +async def me(user: dict = Depends(get_current_user)): + return {"user": {"name": user["name"], "provider": user["provider"]}} + + +# --- Proxy: Projects --- + +@app.get("/api/v1/projects/") +async def list_projects(user: dict = Depends(get_current_user)): + return await tracker_get("/api/v1/projects/") + + +@app.post("/api/v1/projects/") +async def create_project(request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_post("/api/v1/projects/", json=body) + + +@app.get("/api/v1/projects/{project_id}") +async def get_project(project_id: str, user: dict = Depends(get_current_user)): + return await tracker_get(f"/api/v1/projects/{project_id}") + + +@app.patch("/api/v1/projects/{project_id}") +async def update_project(project_id: str, request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_patch(f"/api/v1/projects/{project_id}", json=body) + + +@app.delete("/api/v1/projects/{project_id}") +async def delete_project(project_id: str, user: dict = Depends(get_current_user)): + await tracker_delete(f"/api/v1/projects/{project_id}") + return JSONResponse(status_code=204) + + +# --- Proxy: Tasks --- + +@app.get("/api/v1/tasks/") +async def list_tasks(project_id: str = None, status: str = None, user: dict = Depends(get_current_user)): + params = {} + if project_id: + params["project_id"] = project_id + if status: + params["status"] = status + return await tracker_get("/api/v1/tasks/", params=params) + + +@app.post("/api/v1/tasks/") +async def create_task(request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_post("/api/v1/tasks/", json=body) + + +@app.get("/api/v1/tasks/{task_id}") +async def get_task(task_id: str, user: dict = Depends(get_current_user)): + return await tracker_get(f"/api/v1/tasks/{task_id}") + + +@app.patch("/api/v1/tasks/{task_id}") +async def update_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_patch(f"/api/v1/tasks/{task_id}", json=body) + + +@app.delete("/api/v1/tasks/{task_id}") +async def delete_task(task_id: str, user: dict = Depends(get_current_user)): + await tracker_delete(f"/api/v1/tasks/{task_id}") + return JSONResponse(status_code=204) + + +# --- Proxy: Agents --- + +@app.get("/api/v1/agents/") +async def list_agents(user: dict = Depends(get_current_user)): + return await tracker_get("/api/v1/agents/") + + +@app.get("/api/v1/agents/adapters") +async def list_adapters(user: dict = Depends(get_current_user)): + return await tracker_get("/api/v1/agents/adapters") + + +# --- Proxy: Labels --- + +@app.get("/api/v1/labels/") +async def list_labels(user: dict = Depends(get_current_user)): + return await tracker_get("/api/v1/labels/") + + +@app.post("/api/v1/labels/") +async def create_label(request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_post("/api/v1/labels/", json=body) + + +# --- Health --- + +@app.get("/health") +async def health(): + return {"status": "ok", "service": "bff", "version": "0.1.0"} + + +# --- WebSocket --- + +app.include_router(ws_router) + + +# --- Shutdown --- + +@app.on_event("shutdown") +async def shutdown(): + await close_client() diff --git a/bff/auth.py b/bff/auth.py new file mode 100644 index 0000000..10e677b --- /dev/null +++ b/bff/auth.py @@ -0,0 +1,39 @@ +"""JWT auth for web users.""" + +import time +from typing import Optional + +import jwt +from fastapi import Depends, HTTPException, Request + +from config import JWT_SECRET, JWT_ALGORITHM + +TOKEN_EXPIRY = 7 * 24 * 3600 # 7 days + + +def create_token(username: str, provider: str = "local") -> str: + payload = { + "sub": username, + "name": username, + "provider": provider, + "iat": int(time.time()), + "exp": int(time.time()) + TOKEN_EXPIRY, + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def verify_token(token: str) -> Optional[dict]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except jwt.PyJWTError: + return None + + +def get_current_user(request: Request) -> dict: + auth = request.headers.get("authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException(401, "Not authenticated") + payload = verify_token(auth[7:]) + if not payload: + raise HTTPException(401, "Invalid token") + return payload diff --git a/bff/config.py b/bff/config.py new file mode 100644 index 0000000..279d397 --- /dev/null +++ b/bff/config.py @@ -0,0 +1,19 @@ +"""BFF configuration.""" + +import os + +# Tracker (internal, not exposed to internet) +TRACKER_URL = os.getenv("TRACKER_URL", "http://localhost:8100") +TRACKER_WS_URL = os.getenv("TRACKER_WS_URL", "ws://localhost:8100/ws") +TRACKER_TOKEN = os.getenv("TRACKER_TOKEN", "tb-tracker-dev-token") + +# Auth +JWT_SECRET = os.getenv("JWT_SECRET", "tb-jwt-Kx9mP4vQ7wZn2bR5") +JWT_ALGORITHM = "HS256" +AUTH_USER = os.getenv("AUTH_USER", "admin") +AUTH_PASS = os.getenv("AUTH_PASS", "teamboard") + +# Server +HOST = os.getenv("BFF_HOST", "0.0.0.0") +PORT = int(os.getenv("BFF_PORT", "8200")) +ENV = os.getenv("BFF_ENV", "dev") diff --git a/bff/requirements.txt b/bff/requirements.txt new file mode 100644 index 0000000..097dd48 --- /dev/null +++ b/bff/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +websockets>=14.0 +pyjwt>=2.9 diff --git a/bff/tracker_client.py b/bff/tracker_client.py new file mode 100644 index 0000000..75b32a2 --- /dev/null +++ b/bff/tracker_client.py @@ -0,0 +1,62 @@ +"""HTTP client for Tracker API.""" + +import logging +from typing import Any, Optional + +import httpx + +from config import TRACKER_URL, TRACKER_TOKEN + +logger = logging.getLogger("bff.tracker") + +_client: Optional[httpx.AsyncClient] = None + + +def get_client() -> httpx.AsyncClient: + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + base_url=TRACKER_URL, + headers={"Authorization": f"Bearer {TRACKER_TOKEN}"}, + timeout=30.0, + ) + return _client + + +async def tracker_request(method: str, path: str, **kwargs) -> httpx.Response: + client = get_client() + logger.info("[TRACKER] %s %s", method, path) + resp = await client.request(method, path, **kwargs) + logger.info("[TRACKER] %s %s → %d", method, path, resp.status_code) + return resp + + +async def tracker_get(path: str, **kwargs) -> Any: + resp = await tracker_request("GET", path, **kwargs) + resp.raise_for_status() + return resp.json() + + +async def tracker_post(path: str, **kwargs) -> Any: + resp = await tracker_request("POST", path, **kwargs) + resp.raise_for_status() + return resp.json() + + +async def tracker_patch(path: str, **kwargs) -> Any: + resp = await tracker_request("PATCH", path, **kwargs) + resp.raise_for_status() + return resp.json() + + +async def tracker_delete(path: str, **kwargs) -> int: + resp = await tracker_request("DELETE", path, **kwargs) + resp.raise_for_status() + return resp.status_code + + +async def close_client(): + global _client + if _client and not _client.is_closed: + await _client.aclose() + _client = None diff --git a/bff/ws_proxy.py b/bff/ws_proxy.py new file mode 100644 index 0000000..7288eb0 --- /dev/null +++ b/bff/ws_proxy.py @@ -0,0 +1,54 @@ +"""WebSocket proxy: authenticated users ↔ Tracker.""" + +import asyncio +import logging +import json + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +import websockets + +from auth import verify_token +from config import TRACKER_WS_URL, TRACKER_TOKEN + +logger = logging.getLogger("bff.ws") +router = APIRouter() + + +@router.websocket("/ws") +async def ws_proxy(ws: WebSocket, token: str = ""): + # Authenticate user + if not token: + await ws.close(code=4001, reason="No token") + return + user = verify_token(token) + if not user: + await ws.close(code=4001, reason="Invalid token") + return + + await ws.accept() + logger.info("WS connected: %s", user.get("name")) + + # Connect to tracker + tracker_url = f"{TRACKER_WS_URL}?client_type=human&client_id={user['sub']}&token={TRACKER_TOKEN}" + try: + async with websockets.connect(tracker_url) as tracker_ws: + async def forward_to_tracker(): + try: + while True: + data = await ws.receive_text() + await tracker_ws.send(data) + except WebSocketDisconnect: + pass + + async def forward_to_client(): + try: + async for msg in tracker_ws: + await ws.send_text(msg) + except Exception: + pass + + await asyncio.gather(forward_to_tracker(), forward_to_client()) + except Exception as e: + logger.error("WS proxy error: %s", e) + finally: + logger.info("WS disconnected: %s", user.get("name")) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 2785c46..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createToken } from "@/lib/auth"; - -const AUTH_USER = process.env.AUTH_USER || "admin"; -const AUTH_PASS = process.env.AUTH_PASS || "teamboard"; - -export async function POST(req: NextRequest) { - const { username, password } = await req.json(); - - if (username === AUTH_USER && password === AUTH_PASS) { - const token = await createToken({ - sub: username, - name: username, - provider: "local", - }); - return NextResponse.json({ token, user: { name: username, provider: "local" } }); - } - - return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }); -} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts deleted file mode 100644 index 27b5a8d..0000000 --- a/src/app/api/auth/me/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { verifyToken } from "@/lib/auth"; - -export async function GET(req: NextRequest) { - const authHeader = req.headers.get("authorization"); - const token = authHeader?.replace("Bearer ", ""); - - if (!token) { - return NextResponse.json({ error: "No token" }, { status: 401 }); - } - - const payload = await verifyToken(token); - if (!payload) { - return NextResponse.json({ error: "Invalid token" }, { status: 401 }); - } - - return NextResponse.json({ user: { name: payload.name, provider: payload.provider } }); -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index ae6091f..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SignJWT, jwtVerify } from "jose"; - -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "team-board-dev-secret-change-me"); -const TOKEN_EXPIRY = "7d"; - -export interface TokenPayload { - sub: string; // username - name: string; // display name - provider: string; // "local" | "authentik" -} - -export async function createToken(payload: TokenPayload): Promise { - return new SignJWT({ ...payload }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(TOKEN_EXPIRY) - .sign(JWT_SECRET); -} - -export async function verifyToken(token: string): Promise { - try { - const { payload } = await jwtVerify(token, JWT_SECRET); - return payload as unknown as TokenPayload; - } catch { - return null; - } -}