src/WebServer/main.ps1

#Requires -Version 5.0

#_if PSScript
<#
.SYNOPSIS
运行一个 Web 服务器,允许用户编译 PowerShell 脚本
.DESCRIPTION
运行一个 Web 服务器,允许用户编译 PowerShell 脚本
.PARAMETER HostUrl
Web 服务器的 URL
.PARAMETER MaxCompileThreads
最大编译线程数
.PARAMETER MaxCompileTime
单次编译的最大时长(秒)
.PARAMETER ReqLimitPerMin
每个 IP 每分钟的最大请求数
.PARAMETER MaxCachedFileSize
缓存文件的最大大小
.PARAMETER MaxScriptFileSize
脚本文件的最大大小
.PARAMETER CacheDir
存放缓存文件的目录
.PARAMETER Locale
用于服务器端日志记录的语言代码
.PARAMETER help
显示帮助信息
.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]$Locale,
    [switch]$help
)

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

    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
    }

    # 设置控制台窗口标题
    $BackUpTitle = $Host.UI.RawUI.WindowTitle
    $Host.UI.RawUI.WindowTitle = "ps12exe Web Server"

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

    # 定义一个哈希表来跟踪每个 IP 的请求数
    $ipRequestCount = @{}
    # 一个队列用于装载$AsyncResult和$Runspace以及其他信息,直到$AsyncResult结束我们才能对$Runspace进行Dispose。。。
    $AsyncResultArray = New-Object System.Collections.ArrayList
    # 创建 Runspace 池
    $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxCompileThreads)
    $runspacePool.Open()

    function HandleWebCompileRequest($userInput, $context, $Locale) {
        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]++

        # 检查该 IP 是否已超过限制(例如每分钟 5 个请求)
        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
        }
        # 用户输入的哈希
        $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, $Locale)

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

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

            # 编译代码
            try {
                $userInput | ps12exe -outputFile $compiledExePath -Sandbox:$($clientIP -ne '127.0.0.1') -ErrorAction Stop -Locale $Locale
            }
            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($Locale).
        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) {
            '/api/compile' {
                $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 文件
                $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 变量用于一分钟计时
        $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
        # 恢复控制台窗口标题
        $Host.UI.RawUI.WindowTitle = $BackUpTitle
        # 清空缓存
        Remove-Item $CacheDir/* -Recurse -Force -ErrorAction Ignore
    }
#_else
    #_require ps12exe
    #_pragma Resources.iconFile $PSScriptRoot/../../img/icon.ico
    #_pragma Resources.title ps12exeWebServer
    #_pragma Resources.description 'A webserver runner for compile powershell scripts online'
    #_!!Start-ps12exeWebServer @PSBoundParameters
#_endif