Public/Copy-OnePAMFile.ps1

function Copy-OnePAMFile {
    <#
    .SYNOPSIS
        Copies files to/from a OnePAM resource via SFTP over WebSocket.
    .DESCRIPTION
        Transfers files using the SFTP subsystem through the OnePAM gateway.
        Remote paths use resource:/path or user@resource:/path syntax.
    .PARAMETER Source
        One or more source paths. Can be local or remote (resource:/path).
    .PARAMETER Destination
        Destination path. Can be local or remote (resource:/path).
    .PARAMETER Recursive
        Enable recursive copy for directories.
    .EXAMPLE
        Copy-OnePAMFile -Source "myfile.txt" -Destination "my-server:/tmp/"
    .EXAMPLE
        Copy-OnePAMFile -Source "my-server:/var/log/app.log" -Destination "./"
    .EXAMPLE
        Copy-OnePAMFile -Source "./dir" -Destination "my-server:/opt/" -Recursive
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string[]]$Source,

        [Parameter(Mandatory, Position = 1)]
        [string]$Destination,

        [Alias('r')]
        [switch]$Recursive
    )

    $allPaths = @($Source) + @($Destination)
    $remoteResource = $null
    $login = $null
    $remoteIndices = @()

    for ($i = 0; $i -lt $allPaths.Count; $i++) {
        $parsed = ConvertTo-OpRemoteSpec $allPaths[$i]
        if ($parsed) {
            $remoteIndices += $i
            if (-not $remoteResource) {
                $remoteResource = $parsed.Resource
                $login = $parsed.User
            }
            elseif ($parsed.Resource -ne $remoteResource) {
                throw "All remote paths must reference the same resource (found '$remoteResource' and '$($parsed.Resource)')."
            }
            if ($parsed.User) { $login = $parsed.User }
        }
    }

    if ($remoteIndices.Count -eq 0) {
        throw 'At least one source or destination must be a remote path (resource:/path).'
    }

    $res = Get-OnePAMResource -Name $remoteResource
    if (-not $res) { throw "Resource '$remoteResource' not found." }

    $resId = if ($res.id) { $res.id } elseif ($res.ID) { $res.ID } else { throw "Cannot determine resource ID." }
    Assert-OpSafePathSegment -Value $resId -Label 'resource ID'

    $connectBody = @{}
    if ($login) { $connectBody['ssh_user'] = $login }
    $session = Invoke-OpApi -Method POST -Path "/api/v1/resources/$resId/connect" -Body $connectBody

    if (-not $session.session_id) {
        throw 'Failed to create session.'
    }
    Assert-OpSafePathSegment -Value $session.session_id -Label 'session ID'

    $cfg = Get-OpConfig
    if ($session.direct) {
        $baseUri = [Uri]$cfg.api_base
        $wsScheme = if ($baseUri.Scheme -eq 'https') { 'wss' } else { 'ws' }
        $hostPort = $baseUri.Authority
    }
    else {
        if (-not $session.proxy_host) { throw 'Session response missing proxy_host.' }
        $wsScheme = 'wss'
        $hostPort = $session.proxy_host
        if ($session.proxy_port -and $session.proxy_port -ne 443) {
            $hostPort = "$($session.proxy_host):$($session.proxy_port)"
        }
    }

    $connectPath = if ($session.connect_url) { $session.connect_url } else { "/gateway/ssh/$($session.session_id)" }

    $queryParts = [System.Collections.Generic.List[string]]::new()
    if (-not $session.direct -and $session.token) {
        $queryParts.Add("token=$([System.Uri]::EscapeDataString($session.token))")
    }
    $queryParts.Add('mode=sftp')

    $wsUrl = "${wsScheme}://${hostPort}${connectPath}?$($queryParts -join '&')"

    $wsHeaders = @{}
    if ($session.direct) {
        $authToken = Get-OpToken
        if ($authToken) {
            $wsHeaders['Authorization'] = "Bearer $($authToken.access_token)"
        }
    }

    $ws = New-OpWebSocket -Uri $wsUrl -Headers $wsHeaders
    $destIsRemote = $remoteIndices -contains ($allPaths.Count - 1)

    try {
        if ($destIsRemote) {
            $destParsed = ConvertTo-OpRemoteSpec $Destination
            $destPath = if ($destParsed) { $destParsed.Path } else { $Destination }

            foreach ($src in $Source) {
                Copy-OpUpload -WebSocket $ws -LocalPath $src -RemotePath $destPath -Recursive:$Recursive
            }
        }
        else {
            foreach ($src in $Source) {
                $srcParsed = ConvertTo-OpRemoteSpec $src
                if ($srcParsed) {
                    Copy-OpDownload -WebSocket $ws -RemotePath $srcParsed.Path -LocalPath $Destination -Recursive:$Recursive
                }
            }
        }
    }
    finally {
        Close-OpWebSocket -WebSocket $ws
    }
}

function ConvertTo-OpRemoteSpec {
    param([string]$PathString)

    if ($PathString -match '^[a-zA-Z]:[/\\]') { return $null }

    if ($PathString -match '^(?:([^@:]+)@)?([^@:]+):(.+)$') {
        return [PSCustomObject]@{
            User     = $Matches[1]
            Resource = $Matches[2]
            Path     = $Matches[3]
        }
    }
    return $null
}

function Copy-OpUpload {
    param(
        [System.Net.WebSockets.ClientWebSocket]$WebSocket,
        [string]$LocalPath,
        [string]$RemotePath,
        [switch]$Recursive
    )

    if (-not (Test-Path $LocalPath)) {
        throw "Local path '$LocalPath' not found."
    }

    $item = Get-Item $LocalPath
    if ($item.PSIsContainer) {
        if (-not $Recursive) {
            throw "'$LocalPath' is a directory. Use -Recursive for directory transfers."
        }
        $files = Get-ChildItem -Path $LocalPath -Recurse -File
        foreach ($file in $files) {
            $relPath = $file.FullName.Substring($item.FullName.Length).Replace('\', '/')
            $targetPath = $RemotePath.TrimEnd('/') + $relPath

            $parentDir = ($targetPath -split '/')[0..$(($targetPath -split '/').Count - 2)] -join '/'
            if ($parentDir) {
                $mkdirMsg = @{ type = 'sftp_mkdir'; path = $parentDir } | ConvertTo-Json -Compress
                Send-OpWsMessage -WebSocket $WebSocket -Message $mkdirMsg
            }

            Send-OpFileUpload -WebSocket $WebSocket -LocalFile $file.FullName -RemotePath $targetPath
        }
    }
    else {
        Send-OpFileUpload -WebSocket $WebSocket -LocalFile $LocalPath -RemotePath $RemotePath
    }
}

$script:MaxUploadSize = 500 * 1024 * 1024  # 500 MB

function Send-OpFileUpload {
    param(
        [System.Net.WebSockets.ClientWebSocket]$WebSocket,
        [string]$LocalFile,
        [string]$RemotePath
    )

    $fileInfo = [System.IO.FileInfo]::new($LocalFile)
    if ($fileInfo.Length -gt $script:MaxUploadSize) {
        throw "File '$LocalFile' is $([math]::Round($fileInfo.Length / 1MB, 1)) MB, exceeding the $($script:MaxUploadSize / 1MB) MB upload limit."
    }

    $fileBytes = [System.IO.File]::ReadAllBytes($LocalFile)

    $header = @{
        type = 'sftp_upload'
        path = $RemotePath
        size = $fileBytes.Length
    } | ConvertTo-Json -Compress
    Send-OpWsMessage -WebSocket $WebSocket -Message $header

    if ($fileBytes.Length -gt 0) {
        Send-OpWsBinaryMessage -WebSocket $WebSocket -Data $fileBytes
    }

    $resp = Receive-OpWsMessage -WebSocket $WebSocket -TimeoutSeconds 60
    if ($resp.MessageType -eq 'Close') {
        throw "Connection closed during upload of '$LocalFile'."
    }
    if ($resp.Text) {
        $msg = $resp.Text | ConvertFrom-Json -ErrorAction SilentlyContinue
        if ($msg -and $msg.type -eq 'error') {
            throw "Upload failed for '$LocalFile': $($msg.message)"
        }
    }

    $localName = [System.IO.Path]::GetFileName($LocalFile)
    Write-Host "$localName -> $RemotePath ($($fileBytes.Length) bytes)" -ForegroundColor Cyan
}

function Copy-OpDownload {
    param(
        [System.Net.WebSockets.ClientWebSocket]$WebSocket,
        [string]$RemotePath,
        [string]$LocalPath,
        [switch]$Recursive
    )

    $reqMsg = @{
        type      = 'sftp_download'
        path      = $RemotePath
        recursive = [bool]$Recursive
    } | ConvertTo-Json -Compress
    Send-OpWsMessage -WebSocket $WebSocket -Message $reqMsg

    $resp = Receive-OpWsMessage -WebSocket $WebSocket -TimeoutSeconds 120

    if ($resp.MessageType -eq 'Close') {
        throw 'Connection closed during download.'
    }
    if ($resp.Text) {
        $msg = $resp.Text | ConvertFrom-Json -ErrorAction SilentlyContinue
        if ($msg -and $msg.type -eq 'error') {
            throw "Download failed for '$RemotePath': $($msg.message)"
        }
    }

    $localTarget = $LocalPath
    if ((Test-Path $LocalPath) -and (Get-Item $LocalPath).PSIsContainer) {
        $fileName = [System.IO.Path]::GetFileName($RemotePath)
        $localTarget = Join-Path $LocalPath $fileName
    }

    $resolvedTarget = [System.IO.Path]::GetFullPath($localTarget)
    $resolvedBase   = [System.IO.Path]::GetFullPath($LocalPath)
    if (-not $resolvedTarget.StartsWith($resolvedBase, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Remote path '$RemotePath' would write outside the target directory."
    }

    if ($resp.Data) {
        $parentDir = [System.IO.Path]::GetDirectoryName($resolvedTarget)
        if (-not (Test-Path $parentDir)) {
            New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
        }
        [System.IO.File]::WriteAllBytes($resolvedTarget, $resp.Data)
        $remoteName = [System.IO.Path]::GetFileName($RemotePath)
        Write-Host "$remoteName -> $resolvedTarget ($($resp.Data.Length) bytes)" -ForegroundColor Cyan
    }
}