Private/Get-GuacHistoryRecords.ps1

function Get-GuacHistoryRecords {
    <#
    .SYNOPSIS
        Private helper: gets Guacamole activity history records.
 
    .DESCRIPTION
        Shared implementation for Get-GuacHistoryConnections and
        Get-GuacHistoryUsers. Retrieves activity records from the per-data-source
        UserContext history endpoint (ActivityRecordSetResource.getRecords).
 
        The Guacamole REST API returns time values as Unix epoch milliseconds
        (startDate, endDate). This function converts them to [DateTime] UTC.
        Duration is not returned by the API; it is computed client-side as
        endDate - startDate for completed sessions, or now - startDate for
        active sessions (active = true). For sessions that are neither
        completed nor active, duration is $null.
 
        The -Contains parameter (one or more strings) filters the records so
        that every string occurs somewhere within each returned record (the
        server's "contains" query parameter). The -Order parameter sorts the
        results; it takes a sortable property name ("startDate") optionally
        prefixed with "-" for descending order (the server's "order" query
        parameter). The server always caps the result at 1000 records.
 
        Reference (Apache Guacamole 1.6.0):
        - guacamole/src/main/java/org/apache/guacamole/rest/session/UserContextResource.java
          (@Path("history") -> HistoryResource)
        - guacamole/src/main/java/org/apache/guacamole/rest/history/HistoryResource.java
          (getConnectionHistory: @Path("connections"); getUserHistory:
           @Path("users"))
        - guacamole/src/main/java/org/apache/guacamole/rest/history/ActivityRecordSetResource.java
          (getRecords: "contains" and "order" query params)
        - guacamole/src/main/java/org/apache/guacamole/rest/history/APISortPredicate.java
          (order property, "-" descending prefix)
        - guacamole/src/main/java/org/apache/guacamole/rest/history/APIActivityRecord.java
        - guacamole/src/main/java/org/apache/guacamole/rest/history/APIConnectionRecord.java
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false)]
        [AllowNull()]
        $Session,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string] $Server = [string]::Empty,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string] $DataSource = [string]::Empty,

        [Parameter(Mandatory = $false, Position = 0)]
        [ValidateSet('Connection', 'User')]
        [string] $Type = [string]::Empty,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string[]] $Contains,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string] $Order = [string]::Empty
    )

    # Handle -DataSource All by iterating over all available data sources
    if ($DataSource -ieq 'All') {
        $allCtx = Resolve-GuacSessionContext -Session $Session -Server $Server -DataSource $DataSource -CmdletName 'Get-GuacHistoryRecords'
        foreach ($ds in $allCtx.DataSources) {
            $singleCtx = [ordered]@{
                Session    = $allCtx.Session
                Server     = $allCtx.Server
                Token      = $allCtx.Token
                DataSource = $ds
            }
            try {
                $subPath = if ($Type -eq 'Connection') { 'connections' } else { 'users' }

                # For user history, avoid the "contains" query parameter due to
                # the upstream JDBC mapper bug; filter client-side instead.
                $useServerFilter = ($Type -eq 'Connection')
                $hasContains = ($null -ne $Contains) -and ($Contains.Count -gt 0)

                $queryParts = [System.Collections.Generic.List[string]]::new()
                if ($useServerFilter -and $hasContains) {
                    foreach ($c in $Contains) {
                        if (-not [string]::IsNullOrWhiteSpace($c)) {
                            $queryParts.Add(('contains={0}' -f [Uri]::EscapeDataString($c)))
                        }
                    }
                }
                if (-not [string]::IsNullOrWhiteSpace($Order)) {
                    $queryParts.Add(('order={0}' -f [Uri]::EscapeDataString($Order)))
                }

                $query = [string]::Empty
                if ($queryParts.Count -gt 0) { $query = ($queryParts -join '&') }

                $path = Resolve-GuacContextUrl -DataSource $singleCtx['DataSource'] -Collection 'history' -SubPath $subPath -Query $query
                $response = Invoke-GuacRest -Server $singleCtx['Server'] -Token $singleCtx['Token'] -Method GET -Path $path
                if ($response) {
                    $items = if ($response -is [array]) { $response } else { @($response) }
                    foreach ($item in $items) {
                        Convert-GuacHistoryRecord -Record $item | Out-Null
                        $item | Add-Member -NotePropertyName DataSource -NotePropertyValue $ds -Force

                        # Client-side filtering for user history
                        if (-not $useServerFilter -and $hasContains) {
                            $match = $true
                            foreach ($term in $Contains) {
                                if ([string]::IsNullOrWhiteSpace($term)) { continue }
                                $termLower = $term.ToLower()
                                if ($item.Username -and $item.Username.ToString().ToLower().Contains($termLower)) { continue }
                                if ($item.RemoteHost -and $item.RemoteHost.ToString().ToLower().Contains($termLower)) { continue }
                                if ($item.StartDate -and $item.StartDate.ToString('yyyy-MM-dd').Contains($termLower)) { continue }
                                $match = $false
                                break
                            }
                            if (-not $match) { continue }
                        }
                        Write-Output $item
                    }
                }
            }
            catch {
                Write-Warning ('Get-GuacHistoryRecords: error querying data source "{0}": {1}' -f ($ds, $_.Exception.Message))
            }
        }
        return
    }

    $ctx = Resolve-GuacSessionContext -Session $Session -Server $Server -DataSource $DataSource -CmdletName 'Get-GuacHistoryRecords'

    $subPath = if ($Type -eq 'Connection') { 'connections' } else { 'users' }

    # Determine if we should do server-side or client-side filtering.
    # For user history, the Guacamole JDBC mapper has an SQL syntax bug in the
    # search query that causes HTTP 500 when using the "contains" query
    # parameter (verified against guacamole-client-1.6.0 source). Additionally,
    # user history records only contain username and remoteHost — there is no
    # connection name to search. So for user history with -Contains, we always
    # fetch all records and filter client-side.
    $useServerFilter = ($Type -eq 'Connection')
    $hasContains = ($null -ne $Contains) -and ($Contains.Count -gt 0)

    if ($useServerFilter -and $hasContains) {
        # Connection history: use server-side filtering (works correctly)
        $queryParts = [System.Collections.Generic.List[string]]::new()
        foreach ($c in $Contains) {
            if (-not [string]::IsNullOrWhiteSpace($c)) {
                $queryParts.Add(('contains={0}' -f [Uri]::EscapeDataString($c)))
            }
        }
        if (-not [string]::IsNullOrWhiteSpace($Order)) {
            $queryParts.Add(('order={0}' -f [Uri]::EscapeDataString($Order)))
        }
        $query = [string]::Empty
        if ($queryParts.Count -gt 0) { $query = ($queryParts -join '&') }
    }
    else {
        # User history or no contains: only pass order if specified
        $queryParts = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace($Order)) {
            $queryParts.Add(('order={0}' -f [Uri]::EscapeDataString($Order)))
        }
        $query = [string]::Empty
        if ($queryParts.Count -gt 0) { $query = ($queryParts -join '&') }
    }

    $path = Resolve-GuacContextUrl -DataSource $ctx['DataSource'] -Collection 'history' -SubPath $subPath -Query $query
    $response = Invoke-GuacRest -Server $ctx['Server'] -Token $ctx['Token'] -Method GET -Path $path

    # Collect results into an array so we can filter client-side if needed
    $records = [System.Collections.Generic.List[PSCustomObject]]::new()
    if ($response) {
        if ($response -is [array]) {
            foreach ($item in $response) {
                Convert-GuacHistoryRecord -Record $item | Out-Null
                $records.Add($item)
            }
        }
        else {
            Convert-GuacHistoryRecord -Record $response | Out-Null
            $records.Add($response)
        }
    }

    # Client-side filtering for user history
    if (-not $useServerFilter -and $hasContains) {
        $filtered = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($rec in $records) {
            $match = $true
            foreach ($term in $Contains) {
                if ([string]::IsNullOrWhiteSpace($term)) { continue }
                $termLower = $term.ToLower()

                # Check username
                if ($rec.Username -and $rec.Username.ToString().ToLower().Contains($termLower)) {
                    continue
                }
                # Check remoteHost
                if ($rec.RemoteHost -and $rec.RemoteHost.ToString().ToLower().Contains($termLower)) {
                    continue
                }
                # Check startDate as string (for date-like search terms)
                if ($rec.StartDate -and $rec.StartDate.ToString('yyyy-MM-dd').Contains($termLower)) {
                    continue
                }
                $match = $false
                break
            }
            if ($match) {
                $filtered.Add($rec)
            }
        }
        foreach ($rec in $filtered) {
            Write-Output $rec
        }
    }
    else {
        foreach ($rec in $records) {
            Write-Output $rec
        }
    }
}