commit 3793a3ec8835ef4b96e7e52a1b569de2a953de96 Author: Sébastien QUEROL <2+kawa@not.obvious> Date: Fri Jun 5 11:36:54 2026 +0200 Upload files to "/" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2f44f6 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Filer Manager + +Un outil PowerShell mono-fichier pour les serveurs de fichiers Windows. Il analyse des dossiers et rapporte : + +- **Tailles des dossiers** — arborescence visuelle et repliable avec barres de taille (arbre GUI + HTML). +- **Chemins trop longs** — fichiers/dossiers dont le chemin complet atteint ou dépasse un seuil (260 par défaut, le `MAX_PATH` de Windows). +- **Permissions NTFS** — propriétaire + ACL (identité, droits, allow/deny, hérité) par dossier, jusqu'à une profondeur configurable. + Dans l'interface, **cliquez sur n'importe quel en-tête de colonne** (Dossier / Identité / Droits / Type / Hérité) pour une + fenêtre de type Excel : tri A→Z / Z→A et sélection des valeurs à conserver. Les filtres se combinent entre les colonnes ; + **Effacer les filtres** réinitialise le tout. +- **Octroi automatique en cas de refus** *(optionnel)* — lorsqu'un élément ne peut pas être lu, l'outil s'approprie la + propriété pour Administrators et accorde le `FullControl` à `BUILTIN\Administrators` afin que l'analyse puisse se terminer. + Chaque modification est consignée dans l'onglet/la section de rapport **Accès accordé**, et par défaut chacune est + **annulée** après l'analyse (propriétaire + ACL d'origine restaurés là où ils ont pu être lus au préalable). Nécessite une + exécution en mode élevé. + +Tout est exportable vers un **rapport HTML autonome** (aucune ressource externe — fonctionne hors ligne, facile à envoyer par +e-mail ou à archiver). Les rapports peuvent devenir volumineux, vous pouvez donc **n'exporter que les catégories nécessaires** — +une, plusieurs ou toutes — aussi bien dans l'interface (sélecteur à cases sur **Exporter HTML…**) qu'en mode sans interface (`-Category`). + +Aucune installation ni dépendance. Fonctionne sur **Windows PowerShell 5.1** et **PowerShell 7+**. + +## Lancer l'interface graphique + +Double-cliquez sur **`Run-FilerManager.cmd`** (il se lance avec `-ExecutionPolicy Bypass` pour cette exécution uniquement), +ou depuis une console : + +```powershell +pwsh -STA -File .\filer-manager.ps1 +``` + +Dans la fenêtre : **Ajouter…** un ou plusieurs dossiers, ajustez *Longueur max* / *Profondeur des permissions* / +*Inclure les fichiers dans l'arborescence*, cochez éventuellement **Accorder l'accès Administrators aux éléments refusés** +(et **Annuler les modifications après l'analyse**), cliquez sur **Analyser**, puis **Exporter HTML…** — la boîte de dialogue +d'export vous laisse **cocher les catégories** (Tailles des dossiers, Chemins trop longs, Permissions, Accès accordé, Erreurs) +à inclure dans le fichier ; toutes sont sélectionnées par défaut. + +> L'octroi automatique n'agit que sur les éléments refusés, un élément à la fois. Exécutez Filer Manager +> **en tant qu'administrateur** pour qu'il fonctionne — il vous avertit si vous n'êtes pas en mode élevé. + +> L'analyse s'exécute sur un thread d'arrière-plan, la fenêtre reste donc réactive sur les grosses arborescences. + +## Lancer sans interface (pour le Planificateur de tâches / cron) + +```powershell +.\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui +``` + +### Paramètres + +| Paramètre | Défaut | Description | +|-----------------------|---------|----------------------------------------------------------------------| +| `-Path` | — | Un ou plusieurs dossiers racines à analyser. | +| `-Output` | — | Chemin du rapport HTML (mode sans interface). | +| `-MaxPathLength` | `260` | Signale les éléments dont la longueur de chemin complet est ≥ ce nombre de caractères. | +| `-PermissionDepth` | `1` | Niveaux de dossiers sous chaque racine pour lesquels collecter les ACL (0 = racines uniquement). | +| `-IncludeFilesInTree` | off | Liste les fichiers individuels dans l'arborescence des tailles, pas seulement les dossiers. | +| `-GrantAccess` | off | S'approprie la propriété + accorde le FullControl à Administrators sur les éléments refusés, puis réessaye. Nécessite le mode élevé. | +| `-KeepGrants` | off | Conserve l'accès accordé par `-GrantAccess` (par défaut : annulé après l'analyse). | +| `-NoGui` | off | Force le mode sans interface (nécessite `-Path` et `-Output`). | +| `-Category` | `All` | Catégories à inclure dans le rapport — toute combinaison de `Tree`, `LongPaths`, `Permissions`, `Grants`, `Errors` (pour garder les gros rapports petits). | + +N'exporter que les permissions, ou seulement les tailles + chemins trop longs : + +```powershell +.\filer-manager.ps1 -Path "D:\Shares" -Output perms.html -NoGui -Category Permissions +.\filer-manager.ps1 -Path "D:\Shares" -Output sizes.html -NoGui -Category Tree,LongPaths +``` + +### Planifier un rapport hebdomadaire + +```powershell +$action = New-ScheduledTaskAction -Execute "pwsh.exe" ` + -Argument '-NoProfile -File "C:\Tools\filer-manager\filer-manager.ps1" -Path "D:\Shares" -Output "C:\Reports\filer.html" -NoGui' +$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6am +Register-ScheduledTask -TaskName "Filer Report" -Action $action -Trigger $trigger -RunLevel Highest +``` + +## Notes + +- Exécutez l'outil avec un compte disposant d'un accès en lecture aux dossiers (et `SeBackupPrivilege` / Administrateur aide à atteindre les ACL et à contourner les refus par dossier). Les éléments qui ne peuvent pas être lus sont listés dans la section **Erreurs / accès refusé** au lieu de faire échouer l'analyse — ou, avec `-GrantAccess` (en mode élevé), l'outil s'accorde l'accès et liste les modifications dans **Accès accordé**. +- Taille d'un dossier = somme des longueurs des fichiers (taille logique), pas la taille sur le disque. +- Le rapport est un seul fichier `.html` avec CSS/JS inline — sans risque à envoyer par e-mail ou à conserver comme instantané d'audit. diff --git a/Run-FilerManager.cmd b/Run-FilerManager.cmd new file mode 100644 index 0000000..b60e49c --- /dev/null +++ b/Run-FilerManager.cmd @@ -0,0 +1,12 @@ +@echo off +REM Lanceur double-clic pour Filer Manager (contourne la stratégie d'exécution pour cette exécution uniquement). +setlocal +set "SCRIPT=%~dp0filer-manager.ps1" + +where pwsh >nul 2>&1 +if %errorlevel%==0 ( + pwsh -NoProfile -ExecutionPolicy Bypass -STA -File "%SCRIPT%" +) else ( + powershell -NoProfile -ExecutionPolicy Bypass -STA -File "%SCRIPT%" +) +endlocal diff --git a/filer-manager.ps1 b/filer-manager.ps1 new file mode 100644 index 0000000..e8b8b68 --- /dev/null +++ b/filer-manager.ps1 @@ -0,0 +1,1220 @@ +#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 { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers" } + $kids = @($Node.Children | Where-Object { $_ } ) + + $summary = "$icon $(_enc $Node.Name) $sizeStr" + + "$pct%$meta" + + if ($kids.Count -gt 0) { + $open = if ($Node.Depth -eq 0) { ' open' } else { '' } + [void]$Out.Append("$summary
") + foreach ($c in ($kids | Sort-Object @{E={$_.Size}} -Descending)) { + _renderNode -Node $c -ParentSize $Node.Size -Out $Out + } + [void]$Out.Append("
") + } + else { + [void]$Out.Append("
$summary
") + } + } + + $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("$($lp.Length)$(_enc $lp.Type)$(_enc $lp.Path)") + } + if ($Scan.LongPaths.Count -eq 0) { [void]$longRows.Append("Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. ✅") } + } + + # ---- 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("
$(_enc $grp.Name) Propriétaire : $(_enc $owner)") + [void]$permSb.Append("") + foreach ($ace in $grp.Group) { + $cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' } + [void]$permSb.Append("") + } + [void]$permSb.Append("
IdentitéDroitsTypeHérité
$(_enc $ace.Identity)$(_enc $ace.Rights)$(_enc $ace.Type)$($ace.Inherited)
") + } + if ($byFolder.Count -eq 0) { [void]$permSb.Append("

Aucune permission collectée.

") } + } + + # ---- 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 = "annulé" } + elseif ($Scan.Settings.RevertGrants) { $state = "NON annulé$(if($g.RevertError){' - ' + (_enc $g.RevertError)})" } + else { $state = "conservé" } + [void]$grantSb.Append("$(_enc $g.Path)$(_enc $g.Reason)$(_enc $g.Changes)$(_enc $g.OriginalOwner)$state") + } + } + + # ---- errors ---- + $errSb = New-Object System.Text.StringBuilder + if ($wantErrors) { + foreach ($e in $Scan.Errors) { + [void]$errSb.Append("$(_enc $e.Path)$(_enc $e.Error)") + } + } + + $rootsList = ($Scan.Roots | ForEach-Object { _enc $_.FullPath }) -join '
' + $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 { "
Catégories : $(_enc ($included -join ', '))
" } + + $html = @" + + + +Rapport Filer Manager - $gen + + +
+

📁 Rapport Filer Manager

+
Généré le $gen · Hôte $(_enc $Scan.Computer) · + Seuil chemin trop long $($Scan.Settings.MaxPathLength) · Profondeur des permissions $($Scan.Settings.PermissionDepth)
+
Analysé : $rootsList
+ $catNote +
+
+
+
$(Format-Bytes $Scan.Stats.TotalSize)
Taille totale
+
$('{0:N0}' -f $Scan.Stats.TotalFolders)
Dossiers
+
$('{0:N0}' -f $Scan.Stats.TotalFiles)
Fichiers
+
$($Scan.Stats.LongPaths)
Chemins trop longs
+
$($Scan.Stats.Errors)
Erreurs / refusés
+ $(if ($Scan.Settings.GrantAccess) { "
$($Scan.Stats.Grants)
Accès accordé
" }) +
+ + $(if ($wantTree) { @" +
+

📊 Tailles des dossiers

+
$($treeSb.ToString())
+
+"@ }) + + $(if ($wantLong) { @" +
+

⚠️ Noms de fichiers / chemins trop longs (≥ $($Scan.Settings.MaxPathLength) caractères)

+ +
+ $($longRows.ToString())
LongueurTypeChemin
+
+"@ }) + + $(if ($wantPerm) { @" +
+

🔐 Permissions

+ $($permSb.ToString()) +
+"@ }) + + $(if ($wantGrants -and $Scan.Settings.GrantAccess) { @" +
+

🔨 Accès accordé pour terminer l'analyse

+

É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' }).

+ $(if ($grantRows.Count -gt 0) { @" +
+ $($grantSb.ToString())
CheminRaisonModificationPropriétaire d'origineÉtat
+"@ } else { "

Aucune modification d'accès n'était nécessaire. ✅

" }) +
+"@ }) + + $(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @" +
+

❌ Erreurs et accès refusé

+
+ $($errSb.ToString())
CheminErreur
+
+"@ }) +
+ + + +"@ + + 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()