src/WebServer/main.ps1

#Requires -Version 5.0

#_if PSScript
<#
.SYNOPSIS
run a web server to allow users to compile powershell scripts
.DESCRIPTION
run a web server to allow users to compile powershell scripts
.PARAMETER HostUrl
The url of the web server
.PARAMETER MaxCompileThreads
The maximum number of compile threads
.PARAMETER MaxCompileTime
The maximum compile time of a compile in seconds
.PARAMETER ReqLimitPerMin
The maximum number of requests per minute per IP
.PARAMETER MaxCachedFileSize
The maximum size of the cached file
.PARAMETER MaxScriptFileSize
The maximum size of the script file
.PARAMETER CacheDir
The directory to store the cached files
.PARAMETER Localize
The language code to be used for server-side logging
.PARAMETER help
Display help message
.EXAMPLE
Start-ps12exeWebServer
.EXAMPLE
Start-ps12exeWebServer -HostUrl 'http://localhost:80/'
#>

[CmdletBinding()]
#_endif
param (
    $HostUrl = 'http://localhost:8080/',
    $MaxCompileThreads = 8,
    $MaxCompileTime = 20,
    $ReqLimitPerMin = 5,
    $MaxCachedFileSize = 32mb,
    $MaxScriptFileSize = 2mb,
    $CacheDir = "$PSScriptRoot/outputs",
    #_if PSScript
    [ArgumentCompleter({
        Param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
        . "$PSScriptRoot\..\LocaleArgCompleter.ps1" @PSBoundParameters
    })]
    #_endif
    [string]$Localize,
    [switch]$help
)

#_if PSScript
$LocalizeData = . $PSScriptRoot\..\LocaleLoader.ps1 -Localize $Localize

if ($help) {
    $MyHelp = $LocalizeData.WebServerHelpData
    . $PSScriptRoot\..\HelpShower.ps1 -HelpData $MyHelp | Write-Host
    return
}

# 创建 HttpListener 对象
$http = [System.Net.HttpListener]::new()
$http.Prefixes.Add($HostUrl)
$http.Start()

if ($http.IsListening) {
    Write-Host $LocalizeData.ServerStarted -ForegroundColor Black -BackgroundColor Green
    Write-Host "$($LocalizeData.ServerListening) $($http.Prefixes)" -ForegroundColor Yellow
}
else {
    if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]"Administrator")) {
        Write-Host $LocalizeData.TryRunAsRoot -ForegroundColor Yellow
    }
    Write-Host $LocalizeData.ServerStartFailed -ForegroundColor Black -BackgroundColor Red
    return
}

# Set Console Window Title
$BackUpTitle = $Host.UI.RawUI.WindowTitle
$Host.UI.RawUI.WindowTitle = "ps12exe Web Server"

Write-Host $LocalizeData.ExitServerTip -ForegroundColor Yellow
$LocalizeData = $LocalizeData.WebServerI18nData

# Define a hashtable to track request counts per IP
$ipRequestCount = @{}
# 一个队列用于装载$AsyncResult和$Runspace以及其他信息,直到$AsyncResult结束我们才能对$Runspace进行Dispose。。。
$AsyncResultArray = New-Object System.Collections.ArrayList
# Create a runspace pool
$runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxCompileThreads)
$runspacePool.Open()

function HandleWebCompileRequest($userInput, $context, $Localize) {
    Write-Verbose ($LocalizeData.CompilingUserInput -f $userInput)
    if (!$userInput) {
        Write-Verbose $LocalizeData.EmptyResponse
        return
    }
    $clientIP = $context.Request.RemoteEndPoint.Address.ToString()
    if ($userInput.Length -gt $MaxScriptFileSize -and $clientIP -ne '127.0.0.1') {
        Write-Verbose $LocalizeData.InputTooLarge413
        $context.Response.StatusCode = 413
        $context.Response.ContentType = "text/plain"
        $buffer = [System.Text.Encoding]::UTF8.GetBytes('File too large')
        return
    }
    $ipRequestCount[$clientIP]++

    # Check if the IP has exceeded the limit (e.g., 5 requests per minute)
    if ($ipRequestCount[$clientIP] -gt $ReqLimitPerMin -and $clientIP -ne '127.0.0.1') {
        Write-Verbose ($LocalizeData.ReqLimitExceeded429 -f @($clientIP, $ReqLimitPerMin))
        $context.Response.StatusCode = 429
        $context.Response.ContentType = "text/plain"
        $buffer = [System.Text.Encoding]::UTF8.GetBytes('Too many requests')
        return
    }
    # hash of user input
    $userInputHash = [System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($userInput))
    $userInputHashStr = ''
    foreach ($byte in $userInputHash) {
        $userInputHashStr += $byte.ToString('x2')
    }
    $compiledExePath = "$CacheDir/$userInputHashStr.bin"
    #if match cached file
    if (Test-Path -Path $compiledExePath -ErrorAction Ignore) {
        $context.Response.ContentType = "application/octet-stream"
        $buffer = [System.IO.File]::ReadAllBytes($compiledExePath)
        return
    }
    $runspace = [powershell]::Create()
    $runspace.RunspacePool = $runspacePool
    $AsyncResult = $runspace.AddScript({
        param ($userInput, $Response, $ScriptRoot, $CacheDir, $compiledExePath, $clientIP, $Localize)

        # 加载ps12exe用于处理编译请求
        Import-Module $ScriptRoot/../../ps12exe.psm1 -ErrorAction Stop

        New-Item -Path $CacheDir -ItemType Directory -Force | Out-Null

        # 编译代码
        try {
            $userInput | ps12exe -outputFile $compiledExePath -GuestMode:$($clientIP -ne '127.0.0.1') -ErrorAction Stop -Localize $Localize
        }
        catch { $LastExitCode = 1 }
        if ($LastExitCode) {
            # 若ErrorId不是ParserError则写入日志
            $e = $Error[0]
            if ($e.CategoryInfo.Category -ine "ParserError") {
                Write-Host "${clientIP}:" -ForegroundColor Red
                Write-Host $e -ForegroundColor Red
            }
            $Response.ContentType = "text/plain"
            $buffer = [System.Text.Encoding]::UTF8.GetBytes(($e,$e.TargetObject.Text -join "`n"))
        }
        else {
            $buffer = [System.IO.File]::ReadAllBytes($compiledExePath)
            $Response.ContentType = "application/octet-stream"
        }

        $Response.ContentLength64 = $buffer.Length
        if ($buffer) {
            $Response.OutputStream.Write($buffer, 0, $buffer.Length)
        }
        $Response.Close()
    }).
    AddArgument($userInput).AddArgument($context.Response).
    AddArgument($PSScriptRoot).AddArgument($CacheDir).
    AddArgument($compiledExePath).AddArgument($clientIP).AddArgument($Localize).
    BeginInvoke()
    $AsyncResultArray.Add(@{
        AsyncHandle = $AsyncResult
        Runspace    = $runspace
        Time        = Get-Date
        IP          = $clientIP
    }) | Out-Null
}
$HostSubUrl = $HostUrl.Substring($HostUrl.IndexOf('://') + 3)
$HostSubUrl = $HostSubUrl.Substring($HostSubUrl.IndexOf('/')).TrimEnd('\/')
function HandleRequest($context) {
    $RequestUrl = $context.Request.RawUrl
    if (-not $RequestUrl.StartsWith($HostSubUrl)) {
        return # 无效,不属于ps12exe Web Server的请求
    }
    $RequestUrl = $RequestUrl.Substring($HostSubUrl.Length)
    switch ($RequestUrl) {
        { $_ -in ('/api/compile', '/api/compile/v1') } {
            $Reader = New-Object System.IO.StreamReader($context.Request.InputStream)
            $userInput = $Reader.ReadToEnd()
            $Reader.Close()
            $Reader.Dispose()
            HandleWebCompileRequest $userInput $context
            return
        }
        '/api/compile/v2' {
            $Reader = New-Object System.IO.StreamReader($context.Request.InputStream)
            $userInput = $Reader.ReadToEnd()
            $Reader.Close()
            $Reader.Dispose()
            try {
                $userInput = $userInput | ConvertFrom-Json
            }
            catch {
                $context.Response.StatusCode = 400
                $context.Response.ContentType = "text/plain"
                $buffer = [System.Text.Encoding]::UTF8.GetBytes('Invalid JSON')
                return
            }
            HandleWebCompileRequest $userInput.content $context $userInput.locale
            return
        }
        '/bgm.mid' {
            # midi file
            $context.Response.ContentType = "audio/midi"
            $buffer = [System.IO.File]::ReadAllBytes("$PSScriptRoot/../bin/Unravel.mid")
        }
        '/favicon.ico' {
            $context.Response.ContentType = "image/x-icon"
            $buffer = [System.IO.File]::ReadAllBytes("$PSScriptRoot/../../img/icon.ico")
        }
        '/' {
            $body = Get-Content -LiteralPath "$PSScriptRoot/index.html" -Encoding utf8 -Raw
            $buffer = [System.Text.Encoding]::UTF8.GetBytes($body)
        }
        default {
            $context.Response.StatusCode = 404
            $context.Response.ContentType = "text/plain"
            $buffer = [System.Text.Encoding]::UTF8.GetBytes('Not Found')
        }
    }
    $context.Response.ContentLength64 = $buffer.Length
    if ($buffer) {
        $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
    }
    $context.Response.Close()
}
function AutoCacheClear {
    $Cache = Get-ChildItem -Path $CacheDir -ErrorAction Ignore
    if ($MaxCachedFileSize -lt ($Cache | Measure-Object -Property Length -Sum).Sum) {
        $Cache | Sort-Object -Property LastAccessTime -Descending |
        Select-Object -First $([math]::Floor($Cache.Count / 2)) |
        Remove-Item -Force -ErrorAction Ignore
    }
}
function AutoRunspaceRecycle {
    while ($AsyncResultArray[0].AsyncHandle.IsCompleted) {
        $AsyncResultArray[0].Runspace.Dispose()
        $AsyncResultArray.RemoveAt(0)
    }
    $TimeNow = Get-Date
    while ($AsyncResultArray[0] -and ($TimeNow - $AsyncResultArray[0].Time).Seconds -ge $MaxCompileTime) {
        if ($AsyncResultArray[0].IP -eq '127.0.0.1') { break }
        $AsyncResultArray[0].Runspace.Stop()
        $AsyncResultArray[0].Runspace.Dispose()
        $AsyncResultArray.RemoveAt(0)
    }
}

try {
    # 无限循环,用于监听请求 直到用户按下 Ctrl+C
    # 变量用于一分钟计时
    $Timer = 0
    while ($http.IsListening) {
        $Async = $http.BeginGetContext($null, $null)
        while (-not $Async.AsyncWaitHandle.WaitOne(500)) {
            $Timer++
            if ($Timer -ge 120) {
                $ipRequestCount = @{}
                AutoCacheClear
                $Timer = 0
            }
            AutoRunspaceRecycle
        }
        HandleRequest($http.EndGetContext($Async))
    }
}
finally {
    # 关闭 HttpListener
    $http.Stop()
    while ($AsyncResultArray.Count) {
        $AsyncResultArray[0].Runspace.Stop()
        $AsyncResultArray[0].Runspace.Dispose()
        $AsyncResultArray.RemoveAt(0)
    }
    $runspacePool.Close()
    $runspacePool.Dispose()
    Write-Host $LocalizeData.ServerStopped -ForegroundColor Yellow
    # Restore Console Window Title
    $Host.UI.RawUI.WindowTitle = $BackUpTitle
    # 清空缓存
    Remove-Item $CacheDir/* -Recurse -Force -ErrorAction Ignore
}
#_else
#_require ps12exe
#_pragma iconFile $PSScriptRoot/../../img/icon.ico
#_pragma title ps12exeWebServer
#_pragma description 'A webserver runner for compile powershell scripts online'
#_!!Start-ps12exeWebServer @PSBoundParameters
#_endif