"""项目配置 — 从环境变量读取,支持 .env 文件""" import os from pathlib import Path from urllib.parse import quote, urlsplit, urlunsplit # 自动加载 .env 文件(如果存在) _env_file = Path(__file__).parent.parent / ".env" if _env_file.exists(): with open(_env_file) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, value = line.partition("=") value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: value = value[1:-1] os.environ.setdefault(key.strip(), value) # 服务项目根目录 SERVER_ROOT = Path(__file__).resolve().parent.parent def _resolve_server_path(value: str) -> Path: """将 .env 中的相对目录固定解析到服务项目根目录。""" path = Path(value).expanduser() if not path.is_absolute(): path = SERVER_ROOT / path return path.resolve() # 项目根目录下的初始化配置文件 BOOTSTRAP_CONFIG_PATH = SERVER_ROOT / "config.json" # 自动打包服务自带的脚本和默认皮肤,不依赖分支源码中的 AutoPacking。 AUTOMATION_DIR = Path(__file__).resolve().parent / "automation" # 持久化数据目录(不随分支变化,存放运行时配置和皮肤包) DATA_DIR = _resolve_server_path(os.getenv("DATA_DIR", str(Path(__file__).parent / "data"))) DATA_DIR.mkdir(parents=True, exist_ok=True) # config.json 路径(持久化,不随分支变化) CONFIG_JSON_PATH = DATA_DIR / "config.json" # 皮肤包存储目录(持久化,不随分支变化) SKINS_DIR = DATA_DIR / "skins" # 打包基础目录 BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build"))) # Git 源码目录 # 配置 GIT_REMOTE_URL 时,所有分支共用 GIT_SOURCE_DIR;构建前会串行切换分支并复制快照。 # 未配置远程仓库时,继续兼容 GIT_SOURCE_BASE/ 的人工维护目录。 GIT_SOURCE_BASE = _resolve_server_path(os.getenv("GIT_SOURCE_BASE", str(SERVER_ROOT / "ReadoorBranches"))) GIT_SOURCE_DIR = _resolve_server_path(os.getenv("GIT_SOURCE_DIR", str(GIT_SOURCE_BASE / "workspace"))) GIT_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "") GIT_USERNAME = os.getenv("GIT_USERNAME", "") GIT_PASSWORD = os.getenv("GIT_PASSWORD", "") def get_git_remote_url(with_credentials: bool = False) -> str: """获取 Git 远程地址,可选注入用户名密码""" if not with_credentials or not GIT_REMOTE_URL or not GIT_USERNAME: return GIT_REMOTE_URL if "://" not in GIT_REMOTE_URL: return GIT_REMOTE_URL parts = urlsplit(GIT_REMOTE_URL) if "@" in parts.netloc: return GIT_REMOTE_URL username = quote(GIT_USERNAME, safe="") if GIT_PASSWORD: password = quote(GIT_PASSWORD, safe="") auth = f"{username}:{password}" else: auth = username netloc = f"{auth}@{parts.netloc}" return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) def mask_git_remote_url(url: str) -> str: """对 Git 远程地址中的凭证做脱敏""" if not url or "://" not in url: return url parts = urlsplit(url) if "@" not in parts.netloc: return url _, host = parts.netloc.rsplit("@", 1) return urlunsplit((parts.scheme, f"***:***@{host}", parts.path, parts.query, parts.fragment)) def get_source_dir(branch: str) -> Path: """获取人工维护模式下指定分支的源码目录。""" return GIT_SOURCE_BASE / branch def get_shared_source_dir() -> Path: """获取配置远程仓库时使用的唯一共享源码工作目录。""" return GIT_SOURCE_DIR # 需要拷贝的目录和文件 COPY_ITEMS = [ "readoor", "readoor.xcodeproj", "readoorTests", "Vendor", "AutoPacking", "Podfile", "Pods", "Podfile.lock", "readoor.xcworkspace", ] # 服务端口 BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000")) # 公网部署安全配置。开发环境保持低门槛,生产环境会在启动时强制校验关键项。 APP_ENV = os.getenv("APP_ENV", "development").lower() CORS_ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if origin.strip()] TRUSTED_HOSTS = [host.strip() for host in os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver").split(",") if host.strip()] LOGIN_RATE_LIMIT_MAX_ATTEMPTS = int(os.getenv("LOGIN_RATE_LIMIT_MAX_ATTEMPTS", "5")) LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")) LOGIN_RATE_LIMIT_LOCKOUT_SECONDS = int(os.getenv("LOGIN_RATE_LIMIT_LOCKOUT_SECONDS", "900")) # 数据库路径。测试可通过 DATABASE_URL 注入临时 SQLite,避免影响运行库。 DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}") # WebSocket 日志队列最大长度 LOG_QUEUE_MAX_SIZE = 1000 # 默认最大并行打包数 DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2")) # 默认打包目录保留时间(小时) DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24")) # 打包超时时间(小时) BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1")) # 管理员账号配置 ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") # JWT 配置 JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production") JWT_ALGORITHM = "HS256" JWT_EXPIRE_HOURS = 24 def validate_production_security() -> None: """阻止带默认凭据或无访问边界的生产服务启动。""" if APP_ENV != "production": return errors = [] if (JWT_SECRET == "ios-build-server-secret-key-change-in-production" or JWT_SECRET.startswith("replace-") or len(JWT_SECRET) < 32): errors.append("JWT_SECRET 必须设置为至少 32 位的随机字符串") if (ADMIN_PASSWORD == "admin123" or ADMIN_PASSWORD.startswith("change-") or len(ADMIN_PASSWORD) < 12): errors.append("ADMIN_PASSWORD 必须设置为至少 12 位的强密码") if not CORS_ALLOWED_ORIGINS or "*" in CORS_ALLOWED_ORIGINS: errors.append("CORS_ALLOWED_ORIGINS 必须设置为实际 HTTPS 前端域名,且不能为 *") if not os.getenv("TRUSTED_HOSTS") or "*" in TRUSTED_HOSTS: errors.append("TRUSTED_HOSTS 必须设置为实际服务域名") if errors: raise RuntimeError("生产环境安全配置不完整: " + "; ".join(errors))