ShipsDocuments.psm1

# Reads documents out of a SHIPS web imaging portal. A session logs in through the portal's
# form and keeps its credential, so a long batch survives the login cookie expiring: when a
# read comes back as the login page instead of a PDF, the session logs in again and retries once.

function Test-ShipsLoginPage([string]$Content) {
  $Content -match 'name=["'']UN["'']' -and $Content -match 'name=["'']PW["'']'
}

function Connect-ShipsSession($Session) {
  $web = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
  $login = Invoke-WebRequest -Uri $Session.BaseUrl -WebSession $web -TimeoutSec $Session.TimeoutSec -ErrorAction Stop
  $form = @{ UN = $Session.Credential.UserName; PW = $Session.Credential.GetNetworkCredential().Password; btnlogin = 'Log In' }
  foreach ($name in '__VIEWSTATE', '__VIEWSTATEGENERATOR', '__EVENTVALIDATION') {
    $match = [regex]::Match($login.Content,
      '<input[^>]*name=["'']' + $name + '["''][^>]*value=["'']([^"'']*)["'']', 'IgnoreCase')
    if ($match.Success) { $form[$name] = [Net.WebUtility]::HtmlDecode($match.Groups[1].Value) }
  }
  $response = Invoke-WebRequest -Uri $Session.BaseUrl -Method Post -Body $form -WebSession $web -TimeoutSec $Session.TimeoutSec -ErrorAction Stop
  if (Test-ShipsLoginPage $response.Content) { throw 'SHIPS authentication failed.' }
  $Session.Session = $web
  $Session.Logins++
}

function New-ShipsSession {
  <#
  .SYNOPSIS
  Logs in to a SHIPS web portal and returns a session for Get-ShipsDocument.

  .PARAMETER BaseUrl
  The portal root, for example https://host/ships5web/.

  .PARAMETER Credential
  A portal login that can view the documents you will ask for.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory)][ValidateNotNullOrWhiteSpace()][string]$BaseUrl,
    [Parameter(Mandatory)][pscredential]$Credential,
    [ValidateRange(1, 3600)][int]$TimeoutSec = 20
  )
  if ([string]::IsNullOrWhiteSpace($Credential.UserName) -or $Credential.Password.Length -eq 0) { throw 'SHIPS credentials are required.' }
  $session = [pscustomobject]@{ BaseUrl = $BaseUrl.TrimEnd('/') + '/'; Credential = $Credential; TimeoutSec = $TimeoutSec; Session = $null; Logins = 0 }
  Connect-ShipsSession $session
  $session
}

function Read-ShipsBytes($Session, [long]$DocumentId, [int]$TimeoutSec) {
  $uri = $Session.BaseUrl + "Pages/convertFile.aspx?multiProc=True&doc_id=$DocumentId"
  $response = Invoke-WebRequest -Uri $uri -WebSession $Session.Session -TimeoutSec $TimeoutSec -ErrorAction Stop
  $memory = [IO.MemoryStream]::new()
  try {
    if ($response.RawContentStream.CanSeek) { $response.RawContentStream.Position = 0 }
    $response.RawContentStream.CopyTo($memory)
    return ,$memory.ToArray()
  } finally { $memory.Dispose() }
}

function Get-ShipsDocument {
  <#
  .SYNOPSIS
  Returns a document's assembled PDF as bytes.

  .DESCRIPTION
  Fetches convertFile.aspx for the document id on the session. Document ids can come in on the
  pipeline. If the portal answers with its login page, the session logs in again and retries
  that document once. Anything that is still not a PDF throws.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory, ValueFromPipeline)][ValidateRange(1, [long]::MaxValue)][long]$DocumentId,
    [Parameter(Mandatory)]$Session,
    [ValidateRange(1, 3600)][int]$TimeoutSec = 20
  )
  process {
    [byte[]]$bytes = Read-ShipsBytes $Session $DocumentId $TimeoutSec
    if ($bytes.Length -ge 5 -and [Text.Encoding]::ASCII.GetString($bytes, 0, 5) -ceq '%PDF-') { return ,$bytes }
    if (Test-ShipsLoginPage ([Text.Encoding]::UTF8.GetString($bytes))) {
      Connect-ShipsSession $Session
      [byte[]]$bytes = Read-ShipsBytes $Session $DocumentId $TimeoutSec
      if ($bytes.Length -ge 5 -and [Text.Encoding]::ASCII.GetString($bytes, 0, 5) -ceq '%PDF-') { return ,$bytes }
    }
    throw "SHIPS document $DocumentId returned non-PDF content."
  }
}

Export-ModuleMember -Function New-ShipsSession, Get-ShipsDocument