FileReport.ps1

function Get-RjRbStorageSharedKeyAuthHeader {
    <#
        .SYNOPSIS
        Build a SharedKey Authorization header for an Azure Storage REST request.
    #>

    param(
        [Parameter(Mandatory = $true)][string] $StorageAccountName,
        [Parameter(Mandatory = $true)][byte[]] $KeyBytes,
        [Parameter(Mandatory = $true)][string] $Method,
        [Parameter(Mandatory = $true)][string] $CanonicalizedResource,
        [Parameter(Mandatory = $true)][hashtable] $Headers,
        [string] $ContentType = "",
        [int] $ContentLength = 0
    )

    $msHeaders = ($Headers.GetEnumerator() | Where-Object { $_.Key -like "x-ms-*" } | Sort-Object Key | ForEach-Object { "$($_.Key):$($_.Value)" }) -join "`n"
    $contentLengthStr = if ($ContentLength -gt 0) { "$ContentLength" } else { "" }
    $stringToSign = "$Method`n`n`n$contentLengthStr`n`n$ContentType`n`n`n`n`n`n`n$msHeaders`n$CanonicalizedResource"

    $hmac = New-Object System.Security.Cryptography.HMACSHA256
    try {
        $hmac.Key = $KeyBytes
        $sig = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)))
    }
    finally {
        $hmac.Dispose()
    }
    return "SharedKey ${StorageAccountName}:$sig"
}

function New-RjRbBlobSasToken {
    <#
        .SYNOPSIS
        Generate a read-only Blob SAS URL signed with the storage account key.
    #>

    param(
        [Parameter(Mandatory = $true)][string] $StorageAccountName,
        [Parameter(Mandatory = $true)][byte[]] $KeyBytes,
        [Parameter(Mandatory = $true)][string] $Container,
        [Parameter(Mandatory = $true)][string] $Blob,
        [Parameter(Mandatory = $true)][datetime] $ExpiryTime
    )

    $startTime = (Get-Date).ToUniversalTime().AddMinutes(-5).ToString("yyyy-MM-ddTHH:mm:ssZ")
    $expiryStr = $ExpiryTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    $permissions = "r"
    $signedVersion = "2023-11-03"
    $signedResource = "b"
    $signedProtocol = "https"

    $canonicalizedResource = "/blob/$StorageAccountName/$Container/$Blob"
    $stringToSign = "$permissions`n$startTime`n$expiryStr`n$canonicalizedResource`n`n`n$signedProtocol`n$signedVersion`n$signedResource`n`n`n`n`n`n`n"

    $hmac = New-Object System.Security.Cryptography.HMACSHA256
    try {
        $hmac.Key = $KeyBytes
        $sig = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)))
    }
    finally {
        $hmac.Dispose()
    }

    $sasToken = "sp=$permissions&st=$startTime&se=$expiryStr&spr=$signedProtocol&sv=$signedVersion&sr=$signedResource&sig=$([Uri]::EscapeDataString($sig))"
    return "https://$StorageAccountName.blob.core.windows.net/$Container/${Blob}?$sasToken"
}

function Publish-RjRbFilesToStorageContainer {
    <#
        .SYNOPSIS
        Upload one or more local files to an Azure Storage container, returning SAS
        download links.

        .DESCRIPTION
        Performs blob upload and SAS token generation using the Azure Storage REST API
        directly, avoiding the Az.Storage module entirely. This eliminates the well-known
        assembly conflict between Az.Storage and ExchangeOnlineManagement.

        Storage account keys are retrieved via ARM REST API (Invoke-AzRestMethod from
        Az.Accounts). Blob operations (container creation, upload, SAS generation) use
        the Azure Storage REST API with SharedKey authentication.

        Required Azure RBAC on the storage account:
        - Microsoft.Storage/storageAccounts/read
        - Microsoft.Storage/storageAccounts/listKeys/action
        Built-in role: 'Storage Account Contributor'.

        .PARAMETER FilePaths
        Array of local file paths to upload.

        .PARAMETER ContainerName
        Target blob container. Created automatically if missing.

        .PARAMETER ResourceGroupName
        Resource group containing the storage account.

        .PARAMETER StorageAccountName
        Target storage account name.

        .PARAMETER SubscriptionId
        Optional Azure subscription ID. Sets context before storage operations.

        .PARAMETER LinkExpiryDays
        SAS link validity in days (default 6, range 1-3650).

        .PARAMETER AddBlobNamePrefix
        When $true, prefixes blob names with yyyyMMdd-HHmmss (default $false).

        .OUTPUTS
        Array of PSCustomObject with BlobName, EndTime, SASLink for each uploaded file.

        .NOTES
        Dependencies:
        - Requires the Az.Accounts module in the runbook environment (cmdlets Get-AzContext,
          Set-AzContext, Connect-AzAccount, Invoke-AzRestMethod). Declare it explicitly in
          the consuming runbook, e.g.:
            #Requires -Modules @{ModuleName = "Az.Accounts"; ModuleVersion = "5.3.4"}
        - No dependency on Az.Storage: blob operations are performed via the Azure Storage
          REST API directly to avoid the Az.Storage / ExchangeOnlineManagement assembly
          conflict.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string[]] $FilePaths,
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string] $ContainerName,
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string] $ResourceGroupName,
        [Parameter(Mandatory = $true)][ValidateNotNullOrEmpty()][string] $StorageAccountName,
        [Parameter(Mandatory = $false)][string] $SubscriptionId,
        [Parameter(Mandatory = $false)][ValidateRange(1, 3650)][int] $LinkExpiryDays = 6,
        [Parameter(Mandatory = $false)][bool] $AddBlobNamePrefix = $false
    )

    if (-not (Get-Command -Name Get-AzContext -ErrorAction SilentlyContinue)) {
        throw "Publish-RjRbFilesToStorageContainer requires the 'Az.Accounts' module. Add `#Requires -Modules @{ModuleName = 'Az.Accounts'; ModuleVersion = '5.3.4'}` to the calling runbook."
    }

    foreach ($p in $FilePaths) {
        if (-not (Test-Path -Path $p -PathType Leaf)) {
            throw "File '$p' was not found."
        }
    }

    $azContext = Get-AzContext -ErrorAction SilentlyContinue
    if ((-not $azContext) -or (-not $azContext.Account)) {
        Connect-RjRbAzAccount
    }
    if ($SubscriptionId) { Set-AzContext -Subscription $SubscriptionId | Out-Null }

    $effectiveSubscriptionId = (Get-AzContext).Subscription.Id
    $armPath = "/subscriptions/$effectiveSubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Storage/storageAccounts/$StorageAccountName/listKeys?api-version=2023-05-01"
    $keysResponse = Invoke-AzRestMethod -Path $armPath -Method POST
    if ($keysResponse.StatusCode -ne 200) {
        throw "Failed to retrieve storage account keys for '$StorageAccountName' in resource group '$ResourceGroupName'. Status: $($keysResponse.StatusCode)"
    }
    $storageKey = ($keysResponse.Content | ConvertFrom-Json).keys[0].value
    $keyBytes = [Convert]::FromBase64String($storageKey)

    $baseUri = "https://$StorageAccountName.blob.core.windows.net"

    # Create container if it does not exist (using HttpClient to bypass Azure Automation's
    # Invoke-RestMethod interceptor that strips required headers)
    $dateStr = [DateTime]::UtcNow.ToString("R")
    $containerHeaders = @{
        "x-ms-date"    = $dateStr
        "x-ms-version" = "2023-11-03"
    }
    $canonResource = "/$StorageAccountName/$ContainerName`nrestype:container"
    $containerHeaders["Authorization"] = Get-RjRbStorageSharedKeyAuthHeader -StorageAccountName $StorageAccountName -KeyBytes $keyBytes -Method "PUT" -CanonicalizedResource $canonResource -Headers $containerHeaders

    $httpClient = [System.Net.Http.HttpClient]::new()
    try {
        $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Put, "$baseUri/$ContainerName`?restype=container")
        foreach ($h in $containerHeaders.GetEnumerator()) {
            $request.Headers.TryAddWithoutValidation($h.Key, $h.Value) | Out-Null
        }
        $response = $httpClient.SendAsync($request).GetAwaiter().GetResult()
        $statusCode = [int]$response.StatusCode
        if ($statusCode -ne 201 -and $statusCode -ne 409) {
            $errBody = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
            throw "Container creation failed ($statusCode): $errBody"
        }
    }
    finally {
        $httpClient.Dispose()
    }

    $endTime = (Get-Date).AddDays($LinkExpiryDays)
    $results = @()
    foreach ($filePath in $FilePaths) {
        $blobName = Split-Path -Path $filePath -Leaf
        if ($AddBlobNamePrefix) {
            $prefix = (Get-Date).ToString("yyyyMMdd-HHmmss")
            $blobName = "$prefix-$blobName"
        }

        $fileBytes = [System.IO.File]::ReadAllBytes($filePath)
        $contentLength = $fileBytes.Length

        $dateStr = [DateTime]::UtcNow.ToString("R")
        $blobHeaders = @{
            "x-ms-date"      = $dateStr
            "x-ms-version"   = "2023-11-03"
            "x-ms-blob-type" = "BlockBlob"
        }
        $canonResource = "/$StorageAccountName/$ContainerName/$blobName"
        $blobHeaders["Authorization"] = Get-RjRbStorageSharedKeyAuthHeader -StorageAccountName $StorageAccountName -KeyBytes $keyBytes -Method "PUT" -CanonicalizedResource $canonResource -Headers $blobHeaders -ContentType "application/octet-stream" -ContentLength $contentLength

        # Use HttpClient directly to ensure all custom headers (x-ms-blob-type) are sent.
        # Invoke-RestMethod in hosted PowerShell can strip custom headers with binary bodies.
        $httpClient = [System.Net.Http.HttpClient]::new()
        try {
            $content = [System.Net.Http.ByteArrayContent]::new($fileBytes)
            $content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
            $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Put, "$baseUri/$ContainerName/$blobName")
            $request.Content = $content
            foreach ($h in $blobHeaders.GetEnumerator()) {
                $request.Headers.TryAddWithoutValidation($h.Key, $h.Value) | Out-Null
            }
            $response = $httpClient.SendAsync($request).GetAwaiter().GetResult()
            if (-not $response.IsSuccessStatusCode) {
                $errBody = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
                throw "Blob upload failed ($($response.StatusCode)): $errBody"
            }
        }
        finally {
            $httpClient.Dispose()
        }

        $sasLink = New-RjRbBlobSasToken -StorageAccountName $StorageAccountName -KeyBytes $keyBytes -Container $ContainerName -Blob $blobName -ExpiryTime $endTime
        $results += [PSCustomObject]@{ BlobName = $blobName; EndTime = $endTime; SASLink = $sasLink }
    }
    return $results
}