web app mit hinzugefügt
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,923 @@
|
||||
# Microsoft Graph API Helpers
|
||||
# Verwendet ausschliesslich Microsoft.Graph.Authentication + Invoke-MgGraphRequest,
|
||||
# um Versionskonflikte zu vermeiden.
|
||||
|
||||
function Initialize-GraphModule {
|
||||
if (Get-Module Microsoft.Graph.Authentication) { return $true }
|
||||
try {
|
||||
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -MinimumVersion 2.0.0
|
||||
$loaded = Get-Module Microsoft.Graph.Authentication
|
||||
Write-Host "[GRAPH] Microsoft.Graph.Authentication v$($loaded.Version) geladen" -ForegroundColor DarkGray
|
||||
return $true
|
||||
} catch {
|
||||
$available = @(Get-Module Microsoft.Graph.Authentication -ListAvailable)
|
||||
if ($available.Count -eq 0) {
|
||||
throw "Microsoft.Graph.Authentication ist nicht installiert. Bitte ausfuehren: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force"
|
||||
}
|
||||
$maxVer = ($available | Sort-Object Version -Descending | Select-Object -First 1).Version
|
||||
if ($maxVer -lt [version]"2.0.0") {
|
||||
throw "Microsoft.Graph.Authentication v$maxVer ist zu alt. Mindestens v2.0 noetig. Bitte: Update-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force"
|
||||
}
|
||||
throw "Microsoft.Graph.Authentication konnte nicht geladen werden: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MgGraphRequestRetry {
|
||||
# Duenner Wrapper um Invoke-MgGraphRequest mit Retry-Backoff fuer 429/503.
|
||||
# Microsoft-Best-Practice: Throttling (429) immer abfangen und mit
|
||||
# Retry-After-Verzoegerung erneut versuchen. 503 analog (Service busy).
|
||||
# Andere Fehler werden unveraendert durchgereicht (kein Verschlucken).
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Uri,
|
||||
[string]$Method = 'GET',
|
||||
$Body,
|
||||
[string]$ContentType,
|
||||
[hashtable]$Headers,
|
||||
[int]$MaxRetries = 3
|
||||
)
|
||||
$attempt = 0
|
||||
while ($true) {
|
||||
try {
|
||||
$params = @{ Uri = $Uri; Method = $Method }
|
||||
if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params.Body = $Body }
|
||||
if ($ContentType) { $params.ContentType = $ContentType }
|
||||
if ($Headers) { $params.Headers = $Headers }
|
||||
return Invoke-MgGraphRequest @params
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
$is429 = $msg -match '\b429\b|Too many requests|throttl'
|
||||
$is503 = $msg -match '\b503\b|Service Unavailable'
|
||||
if (($is429 -or $is503) -and $attempt -lt $MaxRetries) {
|
||||
$attempt++
|
||||
# Retry-After aus dem Fehler ziehen wenn vorhanden, sonst Backoff 2/5/10s.
|
||||
$wait = $null
|
||||
if ($msg -match 'Retry-After[:\s]+(\d+)') { $wait = [int]$matches[1] }
|
||||
if (-not $wait -or $wait -le 0) { $wait = @(2, 5, 10)[[Math]::Min($attempt - 1, 2)] }
|
||||
$code = if ($is429) { '429' } else { '503' }
|
||||
Write-Host " [THROTTLE] $code -> Retry $attempt/$MaxRetries in ${wait}s" -ForegroundColor DarkYellow
|
||||
Start-Sleep -Seconds $wait
|
||||
continue
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GraphPaged {
|
||||
param([Parameter(Mandatory=$true)][string]$Uri)
|
||||
$results = @()
|
||||
$next = $Uri
|
||||
do {
|
||||
$response = Invoke-MgGraphRequestRetry -Uri $next -Method GET
|
||||
if ($response.value) { $results += $response.value }
|
||||
$next = $response.'@odata.nextLink'
|
||||
} while ($next)
|
||||
return $results
|
||||
}
|
||||
|
||||
function Get-GraphGroupByFilter {
|
||||
param(
|
||||
[string]$Filter,
|
||||
[string[]]$Property = @("id","displayName"),
|
||||
[switch]$ExpandMembers
|
||||
)
|
||||
$select = "`$select=" + ($Property -join ",").ToLower()
|
||||
$uri = "https://graph.microsoft.com/v1.0/groups?$select"
|
||||
if ($Filter) { $uri += "&`$filter=$([uri]::EscapeDataString($Filter))" }
|
||||
# $expand=members($select=id) liefert bis zu 20 Mitglieder-IDs pro Gruppe —
|
||||
# genug, um "leer ja/nein" zuverlaessig zu erkennen (Graph cap ist 20).
|
||||
if ($ExpandMembers) { $uri += "&`$expand=members(`$select=id)" }
|
||||
return Get-GraphPaged -Uri $uri
|
||||
}
|
||||
|
||||
function Get-GraphGroupById {
|
||||
param([string]$Id, [string[]]$Property = @("id","displayName"))
|
||||
$select = "`$select=" + ($Property -join ",").ToLower()
|
||||
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$Id`?$select" -Method GET
|
||||
}
|
||||
|
||||
function Get-GraphGroupMembers {
|
||||
param([string]$GroupId)
|
||||
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=id,displayName,userPrincipalName"
|
||||
}
|
||||
|
||||
function Get-GraphGroupMembersTransitive {
|
||||
param([string]$GroupId)
|
||||
return Get-GraphPaged -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/transitiveMembers?`$select=id,displayName,userPrincipalName"
|
||||
}
|
||||
|
||||
function Search-GraphUser {
|
||||
param([string]$SearchTerm)
|
||||
$term = $SearchTerm -replace "'", "''"
|
||||
$select = "id,displayName,userPrincipalName,mail,department"
|
||||
|
||||
# Welche Felder durchsucht werden ist konfigurierbar (Settings).
|
||||
$allowed = @('displayName','userPrincipalName','mail','department')
|
||||
$cfgFields = @($script:Settings.userSearch.fields | Where-Object { $_ -in $allowed })
|
||||
if ($cfgFields.Count -eq 0) { $cfgFields = @('displayName','userPrincipalName','mail') }
|
||||
|
||||
# 1) startswith — schnell, deckt Praefix-Tippen ab (90%+ aller Suchen)
|
||||
try {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$parts = @($cfgFields | ForEach-Object { "startswith($_,'$term')" })
|
||||
$filter = $parts -join " or "
|
||||
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
|
||||
$items = @()
|
||||
if ($resp -and $resp.value) { $items = @($resp.value) }
|
||||
$sw.Stop()
|
||||
Write-Host " -> User-Search '$SearchTerm' [$($cfgFields -join ',')]: startswith=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray
|
||||
if ($items.Count -gt 0) { return $items }
|
||||
} catch {
|
||||
Write-Host " -> startswith fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
# 2) Fallback: $search (substring) nur wenn startswith leer war
|
||||
try {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$searchTerm = $SearchTerm -replace '"', ''
|
||||
$parts = @($cfgFields | ForEach-Object { "`"$_`:$searchTerm`"" })
|
||||
$searchExpr = $parts -join " OR "
|
||||
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$top=25&`$search=$([uri]::EscapeDataString($searchExpr))"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" }
|
||||
$items = @()
|
||||
if ($resp -and $resp.value) { $items = @($resp.value) }
|
||||
$sw.Stop()
|
||||
Write-Host " -> User-Search '$SearchTerm': `$search-Fallback=$($items.Count) Treffer in $($sw.ElapsedMilliseconds)ms" -ForegroundColor DarkGray
|
||||
return $items
|
||||
} catch {
|
||||
Write-Host " -> `$search-Fallback fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-GroupNamesBulk {
|
||||
# Loest viele Gruppen-IDs in einem Schwung via directoryObjects/getByIds auf
|
||||
# und schreibt {Id => DisplayName} in die uebergebene Hashtable.
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][object[]]$Ids,
|
||||
[Parameter(Mandatory=$true)][hashtable]$Lookup
|
||||
)
|
||||
if (-not $Ids -or $Ids.Count -eq 0) { return }
|
||||
|
||||
# WICHTIG: zu plain strings casten — sonst wickelt ConvertTo-Json (intern in
|
||||
# Invoke-MgGraphRequest) PSObject-gewrappte Strings in .Chars und crasht mit
|
||||
# "Self referencing loop". Genau das hat den Batch unbrauchbar gemacht.
|
||||
$cleanIds = [string[]]@($Ids | ForEach-Object { [string]$_ } | Where-Object { $_ })
|
||||
|
||||
$batchSize = 800 # API-Limit ist 1000, Puffer fuer Safety
|
||||
for ($i = 0; $i -lt $cleanIds.Count; $i += $batchSize) {
|
||||
$end = [Math]::Min($i + $batchSize - 1, $cleanIds.Count - 1)
|
||||
$chunk = [string[]]$cleanIds[$i..$end]
|
||||
|
||||
try {
|
||||
# JSON manuell serialisieren — vermeidet jeden Wrapper-Spass
|
||||
$bodyJson = @{
|
||||
ids = $chunk
|
||||
types = @("group")
|
||||
} | ConvertTo-Json -Depth 3 -Compress
|
||||
|
||||
$resp = Invoke-MgGraphRequest `
|
||||
-Uri "https://graph.microsoft.com/v1.0/directoryObjects/getByIds" `
|
||||
-Method POST `
|
||||
-Body $bodyJson `
|
||||
-ContentType "application/json"
|
||||
|
||||
if ($resp.value) {
|
||||
foreach ($obj in $resp.value) {
|
||||
if ($obj.id -and $obj.displayName) {
|
||||
$Lookup[[string]$obj.id] = [string]$obj.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host " -> getByIds-Batch fehlgeschlagen ($($chunk.Count) IDs): $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
# Fallback nur fuer den fehlgeschlagenen Batch — und auch nur wenn die ID
|
||||
# noch nicht gesetzt wurde
|
||||
foreach ($id in $chunk) {
|
||||
if ($Lookup.ContainsKey($id)) { continue }
|
||||
try {
|
||||
$g = Get-GraphGroupById -Id $id -Property @("id","displayName")
|
||||
if ($g.displayName) { $Lookup[$id] = [string]$g.displayName }
|
||||
} catch {
|
||||
$Lookup[$id] = "($id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-GraphSecurityGroup {
|
||||
param([string]$DisplayName, [string]$MailNickname)
|
||||
$body = @{
|
||||
displayName = $DisplayName
|
||||
mailEnabled = $false
|
||||
securityEnabled = $true
|
||||
mailNickname = $MailNickname
|
||||
}
|
||||
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups" -Method POST -Body $body
|
||||
}
|
||||
|
||||
function Add-GraphMember {
|
||||
param([string]$GroupId, [string]$DirectoryObjectId)
|
||||
# JSON manuell bauen — vermeidet PSObject-Wrapper-Probleme bei der Serialisierung
|
||||
$bodyJson = '{"@odata.id":"https://graph.microsoft.com/v1.0/directoryObjects/' + $DirectoryObjectId + '"}'
|
||||
# Out-Null suppressed das $null das Invoke-MgGraphRequest zurueckgibt — sonst
|
||||
# landet es in der Pipeline des Aufrufers und korrumpiert die Response.
|
||||
# Ueber den Retry-Wrapper: Member-Adds sind die haeufigste Bulk-Write-Operation
|
||||
# (Session-Apply mit vielen Empfaengern) und damit am throttle-anfaelligsten.
|
||||
$null = Invoke-MgGraphRequestRetry `
|
||||
-Uri "https://graph.microsoft.com/v1.0/groups/$GroupId/members/`$ref" `
|
||||
-Method POST `
|
||||
-Body $bodyJson `
|
||||
-ContentType "application/json"
|
||||
}
|
||||
|
||||
function Get-GraphMobileApps {
|
||||
param([switch]$WithAssignments)
|
||||
# WICHTIG: NUR assignments expandieren. categories zusaetzlich auf der
|
||||
# Vollliste zu expandieren liess Graph hart drosseln (429 -> "Too many
|
||||
# retries"). Kategorien werden separat + throttle-sicher per $batch
|
||||
# nachgeladen (Get-GraphMobileAppCategoriesBatch).
|
||||
$expand = if ($WithAssignments) { "?`$expand=assignments" } else { "" }
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps$expand"
|
||||
return Get-GraphPaged -Uri $uri
|
||||
}
|
||||
|
||||
function Get-GraphMobileAppCategoriesBatch {
|
||||
# Liefert eine Map appId -> @(KategorieNamen) fuer die uebergebenen App-IDs
|
||||
# ueber Microsoft Graph $batch (20 Sub-Requests pro Batch — API-Limit).
|
||||
# Throttle-sicher: schlaegt ein Batch fehl, werden dessen Apps einfach
|
||||
# ohne Kategorien gefuehrt — die App-Liste funktioniert unabhaengig davon.
|
||||
param([string[]]$AppIds)
|
||||
$map = @{}
|
||||
if (-not $AppIds -or $AppIds.Count -eq 0) { return $map }
|
||||
|
||||
$chunkSize = 20
|
||||
for ($i = 0; $i -lt $AppIds.Count; $i += $chunkSize) {
|
||||
$end = [Math]::Min($i + $chunkSize - 1, $AppIds.Count - 1)
|
||||
$slice = $AppIds[$i..$end]
|
||||
$requests = @()
|
||||
foreach ($id in $slice) {
|
||||
$requests += @{ id = $id; method = 'GET'; url = "/deviceAppManagement/mobileApps/$id/categories" }
|
||||
}
|
||||
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
|
||||
try {
|
||||
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
|
||||
foreach ($r in @($resp.responses)) {
|
||||
$rid = [string]$r.id
|
||||
$st = [int]$r.status
|
||||
if ($st -eq 200 -and $r.body -and $r.body.value) {
|
||||
$names = @()
|
||||
foreach ($cat in @($r.body.value)) {
|
||||
$n = $null
|
||||
try { if ($cat.displayName) { $n = [string]$cat.displayName } } catch {}
|
||||
if (-not $n -and $cat -is [System.Collections.IDictionary] -and $cat['displayName']) { $n = [string]$cat['displayName'] }
|
||||
if ($n) { $names += $n }
|
||||
}
|
||||
$map[$rid] = $names
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host " [CATS-BATCH] Batch $i-$end fehlgeschlagen (uebersprungen): $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
function Get-GraphMobileAppDetails {
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
# Liefert das vollstaendige App-Objekt inkl. typspezifischer Felder
|
||||
# (Win32: installCommandLine etc., MSI: productCode, Store: licenseType, ...).
|
||||
return Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId" -Method GET
|
||||
}
|
||||
|
||||
function Add-GraphAppAssignment {
|
||||
param(
|
||||
[string]$AppId,
|
||||
[string]$Intent, # "available" | "required"
|
||||
[string]$GroupId # GUID oder "ALL_USERS" / "ALL_DEVICES"
|
||||
)
|
||||
$target = switch ($GroupId) {
|
||||
"ALL_USERS" { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } }
|
||||
"ALL_DEVICES" { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } }
|
||||
default { @{ "@odata.type" = "#microsoft.graph.groupAssignmentTarget"; "groupId" = $GroupId } }
|
||||
}
|
||||
$body = @{
|
||||
mobileAppAssignments = @(@{
|
||||
"@odata.type" = "#microsoft.graph.mobileAppAssignment"
|
||||
intent = $Intent
|
||||
target = $target
|
||||
settings = $null
|
||||
})
|
||||
}
|
||||
Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body
|
||||
}
|
||||
|
||||
function Get-GraphMobileAppAssignments {
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
$resp = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments" -Method GET
|
||||
if ($resp.value) { return @($resp.value) }
|
||||
return @()
|
||||
}
|
||||
|
||||
function Remove-GraphAppAssignmentByGroup {
|
||||
# Loescht jede Zuweisung der App, deren Target zur uebergebenen Group passt.
|
||||
# Optional kann ein Intent gesetzt werden, dann werden nur Zuweisungen mit
|
||||
# genau diesem Intent (required/available) entfernt.
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$AppId,
|
||||
[Parameter(Mandatory=$true)][string]$GroupId,
|
||||
[string]$Intent
|
||||
)
|
||||
$assignments = Get-GraphMobileAppAssignments -AppId $AppId
|
||||
$deleted = @()
|
||||
foreach ($a in $assignments) {
|
||||
$tgt = $a.target
|
||||
if (-not $tgt) { continue }
|
||||
$tgtType = [string]$tgt.'@odata.type'
|
||||
|
||||
$match = $false
|
||||
switch ($GroupId) {
|
||||
"ALL_USERS" { if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $match = $true } }
|
||||
"ALL_DEVICES" { if ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') { $match = $true } }
|
||||
default {
|
||||
if ($tgtType -eq '#microsoft.graph.groupAssignmentTarget' -and [string]$tgt.groupId -eq $GroupId) { $match = $true }
|
||||
}
|
||||
}
|
||||
if (-not $match) { continue }
|
||||
if ($Intent -and ([string]$a.intent) -ne $Intent) { continue }
|
||||
|
||||
$null = Invoke-MgGraphRequest `
|
||||
-Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/assignments/$($a.id)" `
|
||||
-Method DELETE
|
||||
$deleted += [pscustomobject]@{
|
||||
AssignmentId = [string]$a.id
|
||||
Intent = [string]$a.intent
|
||||
TargetType = $tgtType
|
||||
}
|
||||
}
|
||||
return $deleted
|
||||
}
|
||||
|
||||
function Update-GraphMobileApp {
|
||||
# PATCH auf eine MobileApp. Wichtig: '@odata.type' MUSS im Body sein, sonst
|
||||
# gibt Graph "ResourceNotSupported" / 400 zurueck (polymorpher Typ).
|
||||
# Versucht beta, faellt bei 400/404/405 auf v1.0 zurueck.
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$AppId,
|
||||
[Parameter(Mandatory=$true)][string]$AppTypeRaw, # z.B. '#microsoft.graph.win32LobApp'
|
||||
[Parameter(Mandatory=$true)][hashtable]$Patch # z.B. @{ displayName = 'neuer Name' }
|
||||
)
|
||||
|
||||
$body = [ordered]@{}
|
||||
$body['@odata.type'] = $AppTypeRaw
|
||||
foreach ($k in $Patch.Keys) { $body[$k] = $Patch[$k] }
|
||||
$json = $body | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
$endpoints = @(
|
||||
"https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId",
|
||||
"https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId"
|
||||
)
|
||||
$lastErr = $null
|
||||
foreach ($uri in $endpoints) {
|
||||
try {
|
||||
$null = Invoke-MgGraphRequest -Uri $uri -Method PATCH -Body $json -ContentType "application/json"
|
||||
return
|
||||
} catch {
|
||||
$lastErr = $_
|
||||
$msg = "$($_.Exception.Message)"
|
||||
try { $msg += " " + $_.ErrorDetails.Message } catch {}
|
||||
if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') { continue }
|
||||
throw
|
||||
}
|
||||
}
|
||||
if ($lastErr) {
|
||||
$body = $null
|
||||
try { $body = $lastErr.ErrorDetails.Message } catch {}
|
||||
$detail = if ($body) { " | Graph-Response: $body" } else { "" }
|
||||
throw [System.Exception]::new("PATCH fehlgeschlagen: $($lastErr.Exception.Message)$detail")
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Content-Update: neue Setup-Datei in eine native App hochladen
|
||||
# (Phase 1: .intunewin / win32LobApp — vorverschluesselt)
|
||||
# ============================================================
|
||||
|
||||
function Read-IntuneWinPackage {
|
||||
# Entpackt eine .intunewin (ZIP) und liest die bereits verschluesselte
|
||||
# Innen-Datei + die EncryptionInfo aus Metadata/Detection.xml. Es wird
|
||||
# NICHT neu verschluesselt — IntuneWinAppUtil hat das schon getan.
|
||||
param([Parameter(Mandatory=$true)][string]$Path)
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($Path)
|
||||
try {
|
||||
$detEntry = $archive.Entries | Where-Object { $_.FullName -match 'IntuneWinPackage/Metadata/Detection\.xml$' } | Select-Object -First 1
|
||||
if (-not $detEntry) { throw "Detection.xml nicht im .intunewin gefunden — ist die Datei wirklich ein IntuneWinAppUtil-Paket?" }
|
||||
|
||||
# Detection.xml lesen
|
||||
$sr = New-Object System.IO.StreamReader($detEntry.Open())
|
||||
$xmlText = $sr.ReadToEnd(); $sr.Close()
|
||||
[xml]$xml = $xmlText
|
||||
$info = $xml.ApplicationInfo
|
||||
$enc = $info.EncryptionInfo
|
||||
if (-not $enc) { throw "EncryptionInfo fehlt in Detection.xml" }
|
||||
|
||||
$fileName = [string]$info.FileName # innerer Dateiname (z.B. setup.intunewin)
|
||||
$unencSize = [int64]$info.UnencryptedContentSize
|
||||
|
||||
# Innere, bereits verschluesselte Datei extrahieren
|
||||
$contentEntry = $archive.Entries | Where-Object { $_.FullName -match "IntuneWinPackage/Contents/.+" } | Select-Object -First 1
|
||||
if (-not $contentEntry) { throw "Verschluesselte Innen-Datei (Contents/) nicht gefunden" }
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$cs = $contentEntry.Open()
|
||||
$cs.CopyTo($ms); $cs.Close()
|
||||
$encryptedBytes = $ms.ToArray(); $ms.Dispose()
|
||||
|
||||
return @{
|
||||
EncryptedBytes = $encryptedBytes
|
||||
Size = $unencSize
|
||||
SizeEncrypted = [int64]$encryptedBytes.Length
|
||||
FileName = $fileName
|
||||
EncryptionInfo = @{
|
||||
'@odata.type' = '#microsoft.graph.fileEncryptionInfo'
|
||||
encryptionKey = [string]$enc.EncryptionKey
|
||||
macKey = [string]$enc.MacKey
|
||||
initializationVector = [string]$enc.InitializationVector
|
||||
mac = [string]$enc.Mac
|
||||
profileIdentifier = [string]$enc.ProfileIdentifier
|
||||
fileDigest = [string]$enc.FileDigest
|
||||
fileDigestAlgorithm = [string]$enc.FileDigestAlgorithm
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function New-GraphAppContentVersion {
|
||||
# Schritt 1: neue contentVersion anlegen. GraphType z.B. 'win32LobApp'.
|
||||
param([string]$AppId, [string]$GraphType)
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions"
|
||||
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body '{}' -ContentType 'application/json'
|
||||
$cvId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id }
|
||||
if (-not $cvId) { throw "contentVersion-Anlage lieferte keine id zurueck" }
|
||||
return $cvId
|
||||
}
|
||||
|
||||
function New-GraphAppContentFile {
|
||||
# Schritt 2: File-Eintrag in der contentVersion anlegen (mit Klartext-Size +
|
||||
# verschluesselter Size). Liefert fileId.
|
||||
param(
|
||||
[string]$AppId, [string]$GraphType, [string]$CvId,
|
||||
[string]$Name, [int64]$Size, [int64]$SizeEncrypted, $Manifest = $null
|
||||
)
|
||||
$body = [ordered]@{
|
||||
'@odata.type' = '#microsoft.graph.mobileAppContentFile'
|
||||
name = $Name
|
||||
size = $Size
|
||||
sizeEncrypted = $SizeEncrypted
|
||||
isDependency = $false
|
||||
}
|
||||
if ($Manifest) { $body['manifest'] = $Manifest } # base64 (MSI/MSIX); win32 = weglassen
|
||||
$json = $body | ConvertTo-Json -Depth 5 -Compress
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files"
|
||||
$resp = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $json -ContentType 'application/json'
|
||||
$fileId = if ($resp.id) { [string]$resp.id } else { [string]$resp.Id }
|
||||
if (-not $fileId) { throw "File-Anlage lieferte keine id zurueck" }
|
||||
return $fileId
|
||||
}
|
||||
|
||||
function Get-GraphAppContentFile {
|
||||
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId)
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId"
|
||||
return Invoke-MgGraphRequestRetry -Uri $uri -Method GET
|
||||
}
|
||||
|
||||
function Wait-GraphContentFileState {
|
||||
# Schritt 3/6: pollt den File-Eintrag bis TargetState erreicht ist.
|
||||
# Bricht bei *Failed-Zustaenden und Timeout ab.
|
||||
param(
|
||||
[string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId,
|
||||
[string]$TargetState, [int]$TimeoutSec = 180
|
||||
)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ($true) {
|
||||
$f = Get-GraphAppContentFile -AppId $AppId -GraphType $GraphType -CvId $CvId -FileId $FileId
|
||||
$state = if ($f.uploadState) { [string]$f.uploadState } else { [string]$f.uploadstate }
|
||||
if ($state -eq $TargetState) { return $f }
|
||||
if ($state -match 'Failed') { throw "Upload-State '$state' (erwartet '$TargetState') — Graph hat den Schritt abgelehnt." }
|
||||
if ((Get-Date) -gt $deadline) { throw "Timeout beim Warten auf '$TargetState' (letzter State: '$state')." }
|
||||
Start-Sleep -Milliseconds 1500
|
||||
}
|
||||
}
|
||||
|
||||
function Send-GraphContentToAzureBlob {
|
||||
# Schritt 4: verschluesselte Bytes als Block-Blob in die SAS-URL laden.
|
||||
# Chunked (6 MB). WICHTIG: die Bytes werden als ISO-8859-1-String gesendet
|
||||
# (1:1 Byte<->Zeichen) mit content-type 'text/plain; charset=iso-8859-1' —
|
||||
# NICHT als rohes byte[]. Invoke-WebRequest verfaelscht in PS 5.1 binaere
|
||||
# byte[]-Bodies (Bytes > 127), was korrupten Content nach Azure laedt.
|
||||
# Schema 1:1 aus der MSEndpointMgr/IntuneWin32App-Referenz uebernommen.
|
||||
param([Parameter(Mandatory=$true)][string]$SasUri, [Parameter(Mandatory=$true)][byte[]]$Bytes)
|
||||
|
||||
$isoEncoding = [System.Text.Encoding]::GetEncoding("iso-8859-1")
|
||||
$chunkSize = 6 * 1024 * 1024
|
||||
$total = $Bytes.Length
|
||||
$blockIds = New-Object System.Collections.Generic.List[string]
|
||||
$idx = 0
|
||||
for ($offset = 0; $offset -lt $total; $offset += $chunkSize) {
|
||||
$len = [Math]::Min($chunkSize, $total - $offset)
|
||||
$chunk = New-Object byte[] $len
|
||||
[Array]::Copy($Bytes, $offset, $chunk, 0, $len)
|
||||
# Block-ID: base64 einer 0-gepaddeten Nummer (gleiche Laenge fuer alle)
|
||||
$blockId = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($idx.ToString('D6')))
|
||||
$blockIds.Add($blockId)
|
||||
$encodedChunk = $isoEncoding.GetString($chunk)
|
||||
$putUri = "$SasUri&comp=block&blockid=$([uri]::EscapeDataString($blockId))"
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'content-type' = 'text/plain; charset=iso-8859-1' }
|
||||
Invoke-WebRequest -Uri $putUri -Method PUT -Headers $headers -Body $encodedChunk -UseBasicParsing | Out-Null
|
||||
$idx++
|
||||
}
|
||||
# Block-Liste committen (Invoke-RestMethod, content-type text/plain; charset=UTF-8)
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><BlockList>'
|
||||
foreach ($b in $blockIds) { $xml += "<Latest>$b</Latest>" }
|
||||
$xml += '</BlockList>'
|
||||
$listUri = "$SasUri&comp=blocklist"
|
||||
Invoke-RestMethod -Uri $listUri -Method PUT -Body $xml -Headers @{ 'content-type' = 'text/plain; charset=UTF-8' } | Out-Null
|
||||
}
|
||||
|
||||
function Invoke-GraphContentRenewUpload {
|
||||
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId)
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/renewUpload"
|
||||
$null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST
|
||||
}
|
||||
|
||||
function Invoke-GraphContentCommit {
|
||||
# Schritt 5: commit mit fileEncryptionInfo.
|
||||
param([string]$AppId, [string]$GraphType, [string]$CvId, [string]$FileId, [hashtable]$EncryptionInfo)
|
||||
$body = @{ fileEncryptionInfo = $EncryptionInfo } | ConvertTo-Json -Depth 5 -Compress
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/$GraphType/contentVersions/$CvId/files/$FileId/commit"
|
||||
$null = Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $body -ContentType 'application/json'
|
||||
}
|
||||
|
||||
function Update-GraphAppContent {
|
||||
# Orchestrator: laedt eine neue Setup-Datei hoch und aktiviert sie.
|
||||
# Phase 1 unterstuetzt .intunewin (win32LobApp). $ProgressCb (scriptblock)
|
||||
# optional fuer Schritt-Logging. Gibt das Ergebnis-Objekt zurueck.
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$AppId,
|
||||
[Parameter(Mandatory=$true)][string]$GraphTypeRaw, # z.B. '#microsoft.graph.win32LobApp'
|
||||
[Parameter(Mandatory=$true)][string]$FilePath,
|
||||
[string]$DisplayVersion,
|
||||
[scriptblock]$ProgressCb
|
||||
)
|
||||
$report = { param($step) if ($ProgressCb) { & $ProgressCb $step } ; Write-Host " [CONTENT] $step" -ForegroundColor DarkCyan }
|
||||
|
||||
$graphType = ($GraphTypeRaw -replace '^#microsoft\.graph\.', '') # win32LobApp
|
||||
$odataCast = "graph.$graphType"
|
||||
|
||||
# 1) Paket lesen (Phase 1: nur .intunewin)
|
||||
& $report "Lese Paket"
|
||||
$ext = [IO.Path]::GetExtension($FilePath).ToLower()
|
||||
if ($ext -ne '.intunewin') { throw "Phase 1 unterstuetzt nur .intunewin. Datei: $ext" }
|
||||
$pkg = Read-IntuneWinPackage -Path $FilePath
|
||||
|
||||
# 2) contentVersion + file anlegen
|
||||
& $report "Lege Content-Version an"
|
||||
$cvId = New-GraphAppContentVersion -AppId $AppId -GraphType $odataCast
|
||||
$fileId = New-GraphAppContentFile -AppId $AppId -GraphType $odataCast -CvId $cvId -Name $pkg.FileName -Size $pkg.Size -SizeEncrypted $pkg.SizeEncrypted
|
||||
|
||||
# 3) auf SAS warten
|
||||
& $report "Warte auf Azure-Speicher-URL"
|
||||
$f = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'azureStorageUriRequestSuccess' -TimeoutSec 120
|
||||
$sas = if ($f.azureStorageUri) { [string]$f.azureStorageUri } else { [string]$f.azurestorageuri }
|
||||
if (-not $sas) { throw "Keine azureStorageUri erhalten" }
|
||||
|
||||
# 4) Upload zu Azure Blob
|
||||
& $report "Lade Datei hoch ($([int]($pkg.SizeEncrypted/1MB)) MB)"
|
||||
Send-GraphContentToAzureBlob -SasUri $sas -Bytes $pkg.EncryptedBytes
|
||||
|
||||
# 5) commit
|
||||
& $report "Committe Datei"
|
||||
Invoke-GraphContentCommit -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -EncryptionInfo $pkg.EncryptionInfo
|
||||
|
||||
# 6) auf commit-Erfolg warten
|
||||
& $report "Warte auf Commit-Bestaetigung"
|
||||
$null = Wait-GraphContentFileState -AppId $AppId -GraphType $odataCast -CvId $cvId -FileId $fileId -TargetState 'commitFileSuccess' -TimeoutSec 180
|
||||
|
||||
# 7) App auf neue Version zeigen (+ optional displayVersion)
|
||||
& $report "Aktiviere neue Version"
|
||||
$patch = @{ committedContentVersion = $cvId }
|
||||
if ($DisplayVersion) { $patch['displayVersion'] = $DisplayVersion }
|
||||
Update-GraphMobileApp -AppId $AppId -AppTypeRaw $GraphTypeRaw -Patch $patch
|
||||
|
||||
& $report "Fertig"
|
||||
return @{ ok = $true; contentVersion = $cvId; fileId = $fileId; size = $pkg.Size; sizeEncrypted = $pkg.SizeEncrypted; displayVersion = $DisplayVersion }
|
||||
}
|
||||
|
||||
function Get-GraphAppDetailsBatch {
|
||||
# Holt App-Stammdaten + installSummary + relationships in EINEM
|
||||
# HTTP-Roundtrip via Microsoft Graph $batch. Server-seitig parallelisiert
|
||||
# — statt 3 sequenzieller Calls (jeder ~300-800ms) nur ein einziger
|
||||
# Roundtrip in der Zeit des langsamsten Sub-Calls.
|
||||
# Falls ein Sub-Call serverseitig fehlschlaegt, wird der jeweilige
|
||||
# Teil $null gesetzt — der Rest funktioniert weiter.
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
|
||||
$body = @{
|
||||
requests = @(
|
||||
@{ id = 'app'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$($AppId)?`$expand=categories" },
|
||||
@{ id = 'summary'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/installSummary" },
|
||||
@{ id = 'rels'; method = 'GET'; url = "/deviceAppManagement/mobileApps/$AppId/relationships" }
|
||||
)
|
||||
}
|
||||
$json = $body | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$resp = $null
|
||||
try {
|
||||
$resp = Invoke-MgGraphRequest `
|
||||
-Uri 'https://graph.microsoft.com/beta/$batch' `
|
||||
-Method POST `
|
||||
-Body $json `
|
||||
-ContentType 'application/json'
|
||||
} catch {
|
||||
Write-Host " [BATCH] $AppId fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
return @{ App = $null; InstallSummary = $null; Relationships = @() }
|
||||
}
|
||||
$sw.Stop()
|
||||
|
||||
$result = @{ App = $null; InstallSummary = $null; Relationships = @() }
|
||||
$stati = @()
|
||||
foreach ($r in @($resp.responses)) {
|
||||
$st = [int]$r.status
|
||||
$id = [string]$r.id
|
||||
$stati += "$id=$st"
|
||||
switch ($id) {
|
||||
'app' {
|
||||
if ($st -eq 200) { $result.App = $r.body }
|
||||
}
|
||||
'summary' {
|
||||
if ($st -eq 200) { $result.InstallSummary = $r.body }
|
||||
}
|
||||
'rels' {
|
||||
if ($st -eq 200 -and $r.body.value) {
|
||||
$result.Relationships = @($r.body.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host " [BATCH] $AppId in $($sw.ElapsedMilliseconds)ms ($($stati -join ', '))" -ForegroundColor DarkGray
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-GraphMobileAppInstallSummary {
|
||||
# Installations-Statistik einer App. Endpoint ist Singular ("installSummary"),
|
||||
# nicht zu verwechseln mit deviceStatuses/userStatuses (Per-Device/Per-User).
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
try {
|
||||
return Invoke-MgGraphRequest `
|
||||
-Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/installSummary" `
|
||||
-Method GET
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GraphMobileAppRelationships {
|
||||
# Liefert die Beziehungen einer App (Dependency / Supersedence). 400-Fehler
|
||||
# beim Delete kommen oft daher, dass eine andere App diese App als Dependency
|
||||
# oder Supersedence referenziert - dann muss erst die Gegenseite entfernt werden.
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
try {
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId/relationships"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
|
||||
$items = @()
|
||||
if ($resp.value) { $items = @($resp.value) }
|
||||
Write-Host " [RELS] $AppId -> $($items.Count) Eintraege via /relationships" -ForegroundColor DarkGray
|
||||
if ($items.Count -gt 0) {
|
||||
$byType = $items | Group-Object { [string]$_.'@odata.type' } | ForEach-Object { "$($_.Name)=$($_.Count)" }
|
||||
Write-Host " [RELS] Typen: $($byType -join ', ')" -ForegroundColor DarkGray
|
||||
}
|
||||
return $items
|
||||
} catch {
|
||||
Write-Host " [RELS] Lesen fehlgeschlagen fuer $AppId : $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-GraphMobileApp {
|
||||
# Versucht die App ueber beta zu loeschen; fallback auf v1.0 falls beta 400/404 gibt.
|
||||
# Wirft eine angereicherte Exception mit der Original-Graph-Fehlermeldung.
|
||||
param([Parameter(Mandatory=$true)][string]$AppId)
|
||||
|
||||
$endpoints = @(
|
||||
"https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$AppId",
|
||||
"https://graph.microsoft.com/v1.0/deviceAppManagement/mobileApps/$AppId"
|
||||
)
|
||||
$lastErr = $null
|
||||
foreach ($uri in $endpoints) {
|
||||
try {
|
||||
$null = Invoke-MgGraphRequest -Uri $uri -Method DELETE
|
||||
return # success
|
||||
} catch {
|
||||
$lastErr = $_
|
||||
# ErrorDetails.Message ist meist der Raw-JSON-Body von Graph
|
||||
$body = $null
|
||||
try { $body = $_.ErrorDetails.Message } catch {}
|
||||
# 405/Resource-not-found auf beta -> v1.0 probieren. Bei 400 ebenfalls
|
||||
# einen Versuch wagen, das ist im Test mit win32LobApp gelegentlich noetig.
|
||||
$msg = "$($_.Exception.Message) $body"
|
||||
if ($msg -match '404|400|405|MethodNotAllowed|NotSupported') {
|
||||
continue
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
# Beide Endpoints haben versagt -> letzte Fehlermeldung anreichern und werfen
|
||||
if ($lastErr) {
|
||||
$body = $null
|
||||
try { $body = $lastErr.ErrorDetails.Message } catch {}
|
||||
$detail = if ($body) { " | Graph-Response: $body" } else { "" }
|
||||
throw [System.Exception]::new("Delete fehlgeschlagen: $($lastErr.Exception.Message)$detail")
|
||||
}
|
||||
}
|
||||
|
||||
# Excluded App-Typen aus dem Original-Script
|
||||
$script:ExcludedAppTypes = @(
|
||||
'#microsoft.graph.iosLobApp', '#microsoft.graph.iosStoreApp', '#microsoft.graph.iosVppApp',
|
||||
'#microsoft.graph.managedIOSLobApp', '#microsoft.graph.managedIOSStoreApp',
|
||||
'#microsoft.graph.androidLobApp', '#microsoft.graph.androidStoreApp', '#microsoft.graph.androidForWorkApp',
|
||||
'#microsoft.graph.androidManagedStoreApp', '#microsoft.graph.androidManagedStoreWebApp',
|
||||
'#microsoft.graph.managedAndroidLobApp', '#microsoft.graph.managedAndroidStoreApp',
|
||||
'#microsoft.graph.macOSDmgApp', '#microsoft.graph.macOSLobApp', '#microsoft.graph.macOSMicrosoftDefenderApp',
|
||||
'#microsoft.graph.macOSMicrosoftEdgeApp', '#microsoft.graph.macOSOfficeSuiteApp', '#microsoft.graph.macOSPkgApp',
|
||||
'#microsoft.graph.macOsVppApp'
|
||||
)
|
||||
|
||||
function Test-AppTypeAllowed {
|
||||
param([string]$Type)
|
||||
return -not ($script:ExcludedAppTypes -contains $Type)
|
||||
}
|
||||
|
||||
function ConvertTo-AppFriendlyType {
|
||||
param([string]$Type)
|
||||
switch ($Type) {
|
||||
'#microsoft.graph.win32LobApp' { 'Win32' }
|
||||
'#microsoft.graph.windowsStoreApp' { 'Store' }
|
||||
'#microsoft.graph.microsoftStoreForBusinessApp' { 'MS Store' }
|
||||
'#microsoft.graph.officeSuiteApp' { 'M365 Apps' }
|
||||
'#microsoft.graph.windowsMicrosoftEdgeApp' { 'Edge' }
|
||||
'#microsoft.graph.windowsWebApp' { 'WebApp' }
|
||||
'#microsoft.graph.winGetApp' { 'WinGet' }
|
||||
'#microsoft.graph.windowsPhone81AppX' { 'APPX' }
|
||||
'#microsoft.graph.windowsAppX' { 'APPX' }
|
||||
'#microsoft.graph.windowsUniversalAppX' { 'APPX' }
|
||||
'#microsoft.graph.windowsMobileMSI' { 'MSI' }
|
||||
default {
|
||||
($Type -replace '#microsoft\.graph\.','')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GraphErrorFriendly {
|
||||
param([Parameter(Mandatory=$true)]$ErrorRecord)
|
||||
$msg = $ErrorRecord.Exception.Message
|
||||
switch -Regex ($msg) {
|
||||
"already exist|bereits vorhanden|One or more added object references already exist" {
|
||||
return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg }
|
||||
}
|
||||
"Insufficient privileges|Access denied|Authorization_RequestDenied|403" {
|
||||
return @{ Code="Forbidden(403)"; Friendly="Keine Berechtigung. Erforderlich: Group.ReadWrite.All / DeviceManagementApps.ReadWrite.All"; IsWarning=$false; Full=$msg }
|
||||
}
|
||||
"does not exist|not found|Request_ResourceNotFound|404" {
|
||||
return @{ Code="NotFound(404)"; Friendly="Objekt nicht gefunden"; IsWarning=$false; Full=$msg }
|
||||
}
|
||||
"Too many requests|throttled|429" {
|
||||
return @{ Code="Throttled(429)"; Friendly="Zu viele Anfragen. Bitte warten."; IsWarning=$false; Full=$msg }
|
||||
}
|
||||
"BadRequest|400" {
|
||||
if ($msg -match "exist|vorhanden|member") {
|
||||
return @{ Code="MemberExists"; Friendly="Mitglied bereits in Gruppe"; IsWarning=$true; Full=$msg }
|
||||
}
|
||||
return @{ Code="BadRequest(400)"; Friendly="Ungueltige Anfrage"; IsWarning=$false; Full=$msg }
|
||||
}
|
||||
default {
|
||||
return @{ Code="Error"; Friendly=$msg; IsWarning=$false; Full=$msg }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-AppVendorRule {
|
||||
# True wenn die App den angegebenen Detection-Regel-Block matcht.
|
||||
param($RawApp, $Rule)
|
||||
if (-not $Rule -or -not $Rule.field -or -not $Rule.pattern) { return $false }
|
||||
$value = switch ($Rule.field) {
|
||||
'commandLine' { @($RawApp.installCommandLine, $RawApp.uninstallCommandLine) -join " " }
|
||||
'developer' { [string]$RawApp.developer }
|
||||
'publisher' { [string]$RawApp.publisher }
|
||||
'displayName' { [string]$RawApp.displayName }
|
||||
'notes' { [string]$RawApp.notes }
|
||||
'owner' { [string]$RawApp.owner }
|
||||
default { "" }
|
||||
}
|
||||
if (-not $value) { return $false }
|
||||
switch ($Rule.match) {
|
||||
'equals' { return ($value -eq $Rule.pattern) }
|
||||
'startsWith' { return $value.StartsWith($Rule.pattern, [StringComparison]::OrdinalIgnoreCase) }
|
||||
'regex' {
|
||||
try { return ($value -match $Rule.pattern) }
|
||||
catch { return $false } # ungueltiger Regex -> Vendor matcht halt nicht
|
||||
}
|
||||
default { return ($value -match [regex]::Escape($Rule.pattern)) } # 'contains'
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-AppVendorSource {
|
||||
# Iteriert ueber Settings.vendors und liefert displayName des ersten
|
||||
# passenden Vendors. Default: "Intune" wenn keiner matcht.
|
||||
param($RawApp, $Vendors)
|
||||
if ($null -eq $Vendors) { return "Intune" }
|
||||
foreach ($v in @($Vendors)) {
|
||||
if ($v -and (Test-AppVendorRule -RawApp $RawApp -Rule $v.detection)) {
|
||||
return [string]$v.displayName
|
||||
}
|
||||
}
|
||||
return "Intune"
|
||||
}
|
||||
|
||||
function Format-AppForFrontend {
|
||||
param($RawApp, $AllGroupsLookup)
|
||||
# AllGroupsLookup: hashtable groupId -> displayName (fuer Anzeige der zugewiesenen Gruppen)
|
||||
$available = @()
|
||||
$required = @()
|
||||
foreach ($a in @($RawApp.assignments)) {
|
||||
$tgt = $a.target
|
||||
$tgtType = $tgt.'@odata.type'
|
||||
$entry = $null
|
||||
if ($tgtType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') {
|
||||
$entry = @{ GroupId="ALL_USERS"; GroupName="All Users"; IsNative=$true }
|
||||
} elseif ($tgtType -eq '#microsoft.graph.allDevicesAssignmentTarget') {
|
||||
$entry = @{ GroupId="ALL_DEVICES"; GroupName="All Devices"; IsNative=$true }
|
||||
} elseif ($tgtType -eq '#microsoft.graph.groupAssignmentTarget') {
|
||||
$gid = $tgt.groupId
|
||||
$name = if ($AllGroupsLookup -and $AllGroupsLookup.ContainsKey($gid)) { $AllGroupsLookup[$gid] } else { $gid }
|
||||
$entry = @{ GroupId=$gid; GroupName=$name; IsNative=$false }
|
||||
} elseif ($tgtType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') {
|
||||
continue
|
||||
}
|
||||
if ($null -ne $entry) {
|
||||
switch ($a.intent) {
|
||||
'available' { $available += $entry }
|
||||
'required' { $required += $entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
# Version aus moeglichen Feldern (je nach App-Typ)
|
||||
$version = $null
|
||||
foreach ($f in @('displayVersion','version','productVersion','identityVersion','officeSuiteAppVersion')) {
|
||||
if ($RawApp.$f) { $version = [string]$RawApp.$f; break }
|
||||
}
|
||||
|
||||
# Quelle: PMPC-Direkterkennung zuerst (das hat schon immer funktioniert,
|
||||
# bleibt damit auch bei kaputter Settings/Vendor-Registry zuverlaessig).
|
||||
# Erst wenn das nicht greift, fragen wir die Vendor-Registry — dort liegen
|
||||
# Robopack & weitere konfigurierbare Vendoren.
|
||||
$source = "Intune"
|
||||
if ($RawApp.installCommandLine -and $RawApp.installCommandLine -match "PatchMyPC") {
|
||||
$source = "PatchMyPC"
|
||||
} elseif ($RawApp.uninstallCommandLine -and $RawApp.uninstallCommandLine -match "PatchMyPC") {
|
||||
$source = "PatchMyPC"
|
||||
} else {
|
||||
$vendorSource = Resolve-AppVendorSource -RawApp $RawApp -Vendors $script:Settings.vendors
|
||||
if ($vendorSource -and $vendorSource -ne "Intune" -and $vendorSource -ne "PatchMyPC") {
|
||||
$source = $vendorSource
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
AppId = $RawApp.id
|
||||
AppName = $RawApp.displayName
|
||||
AppType = ConvertTo-AppFriendlyType -Type $RawApp.'@odata.type'
|
||||
AppTypeRaw = $RawApp.'@odata.type'
|
||||
Publisher = $RawApp.publisher
|
||||
Version = $version
|
||||
Source = $source
|
||||
IsAssigned = ($RawApp.isAssigned -eq $true)
|
||||
AvailableGroups = $available
|
||||
RequiredGroups = $required
|
||||
AvailableCount = $available.Count
|
||||
RequiredCount = $required.Count
|
||||
# Kategorien fuer Filter + Anzeige. Get-AppCategoryNames liegt in Api.ps1,
|
||||
# ist aber global dot-sourced. Liefert immer ein String-Array (ggf. leer).
|
||||
Categories = (Get-AppCategoryNames -RawCategories $RawApp.categories -AppId $RawApp.id)
|
||||
# ISO-Strings damit JS new Date() es zuverlaessig parst — fuer Filter
|
||||
# nach Aenderungs-Zeitraum (App-Liste, ohne Detail-Roundtrip).
|
||||
CreatedDateTime = ConvertTo-IsoDate $RawApp.createdDateTime
|
||||
LastModifiedDateTime = ConvertTo-IsoDate $RawApp.lastModifiedDateTime
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
# 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 = ""
|
||||
# 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")
|
||||
}
|
||||
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 muss eine GUID sein."
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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