| |
| """ |
| mbok_dev - Main entry point |
| Public Space that loads private ver20 app dynamically |
| """ |
|
|
| import os |
| import sys |
| import time |
| import traceback |
| import logging |
|
|
| logging.getLogger("uvicorn.access").setLevel(logging.WARNING) |
| from pathlib import Path |
| from fastapi import FastAPI, Request, Depends, HTTPException, Form |
| from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse |
| from fastapi.staticfiles import StaticFiles |
| from starlette.middleware.base import BaseHTTPMiddleware |
| import gradio as gr |
| from supabase import create_client, Client |
|
|
| |
| from bootstrap import download_private_app |
| from login import create_login_ui |
| from supabase_logger import init_logger, log_event, set_user_context, get_user_context, set_request_source |
|
|
| |
| print("=" * 80) |
| print("🚀 Starting mbok_dev") |
| print("=" * 80) |
| print(f"[STARTUP_META] Python version: {sys.version}") |
| print(f"[STARTUP_META] CWD: {os.getcwd()}") |
| print(f"[STARTUP_META] PORT: {os.environ.get('PORT', 'not set')}") |
| print(f"[STARTUP_META] SPACE_ID: {os.environ.get('SPACE_ID', 'not set')}") |
| print(f"[STARTUP_META] SPACE_HOST: {os.environ.get('SPACE_HOST', 'not set')}") |
| print(f"[STARTUP_META] GRADIO_SERVER_NAME: {os.environ.get('GRADIO_SERVER_NAME', 'not set')}") |
| print(f"[STARTUP_META] GRADIO_SERVER_PORT: {os.environ.get('GRADIO_SERVER_PORT', 'not set')}") |
| print(f"[STARTUP_META] HF_TOKEN: {'***set***' if os.environ.get('HF_TOKEN') else 'NOT SET'}") |
| print(f"[STARTUP_META] SUPABASE_URL: {'***set***' if os.environ.get('SUPABASE_URL') else 'NOT SET'}") |
| print(f"[STARTUP_META] SUPABASE_KEY: {'***set***' if os.environ.get('SUPABASE_KEY') else 'NOT SET'}") |
| print("=" * 80) |
|
|
| |
| print("[PHASE] bootstrap_start") |
| try: |
| private_app_dir = download_private_app() |
| |
| |
| private_app_path = str(private_app_dir.resolve()) |
| if private_app_path not in sys.path: |
| sys.path.insert(0, private_app_path) |
| print(f"[PHASE] bootstrap_end success=true path={private_app_path}") |
| |
| except Exception as e: |
| print(f"[PHASE] bootstrap_end success=false") |
| print(f"[ERROR] Bootstrap failed: {e}") |
| print(f"[TRACEBACK]\n{traceback.format_exc()}") |
| print("⚠️ Application will start but /app/ route will not work") |
| private_app_dir = None |
|
|
| |
| print("[PHASE] supabase_init_start") |
| SUPABASE_URL = os.environ.get("SUPABASE_URL") |
| SUPABASE_KEY = os.environ.get("SUPABASE_KEY") |
|
|
| if not SUPABASE_URL or not SUPABASE_KEY: |
| print("[ERROR] SUPABASE_URL and/or SUPABASE_KEY not set") |
| raise ValueError( |
| "SUPABASE_URL and SUPABASE_KEY must be set in environment variables. " |
| "Please configure them in HF Space Secrets." |
| ) |
|
|
| try: |
| supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) |
| init_logger(supabase) |
| print(f"[PHASE] supabase_init_end success=true") |
| except Exception as e: |
| print(f"[PHASE] supabase_init_end success=false") |
| print(f"[ERROR] Supabase init failed: {e}") |
| print(f"[TRACEBACK]\n{traceback.format_exc()}") |
| raise |
|
|
| |
| print("[PHASE] fastapi_init_start") |
| app = FastAPI() |
|
|
| |
| _static_dir = Path(__file__).parent |
| app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static") |
|
|
| print("[PHASE] fastapi_init_end") |
|
|
| |
| _user_profile_cache: dict = {} |
|
|
| |
| _last_known_source: dict = {} |
|
|
| |
| _INTERNAL_PROXY_DOMAINS = { |
| "proxy.spaces.internal.huggingface.tech", |
| } |
|
|
| |
| def _is_gradio_background_path(path: str) -> bool: |
| """Gradio が自動送信するバックグラウンドリクエストかどうかを判定する。 |
| これらは未認証時でも大量に飛んでくるためログ対象外とする。 |
| """ |
| return ( |
| path.startswith("/app/gradio_api/heartbeat/") |
| or path == "/app/gradio_api/queue/join" |
| or path.startswith("/app/gradio_api/queue/join/") |
| ) |
|
|
| |
| def _resolve_source(request: Request) -> dict | None: |
| """リクエストヘッダから流入元を判定して source_domain を返す。 |
| 内部プロキシホストの場合は None を返してログを除外する。 |
| 判定優先順: |
| 1. ホストが内部プロキシ → None(ログ除外) |
| 2. Referer に huggingface.co/spaces/ を含む → source_domain="huggingface.co" |
| 3. ホストが *.hf.space → source_domain=host |
| 4. それ以外 → source_domain=host or None |
| """ |
| headers = request.headers |
| referer = (headers.get("referer") or "").lower() |
| host = (headers.get("x-forwarded-host") or headers.get("host") or "").lower() |
| if host in _INTERNAL_PROXY_DOMAINS: |
| return None |
| if "huggingface.co/spaces/" in referer: |
| return {"source_domain": "huggingface.co"} |
| if host.endswith(".hf.space"): |
| return {"source_domain": host} |
| return {"source_domain": host or None} |
|
|
|
|
| class RequestLoggingMiddleware(BaseHTTPMiddleware): |
| async def dispatch(self, request: Request, call_next): |
| start_time = time.time() |
| path = request.url.path |
| method = request.method |
|
|
| |
| user_info = self._resolve_user(request) |
| set_user_context(user_info) |
| user_tag = f" user={user_info['email']}" if user_info else "" |
|
|
| |
| source_info = _resolve_source(request) |
| set_request_source(source_info) |
| if source_info is not None: |
| _last_known_source.update(source_info) |
|
|
| |
| |
|
|
| try: |
| response = await call_next(request) |
| duration = time.time() - start_time |
| |
| if response.status_code >= 400: |
| |
| if not (response.status_code == 401 and _is_gradio_background_path(path)): |
| log_event( |
| "error", |
| "http_response_error", |
| level="WARNING", |
| metadata={"method": method, "path": path, "status": response.status_code, "duration": round(duration, 3)}, |
| ) |
| return response |
| except Exception as e: |
| duration = time.time() - start_time |
| print(f"[RESPONSE] method={method} path={path} status=500 duration={duration:.3f}s error={e}{user_tag}") |
| log_event( |
| "error", |
| "http_response_error", |
| level="ERROR", |
| metadata={"method": method, "path": path, "status": 500, "duration": round(duration, 3), "error": str(e)}, |
| ) |
| raise |
| finally: |
| set_user_context(None) |
| set_request_source(None) |
|
|
| @staticmethod |
| def _resolve_user(request: Request): |
| """User resolution from cookie. Full profile (incl. org_id) fetched once and cached per user_id.""" |
| token = request.cookies.get("sb_access_token") |
| if not token: |
| return None |
| try: |
| res = supabase.auth.get_user(token) |
| user_id = str(res.user.id) |
|
|
| |
| if user_id in _user_profile_cache: |
| return _user_profile_cache[user_id] |
|
|
| |
| email = res.user.email |
| org_id = None |
| org_name = None |
| role = None |
| display_name = None |
| try: |
| profile_res = supabase.from_("profiles").select( |
| "email, org_id, role, display_name, organizations(name)" |
| ).eq("id", user_id).single().execute() |
| d = profile_res.data or {} |
| org_id = d.get("org_id") |
| org_name = (d.get("organizations") or {}).get("name") |
| role = d.get("role") |
| display_name = d.get("display_name") |
| email = d.get("email") or email |
| except Exception as pe: |
| print(f"[ORG_CONTEXT] _resolve_user: profile fetch failed: {pe}") |
| print(f"[ORG_CONTEXT] _resolve_user: first fetch user_id={user_id} email={email} org_id={org_id!r} org_name={org_name!r}") |
| user_info = { |
| "user_id": user_id, |
| "email": email, |
| "display_name": display_name, |
| "role": role, |
| "org_id": org_id, |
| "org_name": org_name, |
| } |
| _user_profile_cache[user_id] = user_info |
| return user_info |
| except Exception: |
| return None |
|
|
| app.add_middleware(RequestLoggingMiddleware) |
| print("[MIDDLEWARE] RequestLoggingMiddleware added") |
|
|
| |
| def handle_login(request: gr.Request, email, password): |
| """Handle login attempt via Supabase""" |
| print(f"[AUTH] Login attempt for: {email}") |
| source = _resolve_source(request) |
| log_event("auth", "login_attempt", |
| user_override={"email": email}, |
| source=source) |
| try: |
| res = supabase.auth.sign_in_with_password({"email": email, "password": password}) |
| if res.session: |
| print(f"[AUTH] Login successful: {email}") |
| user_ctx = {"user_id": str(res.user.id), "email": email} |
| log_event( |
| "auth", "login_success", |
| user_override=user_ctx, |
| source=source, |
| ) |
| return ( |
| gr.update(visible=False), |
| gr.update(visible=True, value=f"### ✅ ログイン成功: {email}"), |
| res.session.access_token |
| ) |
| except Exception as e: |
| print(f"[AUTH] Login failed for {email}: {e}") |
| log_event( |
| "auth", "login_failure", |
| level="WARNING", |
| user_override={"email": email}, |
| metadata={"error": str(e)}, |
| source=source, |
| ) |
| return gr.update(), gr.update(value=f"❌ エラー: {str(e)}"), None |
|
|
| |
| def get_current_user(request: Request): |
| """Verify token from cookie and fetch user profile (uses _user_profile_cache)""" |
| token = request.cookies.get("sb_access_token") |
|
|
| if not token: |
| print("[AUTH_CHECK] No sb_access_token cookie – unauthenticated access") |
| if not _is_gradio_background_path(str(request.url.path)): |
| log_event("auth", "unauthenticated_access", level="INFO", metadata={"path": str(request.url.path)}) |
| return None |
| |
| try: |
| res = supabase.auth.get_user(token) |
| user_id = str(res.user.id) |
|
|
| |
| if user_id in _user_profile_cache: |
| return _user_profile_cache[user_id] |
|
|
| |
| profile_res = supabase.from_("profiles").select( |
| "email, org_id, role, display_name, organizations(name)" |
| ).eq("id", user_id).single().execute() |
| |
| d = profile_res.data or {} |
| user_dict = { |
| "user_id": user_id, |
| "email": d.get("email"), |
| "display_name": d.get("display_name"), |
| "role": d.get("role"), |
| "org_id": d.get("org_id"), |
| "org_name": (d.get("organizations") or {}).get("name"), |
| } |
| _user_profile_cache[user_id] = user_dict |
| return user_dict |
| |
| except Exception as e: |
| print(f"[AUTH_CHECK] Token verify failed: {e}") |
| log_event("auth", "token_verify_fail", level="WARNING", metadata={"error": str(e)}) |
| return None |
|
|
| |
| print("[PHASE] create_ui_start") |
| login_ui = create_login_ui(handle_login) |
| print("[PHASE] create_ui_end component=login_ui") |
|
|
| |
| print("[PHASE] import_ver20_start") |
| ver20_app = None |
| VER20_CSS = None |
| if private_app_dir: |
| try: |
| |
| from app import app as ver20_blocks |
| |
| |
| try: |
| from lib.logging import set_logger_callback |
| |
| def bridge_logger(event_type: str, message: str, metadata=None): |
| """Ver20からのログイベントをSupabaseに転送""" |
| user_override = None |
| session_id = None |
| clean_metadata = None |
| if metadata: |
| clean_metadata = dict(metadata) |
| user_ctx = clean_metadata.pop("_user_context", None) |
| if user_ctx and isinstance(user_ctx, dict): |
| user_override = user_ctx |
| session_id = clean_metadata.pop("session_id", None) |
| log_event(event_type, message, |
| metadata=clean_metadata, |
| user_override=user_override, |
| session_id=session_id, |
| source=dict(_last_known_source) if _last_known_source else None) |
| |
| set_logger_callback(bridge_logger) |
| print("[LOGGING] Connected ver20 logging to Supabase") |
| except ImportError as e: |
| print(f"[LOGGING] Could not import lib.logging or set_logger_callback: {e}") |
| |
|
|
| |
| try: |
| from lib.hf_storage import set_org_context_getter |
|
|
| def get_org_for_storage(): |
| """プロセスレベルの _user_profile_cache から org_id/org_name を返す。 |
| |
| ContextVar (get_user_context) は FastAPI リクエストスレッドでのみ有効で |
| Gradio WebSocket キュースレッドでは伝播しないため、プロセス共有の |
| _user_profile_cache(ログイン時にセットされる)を参照する。 |
| シングルユーザー運用前提; session_org_map が優先されるため |
| マルチユーザー時もStep実行後は正しいorgが使われる。 |
| """ |
| if _user_profile_cache: |
| last_user = next(iter(_user_profile_cache.values())) |
| org_id = last_user.get("org_id") |
| org_name = last_user.get("org_name") |
| if org_id or org_name: |
| return {"org_id": org_id, "org_name": org_name} |
| return None |
|
|
| set_org_context_getter(get_org_for_storage) |
| print("[ORG_CONTEXT] Connected org_context getter to hf_storage (cache-based)") |
| except ImportError as e: |
| print(f"[ORG_CONTEXT] Could not inject org_context getter: {e}") |
| |
| |
| ver20_app = ver20_blocks |
| |
| |
| try: |
| from app import CUSTOM_CSS as VER20_CSS |
| except ImportError: |
| VER20_CSS = None |
| |
| print(f"[PHASE] import_ver20_end success=true type={type(ver20_app)}") |
| except Exception as e: |
| print(f"[PHASE] import_ver20_end success=false") |
| print(f"[ERROR] Failed to import ver20 app: {e}") |
| print(f"[TRACEBACK]\n{traceback.format_exc()}") |
| else: |
| print(f"[PHASE] import_ver20_end success=false reason=bootstrap_failed") |
|
|
| |
| @app.get("/") |
| async def root(user=Depends(get_current_user)): |
| """Root route - redirect to login or app based on auth status""" |
| print(f"[ROUTE] / accessed, user_authenticated={isinstance(user, dict) and user.get('user_id')}") |
| if isinstance(user, dict) and user.get("user_id"): |
| return RedirectResponse(url="/app/") |
| return RedirectResponse(url="/login/") |
|
|
| @app.get("/logout") |
| async def logout(request: Request): |
| """Logout route - clear cookie and redirect to login. |
| Also serves as force-logout endpoint when session is expired/invalid. |
| """ |
| user = get_user_context() |
| token = request.cookies.get("sb_access_token") |
| forced = request.query_params.get("forced", "0") |
| print(f"[ROUTE] /logout accessed forced={forced}") |
| if forced == "1": |
| log_event("auth", "force_logout", user_override=user, metadata={"reason": "session_expired"}) |
| else: |
| log_event("auth", "logout", user_override=user) |
| |
| if token: |
| try: |
| supabase.auth.sign_out() |
| except Exception: |
| pass |
| response = RedirectResponse(url="/login/") |
| response.delete_cookie("sb_access_token", path="/", samesite="none") |
| return response |
|
|
| @app.get("/healthz") |
| async def healthz(): |
| """Health check endpoint""" |
| status = { |
| "ok": True, |
| "ver20_loaded": ver20_app is not None, |
| "private_app_dir": str(private_app_dir) if private_app_dir else None |
| } |
| print(f"[HEALTHZ] {status}") |
| return JSONResponse(content=status) |
|
|
| _RESET_PASSWORD_HTML = """<!DOCTYPE html> |
| <html lang="ja"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>パスワード再設定</title> |
| <style> |
| * {{ box-sizing: border-box; margin: 0; padding: 0; }} |
| body {{ |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| background: #f5f5f5; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| min-height: 100vh; |
| }} |
| .card {{ |
| background: #fff; |
| border-radius: 12px; |
| box-shadow: 0 2px 16px rgba(0,0,0,0.1); |
| padding: 40px; |
| width: 100%; |
| max-width: 400px; |
| }} |
| h1 {{ font-size: 1.4rem; margin-bottom: 24px; color: #333; }} |
| label {{ display: block; font-size: 0.85rem; color: #555; margin-bottom: 6px; }} |
| input[type=password] {{ |
| width: 100%; |
| padding: 10px 14px; |
| border: 1px solid #ddd; |
| border-radius: 8px; |
| font-size: 1rem; |
| margin-bottom: 16px; |
| outline: none; |
| transition: border 0.2s; |
| }} |
| input[type=password]:focus {{ border-color: #f97316; }} |
| button {{ |
| width: 100%; |
| padding: 12px; |
| background: #f97316; |
| color: #fff; |
| border: none; |
| border-radius: 8px; |
| font-size: 1rem; |
| cursor: pointer; |
| transition: background 0.2s; |
| }} |
| button:hover {{ background: #ea6c0a; }} |
| .msg {{ margin-top: 16px; font-size: 0.9rem; color: #e53e3e; text-align: center; }} |
| .msg.success {{ color: #38a169; }} |
| </style> |
| </head> |
| <body> |
| <div class="card"> |
| <h1>🔑 パスワード再設定</h1> |
| <form method="post" action="/reset-password" id="resetForm"> |
| <input type="hidden" name="access_token" id="access_token"> |
| <input type="hidden" name="refresh_token" id="refresh_token"> |
| <label for="new_password">新しいパスワード</label> |
| <input type="password" name="new_password" id="new_password" placeholder="8文字以上" required minlength="8"> |
| <label for="confirm_password">確認(再入力)</label> |
| <input type="password" id="confirm_password" placeholder="同じパスワードを入力" required minlength="8"> |
| <button type="submit">パスワードを変更する</button> |
| </form> |
| <div class="msg" id="msg">{message}</div> |
| </div> |
| <script> |
| // URLフラグメントからSupabaseのトークンを取得してhidden inputにセット |
| (function() {{ |
| var hash = window.location.hash.substring(1); |
| var params = {{}}; |
| hash.split('&').forEach(function(part) {{ |
| var kv = part.split('='); |
| if (kv.length === 2) params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); |
| }}); |
| if (params.access_token) {{ |
| document.getElementById('access_token').value = params.access_token; |
| }} |
| if (params.refresh_token) {{ |
| document.getElementById('refresh_token').value = params.refresh_token; |
| }} |
| if (!params.access_token && !params.refresh_token) {{ |
| document.getElementById('msg').textContent = '⚠️ 無効なリンクです。パスワードリセットメールを再送してください。'; |
| }} |
| }})(); |
| |
| // パスワード一致チェック |
| document.getElementById('resetForm').addEventListener('submit', function(e) {{ |
| var pw = document.getElementById('new_password').value; |
| var cpw = document.getElementById('confirm_password').value; |
| if (pw !== cpw) {{ |
| e.preventDefault(); |
| document.getElementById('msg').textContent = '❌ パスワードが一致しません。'; |
| }} |
| }}); |
| </script> |
| </body> |
| </html>""" |
|
|
| @app.get("/reset-password") |
| async def reset_password_page(): |
| """パスワード再設定フォームを表示""" |
| return HTMLResponse(_RESET_PASSWORD_HTML.format(message="")) |
|
|
|
|
| @app.post("/reset-password") |
| async def reset_password_submit( |
| access_token: str = Form(default=""), |
| refresh_token: str = Form(default=""), |
| new_password: str = Form(...), |
| ): |
| """パスワード再設定を実行""" |
| if not access_token: |
| html = _RESET_PASSWORD_HTML.format(message="❌ トークンが取得できませんでした。メールのリンクを再度クリックしてください。") |
| return HTMLResponse(html, status_code=400) |
|
|
| if len(new_password) < 8: |
| html = _RESET_PASSWORD_HTML.format(message="❌ パスワードは8文字以上で設定してください。") |
| return HTMLResponse(html, status_code=400) |
|
|
| reset_user_ctx = None |
| try: |
| _res = supabase.auth.get_user(access_token) |
| reset_user_ctx = {"user_id": str(_res.user.id), "email": _res.user.email} |
| except Exception: |
| pass |
|
|
| try: |
| supabase.auth.set_session(access_token, refresh_token) |
| supabase.auth.update_user({"password": new_password}) |
| log_event("auth", "password_reset_success", user_override=reset_user_ctx) |
| success_html = """<!DOCTYPE html> |
| <html lang="ja"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta http-equiv="refresh" content="3;url=/login/"> |
| <title>パスワード変更完了</title> |
| <style> |
| body {{ font-family: -apple-system, sans-serif; display: flex; align-items: center; |
| justify-content: center; min-height: 100vh; background: #f5f5f5; }} |
| .card {{ background: #fff; border-radius: 12px; padding: 40px; text-align: center; |
| box-shadow: 0 2px 16px rgba(0,0,0,0.1); max-width: 360px; width: 100%; }} |
| h1 {{ color: #38a169; margin-bottom: 12px; }} |
| p {{ color: #555; font-size: 0.95rem; }} |
| </style> |
| </head> |
| <body> |
| <div class="card"> |
| <h1>✅ パスワードを変更しました</h1> |
| <p>3秒後にログイン画面に移動します...</p> |
| <p><a href="/login/">今すぐログイン画面へ</a></p> |
| </div> |
| </body> |
| </html>""" |
| return HTMLResponse(success_html) |
| except Exception as e: |
| print(f"[AUTH] Password reset failed: {e}") |
| log_event("auth", "password_reset_failure", |
| level="WARNING", user_override=reset_user_ctx, |
| metadata={"error": str(e)}) |
| html = _RESET_PASSWORD_HTML.format(message=f"❌ エラーが発生しました: {str(e)}") |
| return HTMLResponse(html, status_code=400) |
|
|
|
|
| print("[ROUTES] Root, logout, and healthz routes registered") |
|
|
| |
| print("[PHASE] mount_login_start") |
| app = gr.mount_gradio_app(app, login_ui, path="/login") |
| print("[PHASE] mount_login_end path=/login") |
|
|
| |
| print("[PHASE] mount_app_start") |
| if ver20_app: |
| app = gr.mount_gradio_app( |
| app, ver20_app, path="/app", |
| root_path="/app", |
| auth_dependency=get_current_user, |
| theme=gr.themes.Citrus(), |
| css=VER20_CSS, |
| ) |
| print("[PHASE] mount_app_end path=/app protected=true ver20=true") |
| else: |
| |
| with gr.Blocks() as fallback_ui: |
| gr.Markdown("# ⚠️ Application Not Available") |
| gr.Markdown("The private application failed to load. Please check logs.") |
| |
| app = gr.mount_gradio_app(app, fallback_ui, path="/app", auth_dependency=get_current_user) |
| print("[PHASE] mount_app_end path=/app protected=true ver20=false fallback=true") |
|
|
| print("=" * 80) |
| print("🎉 mbok_dev Ready!") |
| print("=" * 80) |
| print(f"[STARTUP_COMPLETE] All phases completed successfully") |
| print(f"[STARTUP_COMPLETE] Access URLs:") |
| print(f" - Root: /") |
| print(f" - Login: /login/") |
| print(f" - App: /app/") |
| print(f" - Health: /healthz") |
| print(f" - Logout: /logout") |
| print("=" * 80) |
|
|
| if __name__ == "__main__": |
| import uvicorn |
| port = int(os.environ.get("PORT", 7860)) |
| print(f"[ENTRYPOINT] Starting uvicorn on 0.0.0.0:{port}") |
| uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning") |
|
|