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 {} } } }