Articles get their own address (shareable, back/forward work); the list is searchable by title and text, shows the article count, and is grouped into Applications and Troubleshooting & Guides via a hidden category line set from the editor. PDF export lays out at page width with page breaks, a title block and page numbers; Print uses the browser's vector print. Inline code is a pill that glows blue on hover, blockquotes no longer squeeze, and GitHub-style [!NOTE] alerts render. [[Badge]] syntax inside code spans is left alone.
520 lines
24 KiB
PowerShell
Executable File
520 lines
24 KiB
PowerShell
Executable File
param(
|
|
[switch]$Demo, # Read-only demo: write routes are refused (the demo front end sandboxes them in the browser)
|
|
[string]$ConfigPath
|
|
)
|
|
|
|
$basePath = $PSScriptRoot
|
|
. (Join-Path $basePath "Lib/Common.ps1")
|
|
$config = Import-DashConfig -Path $ConfigPath -Demo:$Demo
|
|
|
|
$port = $config.Server.Port
|
|
$dataPath = Join-Path $basePath "Data"
|
|
$docsPath = Join-Path $basePath "Documentation"
|
|
$webPath = Join-Path $basePath "web"
|
|
$logsPath = Join-Path $dataPath "Logs"
|
|
|
|
if (-not (Test-Path $dataPath)) { New-Item -ItemType Directory -Path $dataPath | Out-Null }
|
|
if (-not (Test-Path $docsPath)) { New-Item -ItemType Directory -Path $docsPath | Out-Null }
|
|
$initFiles = @{ "Blacklisted-Apps.txt"=""; "Whitelisted-IPs.txt"=""; "Tracked-Devices.json"="{}"; "Data.json"="[]"; "Worker-State.json"="{}"; "Software-Cache.json"="{}"; "Advanced-Cache.json"="{}"; "Completed.json"="[]" }
|
|
foreach ($file in $initFiles.Keys) {
|
|
$fp = Join-Path $dataPath $file
|
|
if (-not (Test-Path $fp)) { Set-Content -Path $fp -Value $initFiles[$file] -Encoding UTF8 }
|
|
}
|
|
|
|
# State files the browser is allowed to read directly (everything else in Data/ stays private)
|
|
$publicDataFiles = @("Data.json", "Worker-State.json", "Tracked-Devices.json", "Advanced-Cache.json", "Software-Cache.json", "Completed.json", "Blacklisted-Apps.txt")
|
|
|
|
$contentTypes = @{
|
|
".html" = "text/html; charset=utf-8"; ".js" = "application/javascript; charset=utf-8"; ".css" = "text/css; charset=utf-8"
|
|
".json" = "application/json; charset=utf-8"; ".md" = "text/markdown; charset=utf-8"; ".txt" = "text/plain; charset=utf-8"
|
|
".png" = "image/png"; ".jpg" = "image/jpeg"; ".jpeg" = "image/jpeg"; ".gif" = "image/gif"; ".webp" = "image/webp"
|
|
".svg" = "image/svg+xml"; ".ico" = "image/x-icon"
|
|
}
|
|
|
|
if ($config.Logging.Enabled) {
|
|
$cmdLine = "PS $basePath> ./USMT-Server.ps1" + $(if ($config.IsDemo) { " -Demo" } else { "" })
|
|
Initialize-ConsoleLog -Path (Join-Path $logsPath "server.log") -MaxLines $config.Logging.MaxLines -CommandLine $cmdLine
|
|
}
|
|
|
|
# Writes a body, gzip-compressed when the client accepts it and it's worth it (the JSON
|
|
# caches the dashboard polls are large but compress ~10:1)
|
|
function Write-ResponseBody {
|
|
param($request, $response, [byte[]]$bytes, [bool]$compressible)
|
|
$acceptsGzip = "$($request.Headers["Accept-Encoding"])" -match 'gzip'
|
|
if ($compressible -and $acceptsGzip -and $bytes.Length -gt 2048) {
|
|
$ms = New-Object System.IO.MemoryStream
|
|
$gz = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionLevel]::Fastest)
|
|
$gz.Write($bytes, 0, $bytes.Length)
|
|
$gz.Close()
|
|
$bytes = $ms.ToArray()
|
|
$response.Headers.Add("Content-Encoding", "gzip")
|
|
}
|
|
$response.Headers.Add("Vary", "Accept-Encoding")
|
|
$response.ContentLength64 = $bytes.Length
|
|
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
|
}
|
|
|
|
function Send-Json {
|
|
param($response, [string]$json, [int]$status = 200)
|
|
$buffer = [System.Text.Encoding]::UTF8.GetBytes($json)
|
|
$response.StatusCode = $status
|
|
$response.ContentType = "application/json; charset=utf-8"
|
|
$response.Headers.Add("Cache-Control", "no-store")
|
|
Write-ResponseBody $request $response $buffer $true
|
|
$response.Close()
|
|
}
|
|
|
|
function Read-JsonBody {
|
|
param($request)
|
|
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
|
return ($reader.ReadToEnd() | ConvertFrom-Json)
|
|
}
|
|
|
|
# Routes that change state on disk. In demo mode the server refuses them outright; the demo
|
|
# front end never sends them (it handles them in the visitor's browser instead).
|
|
$writeRoutes = @("force-update", "force-full", "update-tracked", "upload-image", "rename-image", "delete-image", "save-doc", "delete-doc")
|
|
# get-software takes a visitor-supplied device name and logs it; in demo mode the browser
|
|
# answers it from the published software cache, so the server never sees arbitrary input.
|
|
$demoBlockedRoutes = $writeRoutes + @("get-software")
|
|
|
|
$listener = New-Object System.Net.HttpListener
|
|
$listener.Prefixes.Add(($config.Server.Prefix -f $port))
|
|
|
|
$listener.Start()
|
|
|
|
$hostname = [System.Net.Dns]::GetHostName()
|
|
Write-Host "Dashboard active locally at http://localhost:$port" -ForegroundColor Gray
|
|
Write-Host "Network access enabled at http://$($hostname):$port" -ForegroundColor Cyan
|
|
if ($config.IsDemo) { Write-Host "DEMO MODE: API is read-only. Visitor changes are simulated in each visitor's browser." -ForegroundColor Magenta }
|
|
Write-Host "Press Ctrl+C to stop the server..."
|
|
|
|
while ($listener.IsListening) {
|
|
$response = $null
|
|
try {
|
|
$context = $listener.GetContext()
|
|
$request = $context.Request
|
|
$response = $context.Response
|
|
|
|
$localPath = [uri]::UnescapeDataString($request.Url.AbsolutePath).TrimStart('/')
|
|
|
|
# --- IP Whitelist Check ---
|
|
$whitelistFile = Join-Path $dataPath "Whitelisted-IPs.txt"
|
|
$whitelist = @(Get-Content $whitelistFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() })
|
|
if ($whitelist.Count -gt 0) {
|
|
$clientIP = $request.RemoteEndPoint.Address.ToString()
|
|
$clientHost = try { [System.Net.Dns]::GetHostEntry($clientIP).HostName } catch { "" }
|
|
if ($clientIP -notin $whitelist -and $clientHost -notin $whitelist -and ($clientHost -split '\.')[0] -notin ($whitelist | ForEach-Object { $_.ToLower() } )) {
|
|
Write-Host "Blocked request from $clientIP ($clientHost)" -ForegroundColor Red
|
|
$response.StatusCode = 403
|
|
$response.Close()
|
|
continue
|
|
}
|
|
}
|
|
# --------------------------
|
|
|
|
# --- Demo Guard: no route may change server state ---
|
|
if ($config.IsDemo -and $demoBlockedRoutes -contains $localPath) {
|
|
Send-Json $response '{"error":"This is a read-only demo. Changes are simulated in your browser."}' 403
|
|
continue
|
|
}
|
|
|
|
# --- API: Dashboard Config (mode flags for the front end) ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-config") {
|
|
$json = [PSCustomObject]@{
|
|
Mode = $config.Mode
|
|
Demo = [bool]$config.IsDemo
|
|
Terminals = [bool]$config.Logging.Enabled
|
|
} | ConvertTo-Json -Compress
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
# --- API: Console Log Tail (feeds the dashboard terminal panel) ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-logs") {
|
|
$source = $request.QueryString["source"]
|
|
if ($source -notin @("server", "worker") -or -not $config.Logging.Enabled) { Send-Json $response '{"lines":[]}' 404; continue }
|
|
|
|
$after = [long]0
|
|
[void][long]::TryParse("$($request.QueryString["after"])", [ref]$after)
|
|
$limit = 400
|
|
|
|
$logFile = Join-Path $logsPath "$source.log"
|
|
$picked = [System.Collections.Generic.List[string]]::new()
|
|
if (Test-Path $logFile) {
|
|
$lines = (Read-SharedText $logFile) -split "`n"
|
|
for ($i = $lines.Count - 1; $i -ge 0 -and $picked.Count -lt $limit; $i--) {
|
|
$ln = $lines[$i]
|
|
if ($ln -notmatch '^\{"i":(\d+),') { continue }
|
|
if ([long]$Matches[1] -le $after) { break }
|
|
$picked.Add($ln)
|
|
}
|
|
$picked.Reverse()
|
|
}
|
|
Send-Json $response ('{"lines":[' + ($picked -join ',') + ']}')
|
|
continue
|
|
}
|
|
|
|
# --- API: Force Worker Update ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "force-update") {
|
|
New-Item (Join-Path $dataPath "force-update.flag") -ItemType File -Force | Out-Null
|
|
Write-Host "Received Force Update Command from Dashboard" -ForegroundColor Cyan
|
|
$response.StatusCode = 200
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "force-full") {
|
|
New-Item (Join-Path $dataPath "force-full.flag") -ItemType File -Force | Out-Null
|
|
Write-Host "Received Force FULL Refresh Command from Dashboard" -ForegroundColor Cyan
|
|
$response.StatusCode = 200
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Get Clean Blacklist ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-blacklist") {
|
|
$blacklistFile = Join-Path $dataPath "Blacklisted-Apps.txt"
|
|
$blacklist = @()
|
|
if (Test-Path $blacklistFile) {
|
|
$blacklist = Get-Content $blacklistFile | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
|
|
}
|
|
|
|
$json = @($blacklist) | ConvertTo-Json -Compress
|
|
if (@($blacklist).Count -eq 0) { $json = "[]" } elseif (@($blacklist).Count -eq 1) { $json = "[$json]" }
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
$imgDir = Join-Path $docsPath "images"
|
|
|
|
# --- API: List Images ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "list-images") {
|
|
if (-not (Test-Path $imgDir)) { New-Item -ItemType Directory -Path $imgDir | Out-Null }
|
|
$images = @(Get-ChildItem -Path $imgDir -File | ForEach-Object {
|
|
@{ Name = $_.Name; Date = $_.LastWriteTime.ToString("o") }
|
|
})
|
|
$json = $images | ConvertTo-Json -Compress
|
|
if ($images.Count -eq 0) { $json = "[]" } elseif ($images.Count -eq 1) { $json = "[$json]" }
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
# --- API: Upload Image ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "upload-image") {
|
|
$payload = Read-JsonBody $request
|
|
|
|
if (-not (Test-Path $imgDir)) { New-Item -ItemType Directory -Path $imgDir | Out-Null }
|
|
|
|
$safeName = $payload.filename -replace '[<>:"/\\|?*]', ''
|
|
$filePath = Join-Path $imgDir $safeName
|
|
|
|
try {
|
|
$imageBytes = [Convert]::FromBase64String($payload.data)
|
|
[System.IO.File]::WriteAllBytes($filePath, $imageBytes)
|
|
Write-Host "Uploaded Image: $safeName" -ForegroundColor Cyan
|
|
$response.StatusCode = 200
|
|
} catch {
|
|
$response.StatusCode = 500
|
|
}
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Rename Image (With Markdown Crawler) ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "rename-image") {
|
|
$payload = Read-JsonBody $request
|
|
|
|
$oldNameRaw = $payload.oldName
|
|
$newNameRaw = $payload.newName
|
|
|
|
$oldPath = Join-Path $imgDir ($oldNameRaw -replace '[<>:"/\\|?*]', '')
|
|
$newName = $newNameRaw -replace '[<>:"/\\|?*]', ''
|
|
|
|
if (Test-Path $oldPath) {
|
|
Rename-Item -Path $oldPath -NewName $newName -Force
|
|
|
|
# --- Auto-Fix Broken Markdown Links ---
|
|
$encodedOld = [uri]::EscapeDataString($oldNameRaw)
|
|
$encodedNew = [uri]::EscapeDataString($newName)
|
|
|
|
$mdFiles = Get-ChildItem -Path $docsPath -Filter "*.md" -File
|
|
foreach ($file in $mdFiles) {
|
|
$content = Get-Content $file.FullName -Raw
|
|
$changed = $false
|
|
|
|
# Search for un-encoded spaces (e.g., images/My Photo.png)
|
|
if ($content -match [regex]::Escape("images/" + $oldNameRaw)) {
|
|
$content = $content -replace [regex]::Escape("images/" + $oldNameRaw), ("images/" + $newName)
|
|
$changed = $true
|
|
}
|
|
# Search for encoded spaces (e.g., images/My%20Photo.png)
|
|
if ($content -match [regex]::Escape("images/" + $encodedOld)) {
|
|
$content = $content -replace [regex]::Escape("images/" + $encodedOld), ("images/" + $encodedNew)
|
|
$changed = $true
|
|
}
|
|
|
|
if ($changed) {
|
|
Set-Content -Path $file.FullName -Value $content -Encoding UTF8
|
|
Write-Host "Auto-fixed image link in $($file.Name)" -ForegroundColor Magenta
|
|
}
|
|
}
|
|
# --------------------------------------
|
|
|
|
$response.StatusCode = 200
|
|
} else {
|
|
$response.StatusCode = 404
|
|
}
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Delete Image ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "delete-image") {
|
|
$payload = Read-JsonBody $request
|
|
|
|
$filePath = Join-Path $imgDir ($payload.filename -replace '[<>:"/\\|?*]', '')
|
|
|
|
if (Test-Path $filePath) {
|
|
Remove-Item -Path $filePath -Force
|
|
Write-Host "Deleted Image: $($payload.filename)" -ForegroundColor Yellow
|
|
$response.StatusCode = 200
|
|
} else {
|
|
$response.StatusCode = 404
|
|
}
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Save Documentation (With Rename Support) ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "save-doc") {
|
|
$payload = Read-JsonBody $request
|
|
|
|
$docName = $payload.title -replace '[<>:"/\\|?*]', ''
|
|
$originalName = $payload.originalTitle -replace '[<>:"/\\|?*]', ''
|
|
$content = $payload.content
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($docName)) {
|
|
# If renaming, delete the old file first
|
|
if (-not [string]::IsNullOrWhiteSpace($originalName) -and $originalName -ne $docName) {
|
|
$oldPath = Join-Path $docsPath "$originalName.md"
|
|
if (Test-Path $oldPath) { Remove-Item -Path $oldPath -Force }
|
|
}
|
|
|
|
$filePath = Join-Path $docsPath "$docName.md"
|
|
Set-Content -Path $filePath -Value $content -Encoding UTF8
|
|
Write-Host "Saved documentation: $docName.md" -ForegroundColor Green
|
|
$response.StatusCode = 200
|
|
} else {
|
|
$response.StatusCode = 400
|
|
}
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Archive Documentation ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "delete-doc") {
|
|
$payload = Read-JsonBody $request
|
|
|
|
$docName = $payload.title -replace '[<>:"/\\|?*]', ''
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($docName)) {
|
|
$filePath = Join-Path $docsPath "$docName.md"
|
|
$archivePath = Join-Path $docsPath "Archive"
|
|
|
|
if (-not (Test-Path $archivePath)) {
|
|
New-Item -ItemType Directory -Path $archivePath | Out-Null
|
|
}
|
|
|
|
if (Test-Path $filePath) {
|
|
Move-Item -Path $filePath -Destination (Join-Path $archivePath "$docName.md") -Force
|
|
Write-Host "Archived documentation: $docName.md" -ForegroundColor Yellow
|
|
$response.StatusCode = 200
|
|
} else {
|
|
$response.StatusCode = 404
|
|
}
|
|
} else {
|
|
$response.StatusCode = 400
|
|
}
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API: Get Documentation List ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-docs-list") {
|
|
$docs = @()
|
|
if (Test-Path $docsPath) {
|
|
$docs = @(Get-ChildItem -Path $docsPath -Filter *.md | Sort-Object Name | Select-Object -ExpandProperty BaseName)
|
|
}
|
|
|
|
$json = $docs | ConvertTo-Json -Compress
|
|
if ($docs.Count -eq 1) { $json = "[$json]" } elseif ($docs.Count -eq 0) { $json = "[]" }
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
# --- API: Get All Available Tags ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-all-tags") {
|
|
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
|
$tags = @()
|
|
if (Test-Path $trackedFile) {
|
|
try {
|
|
$rawContent = Read-SharedText $trackedFile
|
|
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
|
$data = $rawContent | ConvertFrom-Json
|
|
if ($data) { $tags = @($data.psobject.properties.name) }
|
|
}
|
|
} catch {}
|
|
}
|
|
$json = $tags | ConvertTo-Json -Compress
|
|
if ($tags.Count -eq 1) { $json = "[$json]" } elseif ($tags.Count -eq 0) { $json = "[]" }
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
# --- API: Get Tag Metadata (skipUSMT flag) ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-tag-meta") {
|
|
$tech = $request.QueryString["tech"]
|
|
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
|
$skipUSMT = $false
|
|
if (Test-Path $trackedFile) {
|
|
try {
|
|
$rawContent = Read-SharedText $trackedFile
|
|
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
|
$data = $rawContent | ConvertFrom-Json
|
|
if ($data -and $data.psobject.properties.name -contains $tech) {
|
|
$tagEntry = $data.$tech
|
|
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'skipUSMT') {
|
|
$skipUSMT = [bool]$tagEntry.skipUSMT
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
Send-Json $response "{""skipUSMT"":$(if($skipUSMT){'true'}else{'false'})}"
|
|
continue
|
|
}
|
|
|
|
# --- API: Get Tracked Devices for a Tech ---
|
|
if ($request.HttpMethod -eq "GET" -and $localPath -eq "get-tracked") {
|
|
$tech = $request.QueryString["tech"]
|
|
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
|
$techList = @()
|
|
if (Test-Path $trackedFile) {
|
|
try {
|
|
$rawContent = Read-SharedText $trackedFile
|
|
if (-not [string]::IsNullOrWhiteSpace($rawContent)) {
|
|
$data = $rawContent | ConvertFrom-Json
|
|
if ($data -and $data.psobject.properties.name -contains $tech) {
|
|
$tagEntry = $data.$tech
|
|
# Support both new object format and legacy flat array
|
|
if ($tagEntry -is [System.Management.Automation.PSCustomObject] -and $tagEntry.psobject.properties.name -contains 'devices') {
|
|
$techList = @($tagEntry.devices)
|
|
} else {
|
|
$techList = @($tagEntry)
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
$json = $techList | ConvertTo-Json -Compress
|
|
if ($techList.Count -eq 1) { $json = "[$json]" } elseif ($techList.Count -eq 0) { $json = "[]" }
|
|
Send-Json $response $json
|
|
continue
|
|
}
|
|
|
|
# --- API: Update Tracked Devices for a Tech ---
|
|
if ($request.HttpMethod -eq "POST" -and $localPath -eq "update-tracked") {
|
|
$payload = Read-JsonBody $request
|
|
$tech = $payload.tech
|
|
$devices = $payload.devices
|
|
$skipUSMT = if ($payload.psobject.properties.name -contains 'skipUSMT') { [bool]$payload.skipUSMT } else { $false }
|
|
$trackedFile = Join-Path $dataPath "Tracked-Devices.json"
|
|
$data = $null
|
|
|
|
if (Test-Path $trackedFile) {
|
|
try {
|
|
$rawContent = Read-SharedText $trackedFile
|
|
if (-not [string]::IsNullOrWhiteSpace($rawContent)) { $data = $rawContent | ConvertFrom-Json }
|
|
} catch {}
|
|
}
|
|
|
|
if (-not $data) { $data = [PSCustomObject]@{} }
|
|
|
|
if (-not $devices -or $devices.Count -eq 0) {
|
|
if ($data.psobject.properties.name -contains $tech) { $data.psobject.properties.Remove($tech) }
|
|
} else {
|
|
$tagEntry = [PSCustomObject]@{ devices = $devices; skipUSMT = $skipUSMT }
|
|
if (-not ($data.psobject.properties.name -contains $tech)) {
|
|
$data | Add-Member -MemberType NoteProperty -Name $tech -Value $tagEntry
|
|
} else {
|
|
$data.$tech = $tagEntry
|
|
}
|
|
}
|
|
$data | ConvertTo-Json -Depth 5 | Out-File $trackedFile -Encoding utf8
|
|
Write-Host "Updated tracking roster for Tech/Tag: $tech" -ForegroundColor Green
|
|
$response.StatusCode = 200
|
|
$response.Close()
|
|
continue
|
|
}
|
|
|
|
# --- API ROUTE: Get Software ---
|
|
if ($localPath -eq "get-software") {
|
|
$targetDevice = $request.QueryString["name"]
|
|
Write-Host "Fetching Software for: $targetDevice" -ForegroundColor Yellow
|
|
$softwareJson = & (Join-Path $basePath "Get-Software.ps1") -ComputerName $targetDevice -ConfigPath $ConfigPath
|
|
|
|
if ($softwareJson -is [array]) { $softwareJson = $softwareJson[-1] }
|
|
if (-not $softwareJson) { $softwareJson = "[]" }
|
|
|
|
Send-Json $response $softwareJson
|
|
continue
|
|
}
|
|
|
|
# --- STATIC FILES ---
|
|
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
|
|
# Article permalinks (/docs/Adobe Acrobat Pro) are pages of the dashboard itself
|
|
if ($localPath -eq "docs" -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
|
|
|
|
# Data/ state files, Documentation/ articles + images, and web/ assets for everything else
|
|
if ($publicDataFiles -contains $localPath) {
|
|
$root = $dataPath
|
|
$relative = $localPath
|
|
} elseif ($localPath -like "Documentation/*") {
|
|
$root = $docsPath
|
|
$relative = $localPath.Substring("Documentation/".Length)
|
|
} else {
|
|
$root = $webPath
|
|
$relative = $localPath
|
|
}
|
|
|
|
# Resolve and refuse anything that escapes its root (e.g. ../)
|
|
$file = [System.IO.Path]::GetFullPath((Join-Path $root $relative))
|
|
$rootFull = [System.IO.Path]::GetFullPath($root).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
|
|
|
if ($file.StartsWith($rootFull) -and (Test-Path -LiteralPath $file -PathType Leaf)) {
|
|
$fileInfo = Get-Item -LiteralPath $file
|
|
$fs = New-Object System.IO.FileStream($file, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
|
|
$br = New-Object System.IO.BinaryReader($fs)
|
|
$content = $br.ReadBytes($fs.Length)
|
|
$br.Close()
|
|
$fs.Close()
|
|
|
|
$ext = [System.IO.Path]::GetExtension($file).ToLower()
|
|
if ($contentTypes.ContainsKey($ext)) { $response.ContentType = $contentTypes[$ext] }
|
|
else { $response.ContentType = "application/octet-stream" }
|
|
|
|
$response.Headers.Add("Last-Modified", $fileInfo.LastWriteTime.ToUniversalTime().ToString("R"))
|
|
$response.Headers.Add("X-Content-Type-Options", "nosniff")
|
|
|
|
Write-ResponseBody $request $response $content ($ext -in @(".html", ".js", ".css", ".json", ".md", ".txt", ".svg"))
|
|
} else {
|
|
$response.StatusCode = 404
|
|
}
|
|
} catch {
|
|
# Request paths are never echoed: in demo mode the log is visible to every visitor
|
|
Write-Host "Request error: $($_.Exception.GetType().Name)" -ForegroundColor DarkRed
|
|
} finally {
|
|
if ($response -ne $null) { try { $response.Close() } catch {} }
|
|
}
|
|
}
|