Modules/MDOP2.Web.psm1
|
Set-StrictMode -Version Latest # Local web UI on Pode. Pode runs on its own runspace and thread; every Microsoft 365 call stays on the main thread, # which owns the sign-in, the session secret and the Exchange Online connection. Routes only validate input and queue # jobs in a shared object that the main thread works through. $script:Server = $null $script:TraceStatuses = 'Delivered', 'Expanded', 'Failed', 'FilteredAsSpam', 'GettingStatus', 'Pending', 'Quarantined' $script:Actions = 'MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete' $script:StaticFiles = @{ '/' = @{ File = 'index.html'; Type = 'text/html; charset=utf-8' } '/app.js' = @{ File = 'app.js'; Type = 'text/javascript; charset=utf-8' } '/app.css' = @{ File = 'app.css'; Type = 'text/css; charset=utf-8' } '/detail.js' = @{ File = 'detail.js'; Type = 'text/javascript; charset=utf-8' } '/history.js' = @{ File = 'history.js'; Type = 'text/javascript; charset=utf-8' } '/fonts/Inter.ttf' = @{ File = 'fonts\Inter.ttf'; Type = 'font/ttf'; Binary = $true } '/fonts/RobotoSlab.ttf' = @{ File = 'fonts\RobotoSlab.ttf'; Type = 'font/ttf'; Binary = $true } # The logo files live in the Assets folder next to Web '/img/logo.png' = @{ File = '..\Assets\logo.png'; Type = 'image/png'; Binary = $true } '/img/logo-dark.png' = @{ File = '..\Assets\logo-dark.png'; Type = 'image/png'; Binary = $true } '/img/wordmark.png' = @{ File = '..\Assets\Krekel81.png'; Type = 'image/png'; Binary = $true } '/img/wordmark-dark.png' = @{ File = '..\Assets\Krekel81-dark.png'; Type = 'image/png'; Binary = $true } } function New-MDOP2RandomToken { <# .SYNOPSIS Returns a URL-safe random token from the OS random number generator. #> [CmdletBinding()] param([ValidateRange(16, 64)][int]$Bytes = 32) $b = [Security.Cryptography.RandomNumberGenerator]::GetBytes($Bytes) [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function Test-MDOP2SecretEqual { <# .SYNOPSIS Compares two secrets in constant time. Null or empty never matches. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Actual, [AllowNull()][AllowEmptyString()][string]$Expected) if ([string]::IsNullOrEmpty($Actual) -or [string]::IsNullOrEmpty($Expected)) { return $false } $a = [Text.Encoding]::UTF8.GetBytes($Actual) $e = [Text.Encoding]::UTF8.GetBytes($Expected) [Security.Cryptography.CryptographicOperations]::FixedTimeEquals($a, $e) } function Test-MDOP2WebRequest { <# .SYNOPSIS Decides whether a request may reach a route. Checks the Host header (DNS rebinding), the session token (first visit by URL, then an HttpOnly SameSite=Strict cookie) and, for anything that changes state, Origin, a CSRF header and a JSON body. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Path, [AllowNull()][AllowEmptyString()][string]$HostHeader, [AllowNull()][AllowEmptyString()][string]$Origin, [AllowNull()][AllowEmptyString()][string]$ContentType, [AllowNull()][AllowEmptyString()][string]$Cookie, [AllowNull()][AllowEmptyString()][string]$CsrfHeader, [AllowNull()][AllowEmptyString()][string]$QueryToken, [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$CsrfToken ) $deny = { param($status, $reason) [pscustomobject]@{ Allow = $false; Status = $status; Reason = $reason; SetCookie = $false; Redirect = $null } } $origin0 = "http://127.0.0.1:$Port" if ($HostHeader -ne "127.0.0.1:$Port") { return & $deny 421 'Unexpected Host header' } $m = $Method.ToUpperInvariant() if ($m -eq 'GET' -and $Path -eq '/' -and $QueryToken) { if (Test-MDOP2SecretEqual -Actual $QueryToken -Expected $Token) { return [pscustomobject]@{ Allow = $false; Status = 303; Reason = 'Signed in'; SetCookie = $true; Redirect = '/' } } return & $deny 403 'Invalid session link' } if (-not (Test-MDOP2SecretEqual -Actual $Cookie -Expected $Token)) { return & $deny 403 'Open the link printed in the console to use this session' } if ($m -notin 'GET', 'HEAD') { if ($Origin -ne $origin0) { return & $deny 403 'Cross-origin request blocked' } if (-not (Test-MDOP2SecretEqual -Actual $CsrfHeader -Expected $CsrfToken)) { return & $deny 403 'Missing or invalid CSRF token' } if (-not $ContentType -or -not $ContentType.StartsWith('application/json', [StringComparison]::OrdinalIgnoreCase)) { return & $deny 415 'Requests must be JSON' } } [pscustomobject]@{ Allow = $true; Status = 200; Reason = $null; SetCookie = $false; Redirect = $null } } function Get-MDOP2SecurityHeader { <# .SYNOPSIS Headers sent with every response. Nothing loads from outside the tool, nothing frames it, nothing is cached. #> [ordered]@{ 'Content-Security-Policy' = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" 'X-Content-Type-Options' = 'nosniff' 'X-Frame-Options' = 'DENY' 'Referrer-Policy' = 'no-referrer' 'Cache-Control' = 'no-store' 'Cross-Origin-Opener-Policy' = 'same-origin' 'Cross-Origin-Resource-Policy' = 'same-origin' } } function Assert-MDOP2RequestKeys { param([System.Collections.IDictionary]$Body, [string[]]$Allowed) if ($null -eq $Body) { throw 'Request body must be a JSON object.' } # get_Keys(): a body field named "keys" would hide the Keys property $unknown = @($Body.get_Keys() | Where-Object { $_ -notin $Allowed }) if ($unknown.Count) { throw "Unknown field(s): $($unknown -join ', ')." } } function ConvertTo-MDOP2StringList { param([AllowNull()][object]$Value, [string]$Name, [int]$MaxItems, [int]$MaxLength) $items = foreach ($v in @($Value)) { if ($null -eq $v) { continue } if ($v -isnot [string]) { throw "$Name must be text." } # Free text from the filter bar: split on commas, semicolons and whitespace $v -split '[,;\s]+' | Where-Object { $_ } } $items = @($items) if ($items.Count -gt $MaxItems) { throw "$Name accepts at most $MaxItems values." } foreach ($i in $items) { if ($i.Length -gt $MaxLength) { throw "$Name value is too long." } } , $items } function ConvertTo-MDOP2SearchRequest { <# .SYNOPSIS Validates a search request from the UI and returns the date range preset plus parameters for Search-MDOP2MessageTrace. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) Assert-MDOP2RequestKeys -Body $Body -Allowed 'range', 'subject', 'subjectFilterType', 'sender', 'recipient', 'status', 'messageId', 'content', 'contentMode' $range = if ($Body['range']) { [string]$Body['range'] } else { '7d' } if ($range -notin '1d', '7d', '30d', '90d') { throw 'Range must be 1d, 7d, 30d or 90d.' } $p = [ordered]@{} $subject = $Body['subject'] if ($null -ne $subject -and $subject -isnot [string]) { throw 'Subject must be text.' } if ($subject) { $subject = $subject.Trim() if ($subject.Length -gt 255) { throw 'Subject is too long (255 characters at most).' } if ($subject -match '[\x00-\x1F\x7F]') { throw 'Subject contains control characters.' } if ($subject) { $p.Subject = $subject $type = if ($Body['subjectFilterType']) { [string]$Body['subjectFilterType'] } else { 'Contains' } if ($type -notin 'Contains', 'StartsWith', 'EndsWith') { throw 'Subject match must be Contains, StartsWith or EndsWith.' } $p.SubjectFilterType = $type } } foreach ($name in 'sender', 'recipient') { $list = ConvertTo-MDOP2StringList -Value $Body[$name] -Name $name -MaxItems 50 -MaxLength 320 if ($list.Count) { # Throws on anything that isn't an address or a domain $null = ConvertTo-MDOP2AddressFilter -Value $list $p[(Get-Culture).TextInfo.ToTitleCase($name)] = $list } } $status = @($Body['status'] | Where-Object { $null -ne $_ }) if ($status.Count) { foreach ($s in $status) { if ($s -isnot [string] -or $s -notin $script:TraceStatuses) { throw "Unknown delivery status '$s'." } } $p.Status = [string[]]($status | Select-Object -Unique) } $mid = $Body['messageId'] if ($null -ne $mid -and $mid -isnot [string]) { throw 'Message-ID must be text.' } if ($mid -and $mid.Trim()) { $mid = $mid.Trim() # Most IDs are <id@host>, but some senders (and Exchange itself) use IDs without @, such as <guid> if ($mid.Length -gt 998 -or $mid -notmatch '^<?[^\s<>]+>?$') { throw 'Message-ID must be one ID without spaces, such as <id@host>.' } if (-not $mid.StartsWith('<')) { $mid = "<$mid" } if (-not $mid.EndsWith('>')) { $mid = "$mid>" } $p.MessageId = $mid } $content = $Body['content'] if ($null -ne $content -and $content -isnot [string]) { throw 'Content must be text.' } $contentMode = 'Text' if ($content) { $content = $content.Trim() if ($content.Length -gt 500) { throw 'Content is too long (500 characters at most).' } if ($content -match '[\x00-\x1F\x7F]') { throw 'Content contains control characters.' } if ($Body['contentMode']) { $contentMode = [string]$Body['contentMode'] } if ($contentMode -notin 'Text', 'Link') { throw 'Content match must be Text or Link.' } } [pscustomobject]@{ Range = $range; Parameters = $p; Content = $(if ($content) { $content } else { $null }); ContentMode = $contentMode } } function ConvertTo-MDOP2KeyList { param([AllowNull()][object]$Value) $keys = @($Value | Where-Object { $null -ne $_ }) if (-not $keys.Count) { throw 'Select at least one message.' } if ($keys.Count -gt 500) { throw 'Select 500 messages at most.' } foreach ($k in $keys) { if ($k -isnot [string] -or -not $k -or $k.Length -gt 1000 -or $k -match '[\x00-\x1F]') { throw 'Invalid message key.' } } , [string[]]@($keys | Select-Object -Unique) } function ConvertTo-MDOP2PlanRequest { <# .SYNOPSIS Validates a request to check an action before it runs: the action and the selected messages. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) Assert-MDOP2RequestKeys -Body $Body -Allowed 'action', 'keys', 'mailboxes', 'restore' $action = $Body['action'] if ($action -isnot [string] -or $action -notin $script:Actions) { throw "Action must be one of: $($script:Actions -join ', ')." } $mailboxes = $null if ($null -ne $Body['mailboxes']) { $list = @($Body['mailboxes'] | Where-Object { $null -ne $_ }) if (-not $list.Count) { throw 'Select at least one recipient.' } if ($list.Count -gt 1000) { throw 'Select 1000 recipients at most.' } foreach ($m in $list) { $null = ConvertTo-MDOP2Mailbox $m } $mailboxes = [string[]]@($list | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique) } $restore = $Body['restore'] if ($null -ne $restore -and $restore -isnot [bool]) { throw 'Restore must be true or false.' } [pscustomobject]@{ Action = $action; Keys = ConvertTo-MDOP2KeyList $Body['keys']; Mailboxes = $mailboxes; Restore = $restore -eq $true } } function ConvertTo-MDOP2Mailbox { param([AllowNull()][object]$Value) if ($Value -isnot [string] -or $Value.Length -gt 320 -or $Value -notmatch '^[^@\s<>]+@[^@\s<>]+$') { throw 'Invalid mailbox address.' } $Value.ToLowerInvariant() } function ConvertTo-MDOP2MessageRequest { <# .SYNOPSIS Validates a request about one message: its key and, optionally, the mailbox to read it from. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body, [switch]$NoMailbox) $allowed = if ($NoMailbox) { @('key') } else { @('key', 'mailbox') } Assert-MDOP2RequestKeys -Body $Body -Allowed $allowed if ($Body['key'] -isnot [string]) { throw 'Send exactly one message key.' } $key = ConvertTo-MDOP2KeyList $Body['key'] $mailbox = if ($null -ne $Body['mailbox'] -and '' -ne $Body['mailbox']) { ConvertTo-MDOP2Mailbox $Body['mailbox'] } else { $null } [pscustomobject]@{ Key = $key[0]; Mailbox = $mailbox } } function ConvertTo-MDOP2ExecuteRequest { <# .SYNOPSIS Validates a request to run a checked plan. Dry run stays on unless dryRun is the boolean false. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) Assert-MDOP2RequestKeys -Body $Body -Allowed 'planId', 'dryRun' $id = $Body['planId'] $g = [guid]::Empty if ($id -isnot [string] -or -not [guid]::TryParse($id, [ref]$g)) { throw 'Invalid plan ID.' } $dry = $Body['dryRun'] if ($null -ne $dry -and $dry -isnot [bool]) { throw 'dryRun must be true or false.' } [pscustomobject]@{ PlanId = $g.ToString(); DryRun = ($dry -ne $false) } } function ConvertTo-MDOP2StatusRequest { [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) Assert-MDOP2RequestKeys -Body $Body -Allowed 'keys' [pscustomobject]@{ Keys = ConvertTo-MDOP2KeyList $Body['keys'] } } function ConvertTo-MDOP2HistoryRequest { <# .SYNOPSIS Validates a request for the action history: the period, whether to include dry runs and whether to show only this session. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) if ($null -eq $Body) { $Body = @{} } Assert-MDOP2RequestKeys -Body $Body -Allowed 'range', 'includeDryRun', 'sessionOnly' $range = if ($Body['range']) { $Body['range'] } else { '30d' } if ($range -isnot [string] -or $range -notin '1d', '7d', '30d', '90d', 'all') { throw 'Range must be 1d, 7d, 30d, 90d or all.' } foreach ($n in 'includeDryRun', 'sessionOnly') { if ($null -ne $Body[$n] -and $Body[$n] -isnot [bool]) { throw "$n must be true or false." } } [pscustomobject]@{ Range = $range; IncludeDryRun = $Body['includeDryRun'] -eq $true; SessionOnly = $Body['sessionOnly'] -eq $true } } function ConvertTo-MDOP2HistoryStatusRequest { <# .SYNOPSIS Validates a status check for action history rows. Only row IDs from the last history load are accepted. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) Assert-MDOP2RequestKeys -Body $Body -Allowed 'ids' $ids = @($Body['ids'] | Where-Object { $null -ne $_ }) if (-not $ids.Count) { throw 'Select at least one action.' } if ($ids.Count -gt 5000) { throw 'Check 5000 actions at most.' } foreach ($i in $ids) { if ($i -isnot [string] -or $i -notmatch '^h\d{1,5}$') { throw 'Invalid history row.' } } [pscustomobject]@{ Ids = [string[]]@($ids | Select-Object -Unique) } } function ConvertTo-MDOP2MailboxesRequest { <# .SYNOPSIS Validates a request for the mailbox list of the recipient picker. It takes no fields. #> [CmdletBinding()] param([AllowNull()][System.Collections.IDictionary]$Body) if ($null -eq $Body) { $Body = @{} } Assert-MDOP2RequestKeys -Body $Body -Allowed @() [pscustomobject]@{} } function ConvertTo-MDOP2GroupView { <# .SYNOPSIS Shapes a message group from Group-MDOP2TraceResult for the UI. #> [CmdletBinding()] param([Parameter(Mandatory)][object]$Group) $status = [ordered]@{} foreach ($k in $Group.StatusCounts.Keys) { $status[[string]$k] = $Group.StatusCounts[$k] } $sender = [string]$Group.SenderAddress [ordered]@{ key = $Group.Key internetMessageId = $Group.InternetMessageId subject = $Group.Subject sender = $sender senderDomain = if ($sender -match '@(.+)$') { $Matches[1].ToLowerInvariant() } else { '' } fromIP = $Group.FromIP firstReceived = ConvertTo-MDOP2IsoDate $Group.FirstReceived lastReceived = ConvertTo-MDOP2IsoDate $Group.LastReceived recipientCount = $Group.RecipientCount statusCounts = $status recipients = @($Group.Recipients | ForEach-Object { [ordered]@{ address = $_.Address; status = $_.Status; received = ConvertTo-MDOP2IsoDate $_.Received } }) } } function New-MDOP2WebState { <# .SYNOPSIS Creates the object shared between the main thread and the web server. #> [CmdletBinding()] param( [Parameter(Mandatory)][int]$Port, [Parameter(Mandatory)][hashtable]$Session ) [hashtable]::Synchronized(@{ Port = $Port Token = New-MDOP2RandomToken CsrfToken = New-MDOP2RandomToken Session = $Session # Read on the main thread: CSV exports use the admin's list separator so Excel splits the columns ListSeparator = (Get-Culture).TextInfo.ListSeparator Queue = [Collections.Concurrent.ConcurrentQueue[object]]::new() Jobs = [hashtable]::Synchronized(@{}) Stop = $false EndRequested = $false Errors = [Collections.Concurrent.ConcurrentQueue[string]]::new() }) } function Add-MDOP2WebJob { <# .SYNOPSIS Queues work for the main thread and returns the job ID. Old finished jobs are dropped. #> [CmdletBinding()] param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Type, [Parameter(Mandatory)][object]$Payload) $id = [guid]::NewGuid().ToString() $job = [hashtable]::Synchronized(@{ Id = $id; Type = $Type; Payload = $Payload; State = 'Queued'; Progress = 'Waiting'; Result = $null; Error = $null Created = [datetime]::UtcNow }) $jobs = $State.Jobs [Threading.Monitor]::Enter($jobs.SyncRoot) try { $old = @($jobs.Values | Where-Object { $_.State -in 'Done', 'Failed' } | Sort-Object Created) for ($i = 0; $i -lt $old.Count - 50; $i++) { $jobs.Remove($old[$i].Id) } $jobs[$id] = $job } finally { [Threading.Monitor]::Exit($jobs.SyncRoot) } $State.Queue.Enqueue($job) $id } function New-MDOP2QueuedJob { <# .SYNOPSIS Validates a request body for a job type and queues it. Returns the HTTP status and response body. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][ValidateSet('Search', 'Plan', 'Execute', 'Status', 'Detail', 'Download', 'Timeline', 'SenderHistory', 'History', 'HistoryStatus', 'Mailboxes')][string]$Type, [AllowNull()][object]$Body ) try { if ($null -ne $Body -and $Body -isnot [System.Collections.IDictionary]) { throw 'Request body must be a JSON object.' } $payload = switch ($Type) { 'Search' { ConvertTo-MDOP2SearchRequest -Body $Body } 'Plan' { ConvertTo-MDOP2PlanRequest -Body $Body } 'Execute' { ConvertTo-MDOP2ExecuteRequest -Body $Body } 'Status' { ConvertTo-MDOP2StatusRequest -Body $Body } { $_ -in 'Detail', 'Download' } { ConvertTo-MDOP2MessageRequest -Body $Body } { $_ -in 'Timeline', 'SenderHistory' } { ConvertTo-MDOP2MessageRequest -Body $Body -NoMailbox } 'History' { ConvertTo-MDOP2HistoryRequest -Body $Body } 'HistoryStatus' { ConvertTo-MDOP2HistoryStatusRequest -Body $Body } 'Mailboxes' { ConvertTo-MDOP2MailboxesRequest -Body $Body } } } catch { return [pscustomobject]@{ Status = 400; Value = @{ error = $_.Exception.Message } } } [pscustomobject]@{ Status = 202; Value = @{ jobId = (Add-MDOP2WebJob -State $State -Type $Type -Payload $payload) } } } function Start-MDOP2WebServer { <# .SYNOPSIS Starts Pode on 127.0.0.1 on its own thread and waits until it accepts connections. Returns the shared state. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Session, [Parameter(Mandatory)][string]$WebRoot, [int]$TimeoutSeconds = 60 ) if ($script:Server) { throw 'The web server is already running.' } if (-not (Get-Module -ListAvailable -Name Pode)) { throw "Module 'Pode' is not installed. Run: Install-Module Pode -Scope CurrentUser" } $state = New-MDOP2WebState -Port (Get-MDOP2FreePort) -Session $Session $modules = @( (Join-Path $PSScriptRoot 'MDOP2.Common.psm1') (Join-Path $PSScriptRoot 'MDOP2.Web.psm1') ) $rs = [runspacefactory]::CreateRunspace([initialsessionstate]::CreateDefault2()) $rs.Open() $ps = [powershell]::Create() $ps.Runspace = $rs $null = $ps.AddScript({ param($State, $Modules, $WebRoot) try { Import-Module Pode -ErrorAction Stop foreach ($m in $Modules) { Import-Module $m -ErrorAction Stop } Invoke-MDOP2WebServer -State $State -WebRoot $WebRoot } catch { $State.Errors.Enqueue("$($_.Exception.Message)`n$($_.ScriptStackTrace)") } }).AddArgument($state).AddArgument($modules).AddArgument((Resolve-Path $WebRoot).Path) $handle = $ps.BeginInvoke() $script:Server = @{ PowerShell = $ps; Runspace = $rs; Handle = $handle; State = $state } $sw = [Diagnostics.Stopwatch]::StartNew() while ($true) { if ($handle.IsCompleted) { $e = $null; $null = $state.Errors.TryDequeue([ref]$e) Stop-MDOP2WebServer throw "The web server did not start: $e" } $c = [Net.Sockets.TcpClient]::new() try { $c.Connect([Net.IPAddress]::Loopback, $state.Port); break } catch { if ($sw.Elapsed.TotalSeconds -gt $TimeoutSeconds) { Stop-MDOP2WebServer; throw 'The web server did not start in time.' } } finally { $c.Dispose() } Start-Sleep -Milliseconds 250 } $state } function Test-MDOP2WebServerRunning { [OutputType([bool])] param() [bool]($script:Server -and -not $script:Server.Handle.IsCompleted) } function Get-MDOP2WebServerError { if (-not $script:Server) { return } $e = $null while ($script:Server.State.Errors.TryDequeue([ref]$e)) { $e } } function Stop-MDOP2WebServer { <# .SYNOPSIS Asks Pode to close (a timer in the server checks the flag every second) and disposes the runspace. #> [CmdletBinding()] param([int]$TimeoutSeconds = 15) if (-not $script:Server) { return } $s = $script:Server $script:Server = $null $s.State.Stop = $true $sw = [Diagnostics.Stopwatch]::StartNew() while (-not $s.Handle.IsCompleted -and $sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) { Start-Sleep -Milliseconds 200 } if (-not $s.Handle.IsCompleted) { try { $s.PowerShell.Stop() } catch { Write-Verbose "Stop: $($_.Exception.Message)" } } $s.PowerShell.Dispose() $s.Runspace.Dispose() } function Invoke-MDOP2WebServer { <# .SYNOPSIS Runs inside the web runspace. Blocks until the shared Stop flag is set. #> param([hashtable]$State, [string]$WebRoot) Start-PodeServer -Quiet -DisableTermination -DisableConsoleInput -Threads 4 -RootPath $WebRoot -ScriptBlock { Add-PodeEndpoint -Address 127.0.0.1 -Port $State.Port -Protocol Http $null = Set-PodeState -Name MDOP2 -Value $State $null = Set-PodeState -Name MDOP2Root -Value $WebRoot Add-PodeTimer -Name 'mdop2-stop' -Interval 1 -ScriptBlock { if ((Get-PodeState -Name MDOP2).Stop) { Close-PodeServer } } Add-PodeMiddleware -Name 'mdop2-guard' -ScriptBlock { $s = Get-PodeState -Name MDOP2 foreach ($h in (Get-MDOP2SecurityHeader).GetEnumerator()) { Set-PodeHeader -Name $h.Key -Value $h.Value } $cookie = Get-PodeCookie -Name 'mdop2_session' $d = Test-MDOP2WebRequest -Method $WebEvent.Method -Path $WebEvent.Path ` -HostHeader (Get-PodeHeader -Name 'Host') -Origin (Get-PodeHeader -Name 'Origin') ` -ContentType (Get-PodeHeader -Name 'Content-Type') -Cookie $(if ($cookie) { $cookie.Value }) ` -CsrfHeader (Get-PodeHeader -Name 'X-MDOP2-CSRF') -QueryToken $WebEvent.Query['t'] ` -Port $s.Port -Token $s.Token -CsrfToken $s.CsrfToken if ($d.Allow) { return $true } if ($d.SetCookie) { Set-PodeHeader -Name 'Set-Cookie' -Value "mdop2_session=$($s.Token); Path=/; HttpOnly; SameSite=Strict" Set-PodeHeader -Name 'Location' -Value $d.Redirect Set-PodeResponseStatus -Code 303 -NoErrorPage return $false } Write-PodeJsonResponse -StatusCode $d.Status -Value @{ error = $d.Reason } return $false } foreach ($path in (Get-MDOP2StaticFile).Keys) { Add-PodeRoute -Method Get -Path $path -ScriptBlock { $f = (Get-MDOP2StaticFile)[$WebEvent.Path] $full = Join-Path (Get-PodeState -Name MDOP2Root) $f.File if ($f['Binary']) { Write-PodeTextResponse -Bytes ([IO.File]::ReadAllBytes($full)) -ContentType $f.Type } else { Write-PodeTextResponse -Value ([IO.File]::ReadAllText($full)) -ContentType $f.Type } } } Add-PodeRoute -Method Get -Path '/api/session' -ScriptBlock { $s = Get-PodeState -Name MDOP2 Write-PodeJsonResponse -Value @{ adminUpn = $s.Session.AdminUpn; tenantId = $s.Session.TenantId; tenantName = $s.Session.TenantName secretExpires = $s.Session.SecretExpires; csrf = $s.CsrfToken; listSeparator = $s.ListSeparator } } # Each route validates its body and queues a job for the main thread foreach ($type in 'Search', 'Plan', 'Execute', 'Status', 'Detail', 'Download', 'Timeline', 'SenderHistory', 'History', 'HistoryStatus', 'Mailboxes') { Add-PodeRoute -Method Post -Path "/api/$($type.ToLowerInvariant())" -ArgumentList $type -ScriptBlock { param($Type) $r = New-MDOP2QueuedJob -State (Get-PodeState -Name MDOP2) -Type $Type -Body $WebEvent.Data Write-PodeJsonResponse -StatusCode $r.Status -Value $r.Value } } Add-PodeRoute -Method Get -Path '/api/jobs/:id' -ScriptBlock { $job = (Get-PodeState -Name MDOP2).Jobs[[string]$WebEvent.Parameters['id']] if (-not $job) { Write-PodeJsonResponse -StatusCode 404 -Value @{ error = 'Unknown job.' }; return } Write-PodeJsonResponse -Depth 12 -Value ([ordered]@{ id = $job.Id; type = $job.Type; state = $job.State; progress = $job.Progress; error = $job.Error; result = $job.Result }) } # Hands a finished .eml download to the browser once, then drops the bytes from memory Add-PodeRoute -Method Get -Path '/api/download/:id' -ScriptBlock { $job = (Get-PodeState -Name MDOP2).Jobs[[string]$WebEvent.Parameters['id']] $file = if ($job -and $job.Type -eq 'Download' -and $job.State -eq 'Done') { $job['File'] } if (-not $file) { Write-PodeJsonResponse -StatusCode 404 -Value @{ error = 'This download is no longer available. Start it again.' }; return } $job.File = $null Set-PodeHeader -Name 'Content-Disposition' -Value (Get-MDOP2ContentDisposition -FileName $file.Name) Write-PodeTextResponse -Bytes $file.Bytes -ContentType 'message/rfc822' } Add-PodeRoute -Method Post -Path '/api/end' -ScriptBlock { (Get-PodeState -Name MDOP2).EndRequested = $true Write-PodeJsonResponse -Value @{ ended = $true } } } } function Get-MDOP2StaticFile { $script:StaticFiles } function Get-MDOP2ContentDisposition { <# .SYNOPSIS Content-Disposition for a download: always an attachment, with an ASCII name and the RFC 5987 UTF-8 name. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$FileName) $ascii = ($FileName -replace '[^\x20-\x7E]', '_') -replace '["\\;]', '_' "attachment; filename=`"$ascii`"; filename*=UTF-8''$([uri]::EscapeDataString($FileName))" } Export-ModuleMember -Function New-MDOP2RandomToken, Test-MDOP2SecretEqual, Test-MDOP2WebRequest, Get-MDOP2SecurityHeader, ConvertTo-MDOP2SearchRequest, ConvertTo-MDOP2PlanRequest, ConvertTo-MDOP2ExecuteRequest, ConvertTo-MDOP2StatusRequest, ConvertTo-MDOP2MessageRequest, ConvertTo-MDOP2HistoryRequest, ConvertTo-MDOP2HistoryStatusRequest, ConvertTo-MDOP2MailboxesRequest, Get-MDOP2ContentDisposition, ConvertTo-MDOP2GroupView, New-MDOP2WebState, Add-MDOP2WebJob, New-MDOP2QueuedJob, Start-MDOP2WebServer, Stop-MDOP2WebServer, Test-MDOP2WebServerRunning, Get-MDOP2WebServerError, Invoke-MDOP2WebServer, Get-MDOP2StaticFile |