import asyncio import hashlib import os import shlex import subprocess import uuid from datetime import datetime from pathlib import Path from typing import Literal from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.security import HTTPAuthorizationCredentials from fastapi.responses import FileResponse from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import ( AuthBearer, create_download_token, decode_token, get_current_admin_user, verify_download_token, ) from app.config import settings from app.database import async_session, get_session from app.models import MobileBuildArtifact, MobileBuildRequest, StationConfig from app.schemas import ( MobileBuildArtifactResponse, MobileBuildCreate, MobileBuildDefaultsResponse, MobileBuildPreflightResponse, MobileBuildRequestResponse, ) from app.user_models import User router = APIRouter() _ALLOWED_KINDS = { "android": {"keystore", "descriptor"}, "ios": {"certificate", "profile"}, } _FILE_ROLE_DISPLAY = { "android_keystore": "Android Keystore", "android_descriptor": "Android Descriptor JSON", "ios_certificate": "iOS Certificate (.p12)", "ios_profile": "iOS Provisioning Profile", } # Accepted extensions per file kind (platform → {kind → {extensions}}) _ALLOWED_EXTENSIONS = { "android": { "keystore": {".jks", ".keystore", ".p12"}, "descriptor": {".json"}, }, "ios": { "certificate": {".p12"}, "profile": {".mobileprovision"}, }, } _RUNNING_TASKS: set[int] = set() def _utc_iso(value: datetime | None) -> str | None: if value is None: return None return value.replace(microsecond=0).isoformat() + "Z" def _sanitize_filename(filename: str) -> str: """Remove directory traversal components.""" return Path(filename).name def _find_uploaded_file(request_id: int, platform: str, kind: str) -> Path | None: """Find an uploaded signing file in the request's upload directory. Scans for a file matching {platform}_{kind}_{uuid}{suffix}. Returns the first matching Path, or None if not found. """ upload_dir, _ = _ensure_dirs() req_dir = upload_dir / f"request_{request_id}" if not req_dir.is_dir(): return None pattern = f"{platform}_{kind}_*" matches = list(req_dir.glob(pattern)) return matches[0] if matches else None def _hash_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def _ensure_dirs() -> tuple[Path, Path]: upload_dir = Path(settings.MOBILE_BUILD_UPLOAD_DIR) artifact_dir = Path(settings.MOBILE_BUILD_ARTIFACT_DIR) upload_dir.mkdir(parents=True, exist_ok=True) artifact_dir.mkdir(parents=True, exist_ok=True) return upload_dir, artifact_dir def _validate_upload(platform: str, file_kind: str, filename: str | None, data: bytes) -> None: """Validate uploaded file extension and basic content integrity.""" # Extension check allowed_exts = _ALLOWED_EXTENSIONS[platform][file_kind] suffix = Path(filename or "").suffix.lower() if suffix not in allowed_exts: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File must have one of: {', '.join(sorted(allowed_exts))}", ) # Content checks if file_kind == "descriptor": try: import json json.loads(data) except (ValueError, UnicodeDecodeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Android descriptor must be valid JSON.", ) if file_kind == "profile": # .mobileprovision is a DER-encoded PKCS#7 (signed Apple certificate). # Quick heuristic: it should contain readable markers like "Provision" or "plist". text = data.decode("latin-1", errors="ignore").lower() if "provision" not in text and "plist" not in text: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File does not appear to be a valid iOS provisioning profile.", ) def _check_mobileprovision_for_carplay(path: Path) -> bool: try: raw = path.read_bytes() except Exception: return False text = raw.decode("latin-1", errors="ignore").lower() return "carplay" in text def _run_sync_command(cmd: list[str]) -> tuple[int, str]: result = subprocess.run(cmd, capture_output=True, text=True) output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "") return result.returncode, output.strip() async def _run_command(cmd: list[str]) -> tuple[int, str]: return await asyncio.to_thread(_run_sync_command, cmd) # ── Build container SSH helpers (direct SSH over Docker network) ── def _build_ssh_prefix(host: str) -> list[str]: """Build SSH prefix for connecting to the build container over the Docker network.""" if not host: raise RuntimeError("Build host is not configured") return [ "sshpass", "-p", settings.MOBILE_BUILD_SSH_PASSWORD, "ssh", "-p", str(settings.MOBILE_BUILD_SSH_PORT), "-o", "StrictHostKeyChecking=no", f"{settings.MOBILE_BUILD_SSH_USER}@{host}", ] def _build_scp_prefix(host: str) -> list[str]: """Build SCP prefix for connecting to the build container over the Docker network.""" if not host: raise RuntimeError("Build host is not configured") return [ "sshpass", "-p", settings.MOBILE_BUILD_SSH_PASSWORD, "scp", "-P", str(settings.MOBILE_BUILD_SSH_PORT), "-o", "StrictHostKeyChecking=no", ] async def _ssh_run(host: str, cmd: str) -> tuple[int, str]: """Run a command inside the build container via SSH.""" ssh_prefix = _build_ssh_prefix(host) full_cmd = ssh_prefix + [cmd] return await _run_command(full_cmd) async def _scp_to_container(host: str, local_path: str, remote_path: str) -> tuple[int, str]: """SCP a file from the backend into the build container.""" scp_prefix = _build_scp_prefix(host) full_cmd = scp_prefix + [str(local_path), f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}"] return await _run_command(full_cmd) async def _scp_from_container(host: str, remote_path: str, local_path: str) -> tuple[int, str]: """SCP a file from the build container to the backend.""" scp_prefix = _build_scp_prefix(host) full_cmd = scp_prefix + [f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}", str(local_path)] return await _run_command(full_cmd) async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: str) -> None: if request.build_log: request.build_log += "\n" request.build_log += line request.updated_at = datetime.utcnow() await session.commit() def _preflight_issues(request: MobileBuildRequest, request_id: int) -> list[str]: issues: list[str] = [] if not request.platform_android and not request.platform_ios: issues.append("Select at least one platform.") if request.platform_android and not request.android_application_id: issues.append("Android application ID is required when Android is selected.") if request.platform_ios and not request.ios_bundle_id: issues.append("iOS bundle ID is required when iOS is selected.") if not request.version_name.strip(): issues.append("Version name is required.") if not request.build_number.strip(): issues.append("Build number is required.") if request.platform_android and not settings.FLUTTER_BUILD_HOST: issues.append("Android build container host is not configured.") if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST: issues.append("iOS build host is not configured.") # Check for expected files on disk if request.platform_android: for kind in _ALLOWED_KINDS["android"]: role_key = f"android_{kind}" display = _FILE_ROLE_DISPLAY[role_key] if not _find_uploaded_file(request_id, "android", kind): issues.append(f"Missing: {display}") if request.platform_ios: for kind in _ALLOWED_KINDS["ios"]: role_key = f"ios_{kind}" display = _FILE_ROLE_DISPLAY[role_key] if not _find_uploaded_file(request_id, "ios", kind): issues.append(f"Missing: {display}") profile_path = _find_uploaded_file(request_id, "ios", "profile") if profile_path: if not _check_mobileprovision_for_carplay(profile_path): issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.") return issues def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse: return MobileBuildArtifactResponse( id=row.id, request_id=row.request_id, platform=row.platform, artifact_type=row.artifact_type, file_name=row.file_name, sha256=row.sha256, size=row.size, created_at=_utc_iso(row.created_at) or "", download_url=f"/api/mobile-builds/artifacts/{row.id}/download", ) async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse: artifact_result = await session.execute( select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id) ) artifacts = list(artifact_result.scalars().all()) return MobileBuildRequestResponse( id=request.id, tenant_key=request.tenant_key, platform_android=request.platform_android, platform_ios=request.platform_ios, android_application_id=request.android_application_id, ios_bundle_id=request.ios_bundle_id, version_name=request.version_name, build_number=request.build_number, release_profile=request.release_profile, status=request.status, preflight_passed=request.preflight_passed, preflight_report=request.preflight_report, build_log=request.build_log, error_message=request.error_message, created_at=_utc_iso(request.created_at) or "", updated_at=_utc_iso(request.updated_at) or "", started_at=_utc_iso(request.started_at), completed_at=_utc_iso(request.completed_at), artifacts=[_to_artifact_response(a) for a in artifacts], ) async def _copy_worker_artifact( session: AsyncSession, request: MobileBuildRequest, platform: Literal["android", "ios"], host: str, artifact_glob: str, ) -> None: _, artifact_dir = _ensure_dirs() local_dir = artifact_dir / f"request_{request.id}" / platform local_dir.mkdir(parents=True, exist_ok=True) ssh_prefix = _build_ssh_prefix(host) # Don't shlex.quote the glob — the shell needs to expand the wildcard list_cmd = ssh_prefix + [f"ls -1t {artifact_glob} 2>/dev/null | head -n 1"] code, out = await _run_command(list_cmd) if code != 0 or not out: raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}") remote_path = out.splitlines()[-1].strip() if not remote_path: raise RuntimeError(f"No {platform} artifact path returned by worker") file_name = Path(remote_path).name local_path = local_dir / file_name scp_cmd = _build_scp_prefix(host) + [ f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}", str(local_path), ] code, scp_out = await _run_command(scp_cmd) if code != 0: raise RuntimeError(f"Failed to copy {platform} artifact: {scp_out}") artifact = MobileBuildArtifact( request_id=request.id, platform=platform, artifact_type="aab" if platform == "android" else "ipa", file_name=file_name, file_path=str(local_path), sha256=_hash_file(local_path), size=local_path.stat().st_size, ) session.add(artifact) await session.commit() async def _upload_files_to_worker( request_id: int, platform: str, host: str ) -> None: scp_prefix = _build_scp_prefix(host) remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/" for kind in _ALLOWED_KINDS[platform]: role_key = f"{platform}_{kind}" display = _FILE_ROLE_DISPLAY[role_key] local_path = _find_uploaded_file(request_id, platform, kind) if not local_path or not local_path.exists(): raise RuntimeError(f"Uploaded file missing: {display}") cmd = scp_prefix + [str(local_path), remote_target] code, out = await _run_command(cmd) if code != 0: raise RuntimeError(f"Failed to upload {display}: {out}") async def _upload_theme_to_worker( session: AsyncSession, request: MobileBuildRequest, host: str, ) -> None: """SCP theme.json to the build worker (no-op if file doesn't exist).""" theme_path = Path(settings.THEME_JSON_PATH) if not theme_path.exists(): await _append_log(session, request, "[theme] theme.json not found — skipping upload") return scp_prefix = _build_scp_prefix(host) remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/theme.json" code, out = await _run_command(scp_prefix + [str(theme_path), remote_target]) if code != 0: raise RuntimeError(f"Failed to upload theme.json: {out}") await _append_log(session, request, "[theme] theme.json uploaded to worker") async def _run_platform_build( session: AsyncSession, request: MobileBuildRequest, platform: Literal["android", "ios"], host: str, script_name: str, ) -> None: await _append_log(session, request, f"[{platform}] Uploading {len(_ALLOWED_KINDS[platform])} file(s) to worker") await _upload_files_to_worker(request.id, platform, host) # Upload theme.json to worker await _upload_theme_to_worker(session, request, host) ssh_prefix = _build_ssh_prefix(host) cmd = ssh_prefix + [ ( f"cd {settings.MOBILE_BUILD_REMOTE_APP_DIR} && " f"chmod +x ./{script_name} && " f"./{script_name}" ) ] await _append_log(session, request, f"[{platform}] Executing {script_name} on {host}") code, out = await _run_command(cmd) await _append_log(session, request, f"[{platform}] Exit code {code}\n{out}") if code != 0: raise RuntimeError(f"{platform} build failed with exit code {code}") artifact_glob = ( settings.MOBILE_BUILD_ANDROID_ARTIFACT_GLOB if platform == "android" else settings.MOBILE_BUILD_IOS_ARTIFACT_GLOB ) await _copy_worker_artifact(session, request, platform, host, artifact_glob) await _append_log(session, request, f"[{platform}] Artifact copied successfully") # ── Container-based Android build flow (direct SSH to build service) ── async def _copy_container_artifact( session: AsyncSession, request: MobileBuildRequest, host: str, project_dir: str, ) -> None: """Copy the APK from the Flutter build container back to the backend via SCP.""" _, artifact_dir = _ensure_dirs() local_dir = artifact_dir / f"request_{request.id}" / "android" local_dir.mkdir(parents=True, exist_ok=True) # Find the APK inside the container apk_glob = settings.FLUTTER_APK_GLOB # Don't shlex.quote the glob — the shell needs to expand the wildcard list_cmd = f"ls -1t {apk_glob} 2>/dev/null | head -n 1" code, out = await _ssh_run(host, list_cmd) if code != 0 or not out: raise RuntimeError(f"No APK artifact found using glob {apk_glob}") apk_path = out.strip() file_name = Path(apk_path).name # SCP directly from build container to backend local_path = local_dir / file_name code, out = await _scp_from_container(host, apk_path, local_path) if code != 0: raise RuntimeError(f"Failed to SCP APK from build container: {out}") artifact = MobileBuildArtifact( request_id=request.id, platform="android", artifact_type="apk", file_name=file_name, file_path=str(local_path), sha256=_hash_file(local_path), size=local_path.stat().st_size, ) session.add(artifact) await session.commit() async def _run_flutter_container_build( session: AsyncSession, request: MobileBuildRequest, host: str, ) -> None: """Execute an Android build inside the Flutter build container via direct SSH. Flow: 1. Clone the Flutter project (if not already cloned) 2. Run flutter pub get 3. Set up Python venv + activate 4. Copy theme.json into the project directory 5. Copy signing files (keystore + descriptor) into the project 6. Run build_whitelabel.py 7. Run flutter build apk 8. Copy the APK artifact back to the backend """ project_dir = settings.FLUTTER_PROJECT_DIR # ~/kryz-go-flutter repo_url = settings.FLUTTER_PROJECT_REPO_URL # Step 1: Clone the Flutter project (skip if already exists) await _append_log(session, request, "[android] Checking Flutter project directory...") check_cmd = f"test -d {shlex.quote(project_dir)}/.git" code, out = await _ssh_run(host, check_cmd) directory_exists = (code == 0) if not directory_exists: await _append_log(session, request, f"[android] Cloning {repo_url} into {project_dir}") clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter" code, out = await _ssh_run(host, clone_cmd) await _append_log(session, request, f"[android] Clone output: {out}") if code != 0: raise RuntimeError(f"Git clone failed: {out}") else: await _append_log(session, request, "[android] Project directory exists, pulling latest...") pull_cmd = f"cd {shlex.quote(project_dir)} && git pull" code, out = await _ssh_run(host, pull_cmd) await _append_log(session, request, f"[android] Pull output: {out}") if code != 0: # Directory may be stale/corrupt — remove and re-clone await _append_log(session, request, "[android] Pull failed, re-cloning...") rm_cmd = f"rm -rf {shlex.quote(project_dir)}" await _ssh_run(host, rm_cmd) clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter" code, out = await _ssh_run(host, clone_cmd) await _append_log(session, request, f"[android] Re-clone output: {out}") if code != 0: raise RuntimeError(f"Git re-clone failed: {out}") # Step 2: flutter pub get await _append_log(session, request, "[android] Running flutter pub get...") pub_cmd = f"cd {shlex.quote(project_dir)} && flutter pub get" code, out = await _ssh_run(host, pub_cmd) await _append_log(session, request, f"[android] Pub get output: {out}") if code != 0: raise RuntimeError(f"flutter pub get failed: {out}") # Step 3: Python venv setup await _append_log(session, request, "[android] Setting up Python venv...") venv_cmd = ( f"cd {shlex.quote(project_dir)} && " f"python3 -m venv .venv && " f"source .venv/bin/activate && " f"pip install -r requirements.txt" ) code, out = await _ssh_run(host, venv_cmd) await _append_log(session, request, f"[android] Venv setup output: {out}") if code != 0: raise RuntimeError(f"Python venv setup failed: {out}") # Step 4: Copy theme.json into the project directory theme_path = Path(settings.THEME_JSON_PATH) if theme_path.exists(): code, out = await _scp_to_container(host, theme_path, f"{project_dir}/theme.json") if code != 0: raise RuntimeError(f"Failed to copy theme.json to build container: {out}") await _append_log(session, request, "[android] theme.json copied to build container") else: await _append_log(session, request, "[android] theme.json not found, skipping") # Step 5: Copy signing files into the container for kind in _ALLOWED_KINDS["android"]: role_key = f"android_{kind}" display = _FILE_ROLE_DISPLAY[role_key] local_path = _find_uploaded_file(request.id, "android", kind) if not local_path or not local_path.exists(): raise RuntimeError(f"Signing file missing: {display}") # SCP directly to build container remote_path = f"{project_dir}/{_sanitize_filename(local_path.name)}" code, out = await _scp_to_container(host, local_path, remote_path) if code != 0: raise RuntimeError(f"Failed to SCP {display} to build container: {out}") await _append_log(session, request, f"[android] {display} copied to build container") # Step 6: Run build_whitelabel.py await _append_log(session, request, "[android] Running build_whitelabel.py...") whitelabel_cmd = ( f"cd {shlex.quote(project_dir)} && " f"source .venv/bin/activate && " f"python3 build_whitelabel.py" ) code, out = await _ssh_run(host, whitelabel_cmd) await _append_log(session, request, f"[android] build_whitelabel.py output:\n{out}") if code != 0: raise RuntimeError(f"build_whitelabel.py failed: {out}") # Step 7: flutter build apk # Container-safe flags: # - GRADLE_OPTS disables native VFS file watching (inotify EBADF in Docker) # - KOTLIN_COMPILE_DAEMON_ENABLED=false avoids the Kotlin compile daemon # crashing with the same inotify issue # - Project-level gradle.properties ensures the Gradle wrapper always # honors these settings (user-level ~/.gradle/ may not be read) # - Memory is aggressively capped: 1g JVM heap, single worker, no # parallel builds, in-process Kotlin compilation, SerialGC (avoids # parallel GC overhead). Combined with the 4g container memory limit, # this leaves ~3g for native compilation (clang, dll, etc.) await _append_log(session, request, "[android] Running flutter build apk...") android_props = f"{project_dir}/android/gradle.properties" build_cmd = ( f"cd {shlex.quote(project_dir)}/android && " f"echo 'org.gradle.daemon=false' >> gradle.properties && " # f"echo 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseSerialGC' >> gradle.properties && " f"echo 'org.gradle.workers.max=1' >> gradle.properties && " # f"echo 'org.gradle.parallel=false' >> gradle.properties && " # f"echo 'kotlin.compiler.execution.strategy=in-process' >> gradle.properties && " # f"echo 'kotlin.incremental=false' >> gradle.properties && " # f"echo 'android.enableParallelPlugin=false' >> gradle.properties && " f"echo 'org.gradle.vfs.watch=false' >> gradle.properties && " f"cd {shlex.quote(project_dir)} && " f"GRADLE_OPTS='-Dorg.gradle.vfs.watch=false' " # f"KOTLIN_COMPILE_DAEMON_ENABLED=false " f"flutter build apk --release" ) code, out = await _ssh_run(host, build_cmd) await _append_log(session, request, f"[android] Build output:\n{out}") if code != 0: raise RuntimeError(f"flutter build apk failed: {out}") # Step 8: Copy APK artifact back await _copy_container_artifact(session, request, host, project_dir) await _append_log(session, request, "[android] APK artifact copied successfully") async def _execute_request(request_id: int) -> None: async with async_session() as session: result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = result.scalar_one_or_none() if request is None: return request.status = "building" request.error_message = None request.started_at = datetime.utcnow() request.updated_at = datetime.utcnow() await session.commit() try: if request.platform_android: await _run_flutter_container_build( session, request, settings.FLUTTER_BUILD_HOST, ) if request.platform_ios: await _run_platform_build( session, request, "ios", settings.MOBILE_BUILD_IOS_HOST, settings.MOBILE_BUILD_IOS_SCRIPT, ) request.status = "completed" request.completed_at = datetime.utcnow() request.updated_at = datetime.utcnow() await session.commit() except Exception as exc: request.status = "failed" request.error_message = str(exc) request.completed_at = datetime.utcnow() request.updated_at = datetime.utcnow() await _append_log(session, request, f"Build failed: {exc}") await session.commit() finally: _RUNNING_TASKS.discard(request_id) @router.get("/defaults", response_model=MobileBuildDefaultsResponse) async def get_mobile_build_defaults(session: AsyncSession = Depends(get_session)): result = await session.execute( select(StationConfig).where(StationConfig.key == "default") ) cfg = result.scalar_one_or_none() if cfg is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found") app_name = f"{cfg.name_primary} {cfg.name_secondary}".strip() return MobileBuildDefaultsResponse( tenant_key=cfg.key, app_name=app_name, tagline=cfg.tagline, support_email=cfg.email, website=cfg.website, stream_url=cfg.stream_url, stream_metadata_url=cfg.stream_metadata_url, ) @router.get("/requests", response_model=list[MobileBuildRequestResponse]) async def list_mobile_build_requests( session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): result = await session.execute( select(MobileBuildRequest).order_by(MobileBuildRequest.created_at.desc()) ) rows = list(result.scalars().all()) return [await _hydrate_request(session, row) for row in rows] @router.post("/requests", response_model=MobileBuildRequestResponse, status_code=201) async def create_mobile_build_request( payload: MobileBuildCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_admin_user), ): request = MobileBuildRequest( tenant_key="default", platform_android=payload.platform_android, platform_ios=payload.platform_ios, android_application_id=payload.android_application_id, ios_bundle_id=payload.ios_bundle_id, version_name=payload.version_name, build_number=payload.build_number, release_profile=payload.release_profile, status="draft", created_by_id=user.id, updated_at=datetime.utcnow(), ) session.add(request) await session.commit() await session.refresh(request) return await _hydrate_request(session, request) @router.get("/requests/{request_id}", response_model=MobileBuildRequestResponse) async def get_mobile_build_request( request_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = result.scalar_one_or_none() if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") return await _hydrate_request(session, request) @router.delete("/requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_mobile_build_request( request_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): """Delete a draft build request and its uploaded files.""" result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = result.scalar_one_or_none() if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") if request.status in {"queued", "building"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cannot delete a request that is in progress") # Clean up uploaded files on disk upload_dir, _ = _ensure_dirs() req_dir = upload_dir / f"request_{request_id}" if req_dir.is_dir(): import shutil shutil.rmtree(req_dir, ignore_errors=True) # Delete artifacts first (FK cascade may not cover async) await session.execute( delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id) ) # Then the request itself await session.execute( delete(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) await session.commit() @router.post("/requests/{request_id}/files") async def upload_mobile_build_file( request_id: int, platform: Literal["android", "ios"] = Query(...), file_kind: str = Query(...), file: UploadFile = File(...), session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): """Upload a signing file to the filesystem for a build request. Files are stored on disk, not in the database. """ if file_kind not in _ALLOWED_KINDS[platform]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported file_kind '{file_kind}' for platform '{platform}'", ) request_result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = request_result.scalar_one_or_none() if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") upload_dir, _ = _ensure_dirs() req_dir = upload_dir / f"request_{request_id}" req_dir.mkdir(parents=True, exist_ok=True) payload = await file.read() # Validate extension and content _validate_upload(platform, file_kind, file.filename, payload) suffix = Path(file.filename or "upload.bin").suffix local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}" local_path = req_dir / local_name local_path.write_bytes(payload) sha = hashlib.sha256(payload).hexdigest() size = len(payload) request.preflight_passed = False request.preflight_report = "" request.updated_at = datetime.utcnow() await session.commit() return { "platform": platform, "file_kind": file_kind, "display_name": _FILE_ROLE_DISPLAY[f"{platform}_{file_kind}"], "sha256": sha, "size": size, } @router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse) async def preflight_mobile_build( request_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = result.scalar_one_or_none() if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") issues = _preflight_issues(request, request_id) request.preflight_passed = len(issues) == 0 request.preflight_report = "\n".join(issues) request.updated_at = datetime.utcnow() await session.commit() return MobileBuildPreflightResponse(passed=request.preflight_passed, issues=issues) @router.post("/requests/{request_id}/trigger", response_model=MobileBuildRequestResponse) async def trigger_mobile_build( request_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): result = await session.execute( select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) ) request = result.scalar_one_or_none() if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") issues = _preflight_issues(request, request_id) if issues: request.preflight_passed = False request.preflight_report = "\n".join(issues) request.updated_at = datetime.utcnow() await session.commit() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=issues) if request.status in {"queued", "building"}: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Build is already in progress") request.status = "queued" request.preflight_passed = True request.preflight_report = "" request.error_message = None request.build_log = "" request.started_at = None request.completed_at = None request.updated_at = datetime.utcnow() await session.execute( delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id) ) await session.commit() if request_id not in _RUNNING_TASKS: _RUNNING_TASKS.add(request_id) asyncio.create_task(_execute_request(request_id)) return await _hydrate_request(session, request) @router.get("/artifacts/{artifact_id}/download-url") async def get_download_url( artifact_id: int, session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): """Return a short-lived signed download URL for the artifact.""" result = await session.execute( select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id) ) row = result.scalar_one_or_none() if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found") token = create_download_token(artifact_id) download_url = f"/api/mobile-builds/artifacts/{artifact_id}/download?token={token}" return {"download_url": download_url} @router.get("/artifacts/{artifact_id}/download") async def download_mobile_build_artifact( artifact_id: int, token: str | None = Query(None), credentials: HTTPAuthorizationCredentials | None = Depends(AuthBearer(auto_error=False)), session: AsyncSession = Depends(get_session), ): """Download an artifact. Accepts either Bearer auth OR a signed token query param.""" # Authenticate: signed token takes precedence, fall back to Bearer JWT if token: verified_id = verify_download_token(token) if verified_id is None or verified_id != artifact_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired download token") elif credentials: payload = decode_token(credentials.credentials) user_id = int(payload["sub"]) result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None or not user.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required") result = await session.execute( select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id) ) row = result.scalar_one_or_none() if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found") path = Path(row.file_path) if not path.exists(): raise HTTPException(status_code=status.HTTP_410_GONE, detail="Artifact file no longer exists") return FileResponse(path=str(path), filename=row.file_name, media_type="application/octet-stream")