From 2f1778c821501937b9cfe1370fb210c23df67b01 Mon Sep 17 00:00:00 2001 From: Markov Date: Sun, 15 Feb 2026 19:44:55 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20BFF=20(Python=20FastAPI)=20=E2=80=94=20?= =?UTF-8?q?proxy=20to=20Tracker=20with=20JWT=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BFF on port 8200: auth + proxy to tracker - All /api/* routes go through BFF - WebSocket proxy with JWT auth - Tracker no longer exposed to internet - Logging on all requests - Removed Next.js API routes for auth (BFF handles it) --- .gitea/workflows/deploy.yml | 9 +- bff/__pycache__/app.cpython-312.pyc | Bin 0 -> 9653 bytes bff/__pycache__/auth.cpython-312.pyc | Bin 0 -> 1885 bytes bff/__pycache__/config.cpython-312.pyc | Bin 0 -> 1062 bytes .../tracker_client.cpython-312.pyc | Bin 0 -> 3197 bytes bff/__pycache__/ws_proxy.cpython-312.pyc | Bin 0 -> 3411 bytes bff/app.py | 175 ++++++++++++++++++ bff/auth.py | 39 ++++ bff/config.py | 19 ++ bff/requirements.txt | 5 + bff/tracker_client.py | 62 +++++++ bff/ws_proxy.py | 54 ++++++ src/app/api/auth/login/route.ts | 20 -- src/app/api/auth/me/route.ts | 18 -- src/lib/auth.ts | 27 --- 15 files changed, 361 insertions(+), 67 deletions(-) create mode 100644 bff/__pycache__/app.cpython-312.pyc create mode 100644 bff/__pycache__/auth.cpython-312.pyc create mode 100644 bff/__pycache__/config.cpython-312.pyc create mode 100644 bff/__pycache__/tracker_client.cpython-312.pyc create mode 100644 bff/__pycache__/ws_proxy.cpython-312.pyc create mode 100644 bff/app.py create mode 100644 bff/auth.py create mode 100644 bff/config.py create mode 100644 bff/requirements.txt create mode 100644 bff/tracker_client.py create mode 100644 bff/ws_proxy.py delete mode 100644 src/app/api/auth/login/route.ts delete mode 100644 src/app/api/auth/me/route.ts delete mode 100644 src/lib/auth.ts 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 0000000000000000000000000000000000000000..5854bc23cf9cc57cb5de25f8caade4f46d9cd686 GIT binary patch literal 9653 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 BHn#u( literal 0 HcmV?d00001 diff --git a/bff/__pycache__/auth.cpython-312.pyc b/bff/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e14dcc9b1400c9218e3376bc55841cfecee76588 GIT binary patch literal 1885 zcmbVM&2JM&6rcU@?#63-Ly{I6z7`P%KSz@h&^dk6|r!zfjeI8@wB!=cKlZ`O8FqMZ6xJ8$NF z%$s@d{pQzrJc@ws_I~Ges|Y<8iw^1S$;LM@SwI-25SFlvqPc_6(V}%K@Rm;0tlyiKN7hOC{#{ zMJJzBxPG3LND&7-K0Y;dd2H4tp^od5c!A?5z- z)s`)Hc-#Je`#}|3R0W2myOEnR>r@iu#2I%`O=e*a2r-Lwln>Yykxt}+i27Mr^C$=F1C?DNYPC$DiOH^aC_ zimneS6lbF1I7x+eiFvrGUUDjV-@$*$^Z?M_d*RG2#?XO#2SSLs0n> zZr&qBHZ+~d42g~yELC{SrNm(*&HOJ(k&13gz#ojmP)EtTRlIVS{S)A z^8Nd(*5TJz_l`fd249+*rPVLIN}!lEe`fB?x7oYZpTBrwj;^Sq8?YDRkuZY0RbY@s z;16417A6a*3vHKix1!e&+bS2+z$K)t^g!B9{!zp_ie1Y$fGB|Q8mgcfG^<=gGg49( zAc$_2BuGZ+8?Xo0FnK-oJbJkT4L?SyPm>be4@QBwj!pLo``8Uay-cN@NgG5Wo58y< z)KODbB7Kmz`RZJ?aqM3IF9#o4Ppt73WBi|WGckX5?(E-4iu66TdYcOH%|Nu)E&C|? zsdq$qB#i(xX$yk8XYe7I3lQai@1)!ol(seEX!wtba-tSH2rJ9&Y6Y=vP+%2D zI-a*ACYmv%nuIls`ccQ{rd*SE`03kzH5q_nOz^nqL%>4S5pNJjRsAF4P(p3*4#ckR znebWoctP8GF7zpWA&^jwUo75m@*cLsrUJO>*NFo#3AmCaj7J0JP!`Mp#HhF!wP66{ z90A5v5t8nOX$Ll~`gMb6+AUKGvC`rZqi=(aE$ptsP)BQK&-}T$bHAI19-D_6Uq6&r z&Efj_HPaH(#>f-%*m`1rV|3-nsnx{k731`JqQ7x!QCgH6@2w^V>KC4xJ)yzk;nl>+ z72_oASD&E$us1YdB=5WM1ov<~oyNYKPSajcaE(<;UNK8W30rNcXB@a)$)f@bt=kDK zaLx6L885s2QqVpjRsu#tSaTJ7E(e=vZYJJ5?G=SBS^Ap1*S`1Z_kG`wUku~JQ29RaP1$i_*p20P+gOv5QDJDmU=-6zQla;O!~5qoRSY zr#!$fQjRhJn4a`Hsvzj=DcVtmK)9!TNckOQKN#rBE3}WDoe4PVK`_+SB2#uv4p;h@ zS6829HFZzfKhe{MqN&rr?I4@bFL3dd^#mtva~qYR1H(98VA-6OP3I1@g0V3FYGwuo zyJm;)u;EhSKg5;_km))hwVv3l3>uk;p{KJ)vK|3)F=A*(vI>14b_9t}tZ)gTGJ?cm z$%w3fiRHIm7eCFFL^Yb>W}!RDM`zzaI=(F=rENaJL3bMD74@-aE6($fHso|Zqos8K zy=Zw-GQ|rOYG%6gJalcPI02ILQM3liCW+0@kU*(S1|cO$K+8%JbZG_X*_RDjE$Rf? z65YO$y4$Yoy9*gR-}uJ+6t^ZMKk8n4z`pK~v?qjwq8gCwOhlmYKd?JAhTTV5UDFKq zSl15atWjX?TqB)i*^-=zWOItF8f<27k9|0m>ErUf_g!8CCpmdhe~Lob$6WY{s#^lb z@oQ}K2BU91Sl?(hQ&(zAlW9#XTEQjL-wuyA`Y*=L$68OOT9JemUNwX5&{)HDL7&r2 zwlx*ELdzzD{tb>)x%zHxxAC?0V$oujOkX=RVlwT(P<6S!R$FTvUCvp7DU<%U=dBfB zO?zf<*woL;r{%_YbJwCKO!AuYRb$m?Jywe~#LHpJ|I(r+O|tFjGc#w(snVdEvzF)i KDWQ*}W&8o{m-qMp literal 0 HcmV?d00001 diff --git a/bff/__pycache__/tracker_client.cpython-312.pyc b/bff/__pycache__/tracker_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f04adb97be0c9032ae53f5aa39c42cacf5d551b9 GIT binary patch literal 3197 zcmd5;-ER{|5Z}97-`Vj;;(R$IQ818%xDg3bqmYD;l0w>`pb#bI#k6uSzH?&Z_}sm- zLec={Lli_+C`AoYTZsoAjM7S})CZ~}^$+Mv3`mYnk%~&Iwr`QlLzSn_-ua9Pls;8r z?Va73otwM;&CJgJ>~>cmXm@IE#6I8=dO{hi0dKTIErZY^I*ue1LJ}jf2`0qA$R*er zF2v2?5N1rdEo1{ZPVh5ANSLvQ>qJ?}ei4T}l0&YPDxgz6f4LQN9_1w`=y{7eTog+#;H%4cH}ExOya)K& zB3}pkmB80aRTojnH^&CN^UcG-;Fv#>h{;LKA61AyNW{oVnfM3Cj&%J+UppFLb#5>@ zr`wOEw3w0<6CiK~#|ICM42_46j~~_D785)+GBi5xoQy`hG}DYOgiYHzPh>4kl9BRV zQ3r9*3m1VbBCWJ`uIkx5&X&Zb32D=878AGJYzbi0>=r}atr;OgC10Yi*%&zf8@xo{ z3r#G0>jVneMs@dKTANl#Y+j_hofqDgMFMzCnfeC~1~}b5DXMZfO%l2=EsK&&R9(YkM+cbOn|1G3#kWQ(|VrRMaCDOoGBq!x@-LRa?!$)Mi^Pv-1b zJM)d54;nkys(tzD9l7cqYjytBy4I}ly*qn$rTwjy+JmdL4NnDJQ*l{%T7g_OnNdPR zUgu|Iz(Ue-O4Oz!<&L1=eieFQ1;_|usQZfffY1`iXgkemrDx(LhnZ`XO3VUUU@TYA zTwCB?=1!^=Wn*Z8y~BLUT!1|O2;BDtyU1O#+0beHA8%pl^9wj$3W{f6u!PMygBI`v z+Jipn_?DYx0^I0)<%wcczwd8T;h(Mt%}p}@ua|E4+oXWafL-Sk%G8uhbR0`Y6C`S{lMZkz`P<13QEo)A^ILBAHTkzL+AI$ZATZIQeNKHB_n6@^{f3*$UD| z=;{d|f1vptXS?p5#B>-CE*pS9fPvpDD7v&;j@ zJNIM`msC5l?aQtAaK3Li*EjqKA6a!)W=7U+$kn%@(l9#TKiJOv$PBjP`vI6mN-l;q z`k_V$oPx>b5TLF#T*kcAW$XeQFJ>S-TxwR}fIHZi1&p5Uyf- z9&gFvEst;$pmweQO8?cpE4bbSui%1b&wB@$&zU}avu)rtMGlW0n+OuRS<*&#{2VAQ z3zU?iYUB-A@1Q!*p;Di%&3XHl>w)B*eVN0rLZyF$>;wa^XAIfK27`x&{|yRv2?|k* zOp{%(jjk84@MT+b-u~rwAbDs1tFah(6&5Kl{XgVW@u&mirDo)j-^wHY@X*nr;84I! zC9(t5NE0P5#)iiNM3NJ-CX?Nu)&u?d=xE6Hob7t>` zYUWHYe-D$cfcWc)TmU1-dD}7R8if^a9B(x6)0D5 z^HIZLv#fED{knY|Y7+=$LN~eziVgd|8AVDbl|3gP0!$02HnGr5GTyHb4E;&c* zL)5v>+nJi{L;2daTy0wc0kd9jW2$cNSVu5qCye1<`lmBLoGDPrdKJg`ZXH@jFk~YI lI;^{>2sEikEfYly(K1&OHQ!RHsCl`Dikb^9%=ipP{{y0RqIUoQ literal 0 HcmV?d00001 diff --git a/bff/__pycache__/ws_proxy.cpython-312.pyc b/bff/__pycache__/ws_proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cc4dc20285a3194de656a0bab27c5c848f0f7c8 GIT binary patch literal 3411 zcmb_eZ*UXG6@PpGbZ5!Ne=yhvY=Lb+fFv;!ne)DNoAosEp7WA4rv zt9Hk&lYj#=(7!-BX~Il9F(ql3_EXz@z+gI^X+OxLbYw2fG*jAX`%MHgn2-;Bd(ufJ zp-ew?=XT$_-M4Rd-|xM*`=|2qGQjd)%@1PV6aenhAG_ecRqoFrxd1YRKqfL1Cm~`{ zCd62j4Y5esIG5lx*)hBExs!dR$MsT^)zB3NK$hb=EVWl<4iNvR&>ifnE z2hPd$1?@udd6&If&@qJ=ej8{0gt@@J#`8FL3=I6wmvLg-aDo|Uo}rO@wnp3uZ|f{k z8^JH{!La=dhNY6c&B^>YGwihgO0?u5_UQ;1E_?i^AUnnxS**tn_0|%- z(UJiG(5CQ1toPr*=EqDvo+XfAnt570+x}DZ6*liH6EIDNi;-MfU>s~e{^ATotsT*A z#OgrY=sv81xUm$AF<5G;D}<&$m!?FO*a;OMn>B-)Os5l!}uDo5J#rt)D1#N-#Yvaw(h)wIyGZE>Nl=G7X~oR4;fYs?9d4+Ym!Uws?O$9a)X$LA;$Lie?^IssChDIc~(%Bm&DO^dXJr zwI=}8Xnr%RmRLRyVXFC=@Z^Oy3E;Silx-$Nj^{v2&CMi(00Z^bXgXF4*CzHFUu zzRrgb)cO$@-{L(M*83O#zS8{#be>1GQ)Cx;mGQ$V&dZ!3n}oBhmpNDKho6%z!Y_C~ z{E~Q?cZnZ)fnKG|G5=SQ#BgmsT%B)i#K~d?mZPI zAC0T{RG^v36e3Y-GE<1FLqqsF;9|)^)ntbhLv!G`MlWu&3|(Ef9v-^sC}a`S5z)tz z(U@uqLlJz1Xj%gm<*n5{qGfcAKG95$zDrD}9bqF{Tw6(1)7O(p6bmDruJbLoRi4jd zB-(&zn{bSRt&ibD;gJ;>_XKcNEmTyVJ34*zVsNJ6^wCU3XIAV?i=7K%#U1C$i+sk} zI2pX{Tvn25qEg$l;`X$-9i=`lbDi@{d(LdmaB@C)2jAIydGE|i znU>BExvmEStY}FuZ%McGU2nNQGQ0Wunm-PG_);|eqMWW)CI@m%E#G^mW=*!Hb-t$c z?PHmm_J>d@?ISs>y*B(QBb3*FTFIB!Kd6A3h6~>Bd*|17WY%`gckj(~e>GD*kaZ2D zT>}r-(pmYr4>p3d?3{DjnO^Rh>3qNEO3$@Dna$7rMGWM26Og(di0B}H%V{<_p^=2%?c#AiJNuQQND=;w{WxWoygy4s_&QJMh^*kg&W&h z$_M!Cu$ga}MNhF%FcuB}x75?5*k*{cY^6jeLI>+-l=#PdiKbt_^!uvwv94qGo*H zf@itu2!~}g8V+kMcr4RF-9MHbGPz;AJWa=7L^mSGV;a2%G o4Mxn8s(Dg%zBNPY?lCQ-_xxB6kj&J4Ldks=n0l*U>&B)21EYc++yDRo literal 0 HcmV?d00001 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; - } -}