Fixes security issue: potential remote Path Traversal via File Upload
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+160
-101
@@ -23,14 +23,13 @@ from app.auth import (
|
|||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session, get_session
|
from app.database import async_session, get_session
|
||||||
from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig
|
from app.models import MobileBuildArtifact, MobileBuildRequest, StationConfig
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MobileBuildArtifactResponse,
|
MobileBuildArtifactResponse,
|
||||||
MobileBuildCreate,
|
MobileBuildCreate,
|
||||||
MobileBuildDefaultsResponse,
|
MobileBuildDefaultsResponse,
|
||||||
MobileBuildPreflightResponse,
|
MobileBuildPreflightResponse,
|
||||||
MobileBuildRequestResponse,
|
MobileBuildRequestResponse,
|
||||||
MobileBuildUploadedFileResponse,
|
|
||||||
)
|
)
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
|
|
||||||
@@ -41,6 +40,25 @@ _ALLOWED_KINDS = {
|
|||||||
"ios": {"certificate", "profile"},
|
"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()
|
_RUNNING_TASKS: set[int] = set()
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +68,26 @@ def _utc_iso(value: datetime | None) -> str | None:
|
|||||||
return value.replace(microsecond=0).isoformat() + "Z"
|
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:
|
def _hash_file(path: Path) -> str:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with path.open("rb") as f:
|
with path.open("rb") as f:
|
||||||
@@ -66,6 +104,39 @@ def _ensure_dirs() -> tuple[Path, Path]:
|
|||||||
return upload_dir, artifact_dir
|
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:
|
def _check_mobileprovision_for_carplay(path: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
raw = path.read_bytes()
|
raw = path.read_bytes()
|
||||||
@@ -149,14 +220,7 @@ async def _append_log(session: AsyncSession, request: MobileBuildRequest, line:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def _load_files(session: AsyncSession, request_id: int) -> list[MobileBuildUploadedFile]:
|
def _preflight_issues(request: MobileBuildRequest, request_id: int) -> list[str]:
|
||||||
result = await session.execute(
|
|
||||||
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUploadedFile]) -> list[str]:
|
|
||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
|
|
||||||
if not request.platform_android and not request.platform_ios:
|
if not request.platform_android and not request.platform_ios:
|
||||||
@@ -180,53 +244,29 @@ def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUpload
|
|||||||
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
|
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
|
||||||
issues.append("iOS build host is not configured.")
|
issues.append("iOS build host is not configured.")
|
||||||
|
|
||||||
grouped: dict[str, dict[str, MobileBuildUploadedFile]] = {"android": {}, "ios": {}}
|
# Check for expected files on disk
|
||||||
for f in files:
|
|
||||||
grouped.setdefault(f.platform, {})[f.file_kind] = f
|
|
||||||
|
|
||||||
if request.platform_android:
|
if request.platform_android:
|
||||||
present = set(grouped.get("android", {}).keys())
|
for kind in _ALLOWED_KINDS["android"]:
|
||||||
expected = _ALLOWED_KINDS["android"]
|
role_key = f"android_{kind}"
|
||||||
if present != expected:
|
display = _FILE_ROLE_DISPLAY[role_key]
|
||||||
missing = sorted(expected - present)
|
if not _find_uploaded_file(request_id, "android", kind):
|
||||||
extra = sorted(present - expected)
|
issues.append(f"Missing: {display}")
|
||||||
if missing:
|
|
||||||
issues.append(f"Android requires files for kinds: {', '.join(missing)}")
|
|
||||||
if extra:
|
|
||||||
issues.append(f"Android has unsupported file kinds: {', '.join(extra)}")
|
|
||||||
|
|
||||||
if request.platform_ios:
|
if request.platform_ios:
|
||||||
present = set(grouped.get("ios", {}).keys())
|
for kind in _ALLOWED_KINDS["ios"]:
|
||||||
expected = _ALLOWED_KINDS["ios"]
|
role_key = f"ios_{kind}"
|
||||||
if present != expected:
|
display = _FILE_ROLE_DISPLAY[role_key]
|
||||||
missing = sorted(expected - present)
|
if not _find_uploaded_file(request_id, "ios", kind):
|
||||||
extra = sorted(present - expected)
|
issues.append(f"Missing: {display}")
|
||||||
if missing:
|
|
||||||
issues.append(f"iOS requires files for kinds: {', '.join(missing)}")
|
|
||||||
if extra:
|
|
||||||
issues.append(f"iOS has unsupported file kinds: {', '.join(extra)}")
|
|
||||||
|
|
||||||
profile = grouped.get("ios", {}).get("profile")
|
profile_path = _find_uploaded_file(request_id, "ios", "profile")
|
||||||
if profile:
|
if profile_path:
|
||||||
profile_path = Path(profile.stored_path)
|
|
||||||
if not _check_mobileprovision_for_carplay(profile_path):
|
if not _check_mobileprovision_for_carplay(profile_path):
|
||||||
issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.")
|
issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.")
|
||||||
|
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def _to_file_response(row: MobileBuildUploadedFile) -> MobileBuildUploadedFileResponse:
|
|
||||||
return MobileBuildUploadedFileResponse(
|
|
||||||
id=row.id,
|
|
||||||
request_id=row.request_id,
|
|
||||||
platform=row.platform,
|
|
||||||
file_kind=row.file_kind,
|
|
||||||
original_name=row.original_name,
|
|
||||||
size=row.size,
|
|
||||||
uploaded_at=_utc_iso(row.uploaded_at) or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse:
|
def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse:
|
||||||
return MobileBuildArtifactResponse(
|
return MobileBuildArtifactResponse(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
@@ -242,14 +282,9 @@ def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactRespon
|
|||||||
|
|
||||||
|
|
||||||
async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse:
|
async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse:
|
||||||
files_result = await session.execute(
|
|
||||||
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request.id)
|
|
||||||
)
|
|
||||||
artifact_result = await session.execute(
|
artifact_result = await session.execute(
|
||||||
select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id)
|
select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
files = list(files_result.scalars().all())
|
|
||||||
artifacts = list(artifact_result.scalars().all())
|
artifacts = list(artifact_result.scalars().all())
|
||||||
|
|
||||||
return MobileBuildRequestResponse(
|
return MobileBuildRequestResponse(
|
||||||
@@ -271,7 +306,6 @@ async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -
|
|||||||
updated_at=_utc_iso(request.updated_at) or "",
|
updated_at=_utc_iso(request.updated_at) or "",
|
||||||
started_at=_utc_iso(request.started_at),
|
started_at=_utc_iso(request.started_at),
|
||||||
completed_at=_utc_iso(request.completed_at),
|
completed_at=_utc_iso(request.completed_at),
|
||||||
files=[_to_file_response(f) for f in files],
|
|
||||||
artifacts=[_to_artifact_response(a) for a in artifacts],
|
artifacts=[_to_artifact_response(a) for a in artifacts],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,19 +357,21 @@ async def _copy_worker_artifact(
|
|||||||
|
|
||||||
|
|
||||||
async def _upload_files_to_worker(
|
async def _upload_files_to_worker(
|
||||||
files: list[MobileBuildUploadedFile], host: str
|
request_id: int, platform: str, host: str
|
||||||
) -> None:
|
) -> None:
|
||||||
scp_prefix = _build_scp_prefix(host)
|
scp_prefix = _build_scp_prefix(host)
|
||||||
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/"
|
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/"
|
||||||
|
|
||||||
for row in files:
|
for kind in _ALLOWED_KINDS[platform]:
|
||||||
local_path = Path(row.stored_path)
|
role_key = f"{platform}_{kind}"
|
||||||
if not local_path.exists():
|
display = _FILE_ROLE_DISPLAY[role_key]
|
||||||
raise RuntimeError(f"Uploaded file missing: {row.original_name}")
|
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]
|
cmd = scp_prefix + [str(local_path), remote_target]
|
||||||
code, out = await _run_command(cmd)
|
code, out = await _run_command(cmd)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(f"Failed to upload file {row.original_name}: {out}")
|
raise RuntimeError(f"Failed to upload {display}: {out}")
|
||||||
|
|
||||||
|
|
||||||
async def _upload_theme_to_worker(
|
async def _upload_theme_to_worker(
|
||||||
@@ -364,10 +400,8 @@ async def _run_platform_build(
|
|||||||
host: str,
|
host: str,
|
||||||
script_name: str,
|
script_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
files = await _load_files(session, request.id)
|
await _append_log(session, request, f"[{platform}] Uploading {len(_ALLOWED_KINDS[platform])} file(s) to worker")
|
||||||
platform_files = [f for f in files if f.platform == platform]
|
await _upload_files_to_worker(request.id, platform, host)
|
||||||
await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker")
|
|
||||||
await _upload_files_to_worker(platform_files, host)
|
|
||||||
|
|
||||||
# Upload theme.json to worker
|
# Upload theme.json to worker
|
||||||
await _upload_theme_to_worker(session, request, host)
|
await _upload_theme_to_worker(session, request, host)
|
||||||
@@ -519,21 +553,20 @@ async def _run_flutter_container_build(
|
|||||||
await _append_log(session, request, "[android] theme.json not found, skipping")
|
await _append_log(session, request, "[android] theme.json not found, skipping")
|
||||||
|
|
||||||
# Step 5: Copy signing files into the container
|
# Step 5: Copy signing files into the container
|
||||||
files = await _load_files(session, request.id)
|
for kind in _ALLOWED_KINDS["android"]:
|
||||||
android_files = [f for f in files if f.platform == "android"]
|
role_key = f"android_{kind}"
|
||||||
|
display = _FILE_ROLE_DISPLAY[role_key]
|
||||||
for row in android_files:
|
local_path = _find_uploaded_file(request.id, "android", kind)
|
||||||
local_path = Path(row.stored_path)
|
if not local_path or not local_path.exists():
|
||||||
if not local_path.exists():
|
raise RuntimeError(f"Signing file missing: {display}")
|
||||||
raise RuntimeError(f"Signing file missing: {row.original_name}")
|
|
||||||
|
|
||||||
# SCP directly to build container
|
# SCP directly to build container
|
||||||
remote_path = f"{project_dir}/{row.original_name}"
|
remote_path = f"{project_dir}/{_sanitize_filename(local_path.name)}"
|
||||||
code, out = await _scp_to_container(host, local_path, remote_path)
|
code, out = await _scp_to_container(host, local_path, remote_path)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(f"Failed to SCP {row.original_name} to build container: {out}")
|
raise RuntimeError(f"Failed to SCP {display} to build container: {out}")
|
||||||
|
|
||||||
await _append_log(session, request, f"[android] Signing file {row.original_name} copied to build container")
|
await _append_log(session, request, f"[android] {display} copied to build container")
|
||||||
|
|
||||||
# Step 6: Run build_whitelabel.py
|
# Step 6: Run build_whitelabel.py
|
||||||
await _append_log(session, request, "[android] Running build_whitelabel.py...")
|
await _append_log(session, request, "[android] Running build_whitelabel.py...")
|
||||||
@@ -705,7 +738,42 @@ async def get_mobile_build_request(
|
|||||||
return await _hydrate_request(session, request)
|
return await _hydrate_request(session, request)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/files", response_model=MobileBuildUploadedFileResponse)
|
@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(
|
async def upload_mobile_build_file(
|
||||||
request_id: int,
|
request_id: int,
|
||||||
platform: Literal["android", "ios"] = Query(...),
|
platform: Literal["android", "ios"] = Query(...),
|
||||||
@@ -714,6 +782,10 @@ async def upload_mobile_build_file(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
_: User = Depends(get_current_admin_user),
|
_: 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]:
|
if file_kind not in _ALLOWED_KINDS[platform]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -731,43 +803,32 @@ async def upload_mobile_build_file(
|
|||||||
req_dir = upload_dir / f"request_{request_id}"
|
req_dir = upload_dir / f"request_{request_id}"
|
||||||
req_dir.mkdir(parents=True, exist_ok=True)
|
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
|
suffix = Path(file.filename or "upload.bin").suffix
|
||||||
local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}"
|
local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}"
|
||||||
local_path = req_dir / local_name
|
local_path = req_dir / local_name
|
||||||
|
|
||||||
payload = await file.read()
|
|
||||||
local_path.write_bytes(payload)
|
local_path.write_bytes(payload)
|
||||||
|
|
||||||
sha = hashlib.sha256(payload).hexdigest()
|
sha = hashlib.sha256(payload).hexdigest()
|
||||||
size = len(payload)
|
size = len(payload)
|
||||||
|
|
||||||
await session.execute(
|
|
||||||
delete(MobileBuildUploadedFile).where(
|
|
||||||
MobileBuildUploadedFile.request_id == request_id,
|
|
||||||
MobileBuildUploadedFile.platform == platform,
|
|
||||||
MobileBuildUploadedFile.file_kind == file_kind,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
row = MobileBuildUploadedFile(
|
|
||||||
request_id=request_id,
|
|
||||||
platform=platform,
|
|
||||||
file_kind=file_kind,
|
|
||||||
original_name=file.filename or local_name,
|
|
||||||
stored_path=str(local_path),
|
|
||||||
sha256=sha,
|
|
||||||
size=size,
|
|
||||||
)
|
|
||||||
session.add(row)
|
|
||||||
|
|
||||||
request.preflight_passed = False
|
request.preflight_passed = False
|
||||||
request.preflight_report = ""
|
request.preflight_report = ""
|
||||||
request.updated_at = datetime.utcnow()
|
request.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
|
||||||
|
|
||||||
return _to_file_response(row)
|
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)
|
@router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse)
|
||||||
@@ -783,8 +844,7 @@ async def preflight_mobile_build(
|
|||||||
if request is None:
|
if request is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
||||||
|
|
||||||
files = await _load_files(session, request_id)
|
issues = _preflight_issues(request, request_id)
|
||||||
issues = _preflight_issues(request, files)
|
|
||||||
|
|
||||||
request.preflight_passed = len(issues) == 0
|
request.preflight_passed = len(issues) == 0
|
||||||
request.preflight_report = "\n".join(issues)
|
request.preflight_report = "\n".join(issues)
|
||||||
@@ -807,8 +867,7 @@ async def trigger_mobile_build(
|
|||||||
if request is None:
|
if request is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
||||||
|
|
||||||
files = await _load_files(session, request_id)
|
issues = _preflight_issues(request, request_id)
|
||||||
issues = _preflight_issues(request, files)
|
|
||||||
if issues:
|
if issues:
|
||||||
request.preflight_passed = False
|
request.preflight_passed = False
|
||||||
request.preflight_report = "\n".join(issues)
|
request.preflight_report = "\n".join(issues)
|
||||||
|
|||||||
@@ -265,24 +265,6 @@ class MobileBuildRequest(Base):
|
|||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class MobileBuildUploadedFile(Base):
|
|
||||||
__tablename__ = "mobile_build_uploaded_files"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
platform = Column(String(20), nullable=False)
|
|
||||||
file_kind = Column(String(40), nullable=False)
|
|
||||||
original_name = Column(String(260), nullable=False)
|
|
||||||
stored_path = Column(String(800), nullable=False)
|
|
||||||
sha256 = Column(String(64), nullable=False)
|
|
||||||
size = Column(Integer, nullable=False)
|
|
||||||
uploaded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("request_id", "platform", "file_kind", name="uq_mobile_build_file_kind"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MobileBuildArtifact(Base):
|
class MobileBuildArtifact(Base):
|
||||||
__tablename__ = "mobile_build_artifacts"
|
__tablename__ = "mobile_build_artifacts"
|
||||||
|
|
||||||
|
|||||||
@@ -380,16 +380,6 @@ class MobileBuildCreate(BaseModel):
|
|||||||
release_profile: str = "release"
|
release_profile: str = "release"
|
||||||
|
|
||||||
|
|
||||||
class MobileBuildUploadedFileResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
request_id: int
|
|
||||||
platform: str
|
|
||||||
file_kind: str
|
|
||||||
original_name: str
|
|
||||||
size: int
|
|
||||||
uploaded_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class MobileBuildArtifactResponse(BaseModel):
|
class MobileBuildArtifactResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
request_id: int
|
request_id: int
|
||||||
@@ -421,7 +411,6 @@ class MobileBuildRequestResponse(BaseModel):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
started_at: Optional[str]
|
started_at: Optional[str]
|
||||||
completed_at: Optional[str]
|
completed_at: Optional[str]
|
||||||
files: list[MobileBuildUploadedFileResponse]
|
|
||||||
artifacts: list[MobileBuildArtifactResponse]
|
artifacts: list[MobileBuildArtifactResponse]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
PROJECT_NAME=kmtn
|
PROJECT_NAME=kryz
|
||||||
FRONTEND_PORT=4201
|
FRONTEND_PORT=4201
|
||||||
KMTN_CORS_ORIGINS='["http://localhost:4201", "http://localhost"]'
|
KMTN_CORS_ORIGINS='["http://localhost:4201", "http://localhost"]'
|
||||||
@@ -163,6 +163,41 @@ export class AdminMobileBuildsTabComponent implements OnInit {
|
|||||||
return 'iOS requires both certificate and provisioning profile files.';
|
return 'iOS requires both certificate and provisioning profile files.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate file extensions before hitting the backend
|
||||||
|
const extChecks: Array<{ file: File | null; label: string; exts: string[] }> = [];
|
||||||
|
if (this.buildAndroid) {
|
||||||
|
extChecks.push({
|
||||||
|
file: this.androidKeystoreFile,
|
||||||
|
label: 'Android keystore',
|
||||||
|
exts: ['.jks', '.keystore', '.p12'],
|
||||||
|
});
|
||||||
|
extChecks.push({
|
||||||
|
file: this.androidDescriptorFile,
|
||||||
|
label: 'Android descriptor',
|
||||||
|
exts: ['.json'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.buildIos) {
|
||||||
|
extChecks.push({
|
||||||
|
file: this.iosCertificateFile,
|
||||||
|
label: 'iOS certificate',
|
||||||
|
exts: ['.p12'],
|
||||||
|
});
|
||||||
|
extChecks.push({
|
||||||
|
file: this.iosProfileFile,
|
||||||
|
label: 'iOS provisioning profile',
|
||||||
|
exts: ['.mobileprovision'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, label, exts } of extChecks) {
|
||||||
|
if (!file) continue;
|
||||||
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (!exts.includes(ext)) {
|
||||||
|
return `${label} must have one of: ${exts.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +211,7 @@ export class AdminMobileBuildsTabComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.mobileBuildBusy = true;
|
this.mobileBuildBusy = true;
|
||||||
|
let createdRequestId: number | null = null;
|
||||||
try {
|
try {
|
||||||
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
||||||
platform_android: this.buildAndroid,
|
platform_android: this.buildAndroid,
|
||||||
@@ -186,6 +222,7 @@ export class AdminMobileBuildsTabComponent implements OnInit {
|
|||||||
build_number: this.buildNumber.trim(),
|
build_number: this.buildNumber.trim(),
|
||||||
release_profile: this.buildReleaseProfile.trim() || 'release',
|
release_profile: this.buildReleaseProfile.trim() || 'release',
|
||||||
}));
|
}));
|
||||||
|
createdRequestId = request.id;
|
||||||
|
|
||||||
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
||||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
||||||
@@ -201,6 +238,13 @@ export class AdminMobileBuildsTabComponent implements OnInit {
|
|||||||
await this.reloadMobileBuilds();
|
await this.reloadMobileBuilds();
|
||||||
this.onMobileBuildSelected(request.id);
|
this.onMobileBuildSelected(request.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Clean up orphaned request if files couldn't all be uploaded
|
||||||
|
if (createdRequestId !== null) {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.mobileBuildService.deleteRequest(createdRequestId),
|
||||||
|
).catch(() => {}); // best-effort cleanup
|
||||||
|
await this.reloadMobileBuilds().catch(() => {});
|
||||||
|
}
|
||||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
||||||
} finally {
|
} finally {
|
||||||
this.mobileBuildBusy = false;
|
this.mobileBuildBusy = false;
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ export interface MobileBuildDefaults {
|
|||||||
stream_metadata_url: string;
|
stream_metadata_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MobileBuildUploadedFile {
|
|
||||||
id: number;
|
|
||||||
request_id: number;
|
|
||||||
platform: 'android' | 'ios';
|
|
||||||
file_kind: string;
|
|
||||||
original_name: string;
|
|
||||||
size: number;
|
|
||||||
uploaded_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MobileBuildArtifact {
|
export interface MobileBuildArtifact {
|
||||||
id: number;
|
id: number;
|
||||||
request_id: number;
|
request_id: number;
|
||||||
@@ -49,7 +39,6 @@ export interface MobileBuildRequest {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
files: MobileBuildUploadedFile[];
|
|
||||||
artifacts: MobileBuildArtifact[];
|
artifacts: MobileBuildArtifact[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MobileBuildDefaults,
|
MobileBuildDefaults,
|
||||||
MobileBuildPreflightResult,
|
MobileBuildPreflightResult,
|
||||||
MobileBuildRequest,
|
MobileBuildRequest,
|
||||||
MobileBuildUploadedFile,
|
|
||||||
ThemeStatus,
|
ThemeStatus,
|
||||||
} from '../interfaces/mobile-build';
|
} from '../interfaces/mobile-build';
|
||||||
|
|
||||||
@@ -35,19 +34,23 @@ export class MobileBuildService {
|
|||||||
return this.http.post<MobileBuildRequest>(`${this.baseUrl}/requests`, payload);
|
return this.http.post<MobileBuildRequest>(`${this.baseUrl}/requests`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteRequest(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
uploadFile(
|
uploadFile(
|
||||||
requestId: number,
|
requestId: number,
|
||||||
platform: 'android' | 'ios',
|
platform: 'android' | 'ios',
|
||||||
fileKind: string,
|
fileKind: string,
|
||||||
file: File,
|
file: File,
|
||||||
): Observable<MobileBuildUploadedFile> {
|
): Observable<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
const params = new HttpParams()
|
const params = new HttpParams()
|
||||||
.set('platform', platform)
|
.set('platform', platform)
|
||||||
.set('file_kind', fileKind);
|
.set('file_kind', fileKind);
|
||||||
|
|
||||||
return this.http.post<MobileBuildUploadedFile>(
|
return this.http.post<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }>(
|
||||||
`${this.baseUrl}/requests/${requestId}/files`,
|
`${this.baseUrl}/requests/${requestId}/files`,
|
||||||
form,
|
form,
|
||||||
{ params },
|
{ params },
|
||||||
|
|||||||
Reference in New Issue
Block a user