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:
+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