169 lines
6.5 KiB
Python
169 lines
6.5 KiB
Python
"""项目配置 — 从环境变量读取,支持 .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/<branch> 的人工维护目录。
|
||
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) < 11):
|
||
errors.append("ADMIN_PASSWORD 必须设置为至少 11 位的强密码")
|
||
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))
|