corsproxy.ps1

# Copyright 2026 gor-dey
# Licensed under the Apache License, Version 2.0 (the "License")
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software distributed under the License is "AS IS" BASIS.

function corsproxy
{
  param (
    [int]$Port = 8080,
    [string]$LogDir = $null,
    [switch]$NoLog,
    [switch]$ShowAll,
    [switch]$Version
  )

  # --- Fix Encoding for PowerShell 5.1 ---
  if ($PSVersionTable.PSVersion.Major -le 5)
  {
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    [Console]::InputEncoding = [System.Text.Encoding]::UTF8
    $OutputEncoding = [System.Text.Encoding]::UTF8
    Add-Type -AssemblyName System.Net.Http
  }

  $CurrentScriptRoot = $PSScriptRoot
  . (Join-Path $CurrentScriptRoot "constants.ps1")

  if ($Version)
  {
    Write-Host "FrontNox CORS Proxy v$NoxVersion"
    return
  }

  if (-not $LogDir)
  { $LogDir = $NoxProxyLogDir
  }

  # --- Localization ---
  $Lang = if (Test-Path $NoxLangFile)
  { (Get-Content $NoxLangFile -Raw -Encoding UTF8).Trim()
  } else
  { "en"
  }
  $I18nPath = Join-Path $NoxI18nDir "$Lang.json"
  if (-not (Test-Path $I18nPath))
  { $I18nPath = Join-Path $NoxI18nDir "en.json"
  }

  $Messages = Get-Content $I18nPath -Raw -Encoding UTF8 | ConvertFrom-Json
  function Get-Msg($Key)
  { return $Messages.corsproxy.$Key
  }

  if (-not $NoLog)
  {
    if (Test-Path $LogDir)
    { Remove-Item -Path $LogDir -Recurse -Force -ErrorAction SilentlyContinue
    }
    New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
  }

  function Draw-Header
  {
    Clear-Host
    Write-Host "==========================================" -ForegroundColor Cyan
    Write-Host (" " + (Get-Msg Title)) -ForegroundColor White
    Write-Host "==========================================" -ForegroundColor Cyan

    Write-Host (" " + (Get-Msg Portal)) -NoNewline -ForegroundColor DarkGray
    Write-Host "$Port" -ForegroundColor Cyan

    if ($NoLog)
    {
      Write-Host (" " + (Get-Msg Records)) -NoNewline -ForegroundColor DarkGray
      Write-Host (Get-Msg Sealed) -ForegroundColor Red
    } elseif ($ShowAll)
    {
      Write-Host (" " + (Get-Msg Aspect)) -NoNewline -ForegroundColor DarkGray
      Write-Host (Get-Msg Absolute) -ForegroundColor Green
    } else
    {
      Write-Host (" " + (Get-Msg Aspect)) -NoNewline -ForegroundColor DarkGray
      Write-Host (Get-Msg Whisper) -ForegroundColor Yellow
    }
    Write-Host (Get-Msg Hints) -ForegroundColor DarkGray
    Write-Host "------------------------------------------" -ForegroundColor Gray
  }

  $Host.UI.RawUI.WindowTitle = "CORS Proxy :$Port"
  Draw-Header

  $prefix = "http://localhost:$Port/"
  $listener = New-Object System.Net.HttpListener
  $listener.Prefixes.Add($prefix)

  $handler = New-Object System.Net.Http.HttpClientHandler
  # PS5 (.NET Framework) does not have DecompressionMethods.All; use GZip + Deflate instead
  $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
  $httpClient = New-Object System.Net.Http.HttpClient($handler)

  $reqId = 0

  try
  {
    $listener.Start()
    $contextTask = $null

    while ($listener.IsListening)
    {
      if ($null -eq $contextTask)
      { $contextTask = $listener.GetContextAsync()
      }
      # --- Hotkey handling ---
      while ([Console]::KeyAvailable)
      {
        $key = [Console]::ReadKey($true)
        if (($key.Modifiers -band [ConsoleModifiers]::Control) -and $key.Key -eq 'L')
        {
          $reqId = 0
          Draw-Header
          Write-Host (Get-Msg Cleared) -ForegroundColor Green
        }
      }

      if (-not $contextTask.AsyncWaitHandle.WaitOne(300))
      { continue
      }

      try
      {
        $context = $contextTask.Result
        $contextTask = $null
        $reqId++
      } catch
      { break
      }

      $request = $context.Request
      $response = $context.Response
      $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

      # CORS Headers
      $response.Headers.Add("Access-Control-Allow-Origin", "*")
      $response.Headers.Add("Access-Control-Allow-Methods", "*")
      $response.Headers.Add("Access-Control-Allow-Headers", "*")
      $response.Headers.Add("Access-Control-Expose-Headers", "*")

      if ($request.HttpMethod -eq "OPTIONS")
      {
        $response.StatusCode = 200; $response.Close(); continue
      }

      $targetUrl = $request.RawUrl.Substring(1)
      if ([string]::IsNullOrWhiteSpace($targetUrl) -or $targetUrl -eq "favicon.ico")
      {
        $response.StatusCode = 404; $response.Close(); continue
      }

      $logData = $null
      if (-not $NoLog)
      {
        $logData = [Ordered]@{
          Timestamp = Get-Date -Format "HH:mm:ss.fff";
          Method = $request.HttpMethod;
          FullUrl = $targetUrl
          RequestHeaders = @{};
          RequestBody = $null;
          ResponseStatus = 0;
          ResponseHeaders = @{};
          ResponseBody = $null;
          Duration = ""
        }
      }

      try
      {
        try
        { $uriObj = New-Object Uri $targetUrl; $cleanPath = $uriObj.AbsolutePath
        } catch
        { $cleanPath = $targetUrl
        }

        $httpMethod = New-Object System.Net.Http.HttpMethod($request.HttpMethod)
        $reqMsg = New-Object System.Net.Http.HttpRequestMessage($httpMethod, $targetUrl)

        # Headers to skip when forwarding
        $noiseHeaders = @("Host", "Connection", "Content-Length", "Expect", "Referer", "Accept-Encoding", "User-Agent", "Sec-Fetch-Dest", "Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-User", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform", "Origin", "Accept", "Upgrade-Insecure-Requests", "Pragma", "Cache-Control", "DNT", "Cookie")

        foreach ($key in $request.Headers.AllKeys)
        {
          if ($key -in "Host", "Connection", "Content-Length", "Expect", "Referer", "Accept-Encoding")
          { continue
          }
          try
          {
            $val = $request.Headers[$key]
            $reqMsg.Headers.TryAddWithoutValidation($key, $val) | Out-Null

            if ($logData -and ($key -notin $noiseHeaders))
            {
              if ($key -eq "Authorization" -and $val.Length -gt 50)
              {
                $logData.RequestHeaders[$key] = $val.Substring(0, 30) + " ... [TRUNCATED] ... " + $val.Substring($val.Length - 10)
              } else
              { $logData.RequestHeaders[$key] = $val
              }
            }
          } catch
          {
          }
        }

        if ($request.HasEntityBody)
        {
          $memStream = New-Object System.IO.MemoryStream
          $request.InputStream.CopyTo($memStream); $memStream.Position = 0
          if ($logData)
          {
            $bodyStr = (New-Object System.IO.StreamReader($memStream, [System.Text.Encoding]::UTF8)).ReadToEnd()
            $logData.RequestBody = try
            { $bodyStr | ConvertFrom-Json
            } catch
            { $bodyStr
            }
            $memStream.Position = 0
          }
          $reqMsg.Content = New-Object System.Net.Http.StreamContent($memStream)
          if ($request.ContentType)
          { $reqMsg.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($request.ContentType)
          }
        }

        $targetResponse = $httpClient.SendAsync($reqMsg).Result
        $responseCode = [int]$targetResponse.StatusCode
        $response.StatusCode = $responseCode
        if ($targetResponse.Content.Headers.ContentType)
        { $response.ContentType = $targetResponse.Content.Headers.ContentType.ToString()
        }

        # Forward meaningful response headers
        $skipRespHeaders = @(
          "Transfer-Encoding", "Content-Length", "Content-Encoding",
          "Connection", "Keep-Alive"
        )
        foreach ($header in $targetResponse.Headers)
        {
          if ($header.Key -notin $skipRespHeaders)
          {
            try
            { $response.AddHeader($header.Key, [string]::Join(", ", $header.Value)) 
            } catch
            {
            }
          }
        }
        foreach ($header in $targetResponse.Content.Headers)
        {
          if ($header.Key -notin $skipRespHeaders -and $header.Key -ne "Content-Type")
          {
            try
            { $response.AddHeader($header.Key, [string]::Join(", ", $header.Value)) 
            } catch
            {
            }
          }
        }

        $respBytes = $targetResponse.Content.ReadAsByteArrayAsync().Result
        $response.ContentLength64 = $respBytes.Length
        $response.OutputStream.Write($respBytes, 0, $respBytes.Length)

        if ($logData)
        {
          $logData.ResponseStatus = "$responseCode ($($targetResponse.ReasonPhrase))"
          foreach ($header in $targetResponse.Headers)
          {
            if ($header.Key -notin $skipRespHeaders)
            { $logData.ResponseHeaders[$header.Key] = [string]::Join(", ", $header.Value)
            }
          }
          foreach ($header in $targetResponse.Content.Headers)
          {
            if ($header.Key -notin $skipRespHeaders)
            { $logData.ResponseHeaders[$header.Key] = [string]::Join(", ", $header.Value)
            }
          }
          $respStr = [System.Text.Encoding]::UTF8.GetString($respBytes)
          $logData.ResponseBody = try
          { $respStr | ConvertFrom-Json
          } catch
          { $respStr
          }
        }

        $stopwatch.Stop()
        $isError = $responseCode -ge 400
        if ($ShowAll -or $isError)
        {
          $c = if ($responseCode -lt 300)
          { "Green"
          } elseif ($responseCode -lt 500)
          { "Yellow"
          } else
          { "Red"
          }

          Write-Host "[$($reqId)] " -NoNewline -ForegroundColor Magenta
          Write-Host "$(Get-Date -Format 'HH:mm:ss') " -NoNewline -ForegroundColor DarkGray
          Write-Host "$($request.HttpMethod) " -NoNewline -ForegroundColor Cyan
          Write-Host "$responseCode " -NoNewline -ForegroundColor $c
          Write-Host $cleanPath -ForegroundColor White

          if (-not $NoLog)
          {
            $logData.Duration = "{0:N0}ms" -f $stopwatch.Elapsed.TotalMilliseconds
            $safeName = ($cleanPath.TrimStart('/').Replace('/', '_') -replace '[^a-zA-Z0-9_\-\.]', '')
            if ([string]::IsNullOrWhiteSpace($safeName))
            { $safeName = "root"
            }
            $fileName = "{0}_{1}_{2}.json" -f $request.HttpMethod, ($safeName.Substring([Math]::Max(0, $safeName.Length - 50))), $reqId
            $filePath = Join-Path $LogDir $fileName
            $logData | ConvertTo-Json -Depth 10 | Set-Content -Path $filePath -Encoding UTF8

            # Clickable link (OSC 8)
            $esc = [char]27
            $fileUri = ([Uri]$filePath).AbsoluteUri
            $openText = Get-Msg Open
            $openLink = "$esc]8;;$fileUri$esc\[$openText]$esc]8;;$esc\"

            Write-Host " └─ " -NoNewline -ForegroundColor DarkGray
            Write-Host $openLink -ForegroundColor White
          }
        }
      } catch
      { Write-Host "$(Get-Msg ErrPrefix) $_" -ForegroundColor Red; $response.StatusCode = 502
      } finally
      { try
        { $response.Close()
        } catch
        {
        }; if ($reqMsg)
        { $reqMsg.Dispose()
        }
      }
    }
  } finally
  { $listener.Stop(); $httpClient.Dispose(); Write-Host (Get-Msg Stopped) -ForegroundColor Yellow
  }
}