import asyncio import json from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from starlette.middleware.sessions import SessionMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.responses import Response from app.db import close_pool, fetch_all, init_pool from app.routes import ( retail, agents, analytics, documents, api, clients, dashboard, deals, herman, studio, marketing, monitor, products, reports, suppliers, voice, settings, browser, hermes, beurs, packaging, ops, ops_api, revenue_cockpit, export_intel, ) from app.routes.revenue_cockpit import api as revenue_cockpit_api from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api from app.routes.settings_api import settings_router from app.routes import auth_routes from app import auth_users from app.config import settings as app_settings from app.routes.agents_api import router as agents_api_router from app.routes.marketing_api import router as marketing_api_router from app.routes.projects_api import router as projects_api_router BASE_DIR = Path(__file__).resolve().parent.parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) class NoCacheStaticFiles(StaticFiles): """Serve static assets without browser/SW long-lived caching.""" async def get_response(self, path: str, scope) -> Response: response = await super().get_response(path, scope) if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path: response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response app = FastAPI(title="Foodlinkk Command Center", version="2.5.0") PUBLIC_PREFIXES = ( "/login", "/logout", "/static/", "/sw.js", "/manifest.webmanifest", "/static/manifest.json", "/api/auth/login", ) PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"} def _is_public(path: str) -> bool: if path in PUBLIC_EXACT: return True for pref in PUBLIC_PREFIXES: if path == pref or path.startswith(pref): return True return False class AuthGateMiddleware: """Require login; must run *inside* SessionMiddleware.""" def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": await self.app(scope, receive, send) return from starlette.requests import Request request = Request(scope, receive=receive) path = request.url.path user = None try: uid = request.session.get("user_id") except Exception: uid = None if uid: try: user = auth_users.get_user_by_id(int(uid)) except Exception: user = None if not user or not user.get("is_active"): try: request.session.clear() except Exception: pass user = None request.state.user = user async def call_next(): await self.app(scope, receive, send) if _is_public(path): await self.app(scope, receive, send) return if not user: if path.startswith("/api/") or path.startswith("/ws"): response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401) else: nxt = path if request.url.query: nxt = f"{path}?{request.url.query}" from urllib.parse import quote response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303) await response(scope, receive, send) return if ( not path.startswith("/api/") and not path.startswith("/ws") and not path.startswith("/static") and not auth_users.path_allowed(user, path) ): response = RedirectResponse("/", status_code=303) await response(scope, receive, send) return await self.app(scope, receive, send) # Order: first added = innermost. Session must be outermost so request.session works. app.add_middleware(AuthGateMiddleware) app.add_middleware( SessionMiddleware, secret_key=app_settings.SESSION_SECRET, session_cookie="flk_session", same_site="lax", https_only=False, max_age=app_settings.SESSION_MAX_AGE, ) @app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False) async def service_worker(): """Root-scoped service worker for installable PWA (Android + iOS Safari).""" sw_path = BASE_DIR / "static" / "sw.js" return FileResponse( sw_path, media_type="application/javascript; charset=utf-8", headers={ "Service-Worker-Allowed": "/", "Cache-Control": "no-cache, no-store, must-revalidate", }, ) @app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False) async def web_manifest(): man_path = BASE_DIR / "static" / "manifest.json" return FileResponse( man_path, media_type="application/manifest+json", headers={"Cache-Control": "no-cache"}, ) app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static") for r in ( dashboard.router, beurs.router, agents.router, marketing.router, retail.router, clients.router, deals.router, products.router, suppliers.router, monitor.router, analytics.router, documents.router, reports.router, voice.router, settings.router, auth_routes.router, browser.router, herman.router, studio.router, hermes.router, packaging.router, ops.router, revenue_cockpit.router, export_intel.router, revenue_cockpit_api, api.router, ops_api.router, admin_router, ai_router, herman_api, voice_api, settings_router, agents_api_router, marketing_api_router, projects_api_router, auth_routes.api_router, auth_routes.users_api, ): app.include_router(r) @app.on_event("startup") def on_startup() -> None: init_pool() try: auth_users.ensure_admin_seed() except Exception as e: print(f"auth seed warning: {e}") @app.on_event("shutdown") def on_shutdown() -> None: close_pool() @app.websocket("/ws/agents") async def ws_agents(websocket: WebSocket) -> None: await websocket.accept() last_payload: str | None = None try: while True: try: rows = fetch_all( """SELECT id, agent_name, event_type, title, body, status, created_at FROM agent_events ORDER BY created_at DESC LIMIT 50""" ) for row in rows: if row.get("created_at") is not None: row["created_at"] = row["created_at"].isoformat() payload = json.dumps({"events": rows}) except Exception as exc: payload = json.dumps({"error": str(exc), "events": []}) if payload != last_payload: await websocket.send_text(payload) last_payload = payload await asyncio.sleep(3) except WebSocketDisconnect: return @app.websocket("/ws/feed") async def ws_feed(websocket: WebSocket) -> None: await websocket.accept() try: while True: snapshot = {"type": "heartbeat", "events": []} try: snapshot["events"] = fetch_all( "SELECT agent_name, event_type, title, status, created_at FROM agent_events ORDER BY created_at DESC LIMIT 15" ) for row in snapshot["events"]: if row.get("created_at"): row["created_at"] = row["created_at"].isoformat() except Exception as exc: snapshot["error"] = str(exc) await websocket.send_text(json.dumps(snapshot)) await asyncio.sleep(5) except WebSocketDisconnect: return