From a48909f3bc437436550aeb88ae0cd5dc6c4c2b44 Mon Sep 17 00:00:00 2001 From: shen <> Date: Sat, 18 Jul 2026 13:30:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=BA=E5=8C=96=E6=89=93=E5=8C=85?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=9C=8D=E5=8A=A1=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 30 ++- AGENTS.md | 35 ++++ README.md | 53 +++-- backend/config.py | 42 +++- backend/database.py | 6 +- backend/deps.py | 17 +- backend/main.py | 41 ++-- backend/routers/apps.py | 21 +- backend/routers/auth.py | 56 +++++- backend/routers/config.py | 15 +- backend/routers/tasks.py | 16 +- backend/routers/users.py | 18 +- backend/security.py | 19 ++ backend/services/build_service.py | 188 +++++++++--------- deploy.sh | 16 +- deploy/nginx.conf.example | 40 ++++ frontend/src/App.vue | 2 +- .../BuildViewEnvironmentFilter.test.js | 96 +++++++++ frontend/src/views/BuildView.vue | 63 +++++- frontend/src/views/ConfigView.vue | 35 ++-- frontend/src/views/HistoryView.vue | 4 +- requirements.txt | 1 + tests/conftest.py | 2 +- tests/test_api_tasks.py | 57 +++++- tests/test_build_service.py | 49 ++++- tests/test_security.py | 60 ++++++ 26 files changed, 779 insertions(+), 203 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/security.py create mode 100644 deploy/nginx.conf.example create mode 100644 frontend/src/__tests__/BuildViewEnvironmentFilter.test.js create mode 100644 tests/test_security.py diff --git a/.env.example b/.env.example index 02e90e6..6aaef68 100644 --- a/.env.example +++ b/.env.example @@ -2,17 +2,21 @@ # 复制此文件为 .env 并修改为实际值 # ---- 目录配置 ---- -# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev) +# 源码根目录;未配置远程仓库时,每个分支一个子目录 GIT_SOURCE_BASE=./ReadoorBranches +# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照 +GIT_SOURCE_DIR=./ReadoorBranches/workspace + # 打包输出基础目录(每次打包会在此目录下创建子目录) BUILD_BASE_DIR=./build # ---- 分支源码管理 ---- -# Git 远程仓库地址,分支目录不存在时自动 clone -GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git -GIT_USERNAME=shenlei -GIT_PASSWORD='&s9U5fbreA' +# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR +GIT_REMOTE_URL=https://git.example.com/group/repo.git +# 私有仓库:使用只读 Personal Access Token,不要使用账号登录密码。 +GIT_USERNAME=git-readonly-user +GIT_PASSWORD=cc774880b9d9b368c5f24a3821ee745dd5e4c949 # ---- 服务端口 ---- BACKEND_PORT=5002 @@ -20,11 +24,23 @@ FRONTEND_PORT=5999 # ---- 管理员账号 ---- ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 +ADMIN_PASSWORD=change-this-before-starting # ---- 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 # ---- 并发与清理 ---- # 最大并行打包数 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35ec755 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index c9a420f..f943475 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [环境要求](#环境要求) - [快速开始](#快速开始) - [配置说明](#配置说明) +- [公网部署](#公网部署) - [服务管理](#服务管理) - [多用户管理](#多用户管理) - [Watchdog 健康监测](#watchdog-健康监测) @@ -72,11 +73,11 @@ ADMIN_PASSWORD=your_secure_password 启动后访问 `http://<服务器IP>:8000` 即可使用。 -### 4.(可选)初始化分支源码目录 +### 4.(可选)初始化源码目录 -系统现在默认直接从分支源码目录打包,每个分支对应 `GIT_SOURCE_BASE` 下的一个子目录。 +配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建。 -如果已配置 `GIT_REMOTE_URL`,首次打包某个分支时会自动 clone;如果未配置,则需要先手动准备分支目录: +未配置远程仓库时,需要继续手动准备每个分支目录: ```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_REMOTE_URL`,其他分支无需手动 clone,首次打包时会自动拉取。 +配置远程仓库时,无需手动 clone;首次打包会初始化共享目录。 ### 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` | 打包产物输出基础目录 | -| `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` | 初始管理员密码 | | `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: 如何按分支打包 -1. 在 `.env` 中配置 `GIT_SOURCE_BASE` 和 `GIT_REMOTE_URL` +1. 在 `.env` 中配置 `GIT_SOURCE_BASE`、`GIT_SOURCE_DIR` 和 `GIT_REMOTE_URL` 2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`) 3. 打包时在「代码分支」下拉框中选择分支 -4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新 -5. 每个分支有独立的源码目录,支持并行打包不同分支 +4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照 +5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行 -### Q: 分支目录占满磁盘怎么办 +### Q: 源码目录占满磁盘怎么办 ```bash -# 查看各分支目录大小 -du -sh /path/to/ReadoorBranches/* +# 远程仓库模式下只有一个共享工作目录 +du -sh /path/to/ReadoorBranches/workspace -# 删除不用的分支目录 -rm -rf /path/to/ReadoorBranches/old-branch +# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用 +du -sh /path/to/build/build_readoor_* ``` +共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。 + ### Q: 如何运行测试 ```bash diff --git a/backend/config.py b/backend/config.py index 6f14bf5..3882a65 100644 --- a/backend/config.py +++ b/backend/config.py @@ -47,10 +47,11 @@ SKINS_DIR = DATA_DIR / "skins" # 打包基础目录 BUILD_BASE_DIR = _resolve_server_path(os.getenv("BUILD_BASE_DIR", str(SERVER_ROOT / "build"))) -# Git 分支源码目录 -# GIT_SOURCE_BASE: 分支源码的根目录,每个分支一个子目录 -# GIT_REMOTE_URL: 远程仓库地址,分支目录不存在时自动 clone +# 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", "") @@ -89,10 +90,15 @@ def mask_git_remote_url(url: str) -> str: 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", @@ -108,6 +114,14 @@ COPY_ITEMS = [ # 服务端口 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'}") @@ -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_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)) diff --git a/backend/database.py b/backend/database.py index 89b54d7..f0e2aa4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -38,13 +38,13 @@ def _seed_admin(): """初始化管理员账号(从 .env 配置)""" from .models import User from .config import ADMIN_USERNAME, ADMIN_PASSWORD - import hashlib + from .security import hash_password db = SessionLocal() try: 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, password_hash=pw_hash, is_admin=True)) + db.add(User(id="admin-001", username=ADMIN_USERNAME, + password_hash=hash_password(ADMIN_PASSWORD), is_admin=True)) db.commit() finally: db.close() diff --git a/backend/deps.py b/backend/deps.py index 4608b8e..67afa13 100644 --- a/backend/deps.py +++ b/backend/deps.py @@ -8,9 +8,8 @@ from .config import JWT_SECRET, JWT_ALGORITHM security = HTTPBearer() -def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict: - """验证 JWT token,返回用户信息""" - token = credentials.credentials +def decode_current_user(token: str) -> dict: + """验证 JWT token,返回用户身份。""" try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) 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="登录已过期,请重新登录") except jwt.InvalidTokenError: 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 diff --git a/backend/main.py b/backend/main.py index 468f211..669bba7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,15 +1,17 @@ """FastAPI 主入口""" from contextlib import asynccontextmanager 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.trustedhost import TrustedHostMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from .database import init_db from .routers import config, apps, tasks, auth, users 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" @@ -17,6 +19,7 @@ STATIC_DIR = Path(__file__).parent / "static" @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期""" + validate_production_security() init_db() yield @@ -27,14 +30,17 @@ app = FastAPI( lifespan=lifespan, ) -# CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +# 仅接受显式允许的跨域前端;同域部署不需要 CORS 配置。 +if CORS_ALLOWED_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ALLOWED_ORIGINS, + allow_credentials=False, + 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) @@ -46,8 +52,19 @@ app.include_router(tasks.router) @app.websocket("/ws/tasks/{task_id}") async def websocket_logs(websocket: WebSocket, task_id: str): - """WebSocket 实时日志""" - await websocket.accept() + """经 JWT 鉴权的 WebSocket 实时日志。""" + 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: async for msg in log_streamer.subscribe(task_id): await websocket.send_json(msg) diff --git a/backend/routers/apps.py b/backend/routers/apps.py index 1f9291b..546d60b 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -5,12 +5,31 @@ from .config import load_config 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") async def get_apps_for_build(): """获取 apps 列表(供打包选择)""" 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") diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 6ed60b9..cf2101b 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -1,16 +1,24 @@ """认证 API""" import jwt +import threading +import time 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 ..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 ..database import get_db from ..models import User +from ..security import hash_password, verify_password 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: @@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str: return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) -def _verify_password(password: str, password_hash: str) -> bool: - """校验密码""" - import hashlib - return hashlib.sha256(password.encode()).hexdigest() == password_hash +def _check_login_rate_limit(client_ip: str) -> None: + """按来源 IP 限制登录失败次数,降低在线暴力破解风险。""" + now = time.monotonic() + 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) -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() - if not user or not _verify_password(request.password, user.password_hash): + client_ip = request.client.host if request.client else "unknown" + _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="用户名或密码错误") + 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) return LoginResponse(token=token, username=user.username, is_admin=user.is_admin) diff --git a/backend/routers/config.py b/backend/routers/config.py index 2643272..fbe332c 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -6,7 +6,7 @@ import shutil import zipfile from datetime import datetime 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 ..database import get_db @@ -21,9 +21,18 @@ from ..config import ( DEFAULT_BUILD_DIR_RETENTION_HOURS, 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() diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 893b04b..e3f947c 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -34,6 +34,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)): app = apps[task.app_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()) db_task = Task( @@ -42,7 +56,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)): app_name=app.get("name", ""), build_type=task.build_type, 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, branch=task.branch, status="pending", diff --git a/backend/routers/users.py b/backend/routers/users.py index 000d839..84302f2 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -1,12 +1,12 @@ """用户管理 API(仅管理员)""" import uuid -import hashlib from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from ..database import get_db from ..models import User from ..deps import get_current_user +from ..security import hash_password 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="需要管理员权限") -def _hash_password(password: str) -> str: - return hashlib.sha256(password.encode()).hexdigest() - - @router.get("") 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: raise HTTPException(status_code=400, detail="用户名和密码不能为空") - if len(password) < 6: - raise HTTPException(status_code=400, detail="密码至少 6 位") + if len(password) < 12: + raise HTTPException(status_code=400, detail="密码至少 12 位") if db.query(User).filter(User.username == username).first(): raise HTTPException(status_code=400, detail="用户名已存在") new_user = User( id=str(uuid.uuid4()), username=username, - password_hash=_hash_password(password), + password_hash=hash_password(password), is_admin=is_admin, ) 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="用户不存在") new_password = data.get("password", "").strip() - if not new_password or len(new_password) < 6: - raise HTTPException(status_code=400, detail="密码至少 6 位") + if not new_password or len(new_password) < 12: + raise HTTPException(status_code=400, detail="密码至少 12 位") - target.password_hash = _hash_password(new_password) + target.password_hash = hash_password(new_password) db.commit() return {"message": "密码已更新"} diff --git a/backend/security.py b/backend/security.py new file mode 100644 index 0000000..0d3f1da --- /dev/null +++ b/backend/security.py @@ -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 diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 2d19f1c..daf6340 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -16,6 +16,7 @@ from ..config import ( SKINS_DIR, AUTOMATION_DIR, get_source_dir, + get_shared_source_dir, get_git_remote_url, mask_git_remote_url, ) @@ -33,6 +34,11 @@ class BuildError(Exception): self.detail = detail +# 共享源码目录只能同时被一个任务切换、清理和复制。 +# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。 +source_prepare_lock = asyncio.Lock() + + # 错误分类规则:(关键词列表, category, 友好提示) _ERROR_RULES = [ (["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): - """确保分支源码目录存在且为最新""" + """将共享源码工作目录强制同步到指定远程分支。""" remote_url = get_git_remote_url(with_credentials=True) remote_url_masked = mask_git_remote_url(remote_url) - if not source_dir.exists(): - # 首次:从远程 clone + cloned = not source_dir.exists() + if cloned: if not remote_url: raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}") 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( - "git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir), + "git", "clone", remote_url, str(source_dir), stdout=asyncio.subprocess.PIPE, 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 process.wait() if process.returncode != 0: - raise Exception(f"git clone 失败: {branch}") - else: - # 已存在:fetch + checkout + pull - await log_streamer.emit(task_id, f"更新分支源码: {branch}") + raise Exception("git clone 失败") - if remote_url: - process = await asyncio.create_subprocess_exec( - "git", "remote", "set-url", "origin", remote_url, - cwd=str(source_dir), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - await process.wait() + # git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。 + if not cloned and not (source_dir / ".git").exists(): + raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}") - # fetch + await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}") + + if remote_url: process = await asyncio.create_subprocess_exec( - "git", "fetch", "origin", - cwd=str(source_dir), - stdout=asyncio.subprocess.PIPE, + "git", "remote", "set-url", "origin", remote_url, + cwd=str(source_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, + ) + 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, ) 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 process.wait() if process.returncode != 0: - raise Exception("git fetch 失败") - - # 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} 失败") + raise Exception(error_message) 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): """执行打包任务""" from ..database import SessionLocal @@ -251,25 +270,16 @@ async def run_build_task(task_id: str): async def _do_build(): nonlocal build_dir - # 1. 更新分支源码并拷贝 - source_dir = get_source_dir(task.branch) - await asyncio.to_thread(_db_update, db, task, current_step="copy") - 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) + # 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。 + await asyncio.to_thread(_db_update, db, task, current_step="source_prepare") + build_dir, source_commit = await prepare_source_snapshot(task_id, task) await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir)) # 2. 生成配置 await asyncio.to_thread(_db_update, db, task, current_step="config") 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, 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, "清理项目...") - clean_cmd = ( - f"xcodebuild clean -workspace {workspace_path.name} " - f"-scheme {scheme} " - f"-configuration Release " - f"-derivedDataPath {export_path / 'derived_data'}" - ) - process = await asyncio.create_subprocess_shell( - clean_cmd, + process = await asyncio.create_subprocess_exec( + "xcodebuild", "clean", + "-workspace", workspace_path.name, + "-scheme", scheme, + "-configuration", "Release", + "-derivedDataPath", str(export_path / "derived_data"), cwd=str(build_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, @@ -681,17 +689,16 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path) # 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 = [] - process = await asyncio.create_subprocess_shell( - archive_cmd, + process = await asyncio.create_subprocess_exec( + "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), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, @@ -710,15 +717,12 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path) # 导出 IPA await log_streamer.emit(task_id, "导出 IPA...") 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 = [] - process = await asyncio.create_subprocess_shell( - export_cmd, + process = await asyncio.create_subprocess_exec( + "xcodebuild", "-exportArchive", + "-archivePath", str(archive_path), + "-exportPath", str(export_path), + "-exportOptionsPlist", str(export_plist), cwd=str(build_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, diff --git a/deploy.sh b/deploy.sh index 2e9a218..a4a113e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,6 +18,7 @@ if [ -f .env ]; then fi BACKEND_PORT="${BACKEND_PORT:-8000}" +BIND_HOST="${BIND_HOST:-127.0.0.1}" WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒) WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 @@ -159,10 +160,12 @@ do_start() { mkdir -p "$LOG_DIR" - info "启动后端服务 (端口: $BACKEND_PORT)..." + info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ - --host 0.0.0.0 \ + --host "$BIND_HOST" \ --port "$BACKEND_PORT" \ + --proxy-headers \ + --forwarded-allow-ips 127.0.0.1 \ --workers 1 \ > "$LOG_DIR/server.log" 2>&1 & @@ -170,11 +173,8 @@ do_start() { sleep 1 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 "访问地址: http://$access_host:$BACKEND_PORT" + info "访问地址: http://$BIND_HOST:$BACKEND_PORT" else error "启动失败,请查看日志: $LOG_DIR/server.log" rm -f "$PID_FILE" @@ -382,8 +382,10 @@ do_watchdog_loop() { setup_venv mkdir -p "$LOG_DIR" nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ - --host 0.0.0.0 \ + --host "$BIND_HOST" \ --port "$BACKEND_PORT" \ + --proxy-headers \ + --forwarded-allow-ips 127.0.0.1 \ --workers 1 \ >> "$LOG_DIR/server.log" 2>&1 & echo $! > "$PID_FILE" diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..c4c7bbb --- /dev/null +++ b/deploy/nginx.conf.example @@ -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; + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 03db888..4ff42d5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@
diff --git a/frontend/src/__tests__/BuildViewEnvironmentFilter.test.js b/frontend/src/__tests__/BuildViewEnvironmentFilter.test.js new file mode 100644 index 0000000..4a597b8 --- /dev/null +++ b/frontend/src/__tests__/BuildViewEnvironmentFilter.test.js @@ -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') + }) +}) diff --git a/frontend/src/views/BuildView.vue b/frontend/src/views/BuildView.vue index a932a68..1f2c6d1 100644 --- a/frontend/src/views/BuildView.vue +++ b/frontend/src/views/BuildView.vue @@ -5,25 +5,37 @@

打包配置

- - - + +
+
+ +
@@ -138,7 +150,7 @@