Public/Send-JuribaAppRSetupFile.ps1

function Send-JuribaAppRSetupFile {
    <#
      .SYNOPSIS
      Uploads a setup file to Juriba App Readiness using chunked upload.
      .DESCRIPTION
      Uploads a local setup file (MSI, EXE, ZIP, etc.) to App Readiness for
      automated processing. The file is split into chunks, uploaded individually,
      and then combined server-side. Returns an upload identifier (UUID) that can
      be passed to New-JuribaAppRApplication to create the application.

      The server combines the chunks ASYNCHRONOUSLY, so after requesting the
      combine this cmdlet polls GET api/v2/uploadChunk/{uuid}/state until the
      server confirms the file is fully assembled (or the combine fails).
      Without this wait, creating an application immediately after upload can
      start packaging before the source file exists in the store - the job
      then sits at "Downloading" with no logs and no VM allocated. On servers
      older than 5.1 (no state endpoint) or multi-node instances where the
      poll lands on a different node, the cmdlet warns and falls back to the
      previous fire-and-forget behaviour rather than failing the upload.

      Large files are handled automatically by splitting into configurable chunk
      sizes (default 2MB). Progress is reported via Write-Progress.
      .PARAMETER Instance
      The URL of the App Readiness instance. Not required if connected via Connect-JuribaAppR.
      .PARAMETER APIKey
      The API key for authentication. Not required if connected via Connect-JuribaAppR.
      .PARAMETER FilePath
      The full path to the setup file to upload.
      .PARAMETER ChunkSizeMB
      The size of each upload chunk in megabytes. Default is 2MB.
      Increase for faster uploads on high-bandwidth connections.
      .PARAMETER Protected
      When specified, uploads the file to the protected upload endpoint.
      Use this for files that require additional security handling.
      .PARAMETER CombineTimeoutSec
      Maximum seconds to wait for the server-side chunk combine to complete.
      Default is 300. Raise this for very large files or slow storage.
      .PARAMETER SkipCombineWait
      Skip waiting for the server-side combine and return as soon as the
      combine has been requested (the pre-0.3.8 behaviour). Only use this
      when the caller performs its own readiness check before creating the
      application.
      .EXAMPLE
      $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\Firefox-Setup-115.0.exe"
      $upload.Uuid
      Uploads a setup file and returns the upload identifier.
      .EXAMPLE
      $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\BigApp.msi" -ChunkSizeMB 5
      Uploads a large file using 5MB chunks.
      .EXAMPLE
      $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\App.exe"
      New-JuribaAppRApplication -Uuid $upload.Uuid -FileName $upload.FileName
      Uploads a file and immediately creates an application from it.
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [string]$Instance,

        [Parameter(Mandatory = $false)]
        [string]$APIKey,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string]$FilePath,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 100)]
        [int]$ChunkSizeMB = 2,

        [Parameter(Mandatory = $false)]
        [switch]$Protected,

        [Parameter(Mandatory = $false)]
        [ValidateRange(10, 3600)]
        [int]$CombineTimeoutSec = 300,

        [Parameter(Mandatory = $false)]
        [switch]$SkipCombineWait
    )

    $conn = Get-JuribaAppRConnection -Instance $Instance -APIKey $APIKey

    # Resolve the full path and get file info
    $fileInfo = Get-Item $FilePath
    $fileName = $fileInfo.Name
    $fileSize = $fileInfo.Length
    $chunkSize = $ChunkSizeMB * 1024 * 1024
    $totalChunks = [int][Math]::Ceiling($fileSize / $chunkSize)
    $uuid = [Guid]::NewGuid().ToString()

    Write-Verbose "Uploading '$fileName' ($([Math]::Round($fileSize / 1MB, 2)) MB) in $totalChunks chunk(s)"
    Write-Verbose "Upload UUID: $uuid"

    # Determine the upload endpoint
    $chunkEndpoint = if ($Protected) { "api/uploadChunk/protected" } else { "api/uploadChunk" }
    $combineEndpoint = if ($Protected) { "api/v2/uploadChunk/protected/async" } else { "api/v2/uploadChunk/async" }

    $headers = @{
        "x-api-key" = $conn.APIKey
        "Accept"    = "application/json"
    }

    # Resolve the current user ID — upload endpoint requires userId in the form data
    $userId = "0"
    try {
        $whoAmI = Invoke-RestMethod -Uri "$($conn.Instance)/api/apm/user/whoAmI" `
            -Headers $headers -Method GET
        if ($whoAmI) { $userId = $whoAmI.ToString().Trim() }
        Write-Verbose "Resolved userId: $userId"
    }
    catch {
        Write-Verbose "Could not resolve userId: $($_.Exception.Message)"
    }

    # Upload each chunk
    $fileStream = [System.IO.File]::OpenRead($fileInfo.FullName)
    try {
        $buffer = New-Object byte[] $chunkSize
        $chunkIndex = 0

        while ($chunkIndex -lt $totalChunks) {
            $bytesRead = $fileStream.Read($buffer, 0, $chunkSize)

            # Create a temp file for the chunk, using the original filename
            # so the multipart Content-Disposition has the correct extension.
            # The server validates the filename and rejects .tmp files.
            $chunkTempDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), $uuid)
            if (-not (Test-Path $chunkTempDir)) { $null = New-Item -Path $chunkTempDir -ItemType Directory }
            $chunkTempPath = [System.IO.Path]::Combine($chunkTempDir, $fileName)
            try {
                [System.IO.File]::WriteAllBytes($chunkTempPath, $buffer[0..($bytesRead - 1)])

                # Build multipart form data using HttpClient for full control over
                # headers. PowerShell's Invoke-WebRequest -Form may not reliably
                # pass custom headers (x-api-key) with multipart uploads.
                $chunkUri = "{0}/{1}" -f $conn.Instance, $chunkEndpoint
                $chunkByteOffset = $chunkIndex * $chunkSize

                $percentComplete = [Math]::Round(($chunkIndex + 1) / $totalChunks * 100)
                Write-Progress -Activity "Uploading $fileName" `
                    -Status "Chunk $($chunkIndex + 1) of $totalChunks" `
                    -PercentComplete $percentComplete

                Write-Verbose "Uploading chunk $($chunkIndex + 1)/$totalChunks ($bytesRead bytes)"

                try {
                    $httpClient = [System.Net.Http.HttpClient]::new()
                    $httpClient.DefaultRequestHeaders.Add("x-api-key", $conn.APIKey)
                    $httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer $($conn.APIKey)")
                    $httpClient.DefaultRequestHeaders.Add("Accept", "application/json")

                    $multipartContent = [System.Net.Http.MultipartFormDataContent]::new()

                    # Add Dropzone.js 5.9.3 chunked upload fields
                    $multipartContent.Add([System.Net.Http.StringContent]::new($uuid), "dzUuid")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($chunkIndex.ToString()), "dzChunkIndex")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($fileSize.ToString()), "dzTotalFileSize")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($bytesRead.ToString()), "dzCurrentChunkSize")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($totalChunks.ToString()), "dzTotalChunkCount")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($chunkByteOffset.ToString()), "dzChunkByteOffset")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($chunkSize.ToString()), "dzChunkSize")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($fileName), "dzFilename")
                    $multipartContent.Add([System.Net.Http.StringContent]::new($userId), "userId")

                    # Add the file content
                    $chunkBytes = [System.IO.File]::ReadAllBytes($chunkTempPath)
                    $fileContent = [System.Net.Http.ByteArrayContent]::new($chunkBytes)
                    $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
                    $multipartContent.Add($fileContent, "file", $fileName)

                    $uploadResponse = $httpClient.PostAsync($chunkUri, $multipartContent).GetAwaiter().GetResult()
                    if (-not $uploadResponse.IsSuccessStatusCode) {
                        $respBody = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult()
                        throw "HTTP $([int]$uploadResponse.StatusCode) $($uploadResponse.ReasonPhrase): $respBody"
                    }
                }
                catch {
                    $fileStream.Close()
                    $fileStream.Dispose()
                    Write-Progress -Activity "Uploading $fileName" -Completed
                    throw "Chunk $($chunkIndex + 1)/$totalChunks upload failed: $($_.Exception.Message)"
                }
                finally {
                    if ($multipartContent) { $multipartContent.Dispose() }
                    if ($httpClient)        { $httpClient.Dispose() }
                }

            }
            finally {
                if (Test-Path $chunkTempPath) {
                    Remove-Item $chunkTempPath -Force -ErrorAction SilentlyContinue
                }
            }

            $chunkIndex++
        }
    }
    finally {
        $fileStream.Close()
        $fileStream.Dispose()
        # Clean up the temp chunk directory
        if ($chunkTempDir -and (Test-Path $chunkTempDir)) {
            Remove-Item $chunkTempDir -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    Write-Progress -Activity "Uploading $fileName" -Completed

    # Combine the chunks on the server
    # Field names must match the CombineFilesModel expected by the API
    Write-Verbose "Combining chunks on server..."
    $combineBody = @{
        dzIdentifier  = $uuid
        fileName      = $fileName
        totalChunks   = $totalChunks
        expectedBytes = $fileSize
        uploadType    = 0
    }

    $combineUri = "{0}/{1}" -f $conn.Instance, $combineEndpoint
    $jsonBody = $combineBody | ConvertTo-Json -Compress
    Write-Verbose "Combine URI: $combineUri"
    Write-Verbose "Combine body: $jsonBody"

    try {
        $null = Invoke-RestMethod -Uri $combineUri -Method PUT `
            -Headers $headers -ContentType 'application/json' -Body $jsonBody
    }
    catch {
        $errDetail = $_.Exception.Message
        if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
            $errDetail = $_.ErrorDetails.Message
        }
        throw "Combine failed: $errDetail"
    }

    # The combine endpoint is ASYNC: the PUT above only queues a background
    # job, and the server returns 200 before the file exists in the store.
    # Creating an application (with run-immediately) at that point races the
    # combine - if packaging wins, the job hangs at "Downloading" with no
    # logs and no VM, because the source it wants to fetch isn't there yet.
    # So poll the combine state until the server confirms completion.
    #
    # 404 from the state endpoint means no state entry is visible HERE:
    # the server predates the endpoint (< 5.1), the poll landed on a
    # different node behind a load balancer (state is per-node), or the
    # entry expired (~10 min lifetime). After several consecutive 404s we
    # warn and fall back to the previous fire-and-forget behaviour rather
    # than fail an upload that may be completing fine.
    if (-not $SkipCombineWait) {
        Write-Verbose "Waiting for server-side chunk combine (timeout: ${CombineTimeoutSec}s)..."
        $stateUri  = "{0}/api/v2/uploadChunk/{1}/state" -f $conn.Instance, $uuid
        $deadline  = (Get-Date).AddSeconds($CombineTimeoutSec)
        $confirmed = $false
        $unverifiable = $false
        $notFoundStreak = 0

        while (-not $confirmed -and -not $unverifiable) {
            if ((Get-Date) -ge $deadline) {
                throw "Timed out after $CombineTimeoutSec seconds waiting for the server to combine '$fileName' (upload id $uuid). The combine may still finish in the background - check the instance before re-uploading, or raise -CombineTimeoutSec."
            }
            Start-Sleep -Seconds 2

            $state = $null
            try {
                $state = Invoke-RestMethod -Uri $stateUri -Method GET -Headers $headers
                $notFoundStreak = 0
            }
            catch {
                $statusCode = 0
                if ($_.Exception.Response) {
                    try { $statusCode = [int]$_.Exception.Response.StatusCode }
                    catch { $statusCode = 0 }
                }
                if ($statusCode -eq 404) {
                    $notFoundStreak++
                    if ($notFoundStreak -ge 5) {
                        Write-Warning ("Could not verify the server-side chunk combine for '{0}' - the state endpoint returned 404 repeatedly. This is expected on servers older than 5.1 or multi-node instances without session affinity. Proceeding without confirmation; consider waiting before creating the application." -f $fileName)
                        $unverifiable = $true
                    }
                }
                elseif ($statusCode -eq 401 -or $statusCode -eq 403) {
                    # The key could upload but can't read combine state
                    # (the state endpoint needs Administrators/Packager/
                    # SiteAdmin/AppOwner). Retrying won't change that, so
                    # degrade immediately rather than failing a good
                    # upload at the timeout.
                    Write-Warning ("Could not verify the server-side chunk combine for '{0}' - this API key isn't authorised to read combine state (HTTP {1}). Proceeding without confirmation; consider waiting before creating the application." -f $fileName, $statusCode)
                    $unverifiable = $true
                }
                else {
                    Write-Verbose "Combine state poll failed (will retry): $($_.Exception.Message)"
                }
            }

            if ($state -and $state.isCompleted) {
                if (-not $state.status) {
                    $combineError = if ($state.error) { $state.error } else { "error code $($state.errorCode)" }
                    throw "Server-side chunk combine failed for '$fileName': $combineError"
                }
                $confirmed = $true
                Write-Verbose "Chunk combine completed on server."
            }
        }
    }

    Write-Verbose "Upload complete. UUID: $uuid"

    # Extract FileVersionInfo metadata (ProductName, CompanyName, etc.)
    # This is the same data the Angular UI reads client-side before posting.
    $productName  = $null
    $companyName  = $null
    $productVersion = $null
    try {
        $versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($fileInfo.FullName)
        if ($versionInfo.ProductName)      { $productName    = $versionInfo.ProductName.Trim() }
        if ($versionInfo.CompanyName)       { $companyName    = $versionInfo.CompanyName.Trim() }
        if ($versionInfo.ProductVersion)    { $productVersion = $versionInfo.ProductVersion.Trim() }
        elseif ($versionInfo.FileVersion)   { $productVersion = $versionInfo.FileVersion.Trim() }
        Write-Verbose "File metadata: Name='$productName', Manufacturer='$companyName', Version='$productVersion'"
    }
    catch {
        Write-Verbose "Could not read FileVersionInfo: $($_.Exception.Message)"
    }

    # Return an object with the upload details needed for New-JuribaAppRApplication
    [PSCustomObject]@{
        Uuid            = $uuid
        FileName        = $fileName
        FileSize        = $fileSize
        TotalChunks     = $totalChunks
        Name            = $productName
        Manufacturer    = $companyName
        Version         = $productVersion
    }
}