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.

      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.
      .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
    )

    $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"
    }

    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
    }
}