1221 lines
55 KiB
PowerShell
1221 lines
55 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Filer Manager - analyse des dossiers Windows et rapporte les tailles des
|
|
dossiers, les chemins trop longs et les permissions NTFS, avec un export HTML.
|
|
|
|
.DESCRIPTION
|
|
S'exécute avec une interface graphique WinForms par défaut. Peut aussi
|
|
s'exécuter sans interface (mode headless) pour la planification :
|
|
|
|
.\filer-manager.ps1 -Path "D:\Shares\Public" -Output report.html -NoGui
|
|
|
|
Fonctionne sur Windows PowerShell 5.1 et PowerShell 7+.
|
|
|
|
.PARAMETER Path
|
|
Un ou plusieurs dossiers racines à analyser. S'il est fourni avec -Output (ou
|
|
avec -NoGui), le script s'exécute sans interface et écrit un rapport HTML.
|
|
|
|
.PARAMETER Output
|
|
Chemin du rapport HTML à écrire en mode headless.
|
|
|
|
.PARAMETER MaxPathLength
|
|
Longueur de chemin (caractères) à partir de laquelle un élément est signalé
|
|
comme « trop long ». Par défaut 260 (Windows MAX_PATH).
|
|
|
|
.PARAMETER PermissionDepth
|
|
Nombre de niveaux de dossiers sous chaque racine pour lesquels collecter les
|
|
permissions NTFS. 0 = racines uniquement, 1 = racines + enfants immédiats
|
|
(par défaut), etc.
|
|
|
|
.PARAMETER IncludeFilesInTree
|
|
Inclure les fichiers individuels (pas seulement les dossiers) dans
|
|
l'arborescence des tailles.
|
|
|
|
.PARAMETER GrantAccess
|
|
Lorsqu'un dossier/fichier ne peut pas être lu (accès refusé), s'approprie
|
|
automatiquement la propriété pour Administrators et accorde le FullControl à
|
|
BUILTIN\Administrators, puis réessaye. Nécessite une exécution en mode élevé.
|
|
Chaque modification est consignée dans le rapport.
|
|
|
|
.PARAMETER KeepGrants
|
|
Conserver l'accès accordé par -GrantAccess après l'analyse. Par défaut, l'outil
|
|
annule chaque modification une fois l'analyse terminée (en restaurant le
|
|
propriétaire et l'ACL d'origine là où ils ont pu être lus au préalable).
|
|
|
|
.PARAMETER NoGui
|
|
Forcer le mode sans interface (nécessite -Path et -Output).
|
|
|
|
.PARAMETER Category
|
|
Catégories de rapport à inclure dans le HTML. Par défaut 'All'. Passez toute
|
|
combinaison de : Tree (tailles des dossiers), LongPaths, Permissions, Grants
|
|
(journal des accès accordés), Errors. À utiliser pour garder de gros rapports
|
|
petits, p. ex. -Category Permissions ou -Category Tree,LongPaths.
|
|
|
|
.EXAMPLE
|
|
.\filer-manager.ps1
|
|
Lance l'interface graphique.
|
|
|
|
.EXAMPLE
|
|
.\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui
|
|
|
|
.EXAMPLE
|
|
.\filer-manager.ps1 -Path "D:\Shares" -Output filer.html -NoGui -GrantAccess
|
|
Analyse D:\Shares en accordant l'accès Administrators aux éléments refusés afin
|
|
que l'analyse puisse se terminer, puis annule ces modifications ensuite.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string[]]$Path,
|
|
[string]$Output,
|
|
[int]$MaxPathLength = 260,
|
|
[int]$PermissionDepth = 1,
|
|
[switch]$IncludeFilesInTree,
|
|
[switch]$GrantAccess,
|
|
[switch]$KeepGrants,
|
|
[switch]$NoGui,
|
|
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
|
|
[string[]]$Category = @('All')
|
|
)
|
|
|
|
# ============================================================================
|
|
# CORE (GUI-independent). Kept as a string so it can be dot-sourced both here
|
|
# and inside a background runspace used by the GUI to stay responsive.
|
|
# ============================================================================
|
|
$CoreFunctions = @'
|
|
function Format-Bytes {
|
|
param([long]$Bytes)
|
|
if ($Bytes -ge 1TB) { '{0:N2} TB' -f ($Bytes / 1TB) }
|
|
elseif ($Bytes -ge 1GB) { '{0:N2} GB' -f ($Bytes / 1GB) }
|
|
elseif ($Bytes -ge 1MB) { '{0:N2} MB' -f ($Bytes / 1MB) }
|
|
elseif ($Bytes -ge 1KB) { '{0:N2} KB' -f ($Bytes / 1KB) }
|
|
else { "$Bytes B" }
|
|
}
|
|
|
|
function Test-IsElevated {
|
|
# True when the current process is running with Administrator rights.
|
|
try {
|
|
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$pr = New-Object System.Security.Principal.WindowsPrincipal($id)
|
|
return $pr.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
} catch { return $false }
|
|
}
|
|
|
|
function Test-IsAccessDenied {
|
|
# Recognise an "access denied" failure across locales and PowerShell hosts.
|
|
param($ErrorRecord)
|
|
$ex = $ErrorRecord.Exception
|
|
if ($ex -is [System.UnauthorizedAccessException]) { return $true }
|
|
if ($ErrorRecord.CategoryInfo -and $ErrorRecord.CategoryInfo.Category -eq 'PermissionDenied') { return $true }
|
|
return $false
|
|
}
|
|
|
|
function Grant-AdminAccess {
|
|
<# Seizes ownership for Administrators and grants BUILTIN\Administrators
|
|
FullControl on a single item (no recursion), so a denied path can be
|
|
scanned. Captures the original owner/ACL first (when readable) so the
|
|
change can be reverted later. Records every attempt in $Grants. Returns
|
|
$true when something was changed (so the caller can retry). #>
|
|
param(
|
|
[string]$Path,
|
|
[string]$Reason,
|
|
[System.Collections.ArrayList]$Grants,
|
|
[System.Collections.ArrayList]$ScanErrors
|
|
)
|
|
|
|
# BUILTIN\Administrators well-known SID - locale-independent (the group is
|
|
# named differently on non-English systems).
|
|
$adminSid = 'S-1-5-32-544'
|
|
|
|
# Don't act twice on the same path.
|
|
foreach ($g in $Grants) { if ($g.Path -eq $Path) { return $g.Success } }
|
|
|
|
# Capture the original state for a possible revert (may be unreadable).
|
|
$origSddl = $null; $origOwner = $null
|
|
try {
|
|
$a = Get-Acl -LiteralPath $Path -ErrorAction Stop
|
|
$origSddl = $a.Sddl; $origOwner = [string]$a.Owner
|
|
} catch { }
|
|
|
|
$isDir = $true
|
|
try { $isDir = [bool]((Get-Item -LiteralPath $Path -Force -ErrorAction Stop).PSIsContainer) } catch { }
|
|
|
|
$changes = New-Object System.Collections.ArrayList
|
|
$errText = $null
|
|
|
|
# 1) Seize ownership for Administrators (icacls enables the needed privilege
|
|
# itself and, unlike takeown, has no locale-specific confirmation prompt).
|
|
$null = & icacls.exe $Path /setowner "*$adminSid" /C /Q 2>&1
|
|
if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Propriétaire défini -> Administrators') }
|
|
else { $errText = "setowner a échoué (code $LASTEXITCODE)" }
|
|
|
|
# 2) Grant Administrators FullControl (inheritable on directories).
|
|
$perm = if ($isDir) { "*${adminSid}:(OI)(CI)F" } else { "*${adminSid}:F" }
|
|
$null = & icacls.exe $Path /grant $perm /C /Q 2>&1
|
|
if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Administrators -> FullControl accordé') }
|
|
else { if ($errText) { $errText += '; ' }; $errText += "grant a échoué (code $LASTEXITCODE)" }
|
|
|
|
$success = ($changes.Count -gt 0)
|
|
|
|
[void]$Grants.Add([pscustomobject]@{
|
|
Path = $Path
|
|
IsDir = $isDir
|
|
Reason = $Reason
|
|
Changes = ($changes -join '; ')
|
|
OriginalOwner = $origOwner
|
|
OriginalSddl = $origSddl
|
|
Success = $success
|
|
Error = $errText
|
|
Reverted = $false
|
|
RevertError = $null
|
|
})
|
|
|
|
if (-not $success -and $errText) {
|
|
[void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "Octroi automatique échoué : $errText" })
|
|
}
|
|
return $success
|
|
}
|
|
|
|
function Restore-Grants {
|
|
<# Reverts the changes made by Grant-AdminAccess, children before parents.
|
|
Restores the full original security descriptor (owner + ACL) when it was
|
|
captured; otherwise removes only the Administrators ACE we added. #>
|
|
param(
|
|
[System.Collections.ArrayList]$Grants,
|
|
[System.Collections.ArrayList]$ScanErrors,
|
|
[hashtable]$Progress
|
|
)
|
|
$adminSid = 'S-1-5-32-544'
|
|
for ($i = $Grants.Count - 1; $i -ge 0; $i--) {
|
|
$g = $Grants[$i]
|
|
if (-not $g.Success) { continue }
|
|
if ($Progress) { $Progress.Status = "Annulation de l'accès : $($g.Path)" }
|
|
|
|
$done = $false; $err = $null
|
|
if ($g.OriginalSddl) {
|
|
try {
|
|
if ($g.IsDir) { $sec = New-Object System.Security.AccessControl.DirectorySecurity }
|
|
else { $sec = New-Object System.Security.AccessControl.FileSecurity }
|
|
$sec.SetSecurityDescriptorSddlForm($g.OriginalSddl)
|
|
Set-Acl -LiteralPath $g.Path -AclObject $sec -ErrorAction Stop
|
|
$done = $true
|
|
} catch { $err = "Échec de la restauration Set-Acl : $($_.Exception.Message)" }
|
|
}
|
|
if (-not $done) {
|
|
# Best-effort fallback: drop the ACE we added and put the owner back.
|
|
try {
|
|
$null = & icacls.exe $g.Path /remove:g "*$adminSid" /C /Q 2>&1
|
|
if ($g.OriginalOwner) { $null = & icacls.exe $g.Path /setowner $g.OriginalOwner /C /Q 2>&1 }
|
|
if (-not $g.OriginalSddl) {
|
|
$done = $true
|
|
$err = "l'ACL d'origine était illisible ; seul l'ACE Administrators ajouté a été supprimé"
|
|
}
|
|
} catch { if ($err) { $err += '; ' }; $err += "Repli icacls échoué : $($_.Exception.Message)" }
|
|
}
|
|
|
|
$g.Reverted = $done
|
|
$g.RevertError = $err
|
|
if ($err) { [void]$ScanErrors.Add([pscustomobject]@{ Path = $g.Path; Error = "Annulation : $err" }) }
|
|
}
|
|
}
|
|
|
|
function Get-FolderNode {
|
|
<# Recursively builds a size tree. Accumulates long paths and errors via
|
|
synchronized collections passed by the caller. #>
|
|
param(
|
|
[string]$Path,
|
|
[int]$MaxLen,
|
|
[System.Collections.ArrayList]$LongPaths,
|
|
[System.Collections.ArrayList]$ScanErrors,
|
|
[hashtable]$Progress,
|
|
[int]$Depth = 0
|
|
)
|
|
|
|
$name = Split-Path -Path $Path -Leaf
|
|
if ([string]::IsNullOrEmpty($name)) { $name = $Path } # e.g. a drive root
|
|
|
|
$node = [ordered]@{
|
|
Name = $name
|
|
FullPath = $Path
|
|
Size = [long]0
|
|
FileCount = 0
|
|
FolderCount = 0
|
|
Depth = $Depth
|
|
Children = New-Object System.Collections.ArrayList
|
|
}
|
|
|
|
if ($Progress) { $Progress.Status = "Analyse : $Path" }
|
|
|
|
if ($Path.Length -ge $MaxLen) {
|
|
[void]$LongPaths.Add([pscustomobject]@{ Type = 'Dossier'; Length = $Path.Length; Path = $Path })
|
|
}
|
|
|
|
$entries = $null
|
|
$granted = $false
|
|
while ($true) {
|
|
try {
|
|
$entries = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop
|
|
break
|
|
}
|
|
catch {
|
|
if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) {
|
|
$granted = $true
|
|
if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" }
|
|
if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) {
|
|
continue # retry once, now that access has been granted
|
|
}
|
|
}
|
|
[void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = $_.Exception.Message })
|
|
return [pscustomobject]$node
|
|
}
|
|
}
|
|
|
|
foreach ($entry in $entries) {
|
|
if ($entry.PSIsContainer) {
|
|
$child = Get-FolderNode -Path $entry.FullName -MaxLen $MaxLen `
|
|
-LongPaths $LongPaths -ScanErrors $ScanErrors `
|
|
-Progress $Progress -Depth ($Depth + 1)
|
|
$node.Size += $child.Size
|
|
$node.FileCount += $child.FileCount
|
|
$node.FolderCount += 1 + $child.FolderCount
|
|
[void]$node.Children.Add($child)
|
|
}
|
|
else {
|
|
$len = 0
|
|
try { $len = [long]$entry.Length } catch { $len = 0 }
|
|
$node.Size += $len
|
|
$node.FileCount += 1
|
|
if ($entry.FullName.Length -ge $MaxLen) {
|
|
[void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullName.Length; Path = $entry.FullName })
|
|
}
|
|
if ($IncludeFilesInTreeFlag) {
|
|
[void]$node.Children.Add([pscustomobject]@{
|
|
Name = $entry.Name; FullPath = $entry.FullName; Size = $len
|
|
FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsFile = $true
|
|
Children = (New-Object System.Collections.ArrayList)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return [pscustomobject]$node
|
|
}
|
|
|
|
function Get-FolderPermissions {
|
|
param(
|
|
[string]$Path,
|
|
[int]$Depth,
|
|
[System.Collections.ArrayList]$ScanErrors,
|
|
[int]$Current = 0
|
|
)
|
|
$results = New-Object System.Collections.ArrayList
|
|
$granted = $false
|
|
while ($true) {
|
|
try {
|
|
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
|
|
foreach ($ace in $acl.Access) {
|
|
[void]$results.Add([pscustomobject]@{
|
|
Folder = $Path
|
|
Owner = $acl.Owner
|
|
Identity = [string]$ace.IdentityReference
|
|
Rights = [string]$ace.FileSystemRights
|
|
Type = [string]$ace.AccessControlType
|
|
Inherited = $ace.IsInherited
|
|
})
|
|
}
|
|
break
|
|
}
|
|
catch {
|
|
if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) {
|
|
$granted = $true
|
|
if (Grant-AdminAccess -Path $Path -Reason 'lire les permissions' -Grants $GrantList -ScanErrors $ScanErrors) {
|
|
continue # retry once, now that the ACL is readable
|
|
}
|
|
}
|
|
[void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "ACL : $($_.Exception.Message)" })
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($Current -lt $Depth) {
|
|
$subDirs = $null
|
|
try { $subDirs = Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop } catch { $subDirs = @() }
|
|
foreach ($d in $subDirs) {
|
|
$child = Get-FolderPermissions -Path $d.FullName -Depth $Depth -ScanErrors $ScanErrors -Current ($Current + 1)
|
|
foreach ($r in $child) { [void]$results.Add($r) }
|
|
}
|
|
}
|
|
return $results
|
|
}
|
|
|
|
function Invoke-FilerScan {
|
|
param(
|
|
[string[]]$Paths,
|
|
[int]$MaxPathLength = 260,
|
|
[int]$PermissionDepth = 1,
|
|
[bool]$IncludeFilesInTree = $false,
|
|
[bool]$GrantAccess = $false,
|
|
[bool]$RevertGrants = $true,
|
|
[hashtable]$Progress
|
|
)
|
|
|
|
$script:IncludeFilesInTreeFlag = $IncludeFilesInTree
|
|
$script:GrantAccessFlag = $GrantAccess
|
|
$script:GrantList = New-Object System.Collections.ArrayList
|
|
|
|
$longPaths = New-Object System.Collections.ArrayList
|
|
$scanErrors = New-Object System.Collections.ArrayList
|
|
$permissions = New-Object System.Collections.ArrayList
|
|
$roots = New-Object System.Collections.ArrayList
|
|
|
|
foreach ($p in $Paths) {
|
|
if (-not (Test-Path -LiteralPath $p)) {
|
|
[void]$scanErrors.Add([pscustomobject]@{ Path = $p; Error = 'Chemin introuvable' })
|
|
continue
|
|
}
|
|
$fullPath = (Resolve-Path -LiteralPath $p).Path
|
|
$node = Get-FolderNode -Path $fullPath -MaxLen $MaxPathLength `
|
|
-LongPaths $longPaths -ScanErrors $scanErrors -Progress $Progress
|
|
[void]$roots.Add($node)
|
|
|
|
if ($Progress) { $Progress.Status = "Lecture des permissions : $fullPath" }
|
|
$perms = Get-FolderPermissions -Path $fullPath -Depth $PermissionDepth -ScanErrors $scanErrors
|
|
foreach ($r in $perms) { [void]$permissions.Add($r) }
|
|
}
|
|
|
|
# Revert the access we granted, once the scan (and ACL reads) are done.
|
|
if ($GrantAccess -and $RevertGrants -and $script:GrantList.Count -gt 0) {
|
|
if ($Progress) { $Progress.Status = 'Annulation des accès accordés...' }
|
|
Restore-Grants -Grants $script:GrantList -ScanErrors $scanErrors -Progress $Progress
|
|
}
|
|
|
|
$totalSize = ($roots | Measure-Object -Property Size -Sum).Sum
|
|
if (-not $totalSize) { $totalSize = 0 }
|
|
|
|
$grantsMade = @($script:GrantList | Where-Object { $_.Success })
|
|
|
|
return [pscustomobject]@{
|
|
Roots = $roots
|
|
LongPaths = ($longPaths | Sort-Object Length -Descending)
|
|
Permissions = $permissions
|
|
Grants = $script:GrantList
|
|
Errors = $scanErrors
|
|
Settings = [pscustomobject]@{
|
|
MaxPathLength = $MaxPathLength
|
|
PermissionDepth = $PermissionDepth
|
|
IncludeFilesInTree = $IncludeFilesInTree
|
|
GrantAccess = $GrantAccess
|
|
RevertGrants = $RevertGrants
|
|
}
|
|
Stats = [pscustomobject]@{
|
|
TotalSize = [long]$totalSize
|
|
TotalFiles = ($roots | Measure-Object -Property FileCount -Sum).Sum
|
|
TotalFolders = ($roots | Measure-Object -Property FolderCount -Sum).Sum
|
|
LongPaths = $longPaths.Count
|
|
Errors = $scanErrors.Count
|
|
Grants = $grantsMade.Count
|
|
}
|
|
Computer = $env:COMPUTERNAME
|
|
GeneratedAt = (Get-Date)
|
|
}
|
|
}
|
|
|
|
function ConvertTo-FilerHtmlReport {
|
|
param(
|
|
[Parameter(Mandatory)] $Scan,
|
|
[Parameter(Mandatory)] [string]$Path,
|
|
# Which report categories to include. 'All' (default) emits everything;
|
|
# otherwise pass any combination of Tree, LongPaths, Permissions, Grants, Errors.
|
|
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
|
|
[string[]]$Categories = @('All')
|
|
)
|
|
|
|
function _enc([string]$s) { [System.Net.WebUtility]::HtmlEncode($s) }
|
|
|
|
# Resolve the requested categories into per-section switches.
|
|
$all = ($Categories -contains 'All') -or ($Categories.Count -eq 0)
|
|
$wantTree = $all -or ($Categories -contains 'Tree')
|
|
$wantLong = $all -or ($Categories -contains 'LongPaths')
|
|
$wantPerm = $all -or ($Categories -contains 'Permissions')
|
|
$wantGrants = $all -or ($Categories -contains 'Grants')
|
|
$wantErrors = $all -or ($Categories -contains 'Errors')
|
|
|
|
$sb = New-Object System.Text.StringBuilder
|
|
|
|
# ---- recursive tree renderer (folders sorted largest-first) ----
|
|
function _renderNode {
|
|
param($Node, [long]$ParentSize, [System.Text.StringBuilder]$Out)
|
|
$pct = if ($ParentSize -gt 0) { [math]::Round(($Node.Size / $ParentSize) * 100, 1) } else { 100 }
|
|
$sizeStr = Format-Bytes $Node.Size
|
|
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
|
|
$icon = if ($isFile) { '📄' } else { '📁' }
|
|
$meta = if ($isFile) { '' } else { " <span class='meta'>$($Node.FolderCount) dossiers, $($Node.FileCount) fichiers</span>" }
|
|
$kids = @($Node.Children | Where-Object { $_ } )
|
|
|
|
$summary = "$icon <span class='nm'>$(_enc $Node.Name)</span> <span class='sz'>$sizeStr</span>" +
|
|
"<span class='barwrap'><span class='bar' style='width:$pct%'></span></span><span class='pct'>$pct%</span>$meta"
|
|
|
|
if ($kids.Count -gt 0) {
|
|
$open = if ($Node.Depth -eq 0) { ' open' } else { '' }
|
|
[void]$Out.Append("<details$open><summary>$summary</summary><div class='kids'>")
|
|
foreach ($c in ($kids | Sort-Object @{E={$_.Size}} -Descending)) {
|
|
_renderNode -Node $c -ParentSize $Node.Size -Out $Out
|
|
}
|
|
[void]$Out.Append("</div></details>")
|
|
}
|
|
else {
|
|
[void]$Out.Append("<div class='leaf'>$summary</div>")
|
|
}
|
|
}
|
|
|
|
$treeSb = New-Object System.Text.StringBuilder
|
|
if ($wantTree) {
|
|
foreach ($root in $Scan.Roots) { _renderNode -Node $root -ParentSize $root.Size -Out $treeSb }
|
|
}
|
|
|
|
# ---- long paths ----
|
|
$longRows = New-Object System.Text.StringBuilder
|
|
if ($wantLong) {
|
|
foreach ($lp in $Scan.LongPaths) {
|
|
[void]$longRows.Append("<tr><td class='num'>$($lp.Length)</td><td>$(_enc $lp.Type)</td><td class='path'>$(_enc $lp.Path)</td></tr>")
|
|
}
|
|
if ($Scan.LongPaths.Count -eq 0) { [void]$longRows.Append("<tr><td colspan='3' class='ok'>Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. ✅</td></tr>") }
|
|
}
|
|
|
|
# ---- permissions (grouped by folder) ----
|
|
$permSb = New-Object System.Text.StringBuilder
|
|
if ($wantPerm) {
|
|
$byFolder = $Scan.Permissions | Group-Object Folder
|
|
foreach ($grp in $byFolder) {
|
|
$owner = ($grp.Group | Select-Object -First 1).Owner
|
|
[void]$permSb.Append("<details class='perm'><summary><span class='nm'>$(_enc $grp.Name)</span> <span class='meta'>Propriétaire : $(_enc $owner)</span></summary>")
|
|
[void]$permSb.Append("<table class='grid'><thead><tr><th>Identité</th><th>Droits</th><th>Type</th><th>Hérité</th></tr></thead><tbody>")
|
|
foreach ($ace in $grp.Group) {
|
|
$cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' }
|
|
[void]$permSb.Append("<tr$cls><td>$(_enc $ace.Identity)</td><td>$(_enc $ace.Rights)</td><td>$(_enc $ace.Type)</td><td>$($ace.Inherited)</td></tr>")
|
|
}
|
|
[void]$permSb.Append("</tbody></table></details>")
|
|
}
|
|
if ($byFolder.Count -eq 0) { [void]$permSb.Append("<p class='muted'>Aucune permission collectée.</p>") }
|
|
}
|
|
|
|
# ---- granted access (auto-remediation log) ----
|
|
$grantSb = New-Object System.Text.StringBuilder
|
|
$grantRows = @($Scan.Grants | Where-Object { $_.Success })
|
|
if ($wantGrants) {
|
|
foreach ($g in $grantRows) {
|
|
if ($g.Reverted) { $state = "<span class='ok'>annulé</span>" }
|
|
elseif ($Scan.Settings.RevertGrants) { $state = "<span class='deny'>NON annulé$(if($g.RevertError){' - ' + (_enc $g.RevertError)})</span>" }
|
|
else { $state = "<span class='warn'>conservé</span>" }
|
|
[void]$grantSb.Append("<tr><td class='path'>$(_enc $g.Path)</td><td>$(_enc $g.Reason)</td><td>$(_enc $g.Changes)</td><td>$(_enc $g.OriginalOwner)</td><td>$state</td></tr>")
|
|
}
|
|
}
|
|
|
|
# ---- errors ----
|
|
$errSb = New-Object System.Text.StringBuilder
|
|
if ($wantErrors) {
|
|
foreach ($e in $Scan.Errors) {
|
|
[void]$errSb.Append("<tr><td class='path'>$(_enc $e.Path)</td><td>$(_enc $e.Error)</td></tr>")
|
|
}
|
|
}
|
|
|
|
$rootsList = ($Scan.Roots | ForEach-Object { _enc $_.FullPath }) -join '<br>'
|
|
$gen = $Scan.GeneratedAt.ToString('yyyy-MM-dd HH:mm:ss')
|
|
|
|
# When only some categories are exported, name them in the header.
|
|
$catLabels = [ordered]@{
|
|
Tree = 'Tailles des dossiers'; LongPaths = 'Chemins trop longs'; Permissions = 'Permissions'
|
|
Grants = 'Accès accordé'; Errors = 'Erreurs'
|
|
}
|
|
$included = @()
|
|
if ($wantTree) { $included += $catLabels.Tree }
|
|
if ($wantLong) { $included += $catLabels.LongPaths }
|
|
if ($wantPerm) { $included += $catLabels.Permissions }
|
|
if ($wantGrants -and $Scan.Settings.GrantAccess) { $included += $catLabels.Grants }
|
|
if ($wantErrors -and $Scan.Errors.Count -gt 0) { $included += $catLabels.Errors }
|
|
$catNote = if ($all) { '' } else { "<div class='sub' style='margin-top:6px'>Catégories : $(_enc ($included -join ', '))</div>" }
|
|
|
|
$html = @"
|
|
<!DOCTYPE html>
|
|
<html lang='fr'><head><meta charset='utf-8'>
|
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
|
<title>Rapport Filer Manager - $gen</title>
|
|
<style>
|
|
:root { --bg:#0f1116; --panel:#171a21; --pan2:#1d2129; --ink:#e7eaf0; --mut:#8b93a7;
|
|
--acc:#4f8cff; --bar:#3b6fd4; --warn:#ffb454; --deny:#ff6b6b; --ok:#42d392; --line:#2a2f3a; }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; font:14px/1.5 'Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--ink); }
|
|
header { padding:24px 32px; background:linear-gradient(120deg,#1b2a4a,#142035); border-bottom:1px solid var(--line); }
|
|
header h1 { margin:0 0 4px; font-size:22px; }
|
|
header .sub { color:var(--mut); font-size:13px; }
|
|
main { padding:24px 32px; max-width:1200px; margin:0 auto; }
|
|
.cards { display:flex; flex-wrap:wrap; gap:14px; margin:8px 0 28px; }
|
|
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px 20px; min-width:150px; flex:1; }
|
|
.card .v { font-size:24px; font-weight:600; }
|
|
.card .l { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
|
|
.card.warn .v { color:var(--warn); } .card.bad .v { color:var(--deny); }
|
|
section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px 22px; margin-bottom:22px; }
|
|
section h2 { margin:0 0 14px; font-size:16px; display:flex; align-items:center; gap:8px; }
|
|
details { border-left:2px solid var(--line); margin:2px 0; }
|
|
summary { cursor:pointer; padding:3px 6px; border-radius:6px; list-style:none; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
summary:hover { background:var(--pan2); }
|
|
summary::-webkit-details-marker { display:none; }
|
|
.kids { margin-left:18px; }
|
|
.leaf { padding:3px 6px; display:flex; align-items:center; gap:8px; margin-left:4px; }
|
|
.nm { font-weight:500; } .sz { color:var(--acc); font-variant-numeric:tabular-nums; min-width:84px; }
|
|
.pct { color:var(--mut); font-size:12px; min-width:42px; text-align:right; }
|
|
.meta { color:var(--mut); font-size:12px; }
|
|
.barwrap { flex:0 0 120px; height:7px; background:var(--pan2); border-radius:4px; overflow:hidden; }
|
|
.bar { display:block; height:100%; background:linear-gradient(90deg,var(--bar),var(--acc)); }
|
|
table.grid { width:100%; border-collapse:collapse; font-size:13px; }
|
|
table.grid th, table.grid td { text-align:left; padding:6px 10px; border-bottom:1px solid var(--line); }
|
|
table.grid th { color:var(--mut); font-weight:600; position:sticky; top:0; background:var(--panel); }
|
|
td.num { text-align:right; font-variant-numeric:tabular-nums; color:var(--warn); font-weight:600; }
|
|
td.path { font-family:Consolas,'Cascadia Code',monospace; font-size:12px; word-break:break-all; }
|
|
tr.deny td { color:var(--deny); }
|
|
.ok { color:var(--ok); } .muted { color:var(--mut); }
|
|
details.perm { background:var(--pan2); border-radius:10px; padding:6px 10px; margin:6px 0; border-left:2px solid var(--acc); }
|
|
.scroll { max-height:480px; overflow:auto; }
|
|
input.filter { width:100%; padding:8px 12px; margin-bottom:12px; background:var(--pan2); border:1px solid var(--line);
|
|
border-radius:8px; color:var(--ink); font-size:13px; }
|
|
footer { color:var(--mut); font-size:12px; text-align:center; padding:24px; }
|
|
</style></head>
|
|
<body>
|
|
<header>
|
|
<h1>📁 Rapport Filer Manager</h1>
|
|
<div class='sub'>Généré le $gen · Hôte <b>$(_enc $Scan.Computer)</b> ·
|
|
Seuil chemin trop long $($Scan.Settings.MaxPathLength) · Profondeur des permissions $($Scan.Settings.PermissionDepth)</div>
|
|
<div class='sub' style='margin-top:6px'>Analysé : $rootsList</div>
|
|
$catNote
|
|
</header>
|
|
<main>
|
|
<div class='cards'>
|
|
<div class='card'><div class='v'>$(Format-Bytes $Scan.Stats.TotalSize)</div><div class='l'>Taille totale</div></div>
|
|
<div class='card'><div class='v'>$('{0:N0}' -f $Scan.Stats.TotalFolders)</div><div class='l'>Dossiers</div></div>
|
|
<div class='card'><div class='v'>$('{0:N0}' -f $Scan.Stats.TotalFiles)</div><div class='l'>Fichiers</div></div>
|
|
<div class='card $(if($Scan.Stats.LongPaths){'warn'})'><div class='v'>$($Scan.Stats.LongPaths)</div><div class='l'>Chemins trop longs</div></div>
|
|
<div class='card $(if($Scan.Stats.Errors){'bad'})'><div class='v'>$($Scan.Stats.Errors)</div><div class='l'>Erreurs / refusés</div></div>
|
|
$(if ($Scan.Settings.GrantAccess) { "<div class='card $(if($Scan.Stats.Grants){'warn'})'><div class='v'>$($Scan.Stats.Grants)</div><div class='l'>Accès accordé</div></div>" })
|
|
</div>
|
|
|
|
$(if ($wantTree) { @"
|
|
<section>
|
|
<h2>📊 Tailles des dossiers</h2>
|
|
<div class='tree'>$($treeSb.ToString())</div>
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantLong) { @"
|
|
<section>
|
|
<h2>⚠️ Noms de fichiers / chemins trop longs (≥ $($Scan.Settings.MaxPathLength) caractères)</h2>
|
|
<input class='filter' placeholder='Filtrer les chemins…' oninput="filterRows(this,'longtbl')">
|
|
<div class='scroll'><table class='grid' id='longtbl'><thead><tr><th>Longueur</th><th>Type</th><th>Chemin</th></tr></thead>
|
|
<tbody>$($longRows.ToString())</tbody></table></div>
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantPerm) { @"
|
|
<section>
|
|
<h2>🔐 Permissions</h2>
|
|
$($permSb.ToString())
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantGrants -and $Scan.Settings.GrantAccess) { @"
|
|
<section>
|
|
<h2>🔨 Accès accordé pour terminer l'analyse</h2>
|
|
<p class='muted'>Éléments qui étaient refusés, sur lesquels la propriété/le FullControl Administrators a été appliqué afin de pouvoir les analyser.
|
|
Mode : $(if ($Scan.Settings.RevertGrants) { "annuler après l'analyse" } else { 'conserver les modifications' }).</p>
|
|
$(if ($grantRows.Count -gt 0) { @"
|
|
<div class='scroll'><table class='grid'><thead><tr><th>Chemin</th><th>Raison</th><th>Modification</th><th>Propriétaire d'origine</th><th>État</th></tr></thead>
|
|
<tbody>$($grantSb.ToString())</tbody></table></div>
|
|
"@ } else { "<p class='ok'>Aucune modification d'accès n'était nécessaire. ✅</p>" })
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @"
|
|
<section>
|
|
<h2>❌ Erreurs et accès refusé</h2>
|
|
<div class='scroll'><table class='grid'><thead><tr><th>Chemin</th><th>Erreur</th></tr></thead>
|
|
<tbody>$($errSb.ToString())</tbody></table></div>
|
|
</section>
|
|
"@ })
|
|
</main>
|
|
<footer>Filer Manager · rapport autonome · $gen</footer>
|
|
<script>
|
|
function filterRows(inp, tableId){
|
|
var q = inp.value.toLowerCase(), rows = document.getElementById(tableId).tBodies[0].rows;
|
|
for (var i=0;i<rows.length;i++){ rows[i].style.display = rows[i].innerText.toLowerCase().indexOf(q)>-1?'':'none'; }
|
|
}
|
|
</script>
|
|
</body></html>
|
|
"@
|
|
|
|
Set-Content -LiteralPath $Path -Value $html -Encoding UTF8
|
|
return $Path
|
|
}
|
|
'@
|
|
|
|
# Dot-source core functions into the current scope (for headless + GUI export).
|
|
. ([scriptblock]::Create($CoreFunctions))
|
|
|
|
# ============================================================================
|
|
# HEADLESS MODE
|
|
# ============================================================================
|
|
$runHeadless = $NoGui -or ($Path -and $Output)
|
|
if ($runHeadless) {
|
|
if (-not $Path) { throw "Le mode sans interface requiert -Path." }
|
|
if (-not $Output) { throw "Le mode sans interface requiert -Output." }
|
|
|
|
if ($GrantAccess -and -not (Test-IsElevated)) {
|
|
Write-Warning "-GrantAccess nécessite des droits Administrateur ; ce processus n'est pas élevé. Les éléments refusés peuvent ne pas être corrigés."
|
|
}
|
|
|
|
Write-Host "Analyse de $($Path -join ', ') en cours ..." -ForegroundColor Cyan
|
|
$progress = [hashtable]::Synchronized(@{ Status = '' })
|
|
$scan = Invoke-FilerScan -Paths $Path -MaxPathLength $MaxPathLength `
|
|
-PermissionDepth $PermissionDepth `
|
|
-IncludeFilesInTree:$IncludeFilesInTree `
|
|
-GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress
|
|
|
|
$out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category
|
|
Write-Host "Rapport généré : $out" -ForegroundColor Green
|
|
Write-Host (" {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs" -f `
|
|
(Format-Bytes $scan.Stats.TotalSize), $scan.Stats.TotalFolders, $scan.Stats.TotalFiles,
|
|
$scan.Stats.LongPaths, $scan.Stats.Errors)
|
|
if ($GrantAccess) {
|
|
Write-Host (" {0} élément(s) ont reçu un accès ({1})" -f `
|
|
$scan.Stats.Grants, $(if ($KeepGrants) { 'conservé' } else { 'annulé' })) -ForegroundColor Yellow
|
|
}
|
|
return
|
|
}
|
|
|
|
# ============================================================================
|
|
# GUI MODE
|
|
# ============================================================================
|
|
Add-Type -AssemblyName System.Windows.Forms
|
|
Add-Type -AssemblyName System.Drawing
|
|
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
|
|
$form = New-Object System.Windows.Forms.Form
|
|
$form.Text = 'Filer Manager'
|
|
$form.Size = New-Object System.Drawing.Size(960, 720)
|
|
$form.StartPosition = 'CenterScreen'
|
|
$form.MinimumSize = New-Object System.Drawing.Size(760, 560)
|
|
|
|
# ---- top: folder list + add/remove ----
|
|
$lblFolders = New-Object System.Windows.Forms.Label
|
|
$lblFolders.Text = 'Dossiers à analyser :'
|
|
$lblFolders.Location = '12,12'; $lblFolders.AutoSize = $true
|
|
$form.Controls.Add($lblFolders)
|
|
|
|
$lstFolders = New-Object System.Windows.Forms.ListBox
|
|
$lstFolders.Location = '12,32'; $lstFolders.Size = '700,84'
|
|
$lstFolders.Anchor = 'Top,Left,Right'
|
|
$lstFolders.HorizontalScrollbar = $true
|
|
$form.Controls.Add($lstFolders)
|
|
|
|
$btnAdd = New-Object System.Windows.Forms.Button
|
|
$btnAdd.Text = 'Ajouter...'; $btnAdd.Location = '724,32'; $btnAdd.Size = '110,28'
|
|
$btnAdd.Anchor = 'Top,Right'
|
|
$form.Controls.Add($btnAdd)
|
|
|
|
$btnRemove = New-Object System.Windows.Forms.Button
|
|
$btnRemove.Text = 'Supprimer'; $btnRemove.Location = '724,66'; $btnRemove.Size = '110,28'
|
|
$btnRemove.Anchor = 'Top,Right'
|
|
$form.Controls.Add($btnRemove)
|
|
|
|
# ---- settings row ----
|
|
$lblMax = New-Object System.Windows.Forms.Label
|
|
$lblMax.Text = 'Longueur max :'; $lblMax.Location = '12,128'; $lblMax.AutoSize = $true
|
|
$form.Controls.Add($lblMax)
|
|
|
|
$numMax = New-Object System.Windows.Forms.NumericUpDown
|
|
$numMax.Location = '118,126'; $numMax.Size = '70,24'
|
|
$numMax.Minimum = 1; $numMax.Maximum = 32767; $numMax.Value = $MaxPathLength
|
|
$form.Controls.Add($numMax)
|
|
|
|
$lblDepth = New-Object System.Windows.Forms.Label
|
|
$lblDepth.Text = 'Profondeur :'; $lblDepth.Location = '202,128'; $lblDepth.AutoSize = $true
|
|
$form.Controls.Add($lblDepth)
|
|
|
|
$numDepth = New-Object System.Windows.Forms.NumericUpDown
|
|
$numDepth.Location = '288,126'; $numDepth.Size = '60,24'
|
|
$numDepth.Minimum = 0; $numDepth.Maximum = 20; $numDepth.Value = $PermissionDepth
|
|
$form.Controls.Add($numDepth)
|
|
|
|
$chkFiles = New-Object System.Windows.Forms.CheckBox
|
|
$chkFiles.Text = 'Inclure les fichiers'; $chkFiles.Location = '362,127'; $chkFiles.AutoSize = $true
|
|
$chkFiles.Checked = [bool]$IncludeFilesInTree
|
|
$form.Controls.Add($chkFiles)
|
|
|
|
# ---- auto-grant row ----
|
|
$chkGrant = New-Object System.Windows.Forms.CheckBox
|
|
$chkGrant.Text = "Accorder l'accès Administrators aux éléments refusés"
|
|
$chkGrant.Location = '12,156'; $chkGrant.AutoSize = $true
|
|
$chkGrant.Checked = [bool]$GrantAccess
|
|
$form.Controls.Add($chkGrant)
|
|
|
|
$chkRevert = New-Object System.Windows.Forms.CheckBox
|
|
$chkRevert.Text = "Annuler les modifications après l'analyse"
|
|
$chkRevert.Location = '380,156'; $chkRevert.AutoSize = $true
|
|
$chkRevert.Checked = (-not $KeepGrants)
|
|
$chkRevert.Enabled = $chkGrant.Checked
|
|
$form.Controls.Add($chkRevert)
|
|
|
|
$chkGrant.Add_CheckedChanged({ $chkRevert.Enabled = $chkGrant.Checked })
|
|
|
|
$btnScan = New-Object System.Windows.Forms.Button
|
|
$btnScan.Text = 'Analyser'; $btnScan.Location = '600,124'; $btnScan.Size = '110,30'
|
|
$btnScan.Anchor = 'Top,Right'
|
|
$btnScan.BackColor = [System.Drawing.Color]::FromArgb(79,140,255)
|
|
$btnScan.ForeColor = [System.Drawing.Color]::White
|
|
$btnScan.FlatStyle = 'Flat'
|
|
$form.Controls.Add($btnScan)
|
|
|
|
$btnExport = New-Object System.Windows.Forms.Button
|
|
$btnExport.Text = 'Exporter HTML...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30'
|
|
$btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false
|
|
$form.Controls.Add($btnExport)
|
|
|
|
# ---- tabs ----
|
|
$tabs = New-Object System.Windows.Forms.TabControl
|
|
$tabs.Location = '12,188'; $tabs.Size = '922,456'
|
|
$tabs.Anchor = 'Top,Bottom,Left,Right'
|
|
$form.Controls.Add($tabs)
|
|
|
|
$tabTree = New-Object System.Windows.Forms.TabPage; $tabTree.Text = 'Tailles des dossiers'
|
|
$tabLong = New-Object System.Windows.Forms.TabPage; $tabLong.Text = 'Chemins trop longs'
|
|
$tabPerm = New-Object System.Windows.Forms.TabPage; $tabPerm.Text = 'Permissions'
|
|
$tabGrant = New-Object System.Windows.Forms.TabPage; $tabGrant.Text = 'Accès accordé'
|
|
$tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant))
|
|
|
|
$tree = New-Object System.Windows.Forms.TreeView
|
|
$tree.Dock = 'Fill'; $tree.HideSelection = $false
|
|
$tabTree.Controls.Add($tree)
|
|
|
|
$lvLong = New-Object System.Windows.Forms.ListView
|
|
$lvLong.Dock = 'Fill'; $lvLong.View = 'Details'; $lvLong.FullRowSelect = $true; $lvLong.GridLines = $true
|
|
[void]$lvLong.Columns.Add('Longueur', 70); [void]$lvLong.Columns.Add('Type', 70); [void]$lvLong.Columns.Add('Chemin', 760)
|
|
$tabLong.Controls.Add($lvLong)
|
|
|
|
$lvPerm = New-Object System.Windows.Forms.ListView
|
|
$lvPerm.Dock = 'Fill'; $lvPerm.View = 'Details'; $lvPerm.FullRowSelect = $true; $lvPerm.GridLines = $true
|
|
[void]$lvPerm.Columns.Add('Dossier', 320); [void]$lvPerm.Columns.Add('Identité', 200)
|
|
[void]$lvPerm.Columns.Add('Droits', 200); [void]$lvPerm.Columns.Add('Type', 60); [void]$lvPerm.Columns.Add('Hérité', 70)
|
|
$tabPerm.Controls.Add($lvPerm)
|
|
|
|
$lvGrant = New-Object System.Windows.Forms.ListView
|
|
$lvGrant.Dock = 'Fill'; $lvGrant.View = 'Details'; $lvGrant.FullRowSelect = $true; $lvGrant.GridLines = $true
|
|
[void]$lvGrant.Columns.Add('Chemin', 360); [void]$lvGrant.Columns.Add('Raison', 110)
|
|
[void]$lvGrant.Columns.Add('Modification', 240); [void]$lvGrant.Columns.Add("Propriétaire d'origine", 150)
|
|
[void]$lvGrant.Columns.Add('État', 90)
|
|
$tabGrant.Controls.Add($lvGrant)
|
|
|
|
# Filter toolbar for the permissions tab (docked above the list).
|
|
$permBar = New-Object System.Windows.Forms.Panel
|
|
$permBar.Dock = 'Top'; $permBar.Height = 30
|
|
$lblPermHint = New-Object System.Windows.Forms.Label
|
|
$lblPermHint.Text = 'Cliquez sur un en-tête de colonne pour filtrer ou trier.'
|
|
$lblPermHint.AutoSize = $true; $lblPermHint.Location = '6,7'
|
|
$lblPermHint.ForeColor = [System.Drawing.Color]::DimGray
|
|
$btnPermClear = New-Object System.Windows.Forms.Button
|
|
$btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24'
|
|
$btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false
|
|
$permBar.Controls.Add($lblPermHint)
|
|
$permBar.Controls.Add($btnPermClear)
|
|
$tabPerm.Controls.Add($permBar)
|
|
|
|
$lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column })
|
|
$btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView })
|
|
|
|
# ---- status bar ----
|
|
$status = New-Object System.Windows.Forms.StatusStrip
|
|
$statusLbl = New-Object System.Windows.Forms.ToolStripStatusLabel
|
|
$statusLbl.Text = 'Prêt. Ajoutez un ou plusieurs dossiers, puis Analyser.'
|
|
$progressBar = New-Object System.Windows.Forms.ToolStripProgressBar
|
|
$progressBar.Style = 'Marquee'; $progressBar.Visible = $false; $progressBar.Width = 140
|
|
[void]$status.Items.Add($statusLbl)
|
|
[void]$status.Items.Add($progressBar)
|
|
$form.Controls.Add($status)
|
|
|
|
# ---- state ----
|
|
$script:LastScan = $null
|
|
$script:Runspace = $null
|
|
$script:PowerShell = $null
|
|
$script:Handle = $null
|
|
$script:Shared = $null
|
|
|
|
# Permissions view state: source rows, active per-column filters, and sort.
|
|
$script:AllPerms = @()
|
|
$script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # column index -> property
|
|
# Display labels for the permission columns (property name -> French header).
|
|
$script:PermColLabels = @{ Folder = 'Dossier'; Identity = 'Identité'; Rights = 'Droits'; Type = 'Type'; Inherited = 'Hérité' }
|
|
$script:PermFilters = @{} # property -> System.Collections.Generic.HashSet[string] of allowed values
|
|
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
|
|
|
# ---- helpers ----
|
|
function Add-TreeNodes {
|
|
param($Parent, $Node)
|
|
$sizeStr = Format-Bytes $Node.Size
|
|
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
|
|
if ($isFile) {
|
|
$text = "$($Node.Name) - $sizeStr"
|
|
} else {
|
|
$text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)"
|
|
}
|
|
$tn = New-Object System.Windows.Forms.TreeNode($text)
|
|
$tn.Tag = $Node.FullPath
|
|
[void]$Parent.Add($tn)
|
|
$kids = @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)
|
|
foreach ($c in $kids) { Add-TreeNodes -Parent $tn.Nodes -Node $c }
|
|
}
|
|
|
|
function Show-Results {
|
|
param($Scan)
|
|
$script:LastScan = $Scan
|
|
|
|
$tree.BeginUpdate(); $tree.Nodes.Clear()
|
|
foreach ($root in $Scan.Roots) { Add-TreeNodes -Parent $tree.Nodes -Node $root }
|
|
if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() }
|
|
$tree.EndUpdate()
|
|
|
|
$lvLong.BeginUpdate(); $lvLong.Items.Clear()
|
|
foreach ($lp in $Scan.LongPaths) {
|
|
$it = New-Object System.Windows.Forms.ListViewItem([string]$lp.Length)
|
|
[void]$it.SubItems.Add($lp.Type); [void]$it.SubItems.Add($lp.Path)
|
|
[void]$lvLong.Items.Add($it)
|
|
}
|
|
$lvLong.EndUpdate()
|
|
|
|
$script:AllPerms = @($Scan.Permissions)
|
|
$script:PermFilters = @{}
|
|
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
|
Update-PermView
|
|
|
|
$lvGrant.BeginUpdate(); $lvGrant.Items.Clear()
|
|
foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) {
|
|
$it = New-Object System.Windows.Forms.ListViewItem([string]$g.Path)
|
|
[void]$it.SubItems.Add([string]$g.Reason)
|
|
[void]$it.SubItems.Add([string]$g.Changes)
|
|
[void]$it.SubItems.Add([string]$g.OriginalOwner)
|
|
if ($g.Reverted) { $state = 'annulé' }
|
|
elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé'; $it.ForeColor = [System.Drawing.Color]::Firebrick }
|
|
else { $state = 'conservé'; $it.ForeColor = [System.Drawing.Color]::DarkGoldenrod }
|
|
[void]$it.SubItems.Add($state)
|
|
[void]$lvGrant.Items.Add($it)
|
|
}
|
|
$lvGrant.EndUpdate()
|
|
$tabGrant.Text = if ($lvGrant.Items.Count -gt 0) { "Accès accordé ($($lvGrant.Items.Count))" } else { 'Accès accordé' }
|
|
|
|
$statusLbl.Text = ("Terminé. {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs." -f `
|
|
(Format-Bytes $Scan.Stats.TotalSize), $Scan.Stats.TotalFolders, $Scan.Stats.TotalFiles,
|
|
$Scan.Stats.LongPaths, $Scan.Stats.Errors)
|
|
$btnExport.Enabled = $true
|
|
}
|
|
|
|
function Get-PermCellValue {
|
|
param($Ace, [string]$Prop)
|
|
if ($Prop -eq 'Inherited') { return [string]$Ace.Inherited }
|
|
return [string]$Ace.$Prop
|
|
}
|
|
|
|
function Update-PermView {
|
|
# Re-renders the permissions ListView from $script:AllPerms applying the
|
|
# active per-column filters and the current sort.
|
|
$rows = $script:AllPerms
|
|
foreach ($prop in $script:PermFilters.Keys) {
|
|
$allowed = $script:PermFilters[$prop]
|
|
$rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) })
|
|
}
|
|
|
|
$sortProp = $script:PermSort.Prop
|
|
$rows = @($rows | Sort-Object @{ E = { Get-PermCellValue $_ $sortProp } })
|
|
if (-not $script:PermSort.Asc) { [array]::Reverse($rows) }
|
|
|
|
$lvPerm.BeginUpdate(); $lvPerm.Items.Clear()
|
|
foreach ($ace in $rows) {
|
|
$it = New-Object System.Windows.Forms.ListViewItem($ace.Folder)
|
|
[void]$it.SubItems.Add($ace.Identity); [void]$it.SubItems.Add($ace.Rights)
|
|
[void]$it.SubItems.Add($ace.Type); [void]$it.SubItems.Add([string]$ace.Inherited)
|
|
if ($ace.Type -eq 'Deny') { $it.ForeColor = [System.Drawing.Color]::Firebrick }
|
|
[void]$lvPerm.Items.Add($it)
|
|
}
|
|
$lvPerm.EndUpdate()
|
|
|
|
# Update column headers to show sort arrow and filter funnel.
|
|
for ($i = 0; $i -lt $script:PermCols.Count; $i++) {
|
|
$prop = $script:PermCols[$i]
|
|
$label = $script:PermColLabels[$prop]
|
|
if ($script:PermFilters.ContainsKey($prop)) { $label += ' (v)' }
|
|
if ($prop -eq $sortProp) { $label += $(if ($script:PermSort.Asc) { ' ^' } else { ' v' }) }
|
|
$lvPerm.Columns[$i].Text = $label
|
|
}
|
|
|
|
$shown = $lvPerm.Items.Count; $total = $script:AllPerms.Count
|
|
$nFilt = $script:PermFilters.Count
|
|
$btnPermClear.Enabled = ($nFilt -gt 0)
|
|
if ($script:AllPerms.Count -gt 0) {
|
|
$msg = "Permissions : $shown sur $total affichées"
|
|
if ($nFilt -gt 0) { $msg += " ($nFilt filtre$(if($nFilt -gt 1){'s'}) de colonne) - cliquez sur un en-tête pour filtrer/trier" }
|
|
else { $msg += " - cliquez sur un en-tête de colonne pour filtrer ou trier" }
|
|
$statusLbl.Text = $msg
|
|
}
|
|
}
|
|
|
|
function Show-PermColumnMenu {
|
|
param([int]$ColumnIndex)
|
|
if ($script:AllPerms.Count -eq 0) { return }
|
|
$prop = $script:PermCols[$ColumnIndex]
|
|
|
|
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
$menu.ShowImageMargin = $false
|
|
|
|
$asc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier A -> Z")
|
|
$asc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $true }; Update-PermView }.GetNewClosure())
|
|
$desc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier Z -> A")
|
|
$desc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $false }; Update-PermView }.GetNewClosure())
|
|
[void]$menu.Items.Add($asc); [void]$menu.Items.Add($desc)
|
|
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
|
|
# Select all / Clear toggles for the value checklist.
|
|
$selAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout sélectionner)")
|
|
$clrAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout effacer)")
|
|
[void]$menu.Items.Add($selAll); [void]$menu.Items.Add($clrAll)
|
|
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
|
|
# Distinct values for this column (from the unfiltered source).
|
|
$distinct = @($script:AllPerms | ForEach-Object { Get-PermCellValue $_ $prop } | Sort-Object -Unique)
|
|
$current = $script:PermFilters[$prop] # $null => everything allowed
|
|
$valueItems = New-Object System.Collections.ArrayList
|
|
foreach ($v in $distinct) {
|
|
$label = if ([string]::IsNullOrEmpty($v)) { '(vide)' } else { $v }
|
|
$mi = New-Object System.Windows.Forms.ToolStripMenuItem($label)
|
|
$mi.CheckOnClick = $true
|
|
$mi.Checked = ($null -eq $current) -or $current.Contains($v)
|
|
$mi.Tag = $v
|
|
[void]$menu.Items.Add($mi)
|
|
[void]$valueItems.Add($mi)
|
|
}
|
|
|
|
$selAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $true } }.GetNewClosure())
|
|
$clrAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $false } }.GetNewClosure())
|
|
|
|
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
|
|
$apply = New-Object System.Windows.Forms.ToolStripMenuItem("Appliquer le filtre")
|
|
$apply.Add_Click({
|
|
$checked = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
foreach ($m in $valueItems) { if ($m.Checked) { [void]$checked.Add([string]$m.Tag) } }
|
|
if ($checked.Count -eq $valueItems.Count) { $script:PermFilters.Remove($prop) } # all selected = no filter
|
|
else { $script:PermFilters[$prop] = $checked }
|
|
Update-PermView
|
|
}.GetNewClosure())
|
|
$clear = New-Object System.Windows.Forms.ToolStripMenuItem("Supprimer le filtre de cette colonne")
|
|
$clear.Add_Click({ [void]$script:PermFilters.Remove($prop); Update-PermView }.GetNewClosure())
|
|
[void]$menu.Items.Add($apply); [void]$menu.Items.Add($clear)
|
|
|
|
# Keep the menu open while ticking value checkboxes or using Select/Clear all;
|
|
# close only on Sort / Apply / Remove filter.
|
|
$menu.Add_Closing({
|
|
param($s, $e)
|
|
if ($e.CloseReason -eq [System.Windows.Forms.ToolStripDropDownCloseReason]::ItemClicked) {
|
|
$clicked = $s.GetItemAt($s.PointToClient([System.Windows.Forms.Cursor]::Position))
|
|
if ($clicked -and (($valueItems -contains $clicked) -or ($clicked -eq $selAll) -or ($clicked -eq $clrAll))) {
|
|
$e.Cancel = $true
|
|
}
|
|
}
|
|
}.GetNewClosure())
|
|
|
|
# Show just under the clicked column header.
|
|
$x = 0
|
|
for ($i = 0; $i -lt $ColumnIndex; $i++) { $x += $lvPerm.Columns[$i].Width }
|
|
$menu.Show($lvPerm, $x, 4)
|
|
}
|
|
|
|
function Show-ExportDialog {
|
|
<# Modal picker for which report categories to export. Returns an array of
|
|
canonical category names (Tree/LongPaths/Permissions/Grants/Errors) or
|
|
$null if the user cancelled. Categories with no data are disabled. #>
|
|
param($Scan)
|
|
|
|
$hasGrants = @($Scan.Grants | Where-Object { $_.Success }).Count -gt 0
|
|
$hasErrors = $Scan.Errors.Count -gt 0
|
|
|
|
# label, canonical name, enabled?
|
|
$cats = @(
|
|
@{ Text = 'Tailles des dossiers'; Name = 'Tree'; Enabled = $true },
|
|
@{ Text = 'Chemins trop longs'; Name = 'LongPaths'; Enabled = $true },
|
|
@{ Text = 'Permissions'; Name = 'Permissions'; Enabled = $true },
|
|
@{ Text = 'Accès accordé'; Name = 'Grants'; Enabled = $hasGrants },
|
|
@{ Text = 'Erreurs / accès refusé'; Name = 'Errors'; Enabled = $hasErrors }
|
|
)
|
|
|
|
$dlg = New-Object System.Windows.Forms.Form
|
|
$dlg.Text = 'Exporter HTML - choisir les catégories'
|
|
$dlg.FormBorderStyle = 'FixedDialog'
|
|
$dlg.StartPosition = 'CenterParent'
|
|
$dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
|
|
$dlg.ClientSize = New-Object System.Drawing.Size(300, 250)
|
|
|
|
$lbl = New-Object System.Windows.Forms.Label
|
|
$lbl.Text = 'Inclure ces catégories dans le rapport :'
|
|
$lbl.Location = '14,12'; $lbl.AutoSize = $true
|
|
$dlg.Controls.Add($lbl)
|
|
|
|
$boxes = @()
|
|
$y = 40
|
|
foreach ($c in $cats) {
|
|
$cb = New-Object System.Windows.Forms.CheckBox
|
|
$cb.Text = $c.Text
|
|
$cb.Tag = $c.Name
|
|
$cb.Location = "20,$y"; $cb.AutoSize = $true
|
|
$cb.Checked = $c.Enabled
|
|
$cb.Enabled = $c.Enabled
|
|
if (-not $c.Enabled) { $cb.Text += ' (aucune donnée)' }
|
|
$dlg.Controls.Add($cb)
|
|
$boxes += $cb
|
|
$y += 26
|
|
}
|
|
|
|
$btnOk = New-Object System.Windows.Forms.Button
|
|
$btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = '104,210'
|
|
$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
|
$dlg.Controls.Add($btnOk); $dlg.AcceptButton = $btnOk
|
|
|
|
$btnCancel = New-Object System.Windows.Forms.Button
|
|
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '90,28'; $btnCancel.Location = '200,210'
|
|
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
|
$dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel
|
|
|
|
# Disable Export when nothing is ticked.
|
|
$sync = {
|
|
$btnOk.Enabled = @($boxes | Where-Object { $_.Checked }).Count -gt 0
|
|
}.GetNewClosure()
|
|
foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) }
|
|
|
|
$result = $dlg.ShowDialog($form)
|
|
$picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag })
|
|
$dlg.Dispose()
|
|
|
|
if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null }
|
|
# All categories ticked -> 'All' so the header shows the full report.
|
|
if ($picked.Count -eq $boxes.Count) { return @('All') }
|
|
return $picked
|
|
}
|
|
|
|
# ---- poll timer (reads background runspace) ----
|
|
$timer = New-Object System.Windows.Forms.Timer
|
|
$timer.Interval = 200
|
|
$timer.Add_Tick({
|
|
if ($script:Shared) {
|
|
if ($script:Shared.Status) { $statusLbl.Text = $script:Shared.Status }
|
|
if ($script:Handle -and $script:Handle.IsCompleted) {
|
|
$timer.Stop()
|
|
try {
|
|
$result = $script:PowerShell.EndInvoke($script:Handle)
|
|
$scan = $script:Shared.Result
|
|
if ($scan) { Show-Results -Scan $scan }
|
|
else { $statusLbl.Text = 'Analyse terminée mais aucune donnée renvoyée.' }
|
|
}
|
|
catch {
|
|
[System.Windows.Forms.MessageBox]::Show("Échec de l'analyse :`n$($_.Exception.Message)", 'Filer Manager',
|
|
'OK', 'Error') | Out-Null
|
|
$statusLbl.Text = "Échec de l'analyse."
|
|
}
|
|
finally {
|
|
if ($script:PowerShell) { $script:PowerShell.Dispose() }
|
|
if ($script:Runspace) { $script:Runspace.Close(); $script:Runspace.Dispose() }
|
|
$script:PowerShell = $null; $script:Runspace = $null; $script:Handle = $null
|
|
$progressBar.Visible = $false
|
|
$btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
# ---- events ----
|
|
$btnAdd.Add_Click({
|
|
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
$dlg.Description = 'Sélectionnez un dossier à analyser'
|
|
if ($dlg.ShowDialog() -eq 'OK') {
|
|
if (-not $lstFolders.Items.Contains($dlg.SelectedPath)) { [void]$lstFolders.Items.Add($dlg.SelectedPath) }
|
|
}
|
|
})
|
|
|
|
$btnRemove.Add_Click({
|
|
if ($lstFolders.SelectedIndex -ge 0) { $lstFolders.Items.RemoveAt($lstFolders.SelectedIndex) }
|
|
})
|
|
|
|
$btnScan.Add_Click({
|
|
if ($lstFolders.Items.Count -eq 0) {
|
|
[System.Windows.Forms.MessageBox]::Show('Ajoutez au moins un dossier à analyser.', 'Filer Manager', 'OK', 'Information') | Out-Null
|
|
return
|
|
}
|
|
$paths = @($lstFolders.Items)
|
|
$maxLen = [int]$numMax.Value
|
|
$depth = [int]$numDepth.Value
|
|
$incFiles = [bool]$chkFiles.Checked
|
|
$grant = [bool]$chkGrant.Checked
|
|
$revert = [bool]$chkRevert.Checked
|
|
|
|
if ($grant -and -not (Test-IsElevated)) {
|
|
$msg = "L'octroi automatique nécessite des droits Administrateur, mais Filer Manager n'est pas exécuté en mode élevé.`n`n" +
|
|
"Les éléments refusés peuvent ne pas être corrigés. Continuer quand même ?"
|
|
if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -ne 'Yes') { return }
|
|
}
|
|
|
|
$btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false
|
|
$progressBar.Visible = $true
|
|
$statusLbl.Text = 'Analyse en cours...'
|
|
|
|
$script:Shared = [hashtable]::Synchronized(@{ Status = 'Démarrage...'; Result = $null })
|
|
$script:Runspace = [runspacefactory]::CreateRunspace()
|
|
$script:Runspace.ApartmentState = 'STA'
|
|
$script:Runspace.Open()
|
|
$script:Runspace.SessionStateProxy.SetVariable('Shared', $script:Shared)
|
|
|
|
$script:PowerShell = [powershell]::Create()
|
|
$script:PowerShell.Runspace = $script:Runspace
|
|
[void]$script:PowerShell.AddScript($CoreFunctions)
|
|
[void]$script:PowerShell.AddScript(@'
|
|
param($Paths, $MaxLen, $Depth, $IncFiles, $Grant, $Revert, $Shared)
|
|
$Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared
|
|
'@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($grant).AddArgument($revert).AddArgument($script:Shared)
|
|
|
|
$script:Handle = $script:PowerShell.BeginInvoke()
|
|
$timer.Start()
|
|
})
|
|
|
|
$btnExport.Add_Click({
|
|
if (-not $script:LastScan) { return }
|
|
|
|
$categories = Show-ExportDialog -Scan $script:LastScan
|
|
if (-not $categories) { return } # cancelled or nothing selected
|
|
|
|
# Build a filename hint reflecting the chosen scope.
|
|
$scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() }
|
|
|
|
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
|
$dlg.Filter = 'Rapport HTML (*.html)|*.html'
|
|
$dlg.FileName = "filer-report-$scope-$(Get-Date -Format 'yyyyMMdd-HHmmss').html"
|
|
if ($dlg.ShowDialog() -eq 'OK') {
|
|
try {
|
|
$out = ConvertTo-FilerHtmlReport -Scan $script:LastScan -Path $dlg.FileName -Categories $categories
|
|
$statusLbl.Text = "Rapport enregistré : $out"
|
|
if ([System.Windows.Forms.MessageBox]::Show("Rapport enregistré dans :`n$out`n`nL'ouvrir maintenant ?", 'Filer Manager',
|
|
'YesNo', 'Question') -eq 'Yes') {
|
|
Start-Process $out
|
|
}
|
|
}
|
|
catch {
|
|
[System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null
|
|
}
|
|
}
|
|
})
|
|
|
|
# Pre-fill folders if -Path was supplied without -Output
|
|
if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } }
|
|
|
|
[void]$form.ShowDialog()
|
|
$form.Dispose()
|