Private/Icon.ps1
|
# Template icons (R65, ADR 0011). The module reads an icon from a file, or # downloads it from a URL on the admin's own machine, then uploads the # bytes. PagerArchive's server never fetches anything from another site, and # it checks and converts every icon itself. # The largest icon file the API accepts: 100 KB. $script:PaIconMaxBytes = 100KB # How long downloading an icon may take, from the request to its last byte. $script:PaIconDownloadTimeout = [timespan]::FromSeconds(30) function New-PaIconTooLarge { <# .SYNOPSIS The refusal for an icon over 100 KB, worded as the API's. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Makes an object in memory; changes nothing.')] [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( # Where the icon came from, for the message. [Parameter(Mandatory)] [string] $Source ) New-PaErrorRecord -ErrorId 'icon_too_large' -Status 413 -Message "An icon file can be at most 100 KB, and $Source is larger. Make it smaller (64x64 pixels is enough) and try again." } function Get-PaIconData { <# .SYNOPSIS An icon's bytes, from a file (-Path or -LiteralPath) or a URL (-Uri). .DESCRIPTION A file over 100 KB is refused before it's read. A download is refused as soon as it passes 100 KB, or takes longer than 30 seconds. Nothing else is checked here: the API checks and converts every icon. #> [CmdletBinding()] [OutputType([byte[]])] param( # A file path that may hold wildcards; it must match one file. [string] $Path, # A file path taken literally. [string] $LiteralPath, # An http or https address. [uri] $Uri ) if ($PSBoundParameters.ContainsKey('Uri')) { return Receive-PaIcon -Uri $Uri } $found = if ($PSBoundParameters.ContainsKey('LiteralPath')) { @(Resolve-Path -LiteralPath $LiteralPath -ErrorAction SilentlyContinue) } else { @(Resolve-Path -Path $Path -ErrorAction SilentlyContinue) } $given = if ($PSBoundParameters.ContainsKey('LiteralPath')) { $LiteralPath } else { $Path } $files = @($found | Where-Object -FilterScript { $_.Provider.Name -eq 'FileSystem' -and (Test-Path -LiteralPath $_.ProviderPath -PathType Leaf) }) if ($files.Count -eq 0) { throw (New-PaErrorRecord -ErrorId 'icon_not_found' -Category ObjectNotFound -Message "No file was found at '$given'.") } if ($files.Count -gt 1) { throw (New-PaErrorRecord -ErrorId 'icon_path_ambiguous' -Category InvalidArgument -Message "'$given' matches $($files.Count) files. Give one icon file.") } $file = [System.IO.FileInfo]::new($files[0].ProviderPath) if ($file.Length -gt $script:PaIconMaxBytes) { throw (New-PaIconTooLarge -Source $file.Name) } Write-Verbose -Message "Reading the icon from $($file.Name)" Write-Output -InputObject ([System.IO.File]::ReadAllBytes($file.FullName)) -NoEnumerate } function Receive-PaIcon { <# .SYNOPSIS Downloads an icon on this machine, at most 100 KB within 30 seconds. .DESCRIPTION Sends no credentials and no cookies: never the PagerArchive sign-in. Uses the system's proxy settings. Stops reading as soon as the reply passes 100 KB, and refuses a reply that says it's larger before reading any of it. Waits in slices of 200 ms, so Ctrl+C stops it. #> [CmdletBinding()] [OutputType([byte[]])] param( # An http or https address. [Parameter(Mandatory)] [uri] $Uri ) if (-not $Uri.IsAbsoluteUri -or $Uri.Scheme -notin @('http', 'https')) { throw (New-PaErrorRecord -ErrorId 'icon_uri' -Category InvalidArgument -Message "-Uri must be the http or https address of an image, not '$Uri'.") } Write-Verbose -Message "Downloading the icon from $($Uri.Host)" $handler = [System.Net.Http.HttpClientHandler]::new() $handler.UseCookies = $false $handler.UseDefaultCredentials = $false $handler.MaxAutomaticRedirections = 5 $client = [System.Net.Http.HttpClient]::new($handler) $client.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan $cancel = [System.Threading.CancellationTokenSource]::new($script:PaIconDownloadTimeout) $response = $null $stream = $null try { $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Uri) $response = Wait-PaIconTask -Task $client.SendAsync($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $cancel.Token) -Uri $Uri -Cancel $cancel if (-not $response.IsSuccessStatusCode) { throw (New-PaErrorRecord -ErrorId 'icon_download_failed' -Category ConnectionError -Message "Downloading the icon from $Uri failed: the site answered $([int]$response.StatusCode) $($response.ReasonPhrase).") } $declared = $response.Content.Headers.ContentLength if ($null -ne $declared -and $declared -gt $script:PaIconMaxBytes) { throw (New-PaIconTooLarge -Source "the file at $Uri") } $stream = Wait-PaIconTask -Task $response.Content.ReadAsStreamAsync($cancel.Token) -Uri $Uri -Cancel $cancel $bytes = [System.IO.MemoryStream]::new() $chunk = [byte[]]::new(16KB) while ($true) { $n = Wait-PaIconTask -Task $stream.ReadAsync($chunk, 0, $chunk.Length, $cancel.Token) -Uri $Uri -Cancel $cancel if ($n -eq 0) { break } $bytes.Write($chunk, 0, $n) if ($bytes.Length -gt $script:PaIconMaxBytes) { throw (New-PaIconTooLarge -Source "the file at $Uri") } } Write-Debug -Message "Downloaded $($bytes.Length) bytes" Write-Output -InputObject $bytes.ToArray() -NoEnumerate } finally { if ($stream) { $stream.Dispose() } if ($response) { $response.Dispose() } $client.Dispose() $cancel.Dispose() } } function Wait-PaIconTask { <# .SYNOPSIS A download task's result, waiting in 200 ms slices so Ctrl+C works. #> [CmdletBinding()] [OutputType([object])] param( # The task. [Parameter(Mandatory)] [System.Threading.Tasks.Task] $Task, # The address, for the messages. [Parameter(Mandatory)] [uri] $Uri, # The download's time limit. [Parameter(Mandatory)] [System.Threading.CancellationTokenSource] $Cancel ) try { while (-not $Task.Wait(200)) { } } catch { if ($Cancel.IsCancellationRequested) { throw (New-PaErrorRecord -ErrorId 'icon_download_timeout' -Category OperationTimeout -Message "Downloading the icon from $Uri took longer than $([int]$script:PaIconDownloadTimeout.TotalSeconds) seconds, so it was stopped.") } throw (New-PaErrorRecord -ErrorId 'icon_download_failed' -Category ConnectionError -Message "Downloading the icon from $Uri failed: $($_.Exception.GetBaseException().Message)") } $Task.Result } function Send-PaTemplateIcon { <# .SYNOPSIS Uploads a template's icon; the API checks and converts it. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Called by public commands after their ShouldProcess.')] [CmdletBinding()] [OutputType([void])] param( # The template's ID. [Parameter(Mandatory)] [long] $Id, # The icon file's bytes. [Parameter(Mandatory)] [byte[]] $Bytes ) $null = Invoke-PaApi -Method PUT -Path "/api/v1/admin/templates/$Id/icon" -RawBody $Bytes } |