v0.1.24 — App Report, CSV Import, Read-Only Mode, Group Management
- App Report: Installationszähler pro App via Graph $batch (deviceStatuses) - Geräte-Export: CSV mit Maschinennamen pro App (GET /api/apps/devicestatus) - CSV-Import: Massenimport von Benutzern in Gruppen mit Header-Erkennung - Read-Only-Modus: Schreib-Tabs/Buttons automatisch ausblenden - Ergebnis-Banner nach CSV-Import (hinzugefügt/bereits Mitglied/Fehler/nicht gefunden) - Dokumentation aktualisiert (README, help.md, CHANGELOG) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3008
File diff suppressed because it is too large
Load Diff
+1000
File diff suppressed because it is too large
Load Diff
+300
@@ -0,0 +1,300 @@
|
||||
# Datenklassen / Hilfen fuer Frontend-JSON
|
||||
|
||||
function ConvertTo-Json2 {
|
||||
param([Parameter(ValueFromPipeline=$true)]$InputObject, [int]$Depth = 12)
|
||||
process {
|
||||
return $InputObject | ConvertTo-Json -Depth $Depth -Compress
|
||||
}
|
||||
}
|
||||
|
||||
# Normalisiert ein potentielles Datum (DateTime, DateTimeOffset, String,
|
||||
# /Date(ms)/, $null) auf ein ISO-8601-String — damit JS' new Date() es
|
||||
# zuverlaessig parsen kann.
|
||||
function ConvertTo-IsoDate {
|
||||
param($Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('o') }
|
||||
if ($Value -is [DateTimeOffset]) { return $Value.UtcDateTime.ToString('o') }
|
||||
$s = [string]$Value
|
||||
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
|
||||
# Microsoft-Legacy "/Date(1234567890123)/"
|
||||
if ($s -match '^\/Date\((-?\d+)') {
|
||||
try { return ([DateTimeOffset]::FromUnixTimeMilliseconds([long]$matches[1])).UtcDateTime.ToString('o') } catch {}
|
||||
}
|
||||
# Schon ein parsbares Datum?
|
||||
try { return ([DateTime]$s).ToUniversalTime().ToString('o') } catch {}
|
||||
return $s
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Settings: editierbare Konfiguration, persistiert als JSON
|
||||
# unter %APPDATA%\IntuneAppManager-Web\settings.json
|
||||
# ============================================================
|
||||
|
||||
function Get-SettingsPath {
|
||||
$dir = Join-Path $env:APPDATA "IntuneAppManager-Web"
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
return Join-Path $dir "settings.json"
|
||||
}
|
||||
|
||||
function Get-DefaultSettings {
|
||||
# Tenant-spezifische Felder (tenantId, clientId, departments.prefix,
|
||||
# rpa.groupNames) sind absichtlich leer — eine frische Installation
|
||||
# zwingt den Admin durchs Setup. Generische Werte (Scopes, Naming-Schemas,
|
||||
# Theme) sind branchenuebliche Startpunkte und bleiben besetzt.
|
||||
return [pscustomobject]@{
|
||||
connection = [pscustomobject]@{
|
||||
tenantId = ""
|
||||
clientId = ""
|
||||
clientIdRo = ""
|
||||
# DeviceManagementApps.ReadWrite.All ist Pflicht: assign (native All
|
||||
# Users/Devices), PATCH (Rename) und DELETE auf mobileApps brauchen
|
||||
# ReadWrite — Read.All allein liefert dort 403. Verifiziert via
|
||||
# msgraph-Skill gegen den offiziellen Graph-Permission-Index.
|
||||
scopes = @("Group.ReadWrite.All", "GroupMember.ReadWrite.All", "User.Read.All", "DeviceManagementApps.ReadWrite.All")
|
||||
scopesRo = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All", "DeviceManagementApps.Read.All")
|
||||
}
|
||||
departments = [pscustomobject]@{
|
||||
# Ein oder mehrere Praefixe fuer den Abteilungs-Modus (linke Spalte).
|
||||
# Mehrfach moeglich (z.B. "abt-hm" + "abt-extern"). Pflicht: mindestens
|
||||
# ein Eintrag im Setup. Der alte Single-String 'prefix' bleibt fuer
|
||||
# Rueckwaerts-Kompatibilitaet — Get-DepartmentPrefixes liest beides.
|
||||
prefixes = @()
|
||||
prefix = ""
|
||||
}
|
||||
rpa = [pscustomobject]@{
|
||||
# Explizite Liste der RPA-Gruppen. Leer = RPA-Tab zeigt Hinweis.
|
||||
groupNames = @()
|
||||
}
|
||||
userSearch = [pscustomobject]@{
|
||||
# In welchen Feldern bei der Benutzersuche gesucht wird.
|
||||
# Erlaubt: displayName, userPrincipalName, mail, department.
|
||||
fields = @("displayName", "userPrincipalName", "mail")
|
||||
}
|
||||
requiredGroupNaming = [pscustomobject]@{
|
||||
# Wird beim "+ Required-Gruppe"-Workflow verwendet:
|
||||
# {prefix}{slug-vom-app-name}{suffix}
|
||||
prefix = "intune-win-app-"
|
||||
suffix = "-required"
|
||||
}
|
||||
availableGroupNaming = [pscustomobject]@{
|
||||
# Analog zu requiredGroupNaming — fuer "+ Available-Gruppe"-Workflow.
|
||||
prefix = "intune-win-app-"
|
||||
suffix = "-available"
|
||||
}
|
||||
branding = [pscustomobject]@{
|
||||
# Logo-Dateiname relativ zum Projekt-Root (dort liegen auch
|
||||
# intune.png/pmpc.png/application.png). $null = Letter "I" Fallback.
|
||||
logoFile = "application.png"
|
||||
}
|
||||
# Vendor-Registry: jede App wird gegen diese Liste in Reihenfolge
|
||||
# gepruef; erster Match gewinnt. Source-String im App-Objekt =
|
||||
# vendor.displayName (sonst "Intune"). Tenants koennen Detection-
|
||||
# Regeln, Portal-URLs und Blocking pro Vendor in settings.json
|
||||
# ueberschreiben.
|
||||
vendors = @(
|
||||
[pscustomobject]@{
|
||||
id = "patchmypc"
|
||||
displayName = "PatchMyPC"
|
||||
portalUrl = "https://portal.patchmypc.com/"
|
||||
logoFile = "pmpc.png"
|
||||
detection = [pscustomobject]@{
|
||||
field = "commandLine" # commandLine | developer | publisher | displayName | notes | owner
|
||||
match = "contains" # contains | equals | regex | startsWith
|
||||
pattern = "PatchMyPC"
|
||||
}
|
||||
block = [pscustomobject]@{ delete = $true; rename = $true }
|
||||
},
|
||||
[pscustomobject]@{
|
||||
id = "robopack"
|
||||
displayName = "Robopack"
|
||||
portalUrl = "https://app.robopack.com/"
|
||||
logoFile = "robopack.png"
|
||||
detection = [pscustomobject]@{
|
||||
field = "developer"
|
||||
match = "equals"
|
||||
pattern = "Robopack"
|
||||
}
|
||||
block = [pscustomobject]@{ delete = $true; rename = $true }
|
||||
}
|
||||
)
|
||||
theme = [pscustomobject]@{
|
||||
# Hauptfarben fuer Highlights. Soft/Strong/Border/Bg werden im
|
||||
# Frontend per rgba() aus dem Hex abgeleitet.
|
||||
colors = [pscustomobject]@{
|
||||
brand = "#27a078" # Primaere Firmenfarbe: Logo-Hintergrund, Stepper-Indikator
|
||||
accent = "#3b82f6" # Available-Pillen, Links, Akzent-Hover
|
||||
required = "#cb2a7a" # Pink (Required-Badges)
|
||||
success = "#10b981" # Bereits zugewiesen
|
||||
warning = "#f59e0b" # Teilweise / Skip
|
||||
error = "#ef4444" # Fehler / Destructive
|
||||
rowSelected = "#71e5c4" # Hintergrund der aufgeklappten/markierten App-Zeile
|
||||
detailPanel = "#f7f7f7" # Hintergrund des Details-Bereichs unter der App
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Tiefer Merge: gespeicherte Werte ueberschreiben Defaults, neue Default-
|
||||
# Keys werden trotzdem ergaenzt — robust gegen Settings-Schema-Erweiterungen.
|
||||
function Merge-Settings {
|
||||
param($Base, $Override)
|
||||
if ($null -eq $Override) { return $Base }
|
||||
$result = [ordered]@{}
|
||||
foreach ($p in $Base.PSObject.Properties) {
|
||||
$name = $p.Name
|
||||
$bv = $p.Value
|
||||
$ov = $null
|
||||
$hasOverride = $false
|
||||
if ($Override.PSObject.Properties[$name]) {
|
||||
$ov = $Override.PSObject.Properties[$name].Value
|
||||
$hasOverride = $true
|
||||
}
|
||||
if (-not $hasOverride) {
|
||||
$result[$name] = $bv
|
||||
} elseif ($bv -is [pscustomobject] -and $ov -is [pscustomobject]) {
|
||||
$result[$name] = Merge-Settings -Base $bv -Override $ov
|
||||
} else {
|
||||
$result[$name] = $ov
|
||||
}
|
||||
}
|
||||
# Zusaetzliche Properties aus Override, die nicht im Base sind, ignorieren —
|
||||
# sie wuerden vom Backend ohnehin nicht gelesen.
|
||||
return [pscustomobject]$result
|
||||
}
|
||||
|
||||
function Get-DepartmentPrefixes {
|
||||
# Zentrale Quelle fuer die Abteilungs-Praefixe. Bevorzugt das neue
|
||||
# 'prefixes'-Array; faellt sonst auf den alten Single-String 'prefix'
|
||||
# zurueck (Rueckwaerts-Kompatibilitaet mit existierenden settings.json).
|
||||
# Liefert immer ein String-Array (getrimmt, dedupliziert, ohne leere
|
||||
# Eintraege), ggf. leer wenn nichts konfiguriert ist.
|
||||
param($Settings)
|
||||
$out = [System.Collections.Generic.List[string]]::new()
|
||||
if ($null -eq $Settings -or $null -eq $Settings.departments) { return ,$out.ToArray() }
|
||||
$d = $Settings.departments
|
||||
$candidates = @()
|
||||
if ($d.PSObject.Properties['prefixes']) { $candidates += @($d.prefixes) }
|
||||
if ($d.PSObject.Properties['prefix'] -and $d.prefix) { $candidates += @([string]$d.prefix) }
|
||||
$seen = @{}
|
||||
foreach ($p in $candidates) {
|
||||
if ($null -eq $p) { continue }
|
||||
$t = ([string]$p).Trim()
|
||||
if (-not $t) { continue }
|
||||
$k = $t.ToLowerInvariant()
|
||||
if ($seen.ContainsKey($k)) { continue }
|
||||
$seen[$k] = $true
|
||||
$out.Add($t)
|
||||
}
|
||||
return $out.ToArray()
|
||||
}
|
||||
|
||||
function Read-Settings {
|
||||
$path = Get-SettingsPath
|
||||
$defaults = Get-DefaultSettings
|
||||
if (-not (Test-Path $path)) { return $defaults }
|
||||
try {
|
||||
$raw = Get-Content -Path $path -Raw -Encoding UTF8
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) { return $defaults }
|
||||
$saved = $raw | ConvertFrom-Json
|
||||
return Merge-Settings -Base $defaults -Override $saved
|
||||
} catch {
|
||||
Write-Host "[SETTINGS] Lesen fehlgeschlagen, verwende Defaults: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
return $defaults
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Settings {
|
||||
param([Parameter(Mandatory=$true)]$Settings)
|
||||
$path = Get-SettingsPath
|
||||
$json = $Settings | ConvertTo-Json -Depth 10
|
||||
[IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8)
|
||||
Write-Host "[SETTINGS] Gespeichert: $path" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Get-SettingsValidationErrors {
|
||||
# Rudimentaere Validierung. Schwere Fehler -> Speichern ablehnen.
|
||||
param($S)
|
||||
$errs = @()
|
||||
if (-not $S.connection.tenantId -or $S.connection.tenantId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||
$errs += "Tenant ID muss eine GUID sein."
|
||||
}
|
||||
if (-not $S.connection.clientId -or $S.connection.clientId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||
$errs += "Client ID (Read/Write) muss eine GUID sein."
|
||||
}
|
||||
if ($S.connection.clientIdRo -and $S.connection.clientIdRo.Trim() -and $S.connection.clientIdRo -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||
$errs += "Client ID (Read Only) muss eine GUID sein (oder leer lassen)."
|
||||
}
|
||||
if (-not $S.connection.scopes -or @($S.connection.scopes).Count -eq 0) {
|
||||
$errs += "Mindestens ein Scope erforderlich."
|
||||
}
|
||||
$deptPrefixes = Get-DepartmentPrefixes -Settings $S
|
||||
if ($deptPrefixes.Count -eq 0) {
|
||||
$errs += "Mindestens ein Abteilungs-Praefix erforderlich."
|
||||
} else {
|
||||
$bad = @($deptPrefixes | Where-Object { $_.Trim().Length -lt 2 })
|
||||
if ($bad.Count -gt 0) { $errs += "Abteilungs-Praefixe muessen jeweils mindestens 2 Zeichen lang sein." }
|
||||
}
|
||||
if (-not $S.rpa.groupNames -or @($S.rpa.groupNames).Count -eq 0) {
|
||||
$errs += "Mindestens eine RPA-Gruppe erforderlich."
|
||||
}
|
||||
$allowedUserFields = @('displayName','userPrincipalName','mail','department')
|
||||
$bad = @($S.userSearch.fields | Where-Object { $_ -notin $allowedUserFields })
|
||||
if ($bad.Count -gt 0) { $errs += "Unbekannte User-Search-Felder: $($bad -join ', ')" }
|
||||
if (-not $S.userSearch.fields -or @($S.userSearch.fields).Count -eq 0) {
|
||||
$errs += "Mindestens ein User-Such-Feld erforderlich."
|
||||
}
|
||||
if (-not $S.requiredGroupNaming.prefix -or -not $S.requiredGroupNaming.suffix) {
|
||||
$errs += "Required-Gruppen-Naming: Praefix und Suffix erforderlich."
|
||||
}
|
||||
if ($S.availableGroupNaming -and (-not $S.availableGroupNaming.prefix -or -not $S.availableGroupNaming.suffix)) {
|
||||
$errs += "Available-Gruppen-Naming: Praefix und Suffix erforderlich."
|
||||
}
|
||||
# Vendor-Registry: jeder Eintrag muss id + detection mit field/match/pattern haben.
|
||||
if ($null -ne $S.vendors) {
|
||||
$allowedFields = @('commandLine','developer','publisher','displayName','notes','owner')
|
||||
$allowedMatches = @('contains','equals','regex','startsWith')
|
||||
$seenIds = @{}
|
||||
foreach ($v in @($S.vendors)) {
|
||||
if (-not $v.id -or [string]::IsNullOrWhiteSpace($v.id)) { $errs += "Vendor: 'id' erforderlich."; continue }
|
||||
if ($seenIds.ContainsKey($v.id)) { $errs += "Vendor '$($v.id)': id ist nicht eindeutig."; continue }
|
||||
$seenIds[$v.id] = $true
|
||||
if (-not $v.displayName) { $errs += "Vendor '$($v.id)': displayName erforderlich." }
|
||||
if (-not $v.detection) { $errs += "Vendor '$($v.id)': detection-Block fehlt."; continue }
|
||||
if ($v.detection.field -notin $allowedFields) { $errs += "Vendor '$($v.id)': detection.field muss eines von $($allowedFields -join '|') sein." }
|
||||
if ($v.detection.match -notin $allowedMatches) { $errs += "Vendor '$($v.id)': detection.match muss eines von $($allowedMatches -join '|') sein." }
|
||||
if (-not $v.detection.pattern -or [string]::IsNullOrWhiteSpace($v.detection.pattern)) { $errs += "Vendor '$($v.id)': detection.pattern erforderlich." }
|
||||
}
|
||||
}
|
||||
# Theme-Farben sind Hex-Strings (#RGB oder #RRGGBB)
|
||||
if ($S.theme -and $S.theme.colors) {
|
||||
foreach ($key in @('brand','accent','required','success','warning','error','rowSelected','detailPanel')) {
|
||||
$val = $S.theme.colors.$key
|
||||
if ($val -and $val -notmatch '^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') {
|
||||
$errs += "Farbe '$key' ist kein gueltiger Hex-Wert."
|
||||
}
|
||||
}
|
||||
}
|
||||
return $errs
|
||||
}
|
||||
|
||||
function New-AssignmentItem {
|
||||
param(
|
||||
[string]$AppId,
|
||||
[string]$AppName,
|
||||
[string]$AppType,
|
||||
[string]$Type, # "Available" | "Required"
|
||||
[string]$GroupId,
|
||||
[string]$GroupName
|
||||
)
|
||||
return [pscustomobject]@{
|
||||
Id = [guid]::NewGuid().ToString()
|
||||
AppId = $AppId
|
||||
AppName = $AppName
|
||||
AppType = $AppType
|
||||
Type = $Type
|
||||
GroupId = $GroupId
|
||||
GroupName = $GroupName
|
||||
AddedAt = (Get-Date).ToString("o")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# HTTP-Router: leitet Requests an statische Dateien oder API-Endpoints weiter.
|
||||
|
||||
function Invoke-Router {
|
||||
param([Parameter(Mandatory=$true)]$Context)
|
||||
|
||||
$request = $Context.Request
|
||||
$path = $request.Url.AbsolutePath.TrimEnd('/')
|
||||
if ([string]::IsNullOrEmpty($path)) { $path = "/" }
|
||||
$method = $request.HttpMethod
|
||||
|
||||
$isApi = $path.StartsWith("/api/")
|
||||
if ($isApi) {
|
||||
Write-Host (">>> [{0}] {1} {2}" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path) -ForegroundColor DarkCyan
|
||||
}
|
||||
$reqStart = Get-Date
|
||||
|
||||
# API-Endpoints
|
||||
if ($isApi) {
|
||||
$body = $null
|
||||
if ($method -in @("POST","PUT","PATCH")) {
|
||||
$body = Read-RequestBody -Context $Context
|
||||
}
|
||||
$query = @{}
|
||||
foreach ($key in $request.QueryString.AllKeys) {
|
||||
if ($null -ne $key) { $query[$key] = $request.QueryString[$key] }
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Invoke-ApiHandler -Path $path -Method $method -Body $body -Query $query
|
||||
$handlerMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
|
||||
if ($null -eq $result) {
|
||||
Send-JsonResponse -Context $Context -StatusCode 404 -Body @{ error = "Unknown endpoint: $method $path" }
|
||||
Write-Host ("<<< [{0}] {1} {2} -> 404 ({3}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $handlerMs) -ForegroundColor DarkYellow
|
||||
} else {
|
||||
$statusCode = 200
|
||||
if ($result -is [hashtable] -and $result.ContainsKey('__status')) {
|
||||
$statusCode = $result['__status']
|
||||
$result.Remove('__status')
|
||||
}
|
||||
Send-JsonResponse -Context $Context -StatusCode $statusCode -Body $result
|
||||
$totalMs = [int]((Get-Date) - $reqStart).TotalMilliseconds
|
||||
Write-Host ("<<< [{0}] {1} {2} -> {3} (handler={4}ms total={5}ms)" -f (Get-Date -Format "HH:mm:ss.fff"), $method, $path, $statusCode, $handlerMs, $totalMs) -ForegroundColor DarkCyan
|
||||
}
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
# Client-Disconnects (AbortController) sind harmlos und werden nicht geloggt
|
||||
if ($msg -match "Netzwerkname.*nicht.*verfügbar|connection was forcibly closed|aborted by the software|response has been submitted") {
|
||||
Write-Host " [client disconnect] $path" -ForegroundColor DarkGray
|
||||
} else {
|
||||
Write-Host "<<< [API ERROR] $path -> $msg" -ForegroundColor Red
|
||||
Write-Host " Stack: $($_.ScriptStackTrace)" -ForegroundColor DarkRed
|
||||
try {
|
||||
Send-JsonResponse -Context $Context -StatusCode 500 -Body @{
|
||||
error = $msg
|
||||
stack = $_.ScriptStackTrace
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Assets aus dem Projekt-Root (Logos etc.)
|
||||
if ($path.StartsWith("/assets/")) {
|
||||
$name = $path.Substring("/assets/".Length)
|
||||
$name = $name -replace "[^a-zA-Z0-9._-]", ""
|
||||
$rootDir = Split-Path -Parent $script:Config.WebRoot
|
||||
$file = Join-Path $rootDir $name
|
||||
# Branding-Logos NIE cachen — sonst sieht der User nach dem Upload
|
||||
# weiter die alte Version, auch wenn die URL gleich bleibt.
|
||||
$extraHeaders = $null
|
||||
if ($name -like 'branding-logo.*') {
|
||||
$extraHeaders = @{ 'Cache-Control' = 'no-store, max-age=0' }
|
||||
}
|
||||
Send-FileResponse -Context $Context -FilePath $file -ExtraHeaders $extraHeaders
|
||||
return
|
||||
}
|
||||
|
||||
# Reports (HTML-Reports werden in Temp gespeichert)
|
||||
if ($path.StartsWith("/reports/")) {
|
||||
$name = $path.Substring("/reports/".Length)
|
||||
$name = $name -replace "[^a-zA-Z0-9._-]", ""
|
||||
$file = Join-Path $script:Config.ReportDir $name
|
||||
Send-FileResponse -Context $Context -FilePath $file
|
||||
return
|
||||
}
|
||||
|
||||
# Statische Dateien aus www/
|
||||
$relativePath = if ($path -eq "/") { "index.html" } else { $path.TrimStart('/') }
|
||||
$relativePath = $relativePath -replace '\.\.', '' # path traversal blocken
|
||||
$file = Join-Path $script:Config.WebRoot $relativePath
|
||||
|
||||
if (Test-Path $file -PathType Leaf) {
|
||||
Send-FileResponse -Context $Context -FilePath $file
|
||||
} else {
|
||||
# SPA fallback - alle nicht erkannten Pfade auf index.html
|
||||
Send-FileResponse -Context $Context -FilePath (Join-Path $script:Config.WebRoot "index.html")
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# Lokaler HTTP-Server (System.Net.HttpListener)
|
||||
# Single-threaded request loop - reicht fuer einen Admin-Nutzer.
|
||||
|
||||
function Start-WebServer {
|
||||
param([int]$Port = 8077)
|
||||
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
$prefix = "http://localhost:$Port/"
|
||||
$listener.Prefixes.Add($prefix)
|
||||
|
||||
try {
|
||||
$listener.Start()
|
||||
} catch {
|
||||
Write-Host "Server konnte nicht gestartet werden auf $prefix" -ForegroundColor Red
|
||||
Write-Host "Moeglicherweise ist der Port belegt oder es fehlen Rechte." -ForegroundColor Red
|
||||
Write-Host "Tipp: anderen Port mit -Port 8088 versuchen, oder als Admin starten." -ForegroundColor Yellow
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Server laeuft. Warte auf Requests..." -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
while ($listener.IsListening) {
|
||||
try {
|
||||
$context = $listener.GetContext() # blockiert
|
||||
try {
|
||||
Invoke-Router -Context $context
|
||||
} catch {
|
||||
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
|
||||
try {
|
||||
Send-JsonResponse -Context $context -StatusCode 500 -Body @{
|
||||
error = $_.Exception.Message
|
||||
stack = $_.ScriptStackTrace
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch [System.Net.HttpListenerException] {
|
||||
break
|
||||
} catch {
|
||||
Write-Host "[FATAL] $_" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
$listener.Stop()
|
||||
$listener.Close()
|
||||
}
|
||||
|
||||
function Send-JsonResponse {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]$Context,
|
||||
[int]$StatusCode = 200,
|
||||
$Body = $null
|
||||
)
|
||||
$response = $Context.Response
|
||||
try {
|
||||
$response.StatusCode = $StatusCode
|
||||
$response.ContentType = "application/json; charset=utf-8"
|
||||
$response.Headers.Add("Cache-Control", "no-store")
|
||||
} catch {
|
||||
# Headers schon gesendet — Client wahrscheinlich schon weg
|
||||
return
|
||||
}
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$json = if ($null -eq $Body) { "{}" } else { $Body | ConvertTo-Json -Depth 12 -Compress }
|
||||
$sw.Stop()
|
||||
if ($sw.ElapsedMilliseconds -gt 100) {
|
||||
Write-Host " [JSON] Serialize $($json.Length) bytes in $($sw.ElapsedMilliseconds)ms (ACHTUNG > 100ms)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
try {
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$response.OutputStream.Close()
|
||||
} catch [System.Net.HttpListenerException], [System.IO.IOException], [System.ObjectDisposedException] {
|
||||
# Client hat die Verbindung abgebrochen (AbortController, Tab geschlossen, etc.) — irrelevant
|
||||
} catch {
|
||||
# Andere Fehler still mitloggen
|
||||
Write-Host " [WRITE] Response-Schreiben fehlgeschlagen: $($_.Exception.GetType().Name): $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
function Send-FileResponse {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]$Context,
|
||||
[Parameter(Mandatory=$true)][string]$FilePath,
|
||||
[string]$ContentType = $null,
|
||||
[hashtable]$ExtraHeaders = $null
|
||||
)
|
||||
$response = $Context.Response
|
||||
if (-not (Test-Path $FilePath)) {
|
||||
$response.StatusCode = 404
|
||||
$response.OutputStream.Close()
|
||||
return
|
||||
}
|
||||
if (-not $ContentType) {
|
||||
$ContentType = switch ([IO.Path]::GetExtension($FilePath).ToLower()) {
|
||||
".html" { "text/html; charset=utf-8" }
|
||||
".js" { "application/javascript; charset=utf-8" }
|
||||
".css" { "text/css; charset=utf-8" }
|
||||
".json" { "application/json; charset=utf-8" }
|
||||
".svg" { "image/svg+xml" }
|
||||
".png" { "image/png" }
|
||||
".jpg" { "image/jpeg" }
|
||||
".jpeg" { "image/jpeg" }
|
||||
".webp" { "image/webp" }
|
||||
".gif" { "image/gif" }
|
||||
".ico" { "image/x-icon" }
|
||||
".woff2"{ "font/woff2" }
|
||||
".md" { "text/markdown; charset=utf-8" }
|
||||
default { "application/octet-stream" }
|
||||
}
|
||||
}
|
||||
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
||||
$response.ContentType = $ContentType
|
||||
|
||||
# Standard-Cache-Header: HTML/JS/CSS NICHT cachen (single-user Dev-Tool,
|
||||
# Performance-Verlust irrelevant, dafuer immer aktueller Code im Browser).
|
||||
# Bilder/Fonts cachen aber muessen — sonst flackert das Logo bei jedem Klick.
|
||||
$ext = [IO.Path]::GetExtension($FilePath).ToLower()
|
||||
$cacheCtl = if ($ext -in @('.html','.htm','.js','.css','.json')) {
|
||||
'no-store, no-cache, must-revalidate, max-age=0'
|
||||
} else {
|
||||
'no-cache, must-revalidate'
|
||||
}
|
||||
if ($ExtraHeaders -and $ExtraHeaders.ContainsKey('Cache-Control')) {
|
||||
$cacheCtl = $ExtraHeaders['Cache-Control']
|
||||
}
|
||||
$response.Headers.Add('Cache-Control', $cacheCtl)
|
||||
|
||||
if ($ExtraHeaders) {
|
||||
foreach ($k in $ExtraHeaders.Keys) {
|
||||
if ($k -eq 'Cache-Control') { continue } # bereits gesetzt
|
||||
try { $response.Headers.Add($k, [string]$ExtraHeaders[$k]) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$response.OutputStream.Close()
|
||||
}
|
||||
|
||||
function Read-RequestBody {
|
||||
param([Parameter(Mandatory=$true)]$Context)
|
||||
$request = $Context.Request
|
||||
if (-not $request.HasEntityBody) { return $null }
|
||||
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
if ([string]::IsNullOrWhiteSpace($body)) { return $null }
|
||||
# -AsHashtable gibt es erst ab PowerShell 6 — Windows PowerShell 5.1 kennt es nicht.
|
||||
# Die Endpoints behandeln sowohl Hashtable als auch PSCustomObject korrekt.
|
||||
if ($PSVersionTable.PSVersion.Major -ge 6) {
|
||||
return ($body | ConvertFrom-Json -AsHashtable)
|
||||
}
|
||||
return ($body | ConvertFrom-Json)
|
||||
}
|
||||
Reference in New Issue
Block a user