chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import uuid
|
||||
|
||||
import dotenv
|
||||
from flask import Flask, Response, jsonify, redirect, request
|
||||
from jose import jwt
|
||||
|
||||
from application.auth import handle_auth
|
||||
|
||||
from application.core import log_context
|
||||
from application.core.logging_config import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
from application.api import api # noqa: E402
|
||||
from application.api.admin import admin_ns # noqa: E402
|
||||
from application.api.answer import answer # noqa: E402
|
||||
from application.api.devices import devices_bp # noqa: E402
|
||||
from application.api.events.routes import events # noqa: E402
|
||||
from application.api.internal.routes import internal # noqa: E402
|
||||
from application.api.oidc import oidc_bp # noqa: E402
|
||||
from application.api.oidc.denylist import is_denied as oidc_session_denied # noqa: E402
|
||||
from application.api.scim import scim_bp # noqa: E402
|
||||
from application.api.user.authz import resolve_roles # noqa: E402
|
||||
from application.api.user.routes import user # noqa: E402
|
||||
from application.api.connector.routes import connector # noqa: E402
|
||||
from application.api.v1 import v1_bp # noqa: E402
|
||||
from application.celery_init import celery # noqa: E402
|
||||
from application.core.settings import settings # noqa: E402
|
||||
from application.storage.db.bootstrap import ensure_database_ready # noqa: E402
|
||||
from application.stt.upload_limits import ( # noqa: E402
|
||||
build_stt_file_size_limit_message,
|
||||
should_reject_stt_request,
|
||||
)
|
||||
|
||||
|
||||
if platform.system() == "Windows":
|
||||
import pathlib
|
||||
|
||||
pathlib.PosixPath = pathlib.WindowsPath
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Self-bootstrap the user-data Postgres DB. Runs before any blueprint or
|
||||
# repository touches the engine, so the first request can't race the
|
||||
# schema being created. Gated by AUTO_CREATE_DB / AUTO_MIGRATE settings
|
||||
# (default ON for dev; disable in prod if schema is managed out-of-band).
|
||||
ensure_database_ready(
|
||||
settings.POSTGRES_URI,
|
||||
create_db=settings.AUTO_CREATE_DB,
|
||||
migrate=settings.AUTO_MIGRATE,
|
||||
logger=logging.getLogger("application.app"),
|
||||
)
|
||||
|
||||
from application.agents.default_tools import ( # noqa: E402
|
||||
validate_default_chat_tools,
|
||||
)
|
||||
|
||||
validate_default_chat_tools()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(user)
|
||||
app.register_blueprint(answer)
|
||||
app.register_blueprint(events)
|
||||
app.register_blueprint(internal)
|
||||
app.register_blueprint(connector)
|
||||
app.register_blueprint(devices_bp)
|
||||
app.register_blueprint(oidc_bp)
|
||||
app.register_blueprint(scim_bp)
|
||||
app.register_blueprint(v1_bp)
|
||||
# Register the admin namespace once. The membership guard makes this idempotent
|
||||
# if application.app is re-imported (coverage tests reload the module): without
|
||||
# it, re-running add_namespace would re-register routes on the already-served
|
||||
# first app and raise "add_url_rule can no longer be called".
|
||||
if admin_ns not in api.namespaces:
|
||||
api.add_namespace(admin_ns)
|
||||
app.config.update(
|
||||
UPLOAD_FOLDER="inputs",
|
||||
CELERY_BROKER_URL=settings.CELERY_BROKER_URL,
|
||||
CELERY_RESULT_BACKEND=settings.CELERY_RESULT_BACKEND,
|
||||
MONGO_URI=settings.MONGO_URI,
|
||||
)
|
||||
celery.config_from_object("application.celeryconfig")
|
||||
api.init_app(app)
|
||||
|
||||
if settings.AUTH_TYPE in ("simple_jwt", "session_jwt", "oidc") and not settings.JWT_SECRET_KEY:
|
||||
key_file = ".jwt_secret_key"
|
||||
try:
|
||||
with open(key_file, "r") as f:
|
||||
settings.JWT_SECRET_KEY = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
new_key = os.urandom(32).hex()
|
||||
with open(key_file, "w") as f:
|
||||
f.write(new_key)
|
||||
settings.JWT_SECRET_KEY = new_key
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to setup JWT_SECRET_KEY: {e}")
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
_missing_oidc = [
|
||||
name
|
||||
for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
|
||||
if not getattr(settings, name)
|
||||
]
|
||||
if _missing_oidc:
|
||||
raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}")
|
||||
SIMPLE_JWT_TOKEN = None
|
||||
if settings.AUTH_TYPE == "simple_jwt":
|
||||
payload = {"sub": "local"}
|
||||
SIMPLE_JWT_TOKEN = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
print(f"Generated Simple JWT Token: {SIMPLE_JWT_TOKEN}")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
if request.remote_addr in ("0.0.0.0", "127.0.0.1", "localhost", "172.18.0.1"):
|
||||
return redirect("http://localhost:5173")
|
||||
else:
|
||||
return "Welcome to DocsGPT Backend!"
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/config")
|
||||
def get_config():
|
||||
from application.graphrag import graphrag_available
|
||||
|
||||
response = {
|
||||
"auth_type": settings.AUTH_TYPE,
|
||||
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
|
||||
"graphrag_available": graphrag_available(),
|
||||
"hybrid_available": settings.VECTOR_STORE == "pgvector",
|
||||
}
|
||||
if settings.AUTH_TYPE == "oidc":
|
||||
response["oidc"] = {
|
||||
"login_path": "/api/auth/oidc/login",
|
||||
"logout_path": "/api/auth/oidc/logout",
|
||||
"provider_name": settings.OIDC_PROVIDER_NAME,
|
||||
}
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route("/api/generate_token")
|
||||
def generate_token():
|
||||
if settings.AUTH_TYPE == "session_jwt":
|
||||
new_user_id = str(uuid.uuid4())
|
||||
token = jwt.encode(
|
||||
{"sub": new_user_id}, settings.JWT_SECRET_KEY, algorithm="HS256"
|
||||
)
|
||||
return jsonify({"token": token})
|
||||
return jsonify({"error": "Token generation not allowed in current auth mode"}), 400
|
||||
|
||||
|
||||
_LOG_CTX_TOKEN_ATTR = "_log_ctx_token"
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _bind_log_context():
|
||||
"""Bind activity_id + endpoint for the duration of this request.
|
||||
|
||||
Runs before ``authenticate_request``; ``user_id`` is overlaid in a
|
||||
follow-up handler once the JWT has been decoded.
|
||||
"""
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
activity_id = str(uuid.uuid4())
|
||||
request.activity_id = activity_id
|
||||
token = log_context.bind(
|
||||
activity_id=activity_id,
|
||||
endpoint=request.endpoint,
|
||||
)
|
||||
setattr(request, _LOG_CTX_TOKEN_ATTR, token)
|
||||
return None
|
||||
|
||||
|
||||
@app.teardown_request
|
||||
def _reset_log_context(_exc):
|
||||
# SSE streams keep yielding after teardown fires, but a2wsgi runs each
|
||||
# request inside ``copy_context().run(...)``, so this reset doesn't
|
||||
# leak into the stream's view of the context.
|
||||
token = getattr(request, _LOG_CTX_TOKEN_ATTR, None)
|
||||
if token is not None:
|
||||
log_context.reset(token)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_stt_request_size_limits():
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
if should_reject_stt_request(request.path, request.content_length):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def authenticate_request():
|
||||
if request.method == "OPTIONS":
|
||||
return "", 200
|
||||
# OpenAI-compatible routes authenticate via opaque agent API keys in the
|
||||
# Authorization header, which the JWT decoder below would reject. Defer
|
||||
# auth to the route handlers (see application/api/v1/routes.py).
|
||||
if request.path.startswith("/v1/"):
|
||||
request.decoded_token = None
|
||||
return None
|
||||
# Remote-device CLI endpoints carry opaque ``tok_…`` session tokens
|
||||
# (not JWTs); ``verify_device_session`` runs inside the route handler.
|
||||
# The redeem endpoint is tokenless — it authenticates via the one-time
|
||||
# ``user_code`` inside ``redeem_pairing`` — so it's exempt too. Pairing
|
||||
# create + status stay JWT-protected (UI calls).
|
||||
if (
|
||||
request.path.startswith("/api/devices/poll")
|
||||
or request.path.startswith("/api/devices/sessions/")
|
||||
or request.path == "/api/devices/me"
|
||||
or request.path == "/api/devices/pairings/redeem"
|
||||
):
|
||||
request.decoded_token = None
|
||||
return None
|
||||
# OIDC login/callback/token endpoints must stay reachable even when the
|
||||
# browser still carries a stale or expired Bearer token — otherwise the
|
||||
# 401 below would lock the user out of the only path to a fresh session.
|
||||
if request.path.startswith("/api/auth/oidc/"):
|
||||
request.decoded_token = None
|
||||
return None
|
||||
# SCIM provisioning authenticates with its own bearer token (SCIM_TOKEN),
|
||||
# validated inside the blueprint.
|
||||
if request.path.startswith("/scim/"):
|
||||
request.decoded_token = None
|
||||
return None
|
||||
decoded_token = handle_auth(request)
|
||||
if not decoded_token:
|
||||
request.decoded_token = None
|
||||
elif "error" in decoded_token:
|
||||
return jsonify(decoded_token), 401
|
||||
elif settings.AUTH_TYPE == "oidc" and oidc_session_denied(decoded_token):
|
||||
# Back-channel logout / SCIM deactivation revoked this session.
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "Authentication error: session revoked",
|
||||
"error": "token_revoked",
|
||||
}
|
||||
),
|
||||
401,
|
||||
)
|
||||
else:
|
||||
# Resolve roles once here, the single authenticated chokepoint. Roles
|
||||
# are computed (never read from the JWT) and overwrite any inbound
|
||||
# 'roles' claim. /v1, device, oidc, and scim paths set decoded_token
|
||||
# above and never reach here, so they stay role-less by design.
|
||||
decoded_token["roles"] = resolve_roles(decoded_token)
|
||||
request.decoded_token = decoded_token
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _bind_user_id_to_log_context():
|
||||
# Registered after ``authenticate_request`` (Flask runs before_request
|
||||
# handlers in registration order), so ``request.decoded_token`` is
|
||||
# populated by the time we read it. ``teardown_request`` unwinds the
|
||||
# whole request-level bind, so no separate reset token is needed here.
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
user_id = decoded_token.get("sub") if isinstance(decoded_token, dict) else None
|
||||
if user_id:
|
||||
log_context.bind(user_id=user_id)
|
||||
return None
|
||||
|
||||
|
||||
@app.after_request
|
||||
def after_request(response: Response) -> Response:
|
||||
"""Add CORS headers for the pure Flask development entrypoint."""
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"Content-Type, Authorization, Idempotency-Key"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
return response
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=settings.FLASK_DEBUG_MODE, port=7091)
|
||||
Reference in New Issue
Block a user