feat: 强化打包配置与服务安全
This commit is contained in:
parent
d1f070b251
commit
a48909f3bc
30
.env.example
30
.env.example
@ -2,17 +2,21 @@
|
|||||||
# 复制此文件为 .env 并修改为实际值
|
# 复制此文件为 .env 并修改为实际值
|
||||||
|
|
||||||
# ---- 目录配置 ----
|
# ---- 目录配置 ----
|
||||||
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev)
|
# 源码根目录;未配置远程仓库时,每个分支一个子目录
|
||||||
GIT_SOURCE_BASE=./ReadoorBranches
|
GIT_SOURCE_BASE=./ReadoorBranches
|
||||||
|
|
||||||
|
# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照
|
||||||
|
GIT_SOURCE_DIR=./ReadoorBranches/workspace
|
||||||
|
|
||||||
# 打包输出基础目录(每次打包会在此目录下创建子目录)
|
# 打包输出基础目录(每次打包会在此目录下创建子目录)
|
||||||
BUILD_BASE_DIR=./build
|
BUILD_BASE_DIR=./build
|
||||||
|
|
||||||
# ---- 分支源码管理 ----
|
# ---- 分支源码管理 ----
|
||||||
# Git 远程仓库地址,分支目录不存在时自动 clone
|
# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR
|
||||||
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
|
GIT_REMOTE_URL=https://git.example.com/group/repo.git
|
||||||
GIT_USERNAME=shenlei
|
# 私有仓库:使用只读 Personal Access Token,不要使用账号登录密码。
|
||||||
GIT_PASSWORD='&s9U5fbreA'
|
GIT_USERNAME=git-readonly-user
|
||||||
|
GIT_PASSWORD=cc774880b9d9b368c5f24a3821ee745dd5e4c949
|
||||||
|
|
||||||
# ---- 服务端口 ----
|
# ---- 服务端口 ----
|
||||||
BACKEND_PORT=5002
|
BACKEND_PORT=5002
|
||||||
@ -20,11 +24,23 @@ FRONTEND_PORT=5999
|
|||||||
|
|
||||||
# ---- 管理员账号 ----
|
# ---- 管理员账号 ----
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=change-this-before-starting
|
||||||
|
|
||||||
# ---- JWT 认证 ----
|
# ---- JWT 认证 ----
|
||||||
# JWT 签名密钥(生产环境务必修改为随机字符串)
|
# JWT 签名密钥(生产环境务必修改为随机字符串)
|
||||||
JWT_SECRET=ios-build-server-secret-key-change-in-production
|
JWT_SECRET=replace-with-a-random-string-of-at-least-32-characters
|
||||||
|
|
||||||
|
# ---- 公网部署 ----
|
||||||
|
# production 会拒绝默认密钥、通配 CORS 和未配置的可信主机。
|
||||||
|
APP_ENV=production
|
||||||
|
# Uvicorn 只监听本机,由 Nginx/Caddy 提供 HTTPS 和公网访问。
|
||||||
|
BIND_HOST=127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS=https://build.example.com
|
||||||
|
TRUSTED_HOSTS=build.example.com
|
||||||
|
# 登录失败 5 次后锁定 15 分钟。
|
||||||
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
|
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS=900
|
||||||
|
|
||||||
# ---- 并发与清理 ----
|
# ---- 并发与清理 ----
|
||||||
# 最大并行打包数
|
# 最大并行打包数
|
||||||
|
|||||||
35
AGENTS.md
Normal file
35
AGENTS.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# iOS Build Server
|
||||||
|
|
||||||
|
FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store builds with web UI, real-time logs, multi-user auth, and health monitoring.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy (SQLite), JWT auth
|
||||||
|
- **Frontend**: Vue 3, Vite, Vue Router
|
||||||
|
- **Tests**: pytest (backend), vitest (frontend)
|
||||||
|
- **Deploy**: macOS launchd, deploy.sh management script
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh # Dev mode (backend + frontend hot reload)
|
||||||
|
./deploy.sh build # Install deps + build frontend
|
||||||
|
./deploy.sh start/stop/restart/status
|
||||||
|
./deploy.sh test # Run all tests (backend + frontend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `backend/routers/` — API routes: auth, users, config, apps, tasks
|
||||||
|
- `backend/services/` — build_service (packaging), build_queue (async queue), log_streamer (WebSocket)
|
||||||
|
- `backend/deps.py` — JWT auth dependency used by all protected routes
|
||||||
|
- `backend/config.py` — loads .env, defines all config constants
|
||||||
|
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
|
||||||
|
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
|
||||||
|
- Admin-only routes: user management (`/api/users/*`)
|
||||||
|
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
|
||||||
|
- Chinese UI and documentation throughout
|
||||||
53
README.md
53
README.md
@ -7,6 +7,7 @@
|
|||||||
- [环境要求](#环境要求)
|
- [环境要求](#环境要求)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [配置说明](#配置说明)
|
- [配置说明](#配置说明)
|
||||||
|
- [公网部署](#公网部署)
|
||||||
- [服务管理](#服务管理)
|
- [服务管理](#服务管理)
|
||||||
- [多用户管理](#多用户管理)
|
- [多用户管理](#多用户管理)
|
||||||
- [Watchdog 健康监测](#watchdog-健康监测)
|
- [Watchdog 健康监测](#watchdog-健康监测)
|
||||||
@ -72,11 +73,11 @@ ADMIN_PASSWORD=your_secure_password
|
|||||||
|
|
||||||
启动后访问 `http://<服务器IP>:8000` 即可使用。
|
启动后访问 `http://<服务器IP>:8000` 即可使用。
|
||||||
|
|
||||||
### 4.(可选)初始化分支源码目录
|
### 4.(可选)初始化源码目录
|
||||||
|
|
||||||
系统现在默认直接从分支源码目录打包,每个分支对应 `GIT_SOURCE_BASE` 下的一个子目录。
|
配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建。
|
||||||
|
|
||||||
如果已配置 `GIT_REMOTE_URL`,首次打包某个分支时会自动 clone;如果未配置,则需要先手动准备分支目录:
|
未配置远程仓库时,需要继续手动准备每个分支目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建分支源码根目录
|
# 创建分支源码根目录
|
||||||
@ -86,7 +87,7 @@ mkdir -p /path/to/ReadoorBranches
|
|||||||
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
||||||
```
|
```
|
||||||
|
|
||||||
如果已配置 `GIT_REMOTE_URL`,其他分支无需手动 clone,首次打包时会自动拉取。
|
配置远程仓库时,无需手动 clone;首次打包会初始化共享目录。
|
||||||
|
|
||||||
### 5.(可选)启用健康监测
|
### 5.(可选)启用健康监测
|
||||||
|
|
||||||
@ -110,9 +111,12 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
|||||||
|
|
||||||
| 变量 | 示例 | 说明 |
|
| 变量 | 示例 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 分支源码根目录,每个分支一个子目录,仅提供 iOS 工程源码 |
|
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 人工维护模式下的分支源码根目录 |
|
||||||
|
| `GIT_SOURCE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches/workspace` | 配置远程仓库时唯一的共享源码工作目录 |
|
||||||
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
|
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
|
||||||
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone |
|
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,首次构建时自动初始化共享源码目录 |
|
||||||
|
|
||||||
|
私有仓库请创建仅有 `read_repository` 权限的 Personal Access Token,并配置为 `GIT_PASSWORD`;`GIT_USERNAME` 使用该 Token 所属账号。不要在 `.env`、`config.json` 或 Git 仓库中保存账号登录密码。
|
||||||
|
|
||||||
### 服务配置
|
### 服务配置
|
||||||
|
|
||||||
@ -123,6 +127,25 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
|||||||
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
|
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
|
||||||
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
|
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
|
||||||
|
|
||||||
|
## 公网部署
|
||||||
|
|
||||||
|
不要将 Uvicorn 端口直接暴露到公网。使用 Nginx 或 Caddy 提供 HTTPS,服务仅监听 `127.0.0.1`。项目提供了 [nginx.conf.example](/Users/shen/Work/Code/iOSBuildServer/deploy/nginx.conf.example) 作为模板。
|
||||||
|
|
||||||
|
公网 `.env` 至少需要如下配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_ENV=production
|
||||||
|
BIND_HOST=127.0.0.1
|
||||||
|
JWT_SECRET=<至少 32 位随机字符串>
|
||||||
|
ADMIN_PASSWORD=<至少 12 位强密码>
|
||||||
|
CORS_ALLOWED_ORIGINS=https://build.example.com
|
||||||
|
TRUSTED_HOSTS=build.example.com
|
||||||
|
GIT_USERNAME=<只读 Token 所属账号>
|
||||||
|
GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
|
||||||
|
```
|
||||||
|
|
||||||
|
设置完成后执行 `chmod 600 .env`,并在云安全组和系统防火墙中只开放 `80/443`。`APP_ENV=production` 会在默认 JWT、弱管理员密码、通配 CORS 或未设置可信域名时拒绝启动。
|
||||||
|
|
||||||
### 打包配置
|
### 打包配置
|
||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
@ -541,22 +564,24 @@ cp .env.example .env
|
|||||||
|
|
||||||
### Q: 如何按分支打包
|
### Q: 如何按分支打包
|
||||||
|
|
||||||
1. 在 `.env` 中配置 `GIT_SOURCE_BASE` 和 `GIT_REMOTE_URL`
|
1. 在 `.env` 中配置 `GIT_SOURCE_BASE`、`GIT_SOURCE_DIR` 和 `GIT_REMOTE_URL`
|
||||||
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`)
|
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`)
|
||||||
3. 打包时在「代码分支」下拉框中选择分支
|
3. 打包时在「代码分支」下拉框中选择分支
|
||||||
4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新
|
4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照
|
||||||
5. 每个分支有独立的源码目录,支持并行打包不同分支
|
5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行
|
||||||
|
|
||||||
### Q: 分支目录占满磁盘怎么办
|
### Q: 源码目录占满磁盘怎么办
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看各分支目录大小
|
# 远程仓库模式下只有一个共享工作目录
|
||||||
du -sh /path/to/ReadoorBranches/*
|
du -sh /path/to/ReadoorBranches/workspace
|
||||||
|
|
||||||
# 删除不用的分支目录
|
# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用
|
||||||
rm -rf /path/to/ReadoorBranches/old-branch
|
du -sh /path/to/build/build_readoor_*
|
||||||
```
|
```
|
||||||
|
|
||||||
|
共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。
|
||||||
|
|
||||||
### Q: 如何运行测试
|
### Q: 如何运行测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -47,10 +47,11 @@ SKINS_DIR = DATA_DIR / "skins"
|
|||||||
# 打包基础目录
|
# 打包基础目录
|
||||||
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
|
BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build")))
|
||||||
|
|
||||||
# Git 分支源码目录
|
# Git 源码目录
|
||||||
# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录
|
# 配置 GIT_REMOTE_URL 时,所有分支共用 GIT_SOURCE_DIR;构建前会串行切换分支并复制快照。
|
||||||
# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone
|
# 未配置远程仓库时,继续兼容 GIT_SOURCE_BASE/<branch> 的人工维护目录。
|
||||||
GIT_SOURCE_BASE = _resolve_server_path(os.getenv("GIT_SOURCE_BASE", str(SERVER_ROOT / "ReadoorBranches")))
|
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_REMOTE_URL = os.getenv("GIT_REMOTE_URL", "")
|
||||||
GIT_USERNAME = os.getenv("GIT_USERNAME", "")
|
GIT_USERNAME = os.getenv("GIT_USERNAME", "")
|
||||||
GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
|
GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
|
||||||
@ -89,10 +90,15 @@ def mask_git_remote_url(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_source_dir(branch: str) -> Path:
|
def get_source_dir(branch: str) -> Path:
|
||||||
"""获取指定分支的源码目录"""
|
"""获取人工维护模式下指定分支的源码目录。"""
|
||||||
return GIT_SOURCE_BASE / branch
|
return GIT_SOURCE_BASE / branch
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_source_dir() -> Path:
|
||||||
|
"""获取配置远程仓库时使用的唯一共享源码工作目录。"""
|
||||||
|
return GIT_SOURCE_DIR
|
||||||
|
|
||||||
|
|
||||||
# 需要拷贝的目录和文件
|
# 需要拷贝的目录和文件
|
||||||
COPY_ITEMS = [
|
COPY_ITEMS = [
|
||||||
"readoor",
|
"readoor",
|
||||||
@ -108,6 +114,14 @@ COPY_ITEMS = [
|
|||||||
# 服务端口
|
# 服务端口
|
||||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
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 注入临时 SQLite,避免影响运行库。
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}")
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{Path(__file__).parent / 'build_server.db'}")
|
||||||
|
|
||||||
@ -131,3 +145,23 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
|||||||
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRE_HOURS = 24
|
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))
|
||||||
|
|||||||
@ -38,13 +38,13 @@ def _seed_admin():
|
|||||||
"""初始化管理员账号(从 .env 配置)"""
|
"""初始化管理员账号(从 .env 配置)"""
|
||||||
from .models import User
|
from .models import User
|
||||||
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
|
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
|
||||||
import hashlib
|
from .security import hash_password
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
|
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
|
||||||
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
|
db.add(User(id="admin-001", username=ADMIN_USERNAME,
|
||||||
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
|
password_hash=hash_password(ADMIN_PASSWORD), is_admin=True))
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@ -8,9 +8,8 @@ from .config import JWT_SECRET, JWT_ALGORITHM
|
|||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
def decode_current_user(token: str) -> dict:
|
||||||
"""验证 JWT token,返回用户信息"""
|
"""验证 JWT token,返回用户身份。"""
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
|
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
|
||||||
@ -18,3 +17,15 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
|
|||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||||
|
"""验证 HTTP Bearer JWT token,返回用户信息。"""
|
||||||
|
return decode_current_user(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
"""限制仅管理员可访问的管理和敏感配置接口。"""
|
||||||
|
if not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
|
return user
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
"""FastAPI 主入口"""
|
"""FastAPI 主入口"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers import config, apps, tasks, auth, users
|
from .routers import config, apps, tasks, auth, users
|
||||||
from .services.log_streamer import log_streamer
|
from .services.log_streamer import log_streamer
|
||||||
from .config import BACKEND_PORT
|
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
|
||||||
|
from .deps import decode_current_user
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@ -17,6 +19,7 @@ STATIC_DIR = Path(__file__).parent / "static"
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期"""
|
"""应用生命周期"""
|
||||||
|
validate_production_security()
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@ -27,14 +30,17 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS
|
# 仅接受显式允许的跨域前端;同域部署不需要 CORS 配置。
|
||||||
app.add_middleware(
|
if CORS_ALLOWED_ORIGINS:
|
||||||
CORSMiddleware,
|
app.add_middleware(
|
||||||
allow_origins=["*"],
|
CORSMiddleware,
|
||||||
allow_credentials=True,
|
allow_origins=CORS_ALLOWED_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_credentials=False,
|
||||||
allow_headers=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
)
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=TRUSTED_HOSTS)
|
||||||
|
|
||||||
# 路由
|
# 路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
@ -46,8 +52,19 @@ app.include_router(tasks.router)
|
|||||||
|
|
||||||
@app.websocket("/ws/tasks/{task_id}")
|
@app.websocket("/ws/tasks/{task_id}")
|
||||||
async def websocket_logs(websocket: WebSocket, task_id: str):
|
async def websocket_logs(websocket: WebSocket, task_id: str):
|
||||||
"""WebSocket 实时日志"""
|
"""经 JWT 鉴权的 WebSocket 实时日志。"""
|
||||||
await websocket.accept()
|
protocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||||
|
token_protocol = next((item.strip() for item in protocol.split(",") if item.strip().startswith("jwt.")), "")
|
||||||
|
if not token_protocol:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
decode_current_user(token_protocol.removeprefix("jwt."))
|
||||||
|
except Exception:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept(subprotocol=token_protocol)
|
||||||
try:
|
try:
|
||||||
async for msg in log_streamer.subscribe(task_id):
|
async for msg in log_streamer.subscribe(task_id):
|
||||||
await websocket.send_json(msg)
|
await websocket.send_json(msg)
|
||||||
|
|||||||
@ -5,12 +5,31 @@ from .config import load_config
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["apps"])
|
router = APIRouter(prefix="/api", tags=["apps"])
|
||||||
|
|
||||||
|
# 每个 App 都只能使用明确允许的 Scheme。
|
||||||
|
_APP_SCHEME_RULES = {
|
||||||
|
"申学": ("readoorShenXue",),
|
||||||
|
"申学APP": ("readoorShenXue",),
|
||||||
|
"英汉大词典测试": ("readoorDict",),
|
||||||
|
"英汉大词典": ("readoorDict",),
|
||||||
|
}
|
||||||
|
_DEFAULT_SCHEME_NAMES = ("readoor31", "readoor31OtherPay")
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_scheme_names(app: dict) -> tuple[str, ...]:
|
||||||
|
"""返回 App 可使用的 Scheme 名称。"""
|
||||||
|
return _APP_SCHEME_RULES.get(app.get("name", ""), _DEFAULT_SCHEME_NAMES)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/apps")
|
@router.get("/apps")
|
||||||
async def get_apps_for_build():
|
async def get_apps_for_build():
|
||||||
"""获取 apps 列表(供打包选择)"""
|
"""获取 apps 列表(供打包选择)"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
return config.get("apps", {})
|
apps = {}
|
||||||
|
for app_id, app in config.get("apps", {}).items():
|
||||||
|
app_data = app.copy()
|
||||||
|
app_data["allowed_scheme_names"] = list(get_allowed_scheme_names(app))
|
||||||
|
apps[app_id] = app_data
|
||||||
|
return apps
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schemes")
|
@router.get("/schemes")
|
||||||
|
|||||||
@ -1,16 +1,24 @@
|
|||||||
"""认证 API"""
|
"""认证 API"""
|
||||||
import jwt
|
import jwt
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..schemas import LoginRequest, LoginResponse
|
from ..schemas import LoginRequest, LoginResponse
|
||||||
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
|
from ..config import (
|
||||||
|
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS,
|
||||||
|
LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS,
|
||||||
|
)
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from ..security import hash_password, verify_password
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
_login_attempts: dict[str, tuple[int, float, float]] = {}
|
||||||
|
_login_attempts_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _create_token(username: str, is_admin: bool) -> str:
|
def _create_token(username: str, is_admin: bool) -> str:
|
||||||
@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str:
|
|||||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def _verify_password(password: str, password_hash: str) -> bool:
|
def _check_login_rate_limit(client_ip: str) -> None:
|
||||||
"""校验密码"""
|
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
|
||||||
import hashlib
|
now = time.monotonic()
|
||||||
return hashlib.sha256(password.encode()).hexdigest() == password_hash
|
with _login_attempts_lock:
|
||||||
|
attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0))
|
||||||
|
if locked_until > now:
|
||||||
|
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
|
||||||
|
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
|
||||||
|
_login_attempts.pop(client_ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_login_failure(client_ip: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _login_attempts_lock:
|
||||||
|
attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0))
|
||||||
|
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
|
||||||
|
attempts, first_failure = 0, now
|
||||||
|
attempts += 1
|
||||||
|
locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0
|
||||||
|
_login_attempts[client_ip] = (attempts, first_failure, locked_until)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_login_failures(client_ip: str) -> None:
|
||||||
|
with _login_attempts_lock:
|
||||||
|
_login_attempts.pop(client_ip, None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)):
|
||||||
"""用户登录"""
|
"""用户登录"""
|
||||||
user = db.query(User).filter(User.username == request.username).first()
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if not user or not _verify_password(request.password, user.password_hash):
|
_check_login_rate_limit(client_ip)
|
||||||
|
user = db.query(User).filter(User.username == credentials.username).first()
|
||||||
|
valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False)
|
||||||
|
if not user or not valid:
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
if needs_upgrade:
|
||||||
|
user.password_hash = hash_password(credentials.password)
|
||||||
|
db.commit()
|
||||||
|
_clear_login_failures(client_ip)
|
||||||
|
|
||||||
token = _create_token(user.username, user.is_admin)
|
token = _create_token(user.username, user.is_admin)
|
||||||
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
|
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import shutil
|
|||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@ -21,9 +21,18 @@ from ..config import (
|
|||||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||||
SKINS_DIR,
|
SKINS_DIR,
|
||||||
)
|
)
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user, require_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)])
|
|
||||||
|
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
"""普通用户仅可管理 Apps;其余配置及凭据只允许管理员访问。"""
|
||||||
|
path = request.url.path.rstrip("/")
|
||||||
|
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
|
||||||
|
return user
|
||||||
|
return require_admin(user)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
|
||||||
|
|
||||||
_config_lock = asyncio.Lock()
|
_config_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
app = apps[task.app_id]
|
app = apps[task.app_id]
|
||||||
scheme = schemes[task.scheme_id]
|
scheme = schemes[task.scheme_id]
|
||||||
|
scheme_name = scheme.get("name", "")
|
||||||
|
|
||||||
|
if task.build_type not in {"Ad_Hoc", "App_Store"}:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的打包类型")
|
||||||
|
if not app.get("certificates", {}).get(task.build_type):
|
||||||
|
raise HTTPException(status_code=400, detail=f"该 App 未配置 {task.build_type} 证书")
|
||||||
|
|
||||||
|
from .apps import get_allowed_scheme_names
|
||||||
|
allowed_scheme_names = get_allowed_scheme_names(app)
|
||||||
|
if allowed_scheme_names and scheme_name not in allowed_scheme_names:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
|
||||||
|
)
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
db_task = Task(
|
db_task = Task(
|
||||||
@ -42,7 +56,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
|||||||
app_name=app.get("name", ""),
|
app_name=app.get("name", ""),
|
||||||
build_type=task.build_type,
|
build_type=task.build_type,
|
||||||
scheme_id=task.scheme_id,
|
scheme_id=task.scheme_id,
|
||||||
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
|
scheme_name=scheme.get("displayName") or scheme_name,
|
||||||
obfuscation=task.obfuscation,
|
obfuscation=task.obfuscation,
|
||||||
branch=task.branch,
|
branch=task.branch,
|
||||||
status="pending",
|
status="pending",
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
"""用户管理 API(仅管理员)"""
|
"""用户管理 API(仅管理员)"""
|
||||||
import uuid
|
import uuid
|
||||||
import hashlib
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
|
from ..security import hash_password
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
@ -16,10 +16,6 @@ def _require_admin(user: dict):
|
|||||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
"""获取用户列表"""
|
"""获取用户列表"""
|
||||||
@ -39,15 +35,15 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
|
|||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
|
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
|
||||||
if len(password) < 6:
|
if len(password) < 12:
|
||||||
raise HTTPException(status_code=400, detail="密码至少 6 位")
|
raise HTTPException(status_code=400, detail="密码至少 12 位")
|
||||||
if db.query(User).filter(User.username == username).first():
|
if db.query(User).filter(User.username == username).first():
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=_hash_password(password),
|
password_hash=hash_password(password),
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
@ -65,10 +61,10 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
|
|||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
new_password = data.get("password", "").strip()
|
new_password = data.get("password", "").strip()
|
||||||
if not new_password or len(new_password) < 6:
|
if not new_password or len(new_password) < 12:
|
||||||
raise HTTPException(status_code=400, detail="密码至少 6 位")
|
raise HTTPException(status_code=400, detail="密码至少 12 位")
|
||||||
|
|
||||||
target.password_hash = _hash_password(new_password)
|
target.password_hash = hash_password(new_password)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "密码已更新"}
|
return {"message": "密码已更新"}
|
||||||
|
|
||||||
|
|||||||
19
backend/security.py
Normal file
19
backend/security.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""认证相关的密码散列与兼容迁移工具。"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""使用 bcrypt 保存密码,避免快速散列被离线撞库。"""
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored_hash: str) -> tuple[bool, bool]:
|
||||||
|
"""返回 (密码是否正确, 是否需要将旧 SHA-256 散列升级为 bcrypt)。"""
|
||||||
|
if stored_hash.startswith("$2"):
|
||||||
|
return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8")), False
|
||||||
|
|
||||||
|
legacy_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
return hmac.compare_digest(legacy_hash, stored_hash), True
|
||||||
@ -16,6 +16,7 @@ from ..config import (
|
|||||||
SKINS_DIR,
|
SKINS_DIR,
|
||||||
AUTOMATION_DIR,
|
AUTOMATION_DIR,
|
||||||
get_source_dir,
|
get_source_dir,
|
||||||
|
get_shared_source_dir,
|
||||||
get_git_remote_url,
|
get_git_remote_url,
|
||||||
mask_git_remote_url,
|
mask_git_remote_url,
|
||||||
)
|
)
|
||||||
@ -33,6 +34,11 @@ class BuildError(Exception):
|
|||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
# 共享源码目录只能同时被一个任务切换、清理和复制。
|
||||||
|
# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。
|
||||||
|
source_prepare_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
# 错误分类规则:(关键词列表, category, 友好提示)
|
# 错误分类规则:(关键词列表, category, 友好提示)
|
||||||
_ERROR_RULES = [
|
_ERROR_RULES = [
|
||||||
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
|
||||||
@ -136,18 +142,18 @@ def _db_update(db, task, **fields):
|
|||||||
|
|
||||||
|
|
||||||
async def update_source(task_id: str, source_dir: Path, branch: str):
|
async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||||
"""确保分支源码目录存在且为最新"""
|
"""将共享源码工作目录强制同步到指定远程分支。"""
|
||||||
remote_url = get_git_remote_url(with_credentials=True)
|
remote_url = get_git_remote_url(with_credentials=True)
|
||||||
remote_url_masked = mask_git_remote_url(remote_url)
|
remote_url_masked = mask_git_remote_url(remote_url)
|
||||||
|
|
||||||
if not source_dir.exists():
|
cloned = not source_dir.exists()
|
||||||
# 首次:从远程 clone
|
if cloned:
|
||||||
if not remote_url:
|
if not remote_url:
|
||||||
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
|
||||||
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
source_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked} → {branch}")
|
await log_streamer.emit(task_id, f"初始化共享源码目录: {remote_url_masked}")
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir),
|
"git", "clone", remote_url, str(source_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
@ -157,25 +163,32 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
|||||||
await log_streamer.emit(task_id, decoded)
|
await log_streamer.emit(task_id, decoded)
|
||||||
await process.wait()
|
await process.wait()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise Exception(f"git clone 失败: {branch}")
|
raise Exception("git clone 失败")
|
||||||
else:
|
|
||||||
# 已存在:fetch + checkout + pull
|
|
||||||
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
|
|
||||||
|
|
||||||
if remote_url:
|
# git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。
|
||||||
process = await asyncio.create_subprocess_exec(
|
if not cloned and not (source_dir / ".git").exists():
|
||||||
"git", "remote", "set-url", "origin", remote_url,
|
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
|
||||||
cwd=str(source_dir),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
await process.wait()
|
|
||||||
|
|
||||||
# fetch
|
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}")
|
||||||
|
|
||||||
|
if remote_url:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"git", "fetch", "origin",
|
"git", "remote", "set-url", "origin", remote_url,
|
||||||
cwd=str(source_dir),
|
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
)
|
||||||
|
await process.wait()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise Exception("git remote set-url 失败")
|
||||||
|
|
||||||
|
commands = [
|
||||||
|
(("git", "fetch", "origin"), "git fetch 失败"),
|
||||||
|
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
|
||||||
|
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
|
||||||
|
(("git", "clean", "-ffdx"), "git clean 失败"),
|
||||||
|
]
|
||||||
|
for command, error_message in commands:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command, cwd=str(source_dir), stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
async for line in process.stdout:
|
async for line in process.stdout:
|
||||||
@ -184,41 +197,47 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
|||||||
await log_streamer.emit(task_id, decoded)
|
await log_streamer.emit(task_id, decoded)
|
||||||
await process.wait()
|
await process.wait()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise Exception("git fetch 失败")
|
raise Exception(error_message)
|
||||||
|
|
||||||
# checkout
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
"git", "checkout", "-B", branch, f"origin/{branch}",
|
|
||||||
cwd=str(source_dir),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
async for line in process.stdout:
|
|
||||||
decoded = line.decode("utf-8", errors="replace").strip()
|
|
||||||
if decoded:
|
|
||||||
await log_streamer.emit(task_id, decoded)
|
|
||||||
await process.wait()
|
|
||||||
if process.returncode != 0:
|
|
||||||
raise Exception(f"git checkout {branch} 失败")
|
|
||||||
|
|
||||||
# pull
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
"git", "pull", "origin", branch,
|
|
||||||
cwd=str(source_dir),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
async for line in process.stdout:
|
|
||||||
decoded = line.decode("utf-8", errors="replace").strip()
|
|
||||||
if decoded:
|
|
||||||
await log_streamer.emit(task_id, decoded)
|
|
||||||
await process.wait()
|
|
||||||
if process.returncode != 0:
|
|
||||||
raise Exception(f"git pull {branch} 失败")
|
|
||||||
|
|
||||||
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
|
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_commit(source_dir: Path) -> str:
|
||||||
|
"""读取当前共享源码快照的 commit SHA。"""
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"git", "rev-parse", "HEAD", cwd=str(source_dir),
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
output = await process.stdout.read()
|
||||||
|
await process.wait()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise Exception("读取源码 commit 失败")
|
||||||
|
return output.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
|
||||||
|
"""准备任务独立源码快照;远程仓库模式下此过程全局串行。"""
|
||||||
|
if not get_git_remote_url():
|
||||||
|
source_dir = get_source_dir(task.branch)
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise Exception(
|
||||||
|
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
|
||||||
|
)
|
||||||
|
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||||
|
return await copy_source_code(task_id, task, source_dir), ""
|
||||||
|
|
||||||
|
source_dir = get_shared_source_dir()
|
||||||
|
await log_streamer.emit(task_id, "等待共享源码准备队列...")
|
||||||
|
async with source_prepare_lock:
|
||||||
|
await log_streamer.emit(task_id, "开始准备共享源码快照")
|
||||||
|
await update_source(task_id, source_dir, task.branch)
|
||||||
|
commit = await get_source_commit(source_dir)
|
||||||
|
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
|
||||||
|
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||||
|
await log_streamer.emit(task_id, "共享源码快照已就绪,开始并行打包")
|
||||||
|
return build_dir, commit
|
||||||
|
|
||||||
|
|
||||||
async def run_build_task(task_id: str):
|
async def run_build_task(task_id: str):
|
||||||
"""执行打包任务"""
|
"""执行打包任务"""
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
@ -251,25 +270,16 @@ async def run_build_task(task_id: str):
|
|||||||
|
|
||||||
async def _do_build():
|
async def _do_build():
|
||||||
nonlocal build_dir
|
nonlocal build_dir
|
||||||
# 1. 更新分支源码并拷贝
|
# 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。
|
||||||
source_dir = get_source_dir(task.branch)
|
await asyncio.to_thread(_db_update, db, task, current_step="source_prepare")
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
build_dir, source_commit = await prepare_source_snapshot(task_id, task)
|
||||||
if get_git_remote_url():
|
|
||||||
# 配置了远程仓库,从分支目录获取源码
|
|
||||||
await update_source(task_id, source_dir, task.branch)
|
|
||||||
elif source_dir.exists():
|
|
||||||
# 分支目录已存在,直接使用
|
|
||||||
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
|
||||||
else:
|
|
||||||
raise Exception(
|
|
||||||
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
|
|
||||||
)
|
|
||||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
|
||||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||||
|
|
||||||
# 2. 生成配置
|
# 2. 生成配置
|
||||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||||
config_data = await generate_config(task_id, task, build_dir)
|
config_data = await generate_config(task_id, task, build_dir)
|
||||||
|
config_data["SOURCE_BRANCH"] = task.branch
|
||||||
|
config_data["SOURCE_COMMIT"] = source_commit
|
||||||
await asyncio.to_thread(_db_update, db, task,
|
await asyncio.to_thread(_db_update, db, task,
|
||||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||||
|
|
||||||
@ -661,14 +671,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
|
|
||||||
# 清理
|
# 清理
|
||||||
await log_streamer.emit(task_id, "清理项目...")
|
await log_streamer.emit(task_id, "清理项目...")
|
||||||
clean_cmd = (
|
process = await asyncio.create_subprocess_exec(
|
||||||
f"xcodebuild clean -workspace {workspace_path.name} "
|
"xcodebuild", "clean",
|
||||||
f"-scheme {scheme} "
|
"-workspace", workspace_path.name,
|
||||||
f"-configuration Release "
|
"-scheme", scheme,
|
||||||
f"-derivedDataPath {export_path / 'derived_data'}"
|
"-configuration", "Release",
|
||||||
)
|
"-derivedDataPath", str(export_path / "derived_data"),
|
||||||
process = await asyncio.create_subprocess_shell(
|
|
||||||
clean_cmd,
|
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
@ -681,17 +689,16 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
|
|
||||||
# Archive
|
# Archive
|
||||||
await log_streamer.emit(task_id, "开始 Archive...")
|
await log_streamer.emit(task_id, "开始 Archive...")
|
||||||
archive_cmd = (
|
|
||||||
f"xcodebuild archive -workspace {workspace_path.name} "
|
|
||||||
f"-scheme {scheme} "
|
|
||||||
f"-configuration Release "
|
|
||||||
f"-archivePath {archive_path} "
|
|
||||||
f"-derivedDataPath {export_path / 'derived_data'} "
|
|
||||||
f"-destination generic/platform=ios -quiet"
|
|
||||||
)
|
|
||||||
archive_output = []
|
archive_output = []
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_exec(
|
||||||
archive_cmd,
|
"xcodebuild", "archive",
|
||||||
|
"-workspace", workspace_path.name,
|
||||||
|
"-scheme", scheme,
|
||||||
|
"-configuration", "Release",
|
||||||
|
"-archivePath", str(archive_path),
|
||||||
|
"-derivedDataPath", str(export_path / "derived_data"),
|
||||||
|
"-destination", "generic/platform=ios",
|
||||||
|
"-quiet",
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
@ -710,15 +717,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
|||||||
# 导出 IPA
|
# 导出 IPA
|
||||||
await log_streamer.emit(task_id, "导出 IPA...")
|
await log_streamer.emit(task_id, "导出 IPA...")
|
||||||
export_plist = build_dir / "exportOptions.plist"
|
export_plist = build_dir / "exportOptions.plist"
|
||||||
export_cmd = (
|
|
||||||
f"xcodebuild -exportArchive "
|
|
||||||
f"-archivePath {archive_path} "
|
|
||||||
f"-exportPath {export_path} "
|
|
||||||
f"-exportOptionsPlist {export_plist}"
|
|
||||||
)
|
|
||||||
export_output = []
|
export_output = []
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = await asyncio.create_subprocess_exec(
|
||||||
export_cmd,
|
"xcodebuild", "-exportArchive",
|
||||||
|
"-archivePath", str(archive_path),
|
||||||
|
"-exportPath", str(export_path),
|
||||||
|
"-exportOptionsPlist", str(export_plist),
|
||||||
cwd=str(build_dir),
|
cwd=str(build_dir),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
|||||||
16
deploy.sh
16
deploy.sh
@ -18,6 +18,7 @@ if [ -f .env ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||||
|
BIND_HOST="${BIND_HOST:-127.0.0.1}"
|
||||||
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒)
|
||||||
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒)
|
||||||
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却
|
||||||
@ -159,10 +160,12 @@ do_start() {
|
|||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
info "启动后端服务 (端口: $BACKEND_PORT)..."
|
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
||||||
--host 0.0.0.0 \
|
--host "$BIND_HOST" \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips 127.0.0.1 \
|
||||||
--workers 1 \
|
--workers 1 \
|
||||||
> "$LOG_DIR/server.log" 2>&1 &
|
> "$LOG_DIR/server.log" 2>&1 &
|
||||||
|
|
||||||
@ -170,11 +173,8 @@ do_start() {
|
|||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
local access_host
|
|
||||||
access_host=$(ipconfig getifaddr en0 2>/dev/null || true)
|
|
||||||
access_host="${access_host:-localhost}"
|
|
||||||
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
info "服务已启动 (PID: $(cat "$PID_FILE"))"
|
||||||
info "访问地址: http://$access_host:$BACKEND_PORT"
|
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
|
||||||
else
|
else
|
||||||
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
error "启动失败,请查看日志: $LOG_DIR/server.log"
|
||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
@ -382,8 +382,10 @@ do_watchdog_loop() {
|
|||||||
setup_venv
|
setup_venv
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \
|
||||||
--host 0.0.0.0 \
|
--host "$BIND_HOST" \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
--proxy-headers \
|
||||||
|
--forwarded-allow-ips 127.0.0.1 \
|
||||||
--workers 1 \
|
--workers 1 \
|
||||||
>> "$LOG_DIR/server.log" 2>&1 &
|
>> "$LOG_DIR/server.log" 2>&1 &
|
||||||
echo $! > "$PID_FILE"
|
echo $! > "$PID_FILE"
|
||||||
|
|||||||
40
deploy/nginx.conf.example
Normal file
40
deploy/nginx.conf.example
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# 将 build.example.com、证书路径和端口替换为实际值。
|
||||||
|
limit_req_zone $binary_remote_addr zone=build_login:10m rate=5r/m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name build.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name build.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/build.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/build.example.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location = /api/auth/login {
|
||||||
|
limit_req zone=build_login burst=5 nodelay;
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
<nav v-if="isLoggedIn" class="nav">
|
<nav v-if="isLoggedIn" class="nav">
|
||||||
<router-link to="/build">打包</router-link>
|
<router-link to="/build">打包</router-link>
|
||||||
<router-link to="/history">历史</router-link>
|
<router-link to="/history">历史</router-link>
|
||||||
<router-link v-if="isAdmin" to="/admin">管理</router-link>
|
<router-link to="/admin">管理</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-if="!isLoggedIn" class="user-info">
|
<div v-if="!isLoggedIn" class="user-info">
|
||||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
|
|||||||
96
frontend/src/__tests__/BuildViewEnvironmentFilter.test.js
Normal file
96
frontend/src/__tests__/BuildViewEnvironmentFilter.test.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import BuildView from '../views/BuildView.vue'
|
||||||
|
|
||||||
|
const createTestRouter = () => createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/build', component: BuildView }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountBuildView = async () => {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push('/build')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
const responses = {
|
||||||
|
'/api/apps': {
|
||||||
|
'1': { name: '测试阅读', server: '测试环境' },
|
||||||
|
'2': { name: '正式阅读', server: '正式环境' },
|
||||||
|
'3': { name: '测试词典', server: '测试环境' },
|
||||||
|
},
|
||||||
|
'/api/schemes': { '1': { name: 'readoor31' } },
|
||||||
|
'/api/branches': ['main'],
|
||||||
|
'/api/tasks': [],
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(responses[url] || {}) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(BuildView, {
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
provide: { getToken: () => '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BuildView server environment filter', () => {
|
||||||
|
it('filters Apps by the selected server environment', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const serverSelect = selects[0]
|
||||||
|
const appSelect = selects[1]
|
||||||
|
|
||||||
|
expect(appSelect.attributes('disabled')).toBeDefined()
|
||||||
|
await serverSelect.setValue('测试环境')
|
||||||
|
|
||||||
|
expect(appSelect.text()).toContain('测试阅读')
|
||||||
|
expect(appSelect.text()).toContain('测试词典')
|
||||||
|
expect(appSelect.text()).not.toContain('正式阅读')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the selected App when switching environments', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const serverSelect = selects[0]
|
||||||
|
const appSelect = selects[1]
|
||||||
|
|
||||||
|
await serverSelect.setValue('测试环境')
|
||||||
|
await appSelect.setValue('1')
|
||||||
|
expect(wrapper.vm.form.app_id).toBe('1')
|
||||||
|
|
||||||
|
await serverSelect.setValue('正式环境')
|
||||||
|
expect(wrapper.vm.form.app_id).toBe('')
|
||||||
|
expect(appSelect.text()).toContain('正式阅读')
|
||||||
|
expect(appSelect.text()).not.toContain('测试阅读')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only offers configured build types and special App Schemes', async () => {
|
||||||
|
const wrapper = await mountBuildView()
|
||||||
|
await wrapper.setData({
|
||||||
|
apps: {
|
||||||
|
'1': {
|
||||||
|
name: '英汉大词典',
|
||||||
|
server: '测试环境',
|
||||||
|
certificates: { App_Store: { name: 'com.dictionary.app' } },
|
||||||
|
allowed_scheme_names: ['readoorDict'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemes: {
|
||||||
|
'1': { name: 'readoor31' },
|
||||||
|
'2': { name: 'readoorDict' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
await selects[0].setValue('测试环境')
|
||||||
|
await selects[1].setValue('1')
|
||||||
|
|
||||||
|
expect(selects[2].text()).toContain('App_Store')
|
||||||
|
expect(selects[2].text()).not.toContain('Ad_Hoc')
|
||||||
|
expect(selects[3].text()).toContain('readoorDict')
|
||||||
|
expect(selects[3].text()).not.toContain('readoor31')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -5,25 +5,37 @@
|
|||||||
<div class="config-panel">
|
<div class="config-panel">
|
||||||
<h2>打包配置</h2>
|
<h2>打包配置</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>选择 App</label>
|
<label>选择服务器环境</label>
|
||||||
<select v-model="form.app_id">
|
<select v-model="selectedServer">
|
||||||
<option value="">请选择...</option>
|
<option value="">请选择...</option>
|
||||||
<option v-for="(app, id) in apps" :key="id" :value="id">
|
<option v-for="server in serverEnvironments" :key="server" :value="server">
|
||||||
{{ app.server }} - {{ app.name }}
|
{{ server }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择 App</label>
|
||||||
|
<select v-model="form.app_id" :disabled="!selectedServer">
|
||||||
|
<option value="">{{ selectedServer ? '请选择...' : '请先选择服务器环境' }}</option>
|
||||||
|
<option v-for="[id, app] in filteredApps" :key="id" :value="id">
|
||||||
|
{{ app.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>打包类型</label>
|
<label>打包类型</label>
|
||||||
<select v-model="form.build_type">
|
<select v-model="form.build_type">
|
||||||
<option value="Ad_Hoc">Ad_Hoc</option>
|
<option value="" disabled>请选择...</option>
|
||||||
<option value="App_Store">App_Store</option>
|
<option v-for="buildType in availableBuildTypes" :key="buildType" :value="buildType">
|
||||||
|
{{ buildType }}
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>选择 Scheme</label>
|
<label>选择 Scheme</label>
|
||||||
<select v-model="form.scheme_id">
|
<select v-model="form.scheme_id">
|
||||||
<option v-for="(scheme, id) in schemes" :key="id" :value="id">
|
<option value="" disabled>请选择...</option>
|
||||||
|
<option v-for="[id, scheme] in filteredSchemes" :key="id" :value="id">
|
||||||
{{ scheme.displayName || scheme.name }}
|
{{ scheme.displayName || scheme.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@ -138,7 +150,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
|
import { computed, ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const getToken = inject('getToken')
|
const getToken = inject('getToken')
|
||||||
@ -155,10 +167,28 @@ const apps = ref({})
|
|||||||
const schemes = ref({})
|
const schemes = ref({})
|
||||||
const branches = ref(['main'])
|
const branches = ref(['main'])
|
||||||
const tasks = ref([])
|
const tasks = ref([])
|
||||||
|
const selectedServer = ref('')
|
||||||
|
const serverEnvironments = computed(() => {
|
||||||
|
return [...new Set(Object.values(apps.value).map(app => app.server).filter(Boolean))]
|
||||||
|
})
|
||||||
|
const filteredApps = computed(() => {
|
||||||
|
return Object.entries(apps.value).filter(([, app]) => app.server === selectedServer.value)
|
||||||
|
})
|
||||||
|
const selectedApp = computed(() => apps.value[form.value.app_id] || null)
|
||||||
|
const availableBuildTypes = computed(() => {
|
||||||
|
const certificates = selectedApp.value?.certificates || {}
|
||||||
|
return ['Ad_Hoc', 'App_Store'].filter(buildType => Boolean(certificates[buildType]))
|
||||||
|
})
|
||||||
|
const filteredSchemes = computed(() => {
|
||||||
|
const allowedNames = selectedApp.value?.allowed_scheme_names || []
|
||||||
|
return Object.entries(schemes.value).filter(([, scheme]) => {
|
||||||
|
return !allowedNames.length || allowedNames.includes(scheme.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
const form = ref({
|
const form = ref({
|
||||||
app_id: '',
|
app_id: '',
|
||||||
build_type: 'Ad_Hoc',
|
build_type: '',
|
||||||
scheme_id: '1',
|
scheme_id: '',
|
||||||
obfuscation: false,
|
obfuscation: false,
|
||||||
branch: 'main',
|
branch: 'main',
|
||||||
})
|
})
|
||||||
@ -224,7 +254,9 @@ const connectWs = (taskId) => {
|
|||||||
completedTask.value = null
|
completedTask.value = null
|
||||||
|
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
|
||||||
activeWs = ws
|
activeWs = ws
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
@ -258,6 +290,15 @@ watch(() => currentTaskId.value, (newId) => {
|
|||||||
connectWs(newId)
|
connectWs(newId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(selectedServer, () => {
|
||||||
|
form.value.app_id = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.value.app_id, () => {
|
||||||
|
form.value.scheme_id = filteredSchemes.value[0]?.[0] || ''
|
||||||
|
form.value.build_type = availableBuildTypes.value[0] || ''
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (activeWs) {
|
if (activeWs) {
|
||||||
activeWs.close()
|
activeWs.close()
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-if="!isAdmin" class="access-denied">
|
<div v-if="!isLoggedIn" class="access-denied">
|
||||||
<h3>需要管理员权限</h3>
|
<h3>请先登录</h3>
|
||||||
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
|
<p>登录后可管理 Apps 配置</p>
|
||||||
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="admin-page">
|
<div v-else class="admin-page">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<h3>配置管理</h3>
|
<h3>配置管理</h3>
|
||||||
<ul class="sidebar-menu">
|
<ul class="sidebar-menu">
|
||||||
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
|
||||||
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
||||||
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
||||||
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||||
<li :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'branches' }" @click="tab = 'branches'">分支管理</li>
|
||||||
<li :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'upload' }" @click="tab = 'upload'">上传配置</li>
|
||||||
<li :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'build' }" @click="tab = 'build'">打包设置</li>
|
||||||
<li :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
<li v-if="isAdmin" :class="{ active: tab === 'json' }" @click="tab = 'json'">JSON 编辑</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@ -405,12 +405,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>服务器环境 *</label>
|
<label>服务器环境 *</label>
|
||||||
<select v-model="appForm.server" @change="onServerChange">
|
<select v-if="isAdmin" v-model="appForm.server" @change="onServerChange">
|
||||||
<option value="">请选择环境</option>
|
<option value="">请选择环境</option>
|
||||||
<option v-for="(server, name) in servers" :key="name" :value="name">
|
<option v-for="(server, name) in servers" :key="name" :value="name">
|
||||||
{{ name }} - {{ server.api }}
|
{{ name }} - {{ server.api }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input v-else v-model="appForm.server" type="text" placeholder="例如:测试环境">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@ -479,7 +480,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Provisioning Profile 路径</label>
|
<label>Provisioning Profile 名称</label>
|
||||||
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
<input v-model="appForm.certificates[certType].pro" type="text" placeholder="/Users/.../xxx.mobileprovision">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -563,7 +564,7 @@ const showLogin = inject('showLogin')
|
|||||||
const getToken = inject('getToken')
|
const getToken = inject('getToken')
|
||||||
const isLoggedIn = inject('isLoggedIn')
|
const isLoggedIn = inject('isLoggedIn')
|
||||||
const isAdmin = inject('isAdmin')
|
const isAdmin = inject('isAdmin')
|
||||||
const tab = ref('users')
|
const tab = ref('apps')
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
@ -610,6 +611,12 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
const appsRes = await authFetch('/api/config/apps')
|
||||||
|
if (appsRes.ok) apps.value = await appsRes.json()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
|
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
|
||||||
authFetch('/api/config/apps'),
|
authFetch('/api/config/apps'),
|
||||||
authFetch('/api/config/schemes'),
|
authFetch('/api/config/schemes'),
|
||||||
|
|||||||
@ -262,7 +262,9 @@ const viewLogs = async (taskId) => {
|
|||||||
const connectLogWs = (taskId) => {
|
const connectLogWs = (taskId) => {
|
||||||
if (logWs) { logWs.close(); logWs = null }
|
if (logWs) { logWs.close(); logWs = null }
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`, [`jwt.${token}`])
|
||||||
logWs = ws
|
logWs = ws
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ sqlalchemy>=2.0.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
PyJWT>=2.8.0
|
PyJWT>=2.8.0
|
||||||
|
bcrypt>=4.1.0
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
oss2>=2.19.0
|
oss2>=2.19.0
|
||||||
qrcode[pil]>=7.4.0
|
qrcode[pil]>=7.4.0
|
||||||
|
|||||||
@ -51,7 +51,7 @@ def tmp_config(tmp_path, monkeypatch):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemes": {
|
"schemes": {
|
||||||
"1": {"name": "testScheme", "ossFloder": "test"}
|
"1": {"name": "readoor31", "ossFloder": "test"}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""任务接口测试"""
|
"""任务接口测试"""
|
||||||
|
import json
|
||||||
from unittest.mock import patch, AsyncMock
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ def test_create_task(mock_queue, client, tmp_config):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["status"] == "pending"
|
assert data["status"] == "pending"
|
||||||
assert data["app_name"] == "测试App"
|
assert data["app_name"] == "测试App"
|
||||||
assert data["scheme_name"] == "testScheme"
|
assert data["scheme_name"] == "readoor31"
|
||||||
assert data["branch"] == "main"
|
assert data["branch"] == "main"
|
||||||
assert data["build_type"] == "Ad_Hoc"
|
assert data["build_type"] == "Ad_Hoc"
|
||||||
|
|
||||||
@ -61,6 +62,60 @@ def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_unconfigured_certificate_type(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["apps"]["1"]["certificates"] = {
|
||||||
|
"Ad_Hoc": {"name": "com.test.app"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "App_Store", "scheme_id": "1", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "未配置 App_Store 证书" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_disallowed_special_app_scheme(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["apps"]["1"].update({
|
||||||
|
"name": "英汉大词典",
|
||||||
|
"certificates": {"Ad_Hoc": {"name": "com.dictionary.app"}},
|
||||||
|
})
|
||||||
|
config["schemes"] = {
|
||||||
|
"1": {"name": "readoor31"},
|
||||||
|
"2": {"name": "readoorDict"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "readoorDict" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("backend.services.build_queue.build_queue")
|
||||||
|
def test_create_task_rejects_non_default_scheme_for_regular_app(mock_queue, client, tmp_config):
|
||||||
|
config = json.loads(tmp_config.read_text())
|
||||||
|
config["schemes"] = {
|
||||||
|
"1": {"name": "readoor31"},
|
||||||
|
"2": {"name": "readoorDict"},
|
||||||
|
}
|
||||||
|
tmp_config.write_text(json.dumps(config))
|
||||||
|
|
||||||
|
resp = client.post("/api/tasks", json={
|
||||||
|
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "2", "branch": "main",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "readoor31OtherPay" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
@patch("backend.services.build_queue.build_queue")
|
@patch("backend.services.build_queue.build_queue")
|
||||||
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
||||||
mock_queue.submit = AsyncMock()
|
mock_queue.submit = AsyncMock()
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import pytest
|
|||||||
from backend.services.build_service import (
|
from backend.services.build_service import (
|
||||||
update_source,
|
update_source,
|
||||||
copy_source_code,
|
copy_source_code,
|
||||||
|
prepare_source_snapshot,
|
||||||
|
build_project,
|
||||||
generate_config,
|
generate_config,
|
||||||
_cleanup_old_builds,
|
_cleanup_old_builds,
|
||||||
)
|
)
|
||||||
@ -69,10 +71,8 @@ async def test_update_source_clone(tmp_path, log_streamer):
|
|||||||
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
|
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
|
||||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
||||||
await update_source("t1", source_dir, "main")
|
await update_source("t1", source_dir, "main")
|
||||||
mock_exec.assert_called_once()
|
assert mock_exec.call_count == 6 # clone, set-url, fetch, checkout, reset, clean
|
||||||
args = mock_exec.call_args[0]
|
assert mock_exec.call_args_list[0].args[:2] == ("git", "clone")
|
||||||
assert "git" in args
|
|
||||||
assert "clone" in args
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||||
@ -84,8 +84,8 @@ async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
|||||||
await update_source("t1", source_dir, "main")
|
await update_source("t1", source_dir, "main")
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_pull_existing(tmp_path, log_streamer):
|
async def test_update_source_existing(tmp_path, log_streamer):
|
||||||
"""目录已存在时执行 fetch + checkout + pull"""
|
"""目录已存在时执行 fetch + checkout + reset + clean"""
|
||||||
source_dir = tmp_path / "branches" / "dev"
|
source_dir = tmp_path / "branches" / "dev"
|
||||||
source_dir.parent.mkdir(parents=True)
|
source_dir.parent.mkdir(parents=True)
|
||||||
source_dir.mkdir()
|
source_dir.mkdir()
|
||||||
@ -102,7 +102,7 @@ async def test_update_source_pull_existing(tmp_path, log_streamer):
|
|||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||||
await update_source("t1", source_dir, "dev")
|
await update_source("t1", source_dir, "dev")
|
||||||
|
|
||||||
assert call_count == 3 # fetch, checkout, pull
|
assert call_count == 4 # fetch, checkout, reset, clean
|
||||||
|
|
||||||
|
|
||||||
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||||
@ -110,6 +110,7 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
|||||||
source_dir = tmp_path / "branches" / "dev"
|
source_dir = tmp_path / "branches" / "dev"
|
||||||
source_dir.parent.mkdir(parents=True)
|
source_dir.parent.mkdir(parents=True)
|
||||||
source_dir.mkdir()
|
source_dir.mkdir()
|
||||||
|
(source_dir / ".git").mkdir()
|
||||||
|
|
||||||
async def mock_exec(*args, **kwargs):
|
async def mock_exec(*args, **kwargs):
|
||||||
return _make_mock_process(returncode=1, output=b"error\n")
|
return _make_mock_process(returncode=1, output=b"error\n")
|
||||||
@ -120,6 +121,22 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
|||||||
await update_source("t1", source_dir, "dev")
|
await update_source("t1", source_dir, "dev")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_streamer):
|
||||||
|
"""共享源码模式下,更新和复制必须在同一把锁内完成。"""
|
||||||
|
source_dir, build_dir_parent = tmp_dirs
|
||||||
|
task = MagicMock(branch="main")
|
||||||
|
|
||||||
|
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
|
||||||
|
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
|
||||||
|
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
|
||||||
|
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \
|
||||||
|
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
|
||||||
|
build_dir, commit = await prepare_source_snapshot("t1", task)
|
||||||
|
|
||||||
|
assert build_dir.exists()
|
||||||
|
assert commit == "abc123"
|
||||||
|
|
||||||
|
|
||||||
# ---- copy_source_code ----
|
# ---- copy_source_code ----
|
||||||
|
|
||||||
async def test_copy_source_code(tmp_dirs, log_streamer):
|
async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||||
@ -170,6 +187,24 @@ async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
|
|||||||
assert (result / "readoor").exists()
|
assert (result / "readoor").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_build_project_passes_scheme_as_exec_argument(tmp_path, log_streamer):
|
||||||
|
"""Scheme 中的 shell 特殊字符只能作为 xcodebuild 参数,不能被执行。"""
|
||||||
|
build_dir = tmp_path / "build"
|
||||||
|
build_dir.mkdir()
|
||||||
|
task = MagicMock()
|
||||||
|
config_data = {"SCHEME": "App; touch /tmp/should-not-run"}
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec, \
|
||||||
|
patch("asyncio.create_subprocess_shell") as mock_shell:
|
||||||
|
with pytest.raises(Exception, match="未找到 IPA 文件"):
|
||||||
|
await build_project("t1", task, config_data, build_dir)
|
||||||
|
|
||||||
|
assert mock_shell.call_count == 0
|
||||||
|
archive_args = mock_exec.call_args_list[1].args
|
||||||
|
assert archive_args[0:2] == ("xcodebuild", "archive")
|
||||||
|
assert "App; touch /tmp/should-not-run" in archive_args
|
||||||
|
|
||||||
|
|
||||||
# ---- generate_config ----
|
# ---- generate_config ----
|
||||||
|
|
||||||
async def test_generate_config(tmp_path, log_streamer):
|
async def test_generate_config(tmp_path, log_streamer):
|
||||||
|
|||||||
60
tests/test_security.py
Normal file
60
tests/test_security.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""公网部署安全回归测试。"""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
from backend.main import app
|
||||||
|
from backend.security import hash_password, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def test_passwords_use_bcrypt_and_verify():
|
||||||
|
password_hash = hash_password("a-strong-password")
|
||||||
|
|
||||||
|
assert password_hash.startswith("$2")
|
||||||
|
assert verify_password("a-strong-password", password_hash) == (True, False)
|
||||||
|
assert verify_password("wrong-password", password_hash) == (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejects_connection_without_jwt():
|
||||||
|
with TestClient(app) as unauthenticated_client:
|
||||||
|
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||||
|
with unauthenticated_client.websocket_connect("/ws/tasks/task-1"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert exc_info.value.code == 1008
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_security_settings(monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "APP_ENV", "production")
|
||||||
|
monkeypatch.setattr(config, "JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||||
|
monkeypatch.setattr(config, "ADMIN_PASSWORD", "admin123")
|
||||||
|
monkeypatch.setattr(config, "CORS_ALLOWED_ORIGINS", ["*"])
|
||||||
|
monkeypatch.setattr(config, "TRUSTED_HOSTS", ["*"])
|
||||||
|
monkeypatch.setenv("TRUSTED_HOSTS", "*")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="生产环境安全配置不完整"):
|
||||||
|
config.validate_production_security()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
|
||||||
|
"""普通账号只能管理 Apps,不能读取或修改包含密钥的配置。"""
|
||||||
|
response = client.post("/api/users", json={
|
||||||
|
"username": "builder",
|
||||||
|
"password": "builder-password-123",
|
||||||
|
"is_admin": False,
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
login = client.post("/api/auth/login", json={
|
||||||
|
"username": "builder",
|
||||||
|
"password": "builder-password-123",
|
||||||
|
})
|
||||||
|
token = login.json()["token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
assert client.get("/api/config/apps", headers=headers).status_code == 200
|
||||||
|
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200
|
||||||
|
assert client.get("/api/config", headers=headers).status_code == 403
|
||||||
|
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
||||||
|
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403
|
||||||
Loading…
Reference in New Issue
Block a user