Public social signup into OAUTH_DEFAULT_ORG (role user, seat-checked); email-match links existing active user instead of duplicating. Server-side provider token validation via stdlib urllib only (no new dep): Google tokeninfo (aud + email_verified) and Facebook app/debug-token/me (is_valid, app_id, me.id==user_id). Fail-closed when creds unconfigured, rate-limited per-IP + per-email, /oauth/config leaks no secrets. Frontend: login buttons (only enabled providers), GSI + FB SDK on-demand, monochrome glyphs, TH/EN. Login page shows social buttons only when backend reports provider enabled. 348 backend tests pass (337 + 11 new OAuth), frontend build + 4/4 unit clean, manual security review PASS. Not pushed (push auto-deploys).
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
"""Flask application factory."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, abort
|
|
from flask_cors import CORS
|
|
|
|
from .auth.users import AuthError, UserStore
|
|
from .config import Config
|
|
|
|
|
|
def bootstrap_admin(users: UserStore) -> None:
|
|
"""Ensure a default org + super-admin exists on first run (no self-registration)."""
|
|
# Use a stable org record lock even before the record exists. Every worker
|
|
# therefore rechecks the store after the first worker finishes initialization.
|
|
with users.orgs.record_lock("org-default"):
|
|
Config.validate_runtime_security(require_bootstrap=not bool(users.users.all()))
|
|
if users.users.all():
|
|
return
|
|
org = users.orgs.get_or_none("org-default")
|
|
if org is None:
|
|
org = users.create_org("Default Organization", org_id="org-default")
|
|
if users.get_user_or_none("admin") is None:
|
|
users.create_user(
|
|
org_id=org["id"],
|
|
username="admin",
|
|
password=Config.BOOTSTRAP_ADMIN_PASSWORD,
|
|
name="Super Admin",
|
|
role="super_admin",
|
|
must_setup=True,
|
|
)
|
|
print("[bootstrap] created initial super-admin; first-time setup is required")
|
|
|
|
|
|
def create_app() -> Flask:
|
|
Config.ensure_dirs()
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = Config.SECRET_KEY
|
|
app.config["MAX_CONTENT_LENGTH"] = Config.UPLOAD_MAX_MB * 1024 * 1024
|
|
if Config.CORS_ORIGINS:
|
|
CORS(app, resources={r"/api/*": {"origins": list(Config.CORS_ORIGINS)}})
|
|
|
|
from .api.auth_routes import auth_bp
|
|
from .api.oauth_routes import oauth_bp
|
|
from .api.admin_routes import admin_bp
|
|
from .api.group_routes import groups_bp
|
|
from .api.chat_routes import chat_bp
|
|
from .api.me_routes import me_bp
|
|
from .api.analytics_routes import analytics_bp
|
|
from .api.helpers import register_error_handlers
|
|
|
|
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
|
app.register_blueprint(oauth_bp, url_prefix="/api/auth")
|
|
app.register_blueprint(admin_bp, url_prefix="/api/admin")
|
|
app.register_blueprint(groups_bp, url_prefix="/api/groups")
|
|
app.register_blueprint(chat_bp, url_prefix="/api/chat")
|
|
app.register_blueprint(me_bp, url_prefix="/api/me")
|
|
app.register_blueprint(analytics_bp, url_prefix="/api/analytics")
|
|
|
|
register_error_handlers(app)
|
|
|
|
from .auth.users import UserStore
|
|
from .llm import LLMClient
|
|
from .llm import LLMError
|
|
from .services.groups import GroupStore
|
|
from .services.sessions import SessionStore
|
|
from .services.trainee import MyPersonaStore
|
|
|
|
app.extensions["user_store"] = UserStore(Config.DATA_DIR)
|
|
app.extensions["group_store"] = GroupStore(Config.DATA_DIR)
|
|
app.extensions["session_store"] = SessionStore(Config.DATA_DIR)
|
|
app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR)
|
|
try:
|
|
app.extensions["llm"] = LLMClient()
|
|
except LLMError as exc:
|
|
print(f"[warn] LLM not configured yet (error_type={type(exc).__name__})")
|
|
app.extensions["llm"] = None
|
|
|
|
bootstrap_admin(app.extensions["user_store"])
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok", "service": Config.APP_NAME}
|
|
|
|
@app.get("/ready")
|
|
def ready():
|
|
"""Bounded readiness signal for load balancers; never expose config values."""
|
|
stores_ok = all(
|
|
app.extensions.get(name) is not None
|
|
for name in ("user_store", "group_store", "session_store")
|
|
)
|
|
config_ok = bool(Config.DATA_DIR and Config.DATA_DIR.exists())
|
|
checks = {"stores": "ok" if stores_ok else "failed", "config": "ok" if config_ok else "failed"}
|
|
status = "ready" if stores_ok and config_ok else "not_ready"
|
|
return {"status": status, "checks": checks}, (200 if status == "ready" else 503)
|
|
|
|
# Serve built Vue frontend if present (production single-app mode).
|
|
_register_frontend(app)
|
|
return app
|
|
|
|
|
|
def _register_frontend(app: Flask) -> None:
|
|
from flask import send_from_directory
|
|
|
|
# repo-root frontend/dist (factory.py -> app/ -> backend/ -> repo root)
|
|
dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
if not (dist / "index.html").exists():
|
|
print(f"[info] frontend build not found at {dist}; API-only mode")
|
|
return
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return send_from_directory(dist, "index.html")
|
|
|
|
@app.route("/<path:path>", methods=["GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"])
|
|
def assets(path: str):
|
|
# Never let the SPA fallback shadow API/auth routes: return 404 for them.
|
|
if path == "api" or path.startswith("api/") or path.startswith("health"):
|
|
abort(404)
|
|
candidate = dist / path
|
|
if candidate.is_file():
|
|
return send_from_directory(dist, path)
|
|
# SPA fallback for client-side routes
|
|
return send_from_directory(dist, "index.html")
|