from __future__ import annotations import json import os import secrets import shutil from dataclasses import dataclass from pathlib import Path from urllib.parse import quote from cryptography.fernet import Fernet DEFAULT_PARALLEL_LIMIT = 4 POLL_INTERVAL_SECONDS = 10 LOGIN_RETRY_LIMIT = 6 TASK_BACKOFF_SECONDS = 6 BROWSER_PAGE_TIMEOUT = 40 LOGS_PAGE_SIZE = 180 SELENIUM_ERROR_LIMIT = 5 SELENIUM_RESTART_LIMIT = 5 SUBMIT_CAPTCHA_RETRY_LIMIT = 5 SCHEDULE_TIMEZONE = "Asia/Shanghai" AIVEN_MYSQL_TEMPLATE = "mysql://avnadmin:{password}@mysql-2bace9cd-cacode.i.aivencloud.com:21260/SACC?ssl-mode=REQUIRED" def _pick_binary(env_name: str, *fallbacks: str) -> str: env_value = os.getenv(env_name) if env_value: return env_value for candidate in fallbacks: if not candidate: continue if Path(candidate).exists(): return candidate resolved = shutil.which(candidate) if resolved: return resolved return fallbacks[0] if fallbacks else "" def _load_or_create_internal_secrets(data_dir: Path) -> tuple[str, str]: secret_file = data_dir / ".app_secrets.json" payload: dict[str, str] = {} if secret_file.exists(): try: raw_payload = json.loads(secret_file.read_text(encoding="utf-8")) if isinstance(raw_payload, dict): payload = { str(key): str(value) for key, value in raw_payload.items() if isinstance(value, str) } except (OSError, json.JSONDecodeError): payload = {} session_secret = payload.get("session_secret", "").strip() encryption_key = payload.get("encryption_key", "").strip() changed = False if not session_secret: session_secret = secrets.token_hex(32) changed = True if not encryption_key: encryption_key = Fernet.generate_key().decode("utf-8") changed = True if changed or not secret_file.exists(): secret_file.write_text( json.dumps( { "session_secret": session_secret, "encryption_key": encryption_key, }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) return session_secret, encryption_key def _build_database_target(root_dir: Path, data_dir: Path) -> tuple[str | Path, str, Path | None]: explicit_database_url = os.getenv("DATABASE_URL", "").strip() if explicit_database_url: normalized_url = explicit_database_url else: sql_password = os.getenv("SQL_PASSWORD", "").strip() if sql_password: normalized_url = AIVEN_MYSQL_TEMPLATE.format(password=quote(sql_password, safe="")) else: return (data_dir / "course_catcher.db").resolve(), "sqlite", None if normalized_url.startswith("mysql://"): normalized_url = f"mysql+pymysql://{normalized_url[len('mysql://') :]}" if normalized_url.startswith("mysql+pymysql://"): mysql_ssl_ca_path = Path(os.getenv("MYSQL_SSL_CA", str(root_dir / "ca.pem"))).resolve() return normalized_url, "mysql", mysql_ssl_ca_path if normalized_url.startswith("sqlite:///"): sqlite_path = Path(normalized_url[len("sqlite:///") :]).resolve() return sqlite_path, "sqlite", None return Path(normalized_url).resolve(), "sqlite", None @dataclass(slots=True) class AppConfig: root_dir: Path data_dir: Path db_path: str | Path database_backend: str mysql_ssl_ca_path: Path | None session_secret: str encryption_key: str super_admin_username: str super_admin_password: str default_parallel_limit: int poll_interval_seconds: int login_retry_limit: int task_backoff_seconds: int browser_page_timeout: int logs_page_size: int selenium_error_limit: int selenium_restart_limit: int submit_captcha_retry_limit: int chrome_binary: str chromedriver_path: str schedule_timezone: str @classmethod def load(cls) -> "AppConfig": root_dir = Path(__file__).resolve().parent.parent data_dir = Path(os.getenv("DATA_DIR", str(root_dir / "data"))).resolve() data_dir.mkdir(parents=True, exist_ok=True) super_admin_username = os.getenv("ADMIN", "superadmin") super_admin_password = os.getenv("PASSWORD", "change-me-in-hf-space") session_secret, encryption_key = _load_or_create_internal_secrets(data_dir) db_path, database_backend, mysql_ssl_ca_path = _build_database_target(root_dir, data_dir) return cls( root_dir=root_dir, data_dir=data_dir, db_path=db_path, database_backend=database_backend, mysql_ssl_ca_path=mysql_ssl_ca_path, session_secret=session_secret, encryption_key=encryption_key, super_admin_username=super_admin_username, super_admin_password=super_admin_password, default_parallel_limit=DEFAULT_PARALLEL_LIMIT, poll_interval_seconds=POLL_INTERVAL_SECONDS, login_retry_limit=LOGIN_RETRY_LIMIT, task_backoff_seconds=TASK_BACKOFF_SECONDS, browser_page_timeout=BROWSER_PAGE_TIMEOUT, logs_page_size=LOGS_PAGE_SIZE, selenium_error_limit=SELENIUM_ERROR_LIMIT, selenium_restart_limit=SELENIUM_RESTART_LIMIT, submit_captcha_retry_limit=SUBMIT_CAPTCHA_RETRY_LIMIT, chrome_binary=_pick_binary( "CHROME_BIN", "/usr/bin/chromium", "/usr/bin/chromium-browser", "chromium", "chromium-browser", ), chromedriver_path=_pick_binary( "CHROMEDRIVER_PATH", "/usr/bin/chromedriver", "chromedriver", ), schedule_timezone=os.getenv("APP_TIMEZONE", SCHEDULE_TIMEZONE), )