Fixes security issue: potential remote Path Traversal via File Upload

This commit is contained in:
2026-07-19 05:55:58 +00:00
parent 4a65c7562f
commit 39b3e633a6
10 changed files with 211 additions and 145 deletions
Binary file not shown.
Binary file not shown.
+160 -101
View File
@@ -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)
-18
View File
@@ -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"
-11
View File
@@ -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 -1
View File
@@ -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;
-11
View File
@@ -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[];
} }
+6 -3
View File
@@ -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 },