From 26bbf4a061495b6275d8519ad1efc850aa2648f2 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 25 Aug 2025 15:55:13 +0300 Subject: [PATCH 01/14] remove js from embedded --- app/templates/components/left_toolbar.j2 | 77 ------------------------ app/ts/app.ts | 3 + app/ts/modules/left_toolbar.ts | 74 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 77 deletions(-) create mode 100644 app/ts/modules/left_toolbar.ts diff --git a/app/templates/components/left_toolbar.j2 b/app/templates/components/left_toolbar.j2 index 80fc624f..86386caf 100644 --- a/app/templates/components/left_toolbar.j2 +++ b/app/templates/components/left_toolbar.j2 @@ -72,80 +72,3 @@ - - \ No newline at end of file diff --git a/app/ts/app.ts b/app/ts/app.ts index bb1c1a8a..cde7a6a6 100644 --- a/app/ts/app.ts +++ b/app/ts/app.ts @@ -19,6 +19,7 @@ import { } from './modules/ui'; import { setupAuthenticationModal, setupDatabaseModal } from './modules/modals'; import { showGraph } from './modules/schema'; +import { initLeftToolbar } from './modules/left_toolbar'; async function loadAndShowGraph(selected: string | undefined) { if (!selected) return; @@ -102,6 +103,8 @@ function setupUIComponents() { setupAuthenticationModal(); setupDatabaseModal(); setupToolbar(); + // initialize left toolbar behavior (burger, responsive default) + initLeftToolbar(); setupCustomDropdown(); } diff --git a/app/ts/modules/left_toolbar.ts b/app/ts/modules/left_toolbar.ts new file mode 100644 index 00000000..94d2162a --- /dev/null +++ b/app/ts/modules/left_toolbar.ts @@ -0,0 +1,74 @@ +/** + * Left toolbar behavior extracted from template script. + * This module exports initLeftToolbar and can be imported so bundlers include it in the main bundle. + */ +export function initLeftToolbar(): void { + const nav = document.getElementById('left-toolbar') as HTMLElement | null; + const btn = document.getElementById('burger-toggle-btn') as HTMLElement | null; + if (!nav || !btn) return; + + function setOpen(open: boolean) { + // `nav` and `btn` are checked above; use non-null assertions to satisfy TypeScript + if (open) { + nav!.classList.remove('collapsed'); + document.body.classList.add('left-toolbar-open'); + btn!.setAttribute('title', 'Close menu'); + btn!.setAttribute('aria-label', 'Close menu'); + btn!.setAttribute('aria-pressed', 'true'); + btn!.setAttribute('aria-expanded', 'true'); + } else { + nav!.classList.add('collapsed'); + document.body.classList.remove('left-toolbar-open'); + btn!.setAttribute('title', 'Open menu'); + btn!.setAttribute('aria-label', 'Open menu'); + btn!.setAttribute('aria-pressed', 'false'); + btn!.setAttribute('aria-expanded', 'false'); + } + } + + const mq = window.matchMedia('(min-width: 768px)'); + + try { + setOpen(mq.matches); + } catch (e) { + setOpen(true); + } + + // Support both modern and legacy addListener APIs + if (typeof (mq as MediaQueryList).addEventListener === 'function') { + (mq as MediaQueryList).addEventListener('change', (ev: MediaQueryListEvent) => setOpen(ev.matches)); + } else if (typeof (mq as any).addListener === 'function') { + (mq as any).addListener((ev: MediaQueryListEvent) => setOpen(ev.matches)); + } + + let ignoreNextClick = false; + + function handleToggleEvent() { + const isCollapsed = nav!.classList.contains('collapsed'); + setOpen(isCollapsed); + } + + btn.addEventListener('pointerdown', function (e: PointerEvent) { + e.preventDefault(); + handleToggleEvent(); + ignoreNextClick = true; + }); + + btn.addEventListener('click', function (_e: MouseEvent) { + if (ignoreNextClick) { + ignoreNextClick = false; + return; + } + handleToggleEvent(); + }); + + // Expose a minimal API for other scripts + (window as any).__leftToolbar = { + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen(!nav.classList.contains('collapsed')), + }; +} + +// Note: We don't auto-init here. Importing module and calling initLeftToolbar() from the app entry +// ensures the bundler includes this file and initialization timing stays explicit. From f21bfb531722cfdf8e12f4d4573c16e7c85f1453 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 25 Aug 2025 19:30:04 +0300 Subject: [PATCH 02/14] remove style from j2 files --- app/public/css/base.css | 5 +++++ app/public/css/buttons.css | 1 + app/templates/base.j2 | 3 ++- app/templates/components/login_modal.j2 | 2 +- app/templates/components/toolbar.j2 | 4 ++-- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/public/css/base.css b/app/public/css/base.css index 8174cafe..3fe58204 100644 --- a/app/public/css/base.css +++ b/app/public/css/base.css @@ -63,6 +63,11 @@ button, input, select, textarea { text-decoration: underline; } +#powered-by-falkordb img { + height:1.5em; + vertical-align:middle; +} + @media (max-width: 600px) { #powered-by-falkordb { font-size: 0.95em; diff --git a/app/public/css/buttons.css b/app/public/css/buttons.css index 500ea70c..28602d48 100644 --- a/app/public/css/buttons.css +++ b/app/public/css/buttons.css @@ -220,6 +220,7 @@ text-decoration: none; font-weight: 500; transition: background 0.2s; + margin-top: 0.5em; } .github-login-btn:hover { diff --git a/app/templates/base.j2 b/app/templates/base.j2 index ba07d051..aabc3dba 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -5,6 +5,7 @@ {% block title %}Chatbot Interface{% endblock %} + {% block extra_css %}{% endblock %} {% if google_tag_manager_id %} @@ -34,7 +35,7 @@ {% block scripts %} diff --git a/app/templates/components/login_modal.j2 b/app/templates/components/login_modal.j2 index 5489548c..af26b058 100644 --- a/app/templates/components/login_modal.j2 +++ b/app/templates/components/login_modal.j2 @@ -6,7 +6,7 @@ - diff --git a/app/templates/components/toolbar.j2 b/app/templates/components/toolbar.j2 index c1831a6e..331a3752 100644 --- a/app/templates/components/toolbar.j2 +++ b/app/templates/components/toolbar.j2 @@ -5,10 +5,10 @@ - + - - + - {% if is_authenticated and user_info %} From 8037ec575c858bc68479c09ad181ac275c918d31 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 25 Aug 2025 19:31:35 +0300 Subject: [PATCH 03/14] remove style from j2 files --- app/templates/base.j2 | 1 - 1 file changed, 1 deletion(-) diff --git a/app/templates/base.j2 b/app/templates/base.j2 index aabc3dba..adcbe25e 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -5,7 +5,6 @@ {% block title %}Chatbot Interface{% endblock %} - {% block extra_css %}{% endblock %} {% if google_tag_manager_id %} From e4da6a8a1db194c3ec318ef79de22df471a872f2 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 25 Aug 2025 19:32:28 +0300 Subject: [PATCH 04/14] remove style from j2 files --- app/templates/base.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/templates/base.j2 b/app/templates/base.j2 index adcbe25e..6b08eca6 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -34,7 +34,7 @@ {% block scripts %} From f687e0c6cc1800a31552a2b404e6a1e2f24c673f Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 25 Aug 2025 19:45:06 +0300 Subject: [PATCH 05/14] clean imports --- api/auth/user_management.py | 2 -- api/routes/auth.py | 1 + api/routes/graphs.py | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/api/auth/user_management.py b/api/auth/user_management.py index ea5c8baa..9b66de6e 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -5,9 +5,7 @@ from functools import wraps from typing import Tuple, Optional, Dict, Any -import requests from fastapi import Request, HTTPException, status -from fastapi.responses import JSONResponse from authlib.integrations.starlette_client import OAuth from api.extensions import db diff --git a/api/routes/auth.py b/api/routes/auth.py index b2446d26..ed90283d 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -85,6 +85,7 @@ def _build_callback_url(request: Request, path: str) -> str: # ---- Routes ---- @auth_router.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: + """Handle the home page, rendering the landing page for unauthenticated users and the chat page for authenticated users.""" user_info, is_authenticated_flag = await validate_and_cache_user(request) if not is_authenticated_flag: diff --git a/api/routes/graphs.py b/api/routes/graphs.py index bc670b55..02eb7b63 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -4,8 +4,6 @@ import json import logging import time -from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import TimeoutError as FuturesTimeoutError from fastapi import APIRouter, Request, HTTPException, UploadFile, File from fastapi.responses import JSONResponse, StreamingResponse From e60ddcc50b15206be7e2c2bbe8d769a62b8582a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:32:11 +0000 Subject: [PATCH 06/14] Bump eslint from 9.33.0 to 9.34.0 in /app Bumps [eslint](https://github.com/eslint/eslint) from 9.33.0 to 9.34.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.33.0...v9.34.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 9.34.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- app/package-lock.json | 16 ++++++++-------- app/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index c24dedb7..04715822 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11,7 +11,7 @@ "@typescript-eslint/eslint-plugin": "^8.40.0", "@typescript-eslint/parser": "^8.40.0", "esbuild": "^0.25.9", - "eslint": "^9.33.0", + "eslint": "^9.34.0", "typescript": "^5.9.2" } }, @@ -607,9 +607,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "dev": true, "license": "MIT", "engines": { @@ -1239,9 +1239,9 @@ } }, "node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1251,7 +1251,7 @@ "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", + "@eslint/js": "9.34.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", diff --git a/app/package.json b/app/package.json index 0c39ce12..33f9270c 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "devDependencies": { "esbuild": "^0.25.9", "typescript": "^5.9.2", - "eslint": "^9.33.0", + "eslint": "^9.34.0", "@typescript-eslint/parser": "^8.40.0", "@typescript-eslint/eslint-plugin": "^8.40.0" } From ffe4b7b389bbd64cdd1e5d70629011b8fc03dcd7 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:19:37 +0300 Subject: [PATCH 07/14] initial commit with tokens --- api/app_factory.py | 13 +- api/auth/__init__.py | 4 +- api/auth/oauth_handlers.py | 46 +----- api/auth/user_management.py | 206 +++++++++++------------- api/graph.py | 6 +- api/loaders/base_loader.py | 2 +- api/loaders/mysql_loader.py | 2 +- api/loaders/postgres_loader.py | 2 +- api/routes/auth.py | 276 +++++++++++++++++---------------- api/routes/graphs.py | 22 +-- 10 files changed, 263 insertions(+), 316 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index 17bb7a3e..bd934d6e 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -2,7 +2,6 @@ import logging import os -import secrets from dotenv import load_dotenv from fastapi import FastAPI, Request, HTTPException @@ -12,6 +11,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from api.auth.oauth_handlers import setup_oauth_handlers +from api.auth.user_management import SECRET_KEY from api.routes.auth import auth_router, init_auth from api.routes.graphs import graphs_router from api.routes.database import database_router @@ -20,7 +20,6 @@ load_dotenv() logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - class SecurityMiddleware(BaseHTTPMiddleware): """Middleware for security checks including static file access""" @@ -52,16 +51,10 @@ def create_app(): ), ) - # Get secret key for sessions - secret_key = os.getenv("FASTAPI_SECRET_KEY") - if not secret_key: - secret_key = secrets.token_hex(32) - logging.warning("FASTAPI_SECRET_KEY not set, using generated key. Set this in production!") - # Add session middleware with explicit settings to ensure OAuth state persists app.add_middleware( SessionMiddleware, - secret_key=secret_key, + secret_key=SECRET_KEY, session_cookie="qw_session", same_site="lax", # allow top-level OAuth GET redirects to send cookies https_only=False, # allow http on localhost in development @@ -90,9 +83,9 @@ def create_app(): async def handle_oauth_error(request: Request, exc: Exception): """Handle OAuth-related errors gracefully""" # Check if it's an OAuth-related error + # TODO check this scenario if "token" in str(exc).lower() or "oauth" in str(exc).lower(): logging.warning("OAuth error occurred: %s", exc) - request.session.clear() return RedirectResponse(url="/", status_code=302) # If it's an HTTPException, re-raise so FastAPI handles it properly diff --git a/api/auth/__init__.py b/api/auth/__init__.py index 853c3c6b..793b1f17 100644 --- a/api/auth/__init__.py +++ b/api/auth/__init__.py @@ -7,7 +7,7 @@ from .user_management import ( ensure_user_in_organizations, update_identity_last_login, - validate_and_cache_user, + validate_user, token_required, ) from .oauth_handlers import setup_oauth_handlers @@ -15,7 +15,7 @@ __all__ = [ "ensure_user_in_organizations", "update_identity_last_login", - "validate_and_cache_user", + "validate_user", "token_required", "setup_oauth_handlers", ] diff --git a/api/auth/oauth_handlers.py b/api/auth/oauth_handlers.py index b9c216cb..8e25198a 100644 --- a/api/auth/oauth_handlers.py +++ b/api/auth/oauth_handlers.py @@ -7,7 +7,7 @@ import logging from typing import Dict, Any -from fastapi import FastAPI, Request +from fastapi import FastAPI from authlib.integrations.starlette_client import OAuth from .user_management import ensure_user_in_organizations @@ -19,10 +19,8 @@ def setup_oauth_handlers(app: FastAPI, oauth: OAuth): # Store oauth in app state for access in routes app.state.oauth = oauth - async def handle_google_callback(_request: Request, - _token: Dict[str, Any], - user_info: Dict[str, Any]): - """Handle Google OAuth callback processing""" + async def handle_callback(provider: str, user_info: Dict[str, Any], api_token: str): + """Handle Provider OAuth callback processing""" try: user_id = user_info.get("id") email = user_info.get("email") @@ -30,7 +28,7 @@ async def handle_google_callback(_request: Request, # Validate required fields if not user_id or not email: - logging.error("Missing required fields from Google OAuth response") + logging.error("Missing required fields from %s OAuth response", provider) return False # Check if identity exists in Organizations graph, create if new @@ -38,43 +36,15 @@ async def handle_google_callback(_request: Request, user_id, email, name, - "google", + provider, + api_token, user_info.get("picture"), ) return True except Exception as exc: # capture exception for logging - logging.error("Error handling Google OAuth callback: %s", exc) - return False - - async def handle_github_callback(_request: Request, - _token: Dict[str, Any], - user_info: Dict[str, Any]): - """Handle GitHub OAuth callback processing""" - try: - user_id = user_info.get("id") - email = user_info.get("email") - name = user_info.get("name") or user_info.get("login") - - # Validate required fields - if not user_id or not email: - logging.error("Missing required fields from GitHub OAuth response") - return False - - # Check if identity exists in Organizations graph, create if new - _, _ = await ensure_user_in_organizations( - user_id, - email, - name, - "github", - user_info.get("picture"), - ) - - return True - except Exception as exc: # capture exception for logging - logging.error("Error handling GitHub OAuth callback: %s", exc) + logging.error("Error handling %s OAuth callback: %s", provider, exc) return False # Store handlers in app state for use in route callbacks - app.state.google_callback_handler = handle_google_callback - app.state.github_callback_handler = handle_github_callback + app.state.callback_handler = handle_callback diff --git a/api/auth/user_management.py b/api/auth/user_management.py index 9b66de6e..93028757 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -1,17 +1,76 @@ """User management and authentication functions for text2sql API.""" import logging -import time +import os +import secrets from functools import wraps from typing import Tuple, Optional, Dict, Any from fastapi import Request, HTTPException, status -from authlib.integrations.starlette_client import OAuth - from api.extensions import db +# Get secret key for sessions +SECRET_KEY = os.getenv("FASTAPI_SECRET_KEY") +if not SECRET_KEY: + SECRET_KEY = secrets.token_hex(32) + logging.warning("FASTAPI_SECRET_KEY not set, using generated key. Set this in production!") + + +async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: + """ + Get user information from the database by email. + """ + query = """ + MATCH (i:Identity)-[:HAS_TOKEN]->(t:Token {id: $api_token}) + RETURN i.email, i.name, i.picture, (t IS NOT NULL AND timestamp() <= t.expires_at) AS token_valid + """ + + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + result = await organizations_graph.query(query, { + "api_token": api_token, + }) + + if result.result_set and result.result_set[0][3]: + token_valid = result.result_set[0][0] + + # TODO delete invalid token from DB + if token_valid: + return { + "email": result.result_set[0][0], + "name": result.result_set[0][1], + "picture": result.result_set[0][2] + } + + return None + except Exception as e: + logging.error("Error fetching user info: %s", e) + return None + -async def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): +async def delete_user_token(api_token: str): + """ + Delete user token from the database. + """ + query = """ + MATCH (t:Token {id:$api_token}) + DELETE t + """ + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + await organizations_graph.query(query, { + "api_token": api_token, + }) + + except Exception as e: + logging.error("Error deleting user token: %s", e) + + +async def ensure_user_in_organizations(provider_user_id: str, email: str, name: str, provider: str, api_token: str, picture: str = None): """ Check if identity exists in Organizations graph, create if not. Creates separate Identity and User nodes with proper relationships. @@ -69,6 +128,13 @@ async def ensure_user_in_organizations(provider_user_id, email, name, provider, // Ensure relationship exists MERGE (identity)-[:AUTHENTICATES]->(user) + + // Then, create a session linked to the Identity and store the API_Token + MERGE (token:Token {id: $api_token}) + ON CREATE SET + token.created_at = timestamp(), + token.expires_at = timestamp() + 86400000 // 24h expiry + MERGE (identity)-[:HAS_TOKEN]->(token) // Return results with flags to determine if this was a new user/identity RETURN @@ -85,7 +151,8 @@ async def ensure_user_in_organizations(provider_user_id, email, name, provider, "name": name, "picture": picture, "first_name": first_name, - "last_name": last_name + "last_name": last_name, + "api_token": api_token }) if result.result_set: @@ -157,122 +224,26 @@ async def update_identity_last_login(provider, provider_user_id): provider, provider_user_id, e) -async def validate_and_cache_user(request: Request) -> Tuple[Optional[Dict[str, Any]], bool]: +async def validate_user(request: Request) -> Tuple[Optional[Dict[str, Any]], bool]: """ - Helper function to validate OAuth token and cache user info. + Helper function to validate token. Returns (user_info, is_authenticated). - Supports both Google and GitHub OAuth. Includes refresh handling for Google. """ try: - user_info = request.session.get("user_info") - token_validated_at = request.session.get("token_validated_at", 0) - current_time = time.time() - - # Use cached user info if it's less than 15 minutes old - if user_info and (current_time - token_validated_at) < 900: - return user_info, True - - oauth: OAuth = request.app.state.oauth - - # ---- Google OAuth ---- - google_token = request.session.get("google_token") - if google_token and hasattr(oauth, "google"): - try: - resp = await oauth.google.get("/oauth2/v2/userinfo", token=google_token) - - if resp.status_code == 401 and "refresh_token" in google_token: - # Token expired, try refreshing - try: - new_token = await oauth.google.refresh_token( - "https://oauth2.googleapis.com/token", - refresh_token=google_token["refresh_token"], - ) - request.session["google_token"] = new_token - resp = await oauth.google.get("/oauth2/v2/userinfo", token=new_token) - logging.info("Google access token refreshed successfully") - except Exception as e: - logging.error("Google token refresh failed: %s", e) - request.session.pop("google_token", None) - request.session.pop("user_info", None) - return None, False - - if resp.status_code == 200: - google_user = resp.json() - if not google_user.get("id") or not google_user.get("email"): - logging.warning("Invalid Google user data received") - request.session.pop("google_token", None) - request.session.pop("user_info", None) - return None, False - - # Normalize - user_info = { - "id": str(google_user.get("id")), - "name": google_user.get("name", ""), - "email": google_user.get("email"), - "picture": google_user.get("picture", ""), - "provider": "google", - } - request.session["user_info"] = user_info - request.session["token_validated_at"] = current_time - return user_info, True - except Exception as e: - logging.warning("Google OAuth validation error: %s", e) - request.session.pop("google_token", None) - request.session.pop("user_info", None) - - # ---- GitHub OAuth ---- - github_token = request.session.get("github_token") - if github_token and hasattr(oauth, "github"): - try: - resp = await oauth.github.get("/user", token=github_token) - if resp.status_code == 200: - github_user = resp.json() - if not github_user.get("id"): - logging.warning("Invalid GitHub user data received") - request.session.pop("github_token", None) - request.session.pop("user_info", None) - return None, False - - # Get primary email - email_resp = await oauth.github.get("/user/emails", token=github_token) - email = None - if email_resp.status_code == 200: - for email_obj in email_resp.json(): - if email_obj.get("primary", False): - email = email_obj.get("email") - break - if not email and email_resp.json(): - email = email_resp.json()[0].get("email") - - if not email: - logging.warning("No email found for GitHub user") - request.session.pop("github_token", None) - request.session.pop("user_info", None) - return None, False - - user_info = { - "id": str(github_user.get("id")), - "name": github_user.get("name") or github_user.get("login", ""), - "email": email, - "picture": github_user.get("avatar_url", ""), - "provider": "github", - } - request.session["user_info"] = user_info - request.session["token_validated_at"] = current_time - return user_info, True - except Exception as e: - logging.warning("GitHub OAuth validation error: %s", e) - request.session.pop("github_token", None) - request.session.pop("user_info", None) - - # No valid auth - request.session.pop("user_info", None) + #TODO token might be in the URL + api_token = request.cookies.get("api_token") + + if api_token: + db_info = await _get_user_info(api_token) + + if db_info: + return db_info, True + return None, False except Exception as e: - logging.error("Unexpected error in validate_and_cache_user: %s", e) - request.session.pop("user_info", None) + logging.error("Unexpected error in validate_user: %s", e) return None, False def token_required(func): @@ -283,12 +254,11 @@ def token_required(func): @wraps(func) async def wrapper(request: Request, *args, **kwargs): try: - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) if not is_authenticated: # Second attempt after clearing session to force re-validation - request.session.pop("user_info", None) - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) if not is_authenticated: raise HTTPException( @@ -297,9 +267,8 @@ async def wrapper(request: Request, *args, **kwargs): ) # Attach user_id to request.state (like FASTAPI's g.user_id) - request.state.user_id = user_info.get("id") + request.state.user_id = user_info.get("email") if not request.state.user_id: - request.session.pop("user_info", None) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Invalid user" @@ -311,7 +280,6 @@ async def wrapper(request: Request, *args, **kwargs): raise except Exception as e: logging.error("Unexpected error in token_required: %s", e) - request.session.pop("user_info", None) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized - Authentication error" diff --git a/api/graph.py b/api/graph.py index 72f136a4..0b281f49 100644 --- a/api/graph.py +++ b/api/graph.py @@ -140,12 +140,12 @@ async def _find_tables_by_columns( nullable: columns.nullable }) """ - + tasks = [ _query_graph(graph, query, {"embedding": embedding}) for embedding in embeddings ] - + results = await asyncio.gather(*tasks) return [row for rows in results for row in rows] @@ -200,7 +200,7 @@ async def _find_connecting_tables( pairs = [list(pair) for pair in combinations(table_names, 2)] if not pairs: return [] - + query = """ UNWIND $pairs AS pair MATCH (a:Table {name: pair[0]}) diff --git a/api/loaders/base_loader.py b/api/loaders/base_loader.py index d6418382..735a9a57 100644 --- a/api/loaders/base_loader.py +++ b/api/loaders/base_loader.py @@ -8,7 +8,7 @@ class BaseLoader(ABC): """Abstract base class for data loaders.""" @staticmethod - def load(_graph_id: str, _data) -> Tuple[bool, str]: + async def load(_graph_id: str, _data) -> Tuple[bool, str]: """ Load the graph data into the database. This method must be implemented by any subclass. diff --git a/api/loaders/mysql_loader.py b/api/loaders/mysql_loader.py index a174577c..8b521671 100644 --- a/api/loaders/mysql_loader.py +++ b/api/loaders/mysql_loader.py @@ -158,7 +158,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(prefix + "_" + db_name, entities, relationships, + await load_to_graph(f"{prefix}|{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"MySQL schema loaded successfully. " diff --git a/api/loaders/postgres_loader.py b/api/loaders/postgres_loader.py index d3e60989..bfc2f488 100644 --- a/api/loaders/postgres_loader.py +++ b/api/loaders/postgres_loader.py @@ -96,7 +96,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(prefix + "_" + db_name, entities, relationships, + await load_to_graph(f"{prefix}|{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"PostgreSQL schema loaded successfully. " diff --git a/api/routes/auth.py b/api/routes/auth.py index ed90283d..2a97fe76 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -2,20 +2,18 @@ import logging import os -import time +import secrets from pathlib import Path from urllib.parse import urljoin -import httpx from fastapi import APIRouter, Request, HTTPException, status from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.templating import Jinja2Templates -from authlib.common.errors import AuthlibBaseError from authlib.integrations.starlette_client import OAuth from jinja2 import Environment, FileSystemLoader, FileSystemBytecodeCache, select_autoescape from starlette.config import Config -from api.auth.user_management import validate_and_cache_user +from api.auth.user_management import delete_user_token, validate_user # Router auth_router = APIRouter() @@ -50,21 +48,15 @@ def _get_provider_client(request: Request, provider: str): raise HTTPException(status_code=500, detail=f"OAuth provider {provider} not configured") return client -def _clear_auth_session(session: dict): - """Remove only auth-related keys from session instead of clearing everything.""" - for key in [ - "user_info", - "google_token", - "github_token", - "token_validated_at", - "oauth_google_auth", - ]: - session.pop(key, None) - @auth_router.get("/chat", name="auth.chat", response_class=HTMLResponse) async def chat(request: Request) -> HTMLResponse: """Explicit chat route (renders main chat UI).""" - user_info, is_authenticated = await validate_and_cache_user(request) + user_info, is_authenticated = await validate_user(request) + + if not is_authenticated or not user_info: + is_authenticated = False + user_info = None + return templates.TemplateResponse( "chat.j2", { @@ -86,31 +78,31 @@ def _build_callback_url(request: Request, path: str) -> str: @auth_router.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: """Handle the home page, rendering the landing page for unauthenticated users and the chat page for authenticated users.""" - user_info, is_authenticated_flag = await validate_and_cache_user(request) + user_info, is_authenticated_flag = await validate_user(request) - if not is_authenticated_flag: - _clear_auth_session(request.session) - - if not is_authenticated_flag: + if is_authenticated_flag or user_info: return templates.TemplateResponse( - "landing.j2", + "chat.j2", { - "request": request, - "is_authenticated": False, - "user_info": None + "request": request, + "is_authenticated": True, + "user_info": user_info } ) return templates.TemplateResponse( - "chat.j2", + "landing.j2", { - "request": request, - "is_authenticated": is_authenticated_flag, - "user_info": user_info, - }, + "request": request, + "is_authenticated": False, + "user_info": None + } ) + + + @auth_router.get("/login", response_class=RedirectResponse) async def login_page(_: Request) -> RedirectResponse: return RedirectResponse(url="/login/google", status_code=status.HTTP_302_FOUND) @@ -118,6 +110,15 @@ async def login_page(_: Request) -> RedirectResponse: @auth_router.get("/login/google", name="google.login", response_class=RedirectResponse) async def login_google(request: Request) -> RedirectResponse: + """Initiate Google OAuth login flow. + + Args: + request (Request): The incoming request. + + Returns: + RedirectResponse: The redirect response to the Google OAuth endpoint. + """ + google = _get_provider_client(request, "google") redirect_uri = _build_callback_url(request, "login/google/authorized") @@ -134,46 +135,51 @@ async def login_google(request: Request) -> RedirectResponse: @auth_router.get("/login/google/authorized", response_class=RedirectResponse) async def google_authorized(request: Request) -> RedirectResponse: + """Handle Google OAuth callback and user authorization. + + Args: + request (Request): The incoming request. + + Returns: + RedirectResponse: The redirect response after handling the callback. + """ + try: google = _get_provider_client(request, "google") token = await google.authorize_access_token(request) + user_info = token.get("userinfo") - # Always fetch userinfo explicitly - resp = await google.get("https://www.googleapis.com/oauth2/v2/userinfo", token=token) - if resp.status_code != 200: - logging.error("Failed to fetch Google user info: %s", resp.text) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + if user_info: - user_info = resp.json() - if not user_info.get("email"): - logging.error("Invalid Google user data received") - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + user_data = { + 'id': user_info.get('sub'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'picture': user_info.get('picture'), + } - # Normalize - request.session["user_info"] = { - "id": str(user_info.get("id") or user_info.get("sub")), - "name": user_info.get("name", ""), - "email": user_info.get("email"), - "picture": user_info.get("picture", ""), - "provider": "google", - } - request.session["google_token"] = token - request.session["token_validated_at"] = time.time() + # Call the registered Google callback handler if it exists to store user data. + handler = getattr(request.app.state, "callback_handler", None) + if handler: + api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess + + # call the registered handler (await if async) + await handler('google', user_data, api_token) - # Call the registered Google callback handler if it exists to store user data. - handler = getattr(request.app.state, "google_callback_handler", None) - if handler: - # call the registered handler (await if async) - await handler(request, token, user_info) + redirect = RedirectResponse(url="/", status_code=302) + redirect.set_cookie( + key="api_token", + value=api_token, + httponly=True, + secure=True + ) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + return redirect - except AuthlibBaseError as e: - logging.error("Google OAuth error: %s", e) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + raise HTTPException(status_code=400, detail="Failed to get user info from Google") + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") @auth_router.get("/login/google/callback", response_class=RedirectResponse) @@ -206,19 +212,18 @@ async def github_authorized(request: Request) -> RedirectResponse: token = await github.authorize_access_token(request) # Fetch GitHub user info - resp = await github.get("https://api.github.com/user", token=token) + resp = await github.get("user", token=token) if resp.status_code != 200: logging.error("Failed to fetch GitHub user info: %s", resp.text) - _clear_auth_session(request.session) return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) user_info = resp.json() - + # Get user email if not public email = user_info.get("email") if not email: # Try to get primary email from emails endpoint - email_resp = await github.get("https://api.github.com/user/emails", token=token) + email_resp = await github.get("user/emails", token=token) if email_resp.status_code == 200: emails = email_resp.json() for email_obj in emails: @@ -226,34 +231,38 @@ async def github_authorized(request: Request) -> RedirectResponse: email = email_obj.get("email") break - if not user_info.get("id") or not email: - logging.error("Invalid GitHub user data received") - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + if user_info: - # Normalize user info structure - request.session["user_info"] = { - "id": str(user_info.get("id")), - "name": user_info.get("name") or user_info.get("login", ""), - "email": email, - "picture": user_info.get("avatar_url", ""), - "provider": "github", - } - request.session["github_token"] = token - request.session["token_validated_at"] = time.time() + user_data = { + 'id': user_info.get('id'), + 'email': user_info.get('email'), + 'name': user_info.get('name'), + 'picture': user_info.get('avatar_url'), + } + + # Call the registered GitHub callback handler if it exists to store user data. + handler = getattr(request.app.state, "callback_handler", None) + if handler: + + api_token = secrets.token_urlsafe(32) # ~43 chars, hard to guess + + # call the registered handler (await if async) + await handler('github', user_data, api_token) - # Call the registered GitHub callback handler if it exists to store user data. - handler = getattr(request.app.state, "github_callback_handler", None) - if handler: - # call the registered handler (await if async) - await handler(request, token, user_info) + redirect = RedirectResponse(url="/", status_code=302) + redirect.set_cookie( + key="api_token", + value=api_token, + httponly=True, + secure=True + ) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + return redirect + + raise HTTPException(status_code=400, detail="Failed to get user info from Github") - except AuthlibBaseError as e: - logging.error("GitHub OAuth error: %s", e) - _clear_auth_session(request.session) - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Authentication failed: {str(e)}") @auth_router.get("/login/github/callback", response_class=RedirectResponse) @@ -266,47 +275,54 @@ async def github_callback_compat(request: Request) -> RedirectResponse: @auth_router.get("/logout", response_class=RedirectResponse) async def logout(request: Request) -> RedirectResponse: """Handle user logout and revoke tokens for Google (actively) and GitHub (locally).""" - google_token = request.session.get("google_token") - github_token = request.session.get("github_token") - - # ---- Revoke Google tokens ---- - if google_token: - tokens_to_revoke = [] - if access_token := google_token.get("access_token"): - tokens_to_revoke.append(access_token) - if refresh_token := google_token.get("refresh_token"): - tokens_to_revoke.append(refresh_token) - - if tokens_to_revoke: - try: - async with httpx.AsyncClient() as client: - for token in tokens_to_revoke: - resp = await client.post( - "https://oauth2.googleapis.com/revoke", - params={"token": token}, - headers={"content-type": "application/x-www-form-urlencoded"}, - ) - if resp.status_code != 200: - logging.warning( - "Google token revoke failed (%s): %s", - resp.status_code, - resp.text, - ) - else: - logging.info("Successfully revoked Google token") - except Exception as e: - logging.error("Error revoking Google tokens: %s", e) - - # ---- Handle GitHub tokens ---- - if github_token: - logging.info("GitHub token found, clearing from session (no remote revoke available).") - # GitHub logout is local only unless we call the App management API - - # ---- Clear session auth keys ---- - for key in ["user_info", "google_token", "github_token", "token_validated_at"]: - request.session.pop(key, None) - - return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + # google_token = request.session.get("google_token") + # github_token = request.session.get("github_token") + + # # ---- Revoke Google tokens ---- + # if google_token: + # tokens_to_revoke = [] + # if access_token := google_token.get("access_token"): + # tokens_to_revoke.append(access_token) + # if refresh_token := google_token.get("refresh_token"): + # tokens_to_revoke.append(refresh_token) + + # if tokens_to_revoke: + # try: + # async with httpx.AsyncClient() as client: + # for token in tokens_to_revoke: + # resp = await client.post( + # "https://oauth2.googleapis.com/revoke", + # params={"token": token}, + # headers={"content-type": "application/x-www-form-urlencoded"}, + # ) + # if resp.status_code != 200: + # logging.warning( + # "Google token revoke failed (%s): %s", + # resp.status_code, + # resp.text, + # ) + # else: + # logging.info("Successfully revoked Google token") + # except Exception as e: + # logging.error("Error revoking Google tokens: %s", e) + + # # ---- Handle GitHub tokens ---- + # if github_token: + # logging.info("GitHub token found, clearing from session (no remote revoke available).") + # # GitHub logout is local only unless we call the App management API + + # # ---- Clear session auth keys ---- + # for key in ["user_info", "google_token", "github_token", "token_validated_at"]: + # request.session.pop(key, None) + + resp = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + + api_token = request.cookies.get("api_token") + if api_token: + resp.delete_cookie("api_token") + await delete_user_token(api_token) + + return resp # ---- Hook for app factory ---- def init_auth(app): diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 02eb7b63..7d4ac579 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -97,9 +97,9 @@ async def list_graphs(request: Request): """ user_id = request.state.user_id user_graphs = await db.list_graphs() - # Only include graphs that start with user_id + '_', and strip the prefix - filtered_graphs = [graph[len(f"{user_id}_"):] - for graph in user_graphs if graph.startswith(f"{user_id}_")] + # Only include graphs that start with user_id + '|', and strip the prefix + filtered_graphs = [graph[len(f"{user_id}|"):] + for graph in user_graphs if graph.startswith(f"{user_id}|")] return JSONResponse(content=filtered_graphs) @@ -116,7 +116,7 @@ async def get_graph_data(request: Request, graph_id: str): return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400) graph_id = graph_id.strip()[:200] - namespaced = request.state.user_id + "_" + graph_id + namespaced = f"{request.state.user_id}|{graph_id}" try: graph = db.select_graph(namespaced) @@ -220,7 +220,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if not hasattr(data, 'database') or not data.database: raise HTTPException(status_code=400, detail="Invalid JSON data") - graph_id = request.state.user_id + "_" + data.database + graph_id = f"{request.state.user_id}|{data.database}" success, result = await JSONLoader.load(graph_id, data.dict()) # ✅ Handle File Upload @@ -232,7 +232,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if filename.endswith(".json"): try: data = json.loads(content.decode("utf-8")) - graph_id = request.state.user_id + "_" + data.get("database", "") + graph_id = f"{request.state.user_id}|{data.get('database', '')}" success, result = await JSONLoader.load(graph_id, data) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON file") @@ -240,13 +240,13 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile # ✅ Check if file is XML elif filename.endswith(".xml"): xml_data = content.decode("utf-8") - graph_id = request.state.user_id + "_" + filename.replace(".xml", "") + graph_id = f"{request.state.user_id}|{filename.replace('.xml', '')}" success, result = await ODataLoader.load(graph_id, xml_data) # ✅ Check if file is csv elif filename.endswith(".csv"): csv_data = content.decode("utf-8") - graph_id = request.state.user_id + "_" + filename.replace(".csv", "") + graph_id = f"{request.state.user_id}|{filename.replace('.csv', '')}" success, result = await CSVLoader.load(graph_id, csv_data) else: @@ -278,7 +278,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest): if not graph_id: raise HTTPException(status_code=400, detail="Invalid graph_id") - graph_id = request.state.user_id + "_" + graph_id + graph_id = f"{request.state.user_id}|{graph_id}" queries_history = chat_data.chat if hasattr(chat_data, 'chat') else None result_history = chat_data.result if hasattr(chat_data, 'result') else None @@ -537,7 +537,7 @@ async def confirm_destructive_operation( """ Handle user confirmation for destructive SQL operations """ - graph_id = request.state.user_id + "_" + graph_id.strip() + graph_id = f"{request.state.user_id}|{graph_id.strip()}" if hasattr(confirm_data, 'confirmation'): confirmation = confirm_data.confirmation.strip().upper() @@ -658,7 +658,7 @@ async def refresh_graph_schema(request: Request, graph_id: str): This endpoint allows users to manually trigger a schema refresh if they suspect the graph is out of sync with the database. """ - graph_id = request.state.user_id + "_" + graph_id.strip() + graph_id = f"{request.state.user_id}|{graph_id.strip()}" try: # Get database connection details From cacd35e50e29a7e922111ad83db6463f938f4cf7 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:27:37 +0300 Subject: [PATCH 08/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/auth/user_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/auth/user_management.py b/api/auth/user_management.py index 93028757..ecb33848 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -34,7 +34,7 @@ async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: }) if result.result_set and result.result_set[0][3]: - token_valid = result.result_set[0][0] + token_valid = result.result_set[0][3] # TODO delete invalid token from DB if token_valid: From 87a34d23b03bc77e0a0252cf6c833d6b182006cc Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:28:25 +0300 Subject: [PATCH 09/14] read valid once --- api/auth/user_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/auth/user_management.py b/api/auth/user_management.py index ecb33848..2ea67f0b 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -33,7 +33,7 @@ async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: "api_token": api_token, }) - if result.result_set and result.result_set[0][3]: + if result.result_set: token_valid = result.result_set[0][3] # TODO delete invalid token from DB From 64986b1693697c85179095c6471f6d0edf7e7736 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:40:14 +0300 Subject: [PATCH 10/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/routes/auth.py b/api/routes/auth.py index 2a97fe76..47c6b10c 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -152,7 +152,7 @@ async def google_authorized(request: Request) -> RedirectResponse: if user_info: user_data = { - 'id': user_info.get('sub'), + 'id': user_info.get('id') or user_info.get('sub'), 'email': user_info.get('email'), 'name': user_info.get('name'), 'picture': user_info.get('picture'), From 9787d7f9bed0c342838f8c2acac652159b722ffb Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:44:14 +0300 Subject: [PATCH 11/14] clean deadcode --- api/routes/auth.py | 44 ++------------------------------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/api/routes/auth.py b/api/routes/auth.py index 47c6b10c..1843b750 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -258,7 +258,7 @@ async def github_authorized(request: Request) -> RedirectResponse: ) return redirect - + raise HTTPException(status_code=400, detail="Failed to get user info from Github") except Exception as e: @@ -274,47 +274,7 @@ async def github_callback_compat(request: Request) -> RedirectResponse: @auth_router.get("/logout", response_class=RedirectResponse) async def logout(request: Request) -> RedirectResponse: - """Handle user logout and revoke tokens for Google (actively) and GitHub (locally).""" - # google_token = request.session.get("google_token") - # github_token = request.session.get("github_token") - - # # ---- Revoke Google tokens ---- - # if google_token: - # tokens_to_revoke = [] - # if access_token := google_token.get("access_token"): - # tokens_to_revoke.append(access_token) - # if refresh_token := google_token.get("refresh_token"): - # tokens_to_revoke.append(refresh_token) - - # if tokens_to_revoke: - # try: - # async with httpx.AsyncClient() as client: - # for token in tokens_to_revoke: - # resp = await client.post( - # "https://oauth2.googleapis.com/revoke", - # params={"token": token}, - # headers={"content-type": "application/x-www-form-urlencoded"}, - # ) - # if resp.status_code != 200: - # logging.warning( - # "Google token revoke failed (%s): %s", - # resp.status_code, - # resp.text, - # ) - # else: - # logging.info("Successfully revoked Google token") - # except Exception as e: - # logging.error("Error revoking Google tokens: %s", e) - - # # ---- Handle GitHub tokens ---- - # if github_token: - # logging.info("GitHub token found, clearing from session (no remote revoke available).") - # # GitHub logout is local only unless we call the App management API - - # # ---- Clear session auth keys ---- - # for key in ["user_info", "google_token", "github_token", "token_validated_at"]: - # request.session.pop(key, None) - + """Handle user logout and delete session cookies.""" resp = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) api_token = request.cookies.get("api_token") From f17ba6603e3690c7ed1b85ba7b1d1e746424d0b7 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 11:54:11 +0300 Subject: [PATCH 12/14] work with api token --- api/auth/user_management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/auth/user_management.py b/api/auth/user_management.py index 2ea67f0b..b08ee843 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -231,8 +231,10 @@ async def validate_user(request: Request) -> Tuple[Optional[Dict[str, Any]], boo Includes refresh handling for Google. """ try: - #TODO token might be in the URL + # token might be in the URL if not in the cookie for API access api_token = request.cookies.get("api_token") + if not api_token: + api_token = request.query_params.get("api_token") if api_token: db_info = await _get_user_info(api_token) From 4a29294b109b1feeb3e16daf08f2d6c59ddcb75e Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 13:15:12 +0300 Subject: [PATCH 13/14] use BASE64 encodded for the graph name --- api/auth/user_management.py | 4 +++- api/loaders/mysql_loader.py | 2 +- api/loaders/postgres_loader.py | 2 +- api/routes/graphs.py | 16 ++++++++-------- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/auth/user_management.py b/api/auth/user_management.py index b08ee843..a33d9fdb 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -1,5 +1,6 @@ """User management and authentication functions for text2sql API.""" +import base64 import logging import os import secrets @@ -269,7 +270,8 @@ async def wrapper(request: Request, *args, **kwargs): ) # Attach user_id to request.state (like FASTAPI's g.user_id) - request.state.user_id = user_info.get("email") + # we're using the email as BASE64 encoded + request.state.user_id = base64.b64encode(user_info.get("email").encode()).decode() if not request.state.user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/api/loaders/mysql_loader.py b/api/loaders/mysql_loader.py index 8b521671..5c22c03a 100644 --- a/api/loaders/mysql_loader.py +++ b/api/loaders/mysql_loader.py @@ -158,7 +158,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(f"{prefix}|{db_name}", entities, relationships, + await load_to_graph(f"{prefix}_{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"MySQL schema loaded successfully. " diff --git a/api/loaders/postgres_loader.py b/api/loaders/postgres_loader.py index bfc2f488..0ac50e49 100644 --- a/api/loaders/postgres_loader.py +++ b/api/loaders/postgres_loader.py @@ -96,7 +96,7 @@ async def load(prefix: str, connection_url: str) -> Tuple[bool, str]: conn.close() # Load data into graph - await load_to_graph(f"{prefix}|{db_name}", entities, relationships, + await load_to_graph(f"{prefix}_{db_name}", entities, relationships, db_name=db_name, db_url=connection_url) return True, (f"PostgreSQL schema loaded successfully. " diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 0ac29247..f0f552c6 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -116,7 +116,7 @@ async def get_graph_data(request: Request, graph_id: str): return JSONResponse(content={"error": "Invalid graph_id"}, status_code=400) graph_id = graph_id.strip()[:200] - namespaced = f"{request.state.user_id}|{graph_id}" + namespaced = f"{request.state.user_id}_{graph_id}" try: graph = db.select_graph(namespaced) @@ -220,7 +220,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if not hasattr(data, 'database') or not data.database: raise HTTPException(status_code=400, detail="Invalid JSON data") - graph_id = f"{request.state.user_id}|{data.database}" + graph_id = f"{request.state.user_id}_{data.database}" success, result = await JSONLoader.load(graph_id, data.dict()) # ✅ Handle File Upload @@ -232,7 +232,7 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile if filename.endswith(".json"): try: data = json.loads(content.decode("utf-8")) - graph_id = f"{request.state.user_id}|{data.get('database', '')}" + graph_id = f"{request.state.user_id}_{data.get('database', '')}" success, result = await JSONLoader.load(graph_id, data) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON file") @@ -240,13 +240,13 @@ async def load_graph(request: Request, data: GraphData = None, file: UploadFile # ✅ Check if file is XML elif filename.endswith(".xml"): xml_data = content.decode("utf-8") - graph_id = f"{request.state.user_id}|{filename.replace('.xml', '')}" + graph_id = f"{request.state.user_id}_{filename.replace('.xml', '')}" success, result = await ODataLoader.load(graph_id, xml_data) # ✅ Check if file is csv elif filename.endswith(".csv"): csv_data = content.decode("utf-8") - graph_id = f"{request.state.user_id}|{filename.replace('.csv', '')}" + graph_id = f"{request.state.user_id}_{filename.replace('.csv', '')}" success, result = await CSVLoader.load(graph_id, csv_data) else: @@ -278,7 +278,7 @@ async def query_graph(request: Request, graph_id: str, chat_data: ChatRequest): if not graph_id: raise HTTPException(status_code=400, detail="Invalid graph_id") - graph_id = f"{request.state.user_id}|{graph_id}" + graph_id = f"{request.state.user_id}_{graph_id}" queries_history = chat_data.chat if hasattr(chat_data, 'chat') else None result_history = chat_data.result if hasattr(chat_data, 'result') else None @@ -553,7 +553,7 @@ async def confirm_destructive_operation( """ Handle user confirmation for destructive SQL operations """ - graph_id = f"{request.state.user_id}|{graph_id.strip()}" + graph_id = f"{request.state.user_id}_{graph_id.strip()}" if hasattr(confirm_data, 'confirmation'): confirmation = confirm_data.confirmation.strip().upper() @@ -674,7 +674,7 @@ async def refresh_graph_schema(request: Request, graph_id: str): This endpoint allows users to manually trigger a schema refresh if they suspect the graph is out of sync with the database. """ - graph_id = f"{request.state.user_id}|{graph_id.strip()}" + graph_id = f"{request.state.user_id}_{graph_id.strip()}" try: # Get database connection details From 51679ae3e879d075a9956b0e550003d86ba8dc4b Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 26 Aug 2025 13:20:16 +0300 Subject: [PATCH 14/14] fix | with _ --- api/routes/graphs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/routes/graphs.py b/api/routes/graphs.py index f0f552c6..ad2eac71 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -97,9 +97,9 @@ async def list_graphs(request: Request): """ user_id = request.state.user_id user_graphs = await db.list_graphs() - # Only include graphs that start with user_id + '|', and strip the prefix - filtered_graphs = [graph[len(f"{user_id}|"):] - for graph in user_graphs if graph.startswith(f"{user_id}|")] + # Only include graphs that start with user_id + '_', and strip the prefix + filtered_graphs = [graph[len(f"{user_id}_"):] + for graph in user_graphs if graph.startswith(f"{user_id}_")] return JSONResponse(content=filtered_graphs)