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).
153 lines
6.4 KiB
Python
153 lines
6.4 KiB
Python
"""Configuration from environment / .env."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env from backend/ (project root for this app)
|
|
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
|
load_dotenv(_BACKEND_DIR / ".env", override=False)
|
|
|
|
|
|
def _get_bool(name: str, default: bool = False) -> bool:
|
|
raw = os.environ.get(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def resolve_llm() -> tuple[str, str, str, str | None]:
|
|
"""Return (base_url, model, api_key, provider_name)."""
|
|
provider = os.environ.get("LLM_PROVIDER", "").strip()
|
|
explicit_base = os.environ.get("LLM_BASE_URL", "").strip()
|
|
explicit_model = os.environ.get("LLM_MODEL_NAME", "").strip()
|
|
api_key = os.environ.get("LLM_API_KEY", "").strip() or None
|
|
|
|
presets = {
|
|
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
|
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
|
|
"custom": ("", ""),
|
|
}
|
|
if provider and provider in presets:
|
|
base_url, model = presets[provider]
|
|
if explicit_base:
|
|
base_url = explicit_base
|
|
if explicit_model:
|
|
model = explicit_model
|
|
return base_url, model, api_key or "", provider
|
|
# No/unknown provider: fall back to explicit config
|
|
return (
|
|
explicit_base or "https://api.openai.com/v1",
|
|
explicit_model or "gpt-4o-mini",
|
|
api_key or "",
|
|
provider or None,
|
|
)
|
|
|
|
|
|
class Config:
|
|
APP_NAME = "Sales Trainer"
|
|
# Secure-by-default: deployments must opt into development/test explicitly.
|
|
APP_ENV = os.environ.get("APP_ENV", "production").strip().lower()
|
|
BOOTSTRAP_ADMIN_PASSWORD = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD", "").strip()
|
|
SECRET_KEY = os.environ.get("JWT_SECRET", "").strip()
|
|
JWT_ALGO = "HS256"
|
|
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
|
|
MIN_PASSWORD_LENGTH = 12
|
|
|
|
DATA_DIR = Path(
|
|
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
|
|
).resolve()
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
|
|
|
|
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
|
|
FLASK_PORT = int(os.environ.get("FLASK_PORT", "5001"))
|
|
FLASK_DEBUG = _get_bool("FLASK_DEBUG", False)
|
|
CORS_ORIGINS = tuple(
|
|
origin.strip()
|
|
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
|
|
if origin.strip()
|
|
)
|
|
|
|
UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15"))
|
|
ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"}
|
|
UPLOAD_TEXT_MAX_KB = int(os.environ.get("UPLOAD_TEXT_MAX_KB", "256"))
|
|
UPLOAD_TEXT_MAX_BYTES = UPLOAD_TEXT_MAX_KB * 1024
|
|
UPLOAD_MAX_PDF_PAGES = int(os.environ.get("UPLOAD_MAX_PDF_PAGES", "100"))
|
|
UPLOAD_MAX_EXTRACTED_CHARS = int(os.environ.get("UPLOAD_MAX_EXTRACTED_CHARS", "60000"))
|
|
UPLOAD_MAX_FILES = int(os.environ.get("UPLOAD_MAX_FILES", "10"))
|
|
UPLOAD_MAX_PDF_CHUNK_CHARS = int(os.environ.get("UPLOAD_MAX_PDF_CHUNK_CHARS", "8192"))
|
|
|
|
ANALYTICS_EXPORT_MAX_ROWS = int(os.environ.get("ANALYTICS_EXPORT_MAX_ROWS", "10000"))
|
|
ANALYTICS_EXPORT_MAX_BYTES = int(
|
|
os.environ.get("ANALYTICS_EXPORT_MAX_BYTES", str(4 * 1024 * 1024))
|
|
)
|
|
ANALYTICS_EXPORT_MAX_CELL_CHARS = int(
|
|
os.environ.get("ANALYTICS_EXPORT_MAX_CELL_CHARS", "10000")
|
|
)
|
|
ANALYTICS_EXPORT_MAX_SCAN_RECORDS = int(
|
|
os.environ.get("ANALYTICS_EXPORT_MAX_SCAN_RECORDS", "100000")
|
|
)
|
|
|
|
# LLM
|
|
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm()
|
|
|
|
# OAuth (Google + Facebook) — all optional; OAuth is disabled unless creds
|
|
# are fully configured (fail closed). Client IDs / app IDs are public and
|
|
# may be exposed to the frontend; the *secrets* must never be.
|
|
OAUTH_GOOGLE_CLIENT_ID = os.environ.get("OAUTH_GOOGLE_CLIENT_ID", "").strip()
|
|
OAUTH_GOOGLE_CLIENT_SECRET = os.environ.get("OAUTH_GOOGLE_CLIENT_SECRET", "").strip()
|
|
OAUTH_FACEBOOK_APP_ID = os.environ.get("OAUTH_FACEBOOK_APP_ID", "").strip()
|
|
OAUTH_FACEBOOK_APP_SECRET = os.environ.get("OAUTH_FACEBOOK_APP_SECRET", "").strip()
|
|
# Tenant id that public social signups land in. Missing/placeholder disables OAuth.
|
|
OAUTH_DEFAULT_ORG = os.environ.get("OAUTH_DEFAULT_ORG", "").strip()
|
|
|
|
_OAUTH_PROVIDER_CREDS = {
|
|
"google": ("OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET"),
|
|
"facebook": ("OAUTH_FACEBOOK_APP_ID", "OAUTH_FACEBOOK_APP_SECRET"),
|
|
}
|
|
|
|
ROLES = ("super_admin", "admin", "user")
|
|
|
|
@classmethod
|
|
def oauth_provider_enabled(cls, provider: str) -> bool:
|
|
"""A provider is enabled only when every one of its creds + the default
|
|
org are configured and not a placeholder. Fail closed otherwise."""
|
|
names = cls._OAUTH_PROVIDER_CREDS.get(provider)
|
|
if names is None or cls._is_placeholder(cls.OAUTH_DEFAULT_ORG):
|
|
return False
|
|
for name in names:
|
|
if cls._is_placeholder(getattr(cls, name)):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _is_placeholder(value: str) -> bool:
|
|
normalized = (value or "").strip().lower()
|
|
return (
|
|
not normalized
|
|
or normalized in {"replace_me", "changeme", "change_me", "default", "password"}
|
|
or normalized.startswith("replace_with_")
|
|
or normalized.startswith("your_")
|
|
)
|
|
|
|
@classmethod
|
|
def validate_runtime_security(cls, *, require_bootstrap: bool = False) -> None:
|
|
"""Fail closed for production secrets and first-run initialization."""
|
|
secure_runtime = cls.APP_ENV not in {"development", "test"} or not cls.FLASK_DEBUG
|
|
if secure_runtime and (cls._is_placeholder(cls.SECRET_KEY) or len(cls.SECRET_KEY) < 32):
|
|
raise RuntimeError("JWT_SECRET must be configured with at least 32 characters")
|
|
if require_bootstrap:
|
|
if cls._is_placeholder(cls.BOOTSTRAP_ADMIN_PASSWORD):
|
|
raise RuntimeError("BOOTSTRAP_ADMIN_PASSWORD is required to initialize the first admin")
|
|
if len(cls.BOOTSTRAP_ADMIN_PASSWORD) < cls.MIN_PASSWORD_LENGTH:
|
|
raise RuntimeError(
|
|
"BOOTSTRAP_ADMIN_PASSWORD must be at least "
|
|
f"{cls.MIN_PASSWORD_LENGTH} characters"
|
|
)
|
|
|
|
@classmethod
|
|
def ensure_dirs(cls) -> None:
|
|
for name in ("users", "orgs", "groups", "sessions"):
|
|
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)
|