Update filer-manager.ps1
This commit is contained in:
+590
-48
@@ -1,8 +1,8 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Filer Manager - analyse des dossiers Windows et rapporte les tailles des
|
Filer Manager - analyse des dossiers Windows et rapporte les tailles des
|
||||||
dossiers, les chemins trop longs et les permissions NTFS, avec un export HTML.
|
dossiers, les chemins trop longs et les permissions NTFS, avec export HTML et CSV.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
S'exécute avec une interface graphique WinForms par défaut. Peut aussi
|
S'exécute avec une interface graphique WinForms par défaut. Peut aussi
|
||||||
@@ -19,6 +19,12 @@
|
|||||||
.PARAMETER Output
|
.PARAMETER Output
|
||||||
Chemin du rapport HTML à écrire en mode headless.
|
Chemin du rapport HTML à écrire en mode headless.
|
||||||
|
|
||||||
|
.PARAMETER CsvOutput
|
||||||
|
Chemin de base du/des rapport(s) CSV à écrire en mode headless. Un fichier CSV
|
||||||
|
distinct est produit par catégorie ayant des données (p. ex. base.csv ->
|
||||||
|
base-tree.csv, base-permissions.csv, ...). Peut être combiné avec -Output pour
|
||||||
|
écrire HTML et CSV en même temps, ou utilisé seul pour un export CSV uniquement.
|
||||||
|
|
||||||
.PARAMETER MaxPathLength
|
.PARAMETER MaxPathLength
|
||||||
Longueur de chemin (caractères) à partir de laquelle un élément est signalé
|
Longueur de chemin (caractères) à partir de laquelle un élément est signalé
|
||||||
comme « trop long ». Par défaut 260 (Windows MAX_PATH).
|
comme « trop long ». Par défaut 260 (Windows MAX_PATH).
|
||||||
@@ -32,6 +38,19 @@
|
|||||||
Inclure les fichiers individuels (pas seulement les dossiers) dans
|
Inclure les fichiers individuels (pas seulement les dossiers) dans
|
||||||
l'arborescence des tailles.
|
l'arborescence des tailles.
|
||||||
|
|
||||||
|
.PARAMETER HideInheritedChildPerms
|
||||||
|
Omettre des rapports les permissions héritées portées par les dossiers
|
||||||
|
enfants (les entrées héritées qui ne font que recopier celles du parent). Les
|
||||||
|
permissions héritées des dossiers racines sont conservées, car le parent d'une
|
||||||
|
racine est hors analyse et ces entrées sont la seule trace des droits en vigueur.
|
||||||
|
|
||||||
|
.PARAMETER HideSystemPrincipals
|
||||||
|
Omettre des rapports les permissions portées par des comptes/groupes système
|
||||||
|
et intégrés bien connus (p. ex. NT AUTHORITY\SYSTEM, BUILTIN\Administrators,
|
||||||
|
CREATOR OWNER, NT SERVICE\*). La détection se fait par SID (indépendante de la
|
||||||
|
langue), avec un repli sur le nom. Utile pour ne garder que les identités
|
||||||
|
métier dans l'audit des permissions.
|
||||||
|
|
||||||
.PARAMETER GrantAccess
|
.PARAMETER GrantAccess
|
||||||
Lorsqu'un dossier/fichier ne peut pas être lu (accès refusé), s'approprie
|
Lorsqu'un dossier/fichier ne peut pas être lu (accès refusé), s'approprie
|
||||||
automatiquement la propriété pour Administrators et accorde le FullControl à
|
automatiquement la propriété pour Administrators et accorde le FullControl à
|
||||||
@@ -68,9 +87,12 @@
|
|||||||
param(
|
param(
|
||||||
[string[]]$Path,
|
[string[]]$Path,
|
||||||
[string]$Output,
|
[string]$Output,
|
||||||
|
[string]$CsvOutput,
|
||||||
[int]$MaxPathLength = 260,
|
[int]$MaxPathLength = 260,
|
||||||
[int]$PermissionDepth = 1,
|
[int]$PermissionDepth = 1,
|
||||||
[switch]$IncludeFilesInTree,
|
[switch]$IncludeFilesInTree,
|
||||||
|
[switch]$HideInheritedChildPerms,
|
||||||
|
[switch]$HideSystemPrincipals,
|
||||||
[switch]$GrantAccess,
|
[switch]$GrantAccess,
|
||||||
[switch]$KeepGrants,
|
[switch]$KeepGrants,
|
||||||
[switch]$NoGui,
|
[switch]$NoGui,
|
||||||
@@ -110,6 +132,56 @@ function Test-IsAccessDenied {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Resolve-PrincipalSid {
|
||||||
|
# Best-effort translation of an ACE IdentityReference (an NTAccount or a
|
||||||
|
# SecurityIdentifier) to its SID string. Returns $null when unresolvable
|
||||||
|
# (e.g. an orphaned account from a deleted domain user).
|
||||||
|
param($IdentityReference)
|
||||||
|
try {
|
||||||
|
if ($IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { return $IdentityReference.Value }
|
||||||
|
return $IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||||
|
} catch {
|
||||||
|
# The reference may already be a raw SID string (unresolved account).
|
||||||
|
try { return (New-Object System.Security.Principal.SecurityIdentifier ([string]$IdentityReference)).Value } catch { return $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsSystemPrincipal {
|
||||||
|
# True for well-known built-in / system accounts and groups, so the audit can
|
||||||
|
# focus on real business identities. Detection is by SID prefix (locale-
|
||||||
|
# independent), with a name-based fallback for the few cases where the SID
|
||||||
|
# could not be resolved. Examples: NT AUTHORITY\SYSTEM (S-1-5-18),
|
||||||
|
# BUILTIN\Administrators (S-1-5-32-544), CREATOR OWNER (S-1-3-*),
|
||||||
|
# NT SERVICE\TrustedInstaller (S-1-5-80-*).
|
||||||
|
param([string]$Identity, [string]$Sid)
|
||||||
|
|
||||||
|
if ($Sid) {
|
||||||
|
switch -Regex ($Sid) {
|
||||||
|
'^S-1-5-(18|19|20)$' { return $true } # SYSTEM / LOCAL SERVICE / NETWORK SERVICE
|
||||||
|
'^S-1-5-(6|9|17)$' { return $true } # SERVICE / Enterprise DCs / IUSR
|
||||||
|
'^S-1-5-32-' { return $true } # BUILTIN\* (Administrators, Users, ...)
|
||||||
|
'^S-1-5-(80|83|90|96)-' { return $true } # NT SERVICE / VM / Window Manager / Font driver
|
||||||
|
'^S-1-3-' { return $true } # CREATOR OWNER / CREATOR GROUP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Name fallback: only when the SID could not be resolved (offline domain,
|
||||||
|
# broken trust). When a SID is known it is authoritative, so groups like
|
||||||
|
# Authenticated Users (S-1-5-11) are intentionally not treated as system even
|
||||||
|
# though their name carries the NT AUTHORITY prefix. Covers common
|
||||||
|
# English/French/German forms of the built-in domains and standalone principals.
|
||||||
|
if (-not $Sid -and $Identity) {
|
||||||
|
$id = $Identity.Trim()
|
||||||
|
foreach ($p in @('NT AUTHORITY\', 'AUTORITE NT\', "AUTORIT$([char]0xC9) NT\", 'NT-AUTORIT', 'BUILTIN\', 'NT SERVICE\', 'AUTORITE DE SECURITE')) {
|
||||||
|
if ($id.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||||
|
}
|
||||||
|
foreach ($e in @('SYSTEM', 'CREATOR OWNER', 'CREATOR GROUP', 'TrustedInstaller')) {
|
||||||
|
if ($id.Equals($e, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Grant-AdminAccess {
|
function Grant-AdminAccess {
|
||||||
<# Seizes ownership for Administrators and grants BUILTIN\Administrators
|
<# Seizes ownership for Administrators and grants BUILTIN\Administrators
|
||||||
FullControl on a single item (no recursion), so a denied path can be
|
FullControl on a single item (no recursion), so a denied path can be
|
||||||
@@ -318,6 +390,7 @@ function Get-FolderPermissions {
|
|||||||
Folder = $Path
|
Folder = $Path
|
||||||
Owner = $acl.Owner
|
Owner = $acl.Owner
|
||||||
Identity = [string]$ace.IdentityReference
|
Identity = [string]$ace.IdentityReference
|
||||||
|
Sid = Resolve-PrincipalSid $ace.IdentityReference
|
||||||
Rights = [string]$ace.FileSystemRights
|
Rights = [string]$ace.FileSystemRights
|
||||||
Type = [string]$ace.AccessControlType
|
Type = [string]$ace.AccessControlType
|
||||||
Inherited = $ace.IsInherited
|
Inherited = $ace.IsInherited
|
||||||
@@ -420,6 +493,32 @@ function Invoke-FilerScan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-FilerRootPathSet {
|
||||||
|
# Case-insensitive set of the scan-root full paths. Used to decide which
|
||||||
|
# permission rows sit on a scan root (vs. a child folder).
|
||||||
|
param($Scan)
|
||||||
|
$set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
foreach ($r in $Scan.Roots) { [void]$set.Add([string]$r.FullPath) }
|
||||||
|
return $set
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select-FilerPerms {
|
||||||
|
# Returns the scan's permission ACEs, optionally dropping inherited ACEs that
|
||||||
|
# sit on a child folder and/or ACEs held by well-known system principals.
|
||||||
|
# Inherited ACEs on the scan roots are always kept: a root's parent is outside
|
||||||
|
# the scan, so those entries are the only record of the permissions in effect there.
|
||||||
|
param($Scan, [bool]$HideInheritedChildPerms, [bool]$HideSystemPrincipals)
|
||||||
|
$rows = @($Scan.Permissions)
|
||||||
|
if ($HideInheritedChildPerms) {
|
||||||
|
$roots = Get-FilerRootPathSet -Scan $Scan
|
||||||
|
$rows = @($rows | Where-Object { (-not $_.Inherited) -or $roots.Contains([string]$_.Folder) })
|
||||||
|
}
|
||||||
|
if ($HideSystemPrincipals) {
|
||||||
|
$rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) })
|
||||||
|
}
|
||||||
|
return $rows
|
||||||
|
}
|
||||||
|
|
||||||
function ConvertTo-FilerHtmlReport {
|
function ConvertTo-FilerHtmlReport {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)] $Scan,
|
[Parameter(Mandatory)] $Scan,
|
||||||
@@ -427,7 +526,11 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
# Which report categories to include. 'All' (default) emits everything;
|
# Which report categories to include. 'All' (default) emits everything;
|
||||||
# otherwise pass any combination of Tree, LongPaths, Permissions, Grants, Errors.
|
# otherwise pass any combination of Tree, LongPaths, Permissions, Grants, Errors.
|
||||||
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
|
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
|
||||||
[string[]]$Categories = @('All')
|
[string[]]$Categories = @('All'),
|
||||||
|
# When set, inherited permissions on child folders are omitted (roots keep theirs).
|
||||||
|
[bool]$HideInheritedChildPerms = $false,
|
||||||
|
# When set, ACEs held by well-known system/built-in principals are omitted.
|
||||||
|
[bool]$HideSystemPrincipals = $false
|
||||||
)
|
)
|
||||||
|
|
||||||
function _enc([string]$s) { [System.Net.WebUtility]::HtmlEncode($s) }
|
function _enc([string]$s) { [System.Net.WebUtility]::HtmlEncode($s) }
|
||||||
@@ -482,20 +585,63 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
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>") }
|
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) ----
|
# ---- permissions (nested folder tree; every level is collapsible) ----
|
||||||
$permSb = New-Object System.Text.StringBuilder
|
$permSb = New-Object System.Text.StringBuilder
|
||||||
if ($wantPerm) {
|
if ($wantPerm) {
|
||||||
$byFolder = $Scan.Permissions | Group-Object Folder
|
# Canonicalise so 8.3 / trailing-slash / casing differences between a folder
|
||||||
|
# and its parent (from Get-ChildItem) still line up when building the tree.
|
||||||
|
function _canon([string]$p) { try { ([System.IO.Path]::GetFullPath($p)).TrimEnd('\') } catch { $p.TrimEnd('\') } }
|
||||||
|
$byFolder = (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals) | Group-Object Folder
|
||||||
|
|
||||||
|
# One node per folder, keyed by canonical path.
|
||||||
|
$nodes = @{}
|
||||||
foreach ($grp in $byFolder) {
|
foreach ($grp in $byFolder) {
|
||||||
$owner = ($grp.Group | Select-Object -First 1).Owner
|
$canon = _canon ([string]$grp.Name)
|
||||||
[void]$permSb.Append("<details class='perm'><summary><span class='nm'>$(_enc $grp.Name)</span> <span class='meta'>Propriétaire : $(_enc $owner)</span></summary>")
|
$nodes[$canon] = [pscustomobject]@{
|
||||||
[void]$permSb.Append("<table class='grid'><thead><tr><th>Identité</th><th>Droits</th><th>Type</th><th>Hérité</th></tr></thead><tbody>")
|
Canon = $canon
|
||||||
foreach ($ace in $grp.Group) {
|
Display = [string]$grp.Name
|
||||||
|
Owner = ($grp.Group | Select-Object -First 1).Owner
|
||||||
|
Aces = $grp.Group
|
||||||
|
Children = New-Object System.Collections.ArrayList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Attach each folder to its nearest ancestor that also has permissions; the
|
||||||
|
# ones with no such ancestor are the tree's top-level (scan-root) nodes.
|
||||||
|
$tops = New-Object System.Collections.ArrayList
|
||||||
|
foreach ($node in $nodes.Values) {
|
||||||
|
$parent = [System.IO.Path]::GetDirectoryName($node.Canon)
|
||||||
|
$attached = $false
|
||||||
|
while ($parent) {
|
||||||
|
if ($nodes.ContainsKey($parent)) { [void]$nodes[$parent].Children.Add($node); $attached = $true; break }
|
||||||
|
$up = [System.IO.Path]::GetDirectoryName($parent)
|
||||||
|
if ($up -eq $parent) { break }
|
||||||
|
$parent = $up
|
||||||
|
}
|
||||||
|
if (-not $attached) { [void]$tops.Add($node) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPermNode {
|
||||||
|
param($Node, [bool]$IsTop, [System.Text.StringBuilder]$Out)
|
||||||
|
# Top-level nodes show their full path; nested ones just the leaf name.
|
||||||
|
$label = if ($IsTop) { $Node.Display } else { Split-Path -Leaf $Node.Display }
|
||||||
|
$topCls = if ($IsTop) { ' top' } else { '' }
|
||||||
|
[void]$Out.Append("<details class='perm$topCls' open><summary><span class='nm'>$(_enc $label)</span> <span class='meta'>Propriétaire : $(_enc $Node.Owner)</span></summary>")
|
||||||
|
[void]$Out.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 $Node.Aces) {
|
||||||
$cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' }
|
$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>")
|
$sys = Test-IsSystemPrincipal -Identity $ace.Identity -Sid $ace.Sid
|
||||||
|
[void]$Out.Append("<tr$cls data-inh='$($ace.Inherited)' data-sys='$sys'><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>")
|
[void]$Out.Append("</tbody></table>")
|
||||||
|
if ($Node.Children.Count -gt 0) {
|
||||||
|
[void]$Out.Append("<div class='kids'>")
|
||||||
|
foreach ($c in ($Node.Children | Sort-Object Display)) { _renderPermNode -Node $c -IsTop $false -Out $Out }
|
||||||
|
[void]$Out.Append("</div>")
|
||||||
}
|
}
|
||||||
|
[void]$Out.Append("</details>")
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($t in ($tops | Sort-Object Display)) { _renderPermNode -Node $t -IsTop $true -Out $permSb }
|
||||||
if ($byFolder.Count -eq 0) { [void]$permSb.Append("<p class='muted'>Aucune permission collectée.</p>") }
|
if ($byFolder.Count -eq 0) { [void]$permSb.Append("<p class='muted'>Aucune permission collectée.</p>") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +721,12 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
tr.deny td { color:var(--deny); }
|
tr.deny td { color:var(--deny); }
|
||||||
.ok { color:var(--ok); } .muted { color:var(--mut); }
|
.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); }
|
details.perm { background:var(--pan2); border-radius:10px; padding:6px 10px; margin:6px 0; border-left:2px solid var(--acc); }
|
||||||
|
details.perm.top { border-left:2px solid var(--bar); }
|
||||||
|
details.perm.top > summary > .nm { font-family:Consolas,'Cascadia Code',monospace; }
|
||||||
|
details.perm .kids { margin-left:16px; }
|
||||||
|
.permtoggle { display:inline-flex; align-items:center; gap:8px; margin-bottom:14px; color:var(--mut);
|
||||||
|
font-size:13px; cursor:pointer; user-select:none; }
|
||||||
|
.permtoggle input { accent-color:var(--acc); width:15px; height:15px; cursor:pointer; }
|
||||||
.scroll { max-height:480px; overflow:auto; }
|
.scroll { max-height:480px; overflow:auto; }
|
||||||
input.filter { width:100%; padding:8px 12px; margin-bottom:12px; background:var(--pan2); border:1px solid var(--line);
|
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; }
|
border-radius:8px; color:var(--ink); font-size:13px; }
|
||||||
@@ -617,7 +769,11 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
$(if ($wantPerm) { @"
|
$(if ($wantPerm) { @"
|
||||||
<section>
|
<section>
|
||||||
<h2>🔐 Permissions</h2>
|
<h2>🔐 Permissions</h2>
|
||||||
$($permSb.ToString())
|
<label class='permtoggle'><input id='hideInh' type='checkbox' onchange='applyPermFilters()'>
|
||||||
|
Masquer les permissions héritées (afficher uniquement les permissions propres)</label>
|
||||||
|
<label class='permtoggle'><input id='hideSys' type='checkbox'$(if ($HideSystemPrincipals) { ' checked' }) onchange='applyPermFilters()'>
|
||||||
|
Masquer les comptes/groupes système (SYSTEM, Administrators, CREATOR OWNER…)</label>
|
||||||
|
<div id='permroot'>$($permSb.ToString())</div>
|
||||||
</section>
|
</section>
|
||||||
"@ })
|
"@ })
|
||||||
|
|
||||||
@@ -647,6 +803,31 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
var q = inp.value.toLowerCase(), rows = document.getElementById(tableId).tBodies[0].rows;
|
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'; }
|
for (var i=0;i<rows.length;i++){ rows[i].style.display = rows[i].innerText.toLowerCase().indexOf(q)>-1?'':'none'; }
|
||||||
}
|
}
|
||||||
|
function applyPermFilters(){
|
||||||
|
var inhCb = document.getElementById('hideInh'), sysCb = document.getElementById('hideSys');
|
||||||
|
var hideInh = inhCb && inhCb.checked, hideSys = sysCb && sysCb.checked;
|
||||||
|
var anyHide = hideInh || hideSys;
|
||||||
|
// Hide each folder's own rows that match an active filter (inherited and/or system).
|
||||||
|
document.querySelectorAll('#permroot details.perm').forEach(function(g){
|
||||||
|
g.querySelectorAll(':scope > table.grid > tbody > tr').forEach(function(r){
|
||||||
|
var drop = (hideInh && r.getAttribute('data-inh') === 'True') ||
|
||||||
|
(hideSys && r.getAttribute('data-sys') === 'True');
|
||||||
|
r.style.display = drop ? 'none' : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Walk the tree bottom-up: a folder stays visible if it keeps any own row or
|
||||||
|
// any visible child folder.
|
||||||
|
function visit(node){
|
||||||
|
var vis = false;
|
||||||
|
node.querySelectorAll(':scope > table.grid > tbody > tr').forEach(function(r){ if (r.style.display !== 'none') vis = true; });
|
||||||
|
node.querySelectorAll(':scope > .kids > details.perm').forEach(function(c){ if (visit(c)) vis = true; });
|
||||||
|
node.style.display = (anyHide && !vis) ? 'none' : '';
|
||||||
|
return vis;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#permroot > details.perm').forEach(function(top){ visit(top); });
|
||||||
|
}
|
||||||
|
// Apply the initial filter state (e.g. when the system toggle starts checked).
|
||||||
|
applyPermFilters();
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
"@
|
"@
|
||||||
@@ -654,6 +835,127 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
Set-Content -LiteralPath $Path -Value $html -Encoding UTF8
|
Set-Content -LiteralPath $Path -Value $html -Encoding UTF8
|
||||||
return $Path
|
return $Path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConvertTo-FilerCsvReport {
|
||||||
|
<# Writes the scan results to CSV. Because the report categories have very
|
||||||
|
different shapes, each selected category with data is written to its own
|
||||||
|
file, derived from the base $Path (e.g. report.csv -> report-tree.csv,
|
||||||
|
report-permissions.csv, ...). Returns the list of files written. The list
|
||||||
|
separator follows the current culture so the files open cleanly in the
|
||||||
|
local Excel (';' on French systems, ',' elsewhere). #>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $Scan,
|
||||||
|
[Parameter(Mandatory)] [string]$Path,
|
||||||
|
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
|
||||||
|
[string[]]$Categories = @('All'),
|
||||||
|
# When set, inherited permissions on child folders are omitted (roots keep theirs).
|
||||||
|
[bool]$HideInheritedChildPerms = $false,
|
||||||
|
# When set, ACEs held by well-known system/built-in principals are omitted.
|
||||||
|
[bool]$HideSystemPrincipals = $false
|
||||||
|
)
|
||||||
|
|
||||||
|
$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')
|
||||||
|
|
||||||
|
$delim = (Get-Culture).TextInfo.ListSeparator
|
||||||
|
if ([string]::IsNullOrEmpty($delim)) { $delim = ',' }
|
||||||
|
|
||||||
|
# Derive a per-category file path from the base path.
|
||||||
|
$dir = Split-Path -Path $Path -Parent
|
||||||
|
$base = [System.IO.Path]::GetFileNameWithoutExtension($Path)
|
||||||
|
if ([string]::IsNullOrEmpty($base)) { $base = 'filer-report' }
|
||||||
|
|
||||||
|
$written = New-Object System.Collections.ArrayList
|
||||||
|
function _writeCsv {
|
||||||
|
param($Rows, [string]$Suffix)
|
||||||
|
$rows = @($Rows | Where-Object { $null -ne $_ })
|
||||||
|
if ($rows.Count -eq 0) { return } # don't emit empty files
|
||||||
|
$name = "$base-$Suffix.csv"
|
||||||
|
$p = if ([string]::IsNullOrEmpty($dir)) { $name } else { Join-Path $dir $name }
|
||||||
|
$rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding UTF8 -Delimiter $delim
|
||||||
|
[void]$written.Add($p)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- tree (flattened depth-first, folders largest-first like the HTML) ----
|
||||||
|
if ($wantTree) {
|
||||||
|
$treeRows = New-Object System.Collections.ArrayList
|
||||||
|
function _flattenNode {
|
||||||
|
param($Node, [string]$Root, [System.Collections.ArrayList]$Acc)
|
||||||
|
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
|
||||||
|
[void]$Acc.Add([pscustomobject]@{
|
||||||
|
Racine = $Root
|
||||||
|
Chemin = $Node.FullPath
|
||||||
|
Nom = $Node.Name
|
||||||
|
Type = if ($isFile) { 'Fichier' } else { 'Dossier' }
|
||||||
|
Profondeur = $Node.Depth
|
||||||
|
TailleOctets = [long]$Node.Size
|
||||||
|
Taille = Format-Bytes $Node.Size
|
||||||
|
Fichiers = $Node.FileCount
|
||||||
|
Dossiers = $Node.FolderCount
|
||||||
|
})
|
||||||
|
foreach ($c in @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)) {
|
||||||
|
_flattenNode -Node $c -Root $Root -Acc $Acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($root in $Scan.Roots) { _flattenNode -Node $root -Root $root.FullPath -Acc $treeRows }
|
||||||
|
_writeCsv -Rows $treeRows -Suffix 'tree'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- long paths ----
|
||||||
|
if ($wantLong) {
|
||||||
|
$longRows = foreach ($lp in $Scan.LongPaths) {
|
||||||
|
[pscustomobject]@{ Longueur = $lp.Length; Type = $lp.Type; Chemin = $lp.Path }
|
||||||
|
}
|
||||||
|
_writeCsv -Rows $longRows -Suffix 'longpaths'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- permissions ----
|
||||||
|
if ($wantPerm) {
|
||||||
|
$permRows = foreach ($ace in (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals)) {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Dossier = $ace.Folder
|
||||||
|
Proprietaire = $ace.Owner
|
||||||
|
Identite = $ace.Identity
|
||||||
|
Droits = $ace.Rights
|
||||||
|
Type = $ace.Type
|
||||||
|
Herite = $ace.Inherited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_writeCsv -Rows $permRows -Suffix 'permissions'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- granted access (successful remediations only, like the HTML) ----
|
||||||
|
if ($wantGrants -and $Scan.Settings.GrantAccess) {
|
||||||
|
$grantRows = foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) {
|
||||||
|
if ($g.Reverted) { $state = 'annulé' }
|
||||||
|
elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé' }
|
||||||
|
else { $state = 'conservé' }
|
||||||
|
[pscustomobject]@{
|
||||||
|
Chemin = $g.Path
|
||||||
|
Raison = $g.Reason
|
||||||
|
Modification = $g.Changes
|
||||||
|
ProprietaireOrigine = $g.OriginalOwner
|
||||||
|
Etat = $state
|
||||||
|
ErreurAnnulation = $g.RevertError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_writeCsv -Rows $grantRows -Suffix 'grants'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- errors ----
|
||||||
|
if ($wantErrors) {
|
||||||
|
$errRows = foreach ($e in $Scan.Errors) {
|
||||||
|
[pscustomobject]@{ Chemin = $e.Path; Erreur = $e.Error }
|
||||||
|
}
|
||||||
|
_writeCsv -Rows $errRows -Suffix 'errors'
|
||||||
|
}
|
||||||
|
|
||||||
|
return $written.ToArray()
|
||||||
|
}
|
||||||
'@
|
'@
|
||||||
|
|
||||||
# Dot-source core functions into the current scope (for headless + GUI export).
|
# Dot-source core functions into the current scope (for headless + GUI export).
|
||||||
@@ -662,10 +964,10 @@ function ConvertTo-FilerHtmlReport {
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# HEADLESS MODE
|
# HEADLESS MODE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
$runHeadless = $NoGui -or ($Path -and $Output)
|
$runHeadless = $NoGui -or ($Path -and ($Output -or $CsvOutput))
|
||||||
if ($runHeadless) {
|
if ($runHeadless) {
|
||||||
if (-not $Path) { throw "Le mode sans interface requiert -Path." }
|
if (-not $Path) { throw "Le mode sans interface requiert -Path." }
|
||||||
if (-not $Output) { throw "Le mode sans interface requiert -Output." }
|
if (-not $Output -and -not $CsvOutput) { throw "Le mode sans interface requiert -Output et/ou -CsvOutput." }
|
||||||
|
|
||||||
if ($GrantAccess -and -not (Test-IsElevated)) {
|
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-Warning "-GrantAccess nécessite des droits Administrateur ; ce processus n'est pas élevé. Les éléments refusés peuvent ne pas être corrigés."
|
||||||
@@ -678,8 +980,19 @@ if ($runHeadless) {
|
|||||||
-IncludeFilesInTree:$IncludeFilesInTree `
|
-IncludeFilesInTree:$IncludeFilesInTree `
|
||||||
-GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress
|
-GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress
|
||||||
|
|
||||||
$out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category
|
if ($Output) {
|
||||||
Write-Host "Rapport généré : $out" -ForegroundColor Green
|
$out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals
|
||||||
|
Write-Host "Rapport HTML généré : $out" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
if ($CsvOutput) {
|
||||||
|
$csvFiles = @(ConvertTo-FilerCsvReport -Scan $scan -Path $CsvOutput -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals)
|
||||||
|
if ($csvFiles.Count -gt 0) {
|
||||||
|
Write-Host "Rapport(s) CSV généré(s) :" -ForegroundColor Green
|
||||||
|
foreach ($f in $csvFiles) { Write-Host " $f" -ForegroundColor Green }
|
||||||
|
} else {
|
||||||
|
Write-Host "Aucun fichier CSV généré (catégories sans données)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
Write-Host (" {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs" -f `
|
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,
|
(Format-Bytes $scan.Stats.TotalSize), $scan.Stats.TotalFolders, $scan.Stats.TotalFiles,
|
||||||
$scan.Stats.LongPaths, $scan.Stats.Errors)
|
$scan.Stats.LongPaths, $scan.Stats.Errors)
|
||||||
@@ -774,7 +1087,7 @@ $btnScan.FlatStyle = 'Flat'
|
|||||||
$form.Controls.Add($btnScan)
|
$form.Controls.Add($btnScan)
|
||||||
|
|
||||||
$btnExport = New-Object System.Windows.Forms.Button
|
$btnExport = New-Object System.Windows.Forms.Button
|
||||||
$btnExport.Text = 'Exporter HTML...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30'
|
$btnExport.Text = 'Exporter...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30'
|
||||||
$btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false
|
$btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false
|
||||||
$form.Controls.Add($btnExport)
|
$form.Controls.Add($btnExport)
|
||||||
|
|
||||||
@@ -792,7 +1105,66 @@ $tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant))
|
|||||||
|
|
||||||
$tree = New-Object System.Windows.Forms.TreeView
|
$tree = New-Object System.Windows.Forms.TreeView
|
||||||
$tree.Dock = 'Fill'; $tree.HideSelection = $false
|
$tree.Dock = 'Fill'; $tree.HideSelection = $false
|
||||||
|
# Owner-draw the labels so we can paint a proportional "space taken" bar after
|
||||||
|
# each node, mirroring the bars in the HTML report. The control still draws the
|
||||||
|
# +/- expanders and connecting lines itself.
|
||||||
|
$tree.DrawMode = 'OwnerDrawText'
|
||||||
$tabTree.Controls.Add($tree)
|
$tabTree.Controls.Add($tree)
|
||||||
|
# Fill a node's real children the first time it is expanded (lazy loading keeps
|
||||||
|
# the GUI responsive no matter how large the scanned tree is).
|
||||||
|
$tree.Add_BeforeExpand({ param($s, $e) Expand-FilerTreeNode -TreeNode $e.Node })
|
||||||
|
|
||||||
|
# Render each tree node: the label, then a bar showing the folder's share of its
|
||||||
|
# parent's size (roots = 100%), then the percentage - matching the HTML report.
|
||||||
|
$tree.Add_DrawNode({
|
||||||
|
param($s, $e)
|
||||||
|
$nodeData = $e.Node.Tag
|
||||||
|
# Placeholder ("Chargement...") and not-yet-measured nodes: draw normally.
|
||||||
|
if ($null -eq $nodeData -or $e.Bounds.Width -le 0 -or
|
||||||
|
-not ($nodeData.PSObject.Properties.Name -contains 'Size')) {
|
||||||
|
$e.DrawDefault = $true; return
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = ($e.State -band [System.Windows.Forms.TreeNodeStates]::Selected) -ne 0
|
||||||
|
$foreColor = if ($selected) { [System.Drawing.SystemColors]::HighlightText } else { $tree.ForeColor }
|
||||||
|
if ($selected) {
|
||||||
|
$hl = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight)
|
||||||
|
$e.Graphics.FillRectangle($hl, $e.Bounds); $hl.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$flags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor `
|
||||||
|
[System.Windows.Forms.TextFormatFlags]::Left -bor `
|
||||||
|
[System.Windows.Forms.TextFormatFlags]::NoPrefix
|
||||||
|
[System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Node.Text, $tree.Font, $e.Bounds, $foreColor, $flags)
|
||||||
|
|
||||||
|
# Share of the parent folder's size (top-level roots have no parent = 100%).
|
||||||
|
$pct = 100.0
|
||||||
|
$parent = $e.Node.Parent
|
||||||
|
if ($parent -and $parent.Tag -and
|
||||||
|
($parent.Tag.PSObject.Properties.Name -contains 'Size') -and $parent.Tag.Size -gt 0) {
|
||||||
|
$pct = [math]::Round(($nodeData.Size / $parent.Tag.Size) * 100, 1)
|
||||||
|
}
|
||||||
|
if ($pct -lt 0) { $pct = 0 } elseif ($pct -gt 100) { $pct = 100 }
|
||||||
|
|
||||||
|
# Bar just to the right of the label.
|
||||||
|
$barW = 120; $barH = 9
|
||||||
|
$barX = $e.Bounds.Right + 10
|
||||||
|
$barY = $e.Bounds.Top + [int](($e.Bounds.Height - $barH) / 2)
|
||||||
|
|
||||||
|
$track = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235))
|
||||||
|
$e.Graphics.FillRectangle($track, $barX, $barY, $barW, $barH); $track.Dispose()
|
||||||
|
|
||||||
|
$fillW = [int][math]::Round($barW * $pct / 100)
|
||||||
|
if ($fillW -gt 0) {
|
||||||
|
$fill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212))
|
||||||
|
$e.Graphics.FillRectangle($fill, $barX, $barY, $fillW, $barH); $fill.Dispose()
|
||||||
|
}
|
||||||
|
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185))
|
||||||
|
$e.Graphics.DrawRectangle($pen, $barX, $barY, $barW, $barH); $pen.Dispose()
|
||||||
|
|
||||||
|
$pctRect = New-Object System.Drawing.Rectangle (($barX + $barW + 6), $e.Bounds.Top, 52, $e.Bounds.Height)
|
||||||
|
[System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, ('{0:N1}%' -f $pct), $tree.Font, $pctRect, $tree.ForeColor, $flags)
|
||||||
|
})
|
||||||
|
|
||||||
$lvLong = New-Object System.Windows.Forms.ListView
|
$lvLong = New-Object System.Windows.Forms.ListView
|
||||||
$lvLong.Dock = 'Fill'; $lvLong.View = 'Details'; $lvLong.FullRowSelect = $true; $lvLong.GridLines = $true
|
$lvLong.Dock = 'Fill'; $lvLong.View = 'Details'; $lvLong.FullRowSelect = $true; $lvLong.GridLines = $true
|
||||||
@@ -822,12 +1194,24 @@ $lblPermHint.ForeColor = [System.Drawing.Color]::DimGray
|
|||||||
$btnPermClear = New-Object System.Windows.Forms.Button
|
$btnPermClear = New-Object System.Windows.Forms.Button
|
||||||
$btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24'
|
$btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24'
|
||||||
$btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false
|
$btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false
|
||||||
|
$chkPermHideInh = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkPermHideInh.Text = 'Masquer les permissions héritées (enfants)'
|
||||||
|
$chkPermHideInh.AutoSize = $true; $chkPermHideInh.Dock = 'Right'
|
||||||
|
$chkPermHideInh.Padding = '0,4,8,0'
|
||||||
|
$chkPermHideSys = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkPermHideSys.Text = 'Masquer les comptes/groupes système'
|
||||||
|
$chkPermHideSys.AutoSize = $true; $chkPermHideSys.Dock = 'Right'
|
||||||
|
$chkPermHideSys.Padding = '0,4,8,0'
|
||||||
$permBar.Controls.Add($lblPermHint)
|
$permBar.Controls.Add($lblPermHint)
|
||||||
|
$permBar.Controls.Add($chkPermHideInh)
|
||||||
|
$permBar.Controls.Add($chkPermHideSys)
|
||||||
$permBar.Controls.Add($btnPermClear)
|
$permBar.Controls.Add($btnPermClear)
|
||||||
$tabPerm.Controls.Add($permBar)
|
$tabPerm.Controls.Add($permBar)
|
||||||
|
|
||||||
$lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column })
|
$lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column })
|
||||||
$btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView })
|
$btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView })
|
||||||
|
$chkPermHideInh.Add_CheckedChanged({ $script:PermHideInherited = $chkPermHideInh.Checked; Update-PermView })
|
||||||
|
$chkPermHideSys.Add_CheckedChanged({ $script:PermHideSystem = $chkPermHideSys.Checked; Update-PermView })
|
||||||
|
|
||||||
# ---- status bar ----
|
# ---- status bar ----
|
||||||
$status = New-Object System.Windows.Forms.StatusStrip
|
$status = New-Object System.Windows.Forms.StatusStrip
|
||||||
@@ -846,6 +1230,13 @@ $script:PowerShell = $null
|
|||||||
$script:Handle = $null
|
$script:Handle = $null
|
||||||
$script:Shared = $null
|
$script:Shared = $null
|
||||||
|
|
||||||
|
# Background export state (report generation runs off the UI thread too).
|
||||||
|
$script:ExportRunspace = $null
|
||||||
|
$script:ExportPowerShell = $null
|
||||||
|
$script:ExportHandle = $null
|
||||||
|
$script:ExportShared = $null
|
||||||
|
$script:ExportHtmlPath = $null
|
||||||
|
|
||||||
# Permissions view state: source rows, active per-column filters, and sort.
|
# Permissions view state: source rows, active per-column filters, and sort.
|
||||||
$script:AllPerms = @()
|
$script:AllPerms = @()
|
||||||
$script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # column index -> property
|
$script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # column index -> property
|
||||||
@@ -853,10 +1244,19 @@ $script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # c
|
|||||||
$script:PermColLabels = @{ Folder = 'Dossier'; Identity = 'Identité'; Rights = 'Droits'; Type = 'Type'; Inherited = 'Hérité' }
|
$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:PermFilters = @{} # property -> System.Collections.Generic.HashSet[string] of allowed values
|
||||||
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
||||||
|
# When $true, inherited ACEs on child folders are hidden (scan roots keep theirs).
|
||||||
|
$script:PermHideInherited = $false
|
||||||
|
# When $true, ACEs held by well-known system/built-in principals are hidden.
|
||||||
|
$script:PermHideSystem = $false
|
||||||
|
$script:PermRootPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
# ---- helpers ----
|
# ---- helpers ----
|
||||||
function Add-TreeNodes {
|
function New-FilerTreeNode {
|
||||||
param($Parent, $Node)
|
# Creates a TreeNode for a single scan node WITHOUT recursing into its
|
||||||
|
# children. A placeholder child is added so the [+] expander shows; the real
|
||||||
|
# children are filled on demand by Expand-FilerTreeNode (see the tree's
|
||||||
|
# BeforeExpand handler). This makes even very large trees appear instantly.
|
||||||
|
param($Node)
|
||||||
$sizeStr = Format-Bytes $Node.Size
|
$sizeStr = Format-Bytes $Node.Size
|
||||||
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
|
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
|
||||||
if ($isFile) {
|
if ($isFile) {
|
||||||
@@ -865,10 +1265,26 @@ function Add-TreeNodes {
|
|||||||
$text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)"
|
$text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)"
|
||||||
}
|
}
|
||||||
$tn = New-Object System.Windows.Forms.TreeNode($text)
|
$tn = New-Object System.Windows.Forms.TreeNode($text)
|
||||||
$tn.Tag = $Node.FullPath
|
$tn.Tag = $Node # keep the scan node so its children can be filled lazily
|
||||||
[void]$Parent.Add($tn)
|
$kids = @($Node.Children | Where-Object { $_ })
|
||||||
$kids = @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)
|
if ($kids.Count -gt 0) {
|
||||||
foreach ($c in $kids) { Add-TreeNodes -Parent $tn.Nodes -Node $c }
|
# Placeholder (Tag stays $null, which marks the node as "not yet loaded").
|
||||||
|
[void]$tn.Nodes.Add((New-Object System.Windows.Forms.TreeNode('Chargement...')))
|
||||||
|
}
|
||||||
|
return $tn
|
||||||
|
}
|
||||||
|
|
||||||
|
function Expand-FilerTreeNode {
|
||||||
|
# Replaces the placeholder with the node's real children the first time it is
|
||||||
|
# expanded. A node is "not yet loaded" while its single child has a $null Tag.
|
||||||
|
param($TreeNode)
|
||||||
|
if ($TreeNode.Nodes.Count -ne 1 -or $null -ne $TreeNode.Nodes[0].Tag) { return }
|
||||||
|
$node = $TreeNode.Tag
|
||||||
|
$TreeNode.TreeView.BeginUpdate()
|
||||||
|
$TreeNode.Nodes.Clear()
|
||||||
|
$kids = @($node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)
|
||||||
|
foreach ($c in $kids) { [void]$TreeNode.Nodes.Add((New-FilerTreeNode -Node $c)) }
|
||||||
|
$TreeNode.TreeView.EndUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
function Show-Results {
|
function Show-Results {
|
||||||
@@ -876,8 +1292,8 @@ function Show-Results {
|
|||||||
$script:LastScan = $Scan
|
$script:LastScan = $Scan
|
||||||
|
|
||||||
$tree.BeginUpdate(); $tree.Nodes.Clear()
|
$tree.BeginUpdate(); $tree.Nodes.Clear()
|
||||||
foreach ($root in $Scan.Roots) { Add-TreeNodes -Parent $tree.Nodes -Node $root }
|
foreach ($root in $Scan.Roots) { [void]$tree.Nodes.Add((New-FilerTreeNode -Node $root)) }
|
||||||
if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() }
|
if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() } # loads first level lazily
|
||||||
$tree.EndUpdate()
|
$tree.EndUpdate()
|
||||||
|
|
||||||
$lvLong.BeginUpdate(); $lvLong.Items.Clear()
|
$lvLong.BeginUpdate(); $lvLong.Items.Clear()
|
||||||
@@ -889,6 +1305,7 @@ function Show-Results {
|
|||||||
$lvLong.EndUpdate()
|
$lvLong.EndUpdate()
|
||||||
|
|
||||||
$script:AllPerms = @($Scan.Permissions)
|
$script:AllPerms = @($Scan.Permissions)
|
||||||
|
$script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan
|
||||||
$script:PermFilters = @{}
|
$script:PermFilters = @{}
|
||||||
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
$script:PermSort = @{ Prop = 'Folder'; Asc = $true }
|
||||||
Update-PermView
|
Update-PermView
|
||||||
@@ -924,6 +1341,12 @@ function Update-PermView {
|
|||||||
# Re-renders the permissions ListView from $script:AllPerms applying the
|
# Re-renders the permissions ListView from $script:AllPerms applying the
|
||||||
# active per-column filters and the current sort.
|
# active per-column filters and the current sort.
|
||||||
$rows = $script:AllPerms
|
$rows = $script:AllPerms
|
||||||
|
if ($script:PermHideInherited) {
|
||||||
|
$rows = @($rows | Where-Object { (-not $_.Inherited) -or $script:PermRootPaths.Contains([string]$_.Folder) })
|
||||||
|
}
|
||||||
|
if ($script:PermHideSystem) {
|
||||||
|
$rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) })
|
||||||
|
}
|
||||||
foreach ($prop in $script:PermFilters.Keys) {
|
foreach ($prop in $script:PermFilters.Keys) {
|
||||||
$allowed = $script:PermFilters[$prop]
|
$allowed = $script:PermFilters[$prop]
|
||||||
$rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) })
|
$rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) })
|
||||||
@@ -1033,9 +1456,10 @@ function Show-PermColumnMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Show-ExportDialog {
|
function Show-ExportDialog {
|
||||||
<# Modal picker for which report categories to export. Returns an array of
|
<# Modal picker for the export: which report categories and which formats
|
||||||
canonical category names (Tree/LongPaths/Permissions/Grants/Errors) or
|
(HTML and/or CSV). Returns $null if the user cancelled, otherwise an object
|
||||||
$null if the user cancelled. Categories with no data are disabled. #>
|
with .Categories (array of Tree/LongPaths/Permissions/Grants/Errors or
|
||||||
|
@('All')), .Html and .Csv booleans. Categories with no data are disabled. #>
|
||||||
param($Scan)
|
param($Scan)
|
||||||
|
|
||||||
$hasGrants = @($Scan.Grants | Where-Object { $_.Success }).Count -gt 0
|
$hasGrants = @($Scan.Grants | Where-Object { $_.Success }).Count -gt 0
|
||||||
@@ -1051,11 +1475,11 @@ function Show-ExportDialog {
|
|||||||
)
|
)
|
||||||
|
|
||||||
$dlg = New-Object System.Windows.Forms.Form
|
$dlg = New-Object System.Windows.Forms.Form
|
||||||
$dlg.Text = 'Exporter HTML - choisir les catégories'
|
$dlg.Text = 'Exporter - choisir les catégories et formats'
|
||||||
$dlg.FormBorderStyle = 'FixedDialog'
|
$dlg.FormBorderStyle = 'FixedDialog'
|
||||||
$dlg.StartPosition = 'CenterParent'
|
$dlg.StartPosition = 'CenterParent'
|
||||||
$dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
|
$dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false
|
||||||
$dlg.ClientSize = New-Object System.Drawing.Size(300, 250)
|
$dlg.ClientSize = New-Object System.Drawing.Size(320, 330)
|
||||||
|
|
||||||
$lbl = New-Object System.Windows.Forms.Label
|
$lbl = New-Object System.Windows.Forms.Label
|
||||||
$lbl.Text = 'Inclure ces catégories dans le rapport :'
|
$lbl.Text = 'Inclure ces catégories dans le rapport :'
|
||||||
@@ -1077,30 +1501,57 @@ function Show-ExportDialog {
|
|||||||
$y += 26
|
$y += 26
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---- format selection (HTML and/or CSV) ----
|
||||||
|
$y += 8
|
||||||
|
$lblFmt = New-Object System.Windows.Forms.Label
|
||||||
|
$lblFmt.Text = 'Formats à générer :'
|
||||||
|
$lblFmt.Location = "14,$y"; $lblFmt.AutoSize = $true
|
||||||
|
$dlg.Controls.Add($lblFmt)
|
||||||
|
$y += 26
|
||||||
|
|
||||||
|
$chkHtml = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkHtml.Text = 'HTML (rapport unique)'; $chkHtml.Tag = 'Html'
|
||||||
|
$chkHtml.Location = "20,$y"; $chkHtml.AutoSize = $true; $chkHtml.Checked = $true
|
||||||
|
$dlg.Controls.Add($chkHtml)
|
||||||
|
$y += 26
|
||||||
|
|
||||||
|
$chkCsv = New-Object System.Windows.Forms.CheckBox
|
||||||
|
$chkCsv.Text = 'CSV (un fichier par catégorie)'; $chkCsv.Tag = 'Csv'
|
||||||
|
$chkCsv.Location = "20,$y"; $chkCsv.AutoSize = $true; $chkCsv.Checked = $false
|
||||||
|
$dlg.Controls.Add($chkCsv)
|
||||||
|
$y += 36
|
||||||
|
|
||||||
$btnOk = New-Object System.Windows.Forms.Button
|
$btnOk = New-Object System.Windows.Forms.Button
|
||||||
$btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = '104,210'
|
$btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = "124,$y"
|
||||||
$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||||
$dlg.Controls.Add($btnOk); $dlg.AcceptButton = $btnOk
|
$dlg.Controls.Add($btnOk); $dlg.AcceptButton = $btnOk
|
||||||
|
|
||||||
$btnCancel = New-Object System.Windows.Forms.Button
|
$btnCancel = New-Object System.Windows.Forms.Button
|
||||||
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '90,28'; $btnCancel.Location = '200,210'
|
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '90,28'; $btnCancel.Location = "220,$y"
|
||||||
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
||||||
$dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel
|
$dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel
|
||||||
|
|
||||||
# Disable Export when nothing is ticked.
|
# Enable Export only when at least one category AND at least one format is ticked.
|
||||||
$sync = {
|
$sync = {
|
||||||
$btnOk.Enabled = @($boxes | Where-Object { $_.Checked }).Count -gt 0
|
$anyCat = @($boxes | Where-Object { $_.Checked }).Count -gt 0
|
||||||
|
$anyFmt = $chkHtml.Checked -or $chkCsv.Checked
|
||||||
|
$btnOk.Enabled = $anyCat -and $anyFmt
|
||||||
}.GetNewClosure()
|
}.GetNewClosure()
|
||||||
foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) }
|
foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) }
|
||||||
|
$chkHtml.Add_CheckedChanged($sync)
|
||||||
|
$chkCsv.Add_CheckedChanged($sync)
|
||||||
|
|
||||||
$result = $dlg.ShowDialog($form)
|
$result = $dlg.ShowDialog($form)
|
||||||
$picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag })
|
$picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag })
|
||||||
|
$wantHtml = $chkHtml.Checked
|
||||||
|
$wantCsv = $chkCsv.Checked
|
||||||
$dlg.Dispose()
|
$dlg.Dispose()
|
||||||
|
|
||||||
if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null }
|
if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null }
|
||||||
|
if (-not ($wantHtml -or $wantCsv)) { return $null }
|
||||||
# All categories ticked -> 'All' so the header shows the full report.
|
# All categories ticked -> 'All' so the header shows the full report.
|
||||||
if ($picked.Count -eq $boxes.Count) { return @('All') }
|
$categories = if ($picked.Count -eq $boxes.Count) { @('All') } else { $picked }
|
||||||
return $picked
|
return [pscustomobject]@{ Categories = $categories; Html = $wantHtml; Csv = $wantCsv }
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- poll timer (reads background runspace) ----
|
# ---- poll timer (reads background runspace) ----
|
||||||
@@ -1133,6 +1584,54 @@ $timer.Add_Tick({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---- poll timer (reads background export runspace) ----
|
||||||
|
$exportTimer = New-Object System.Windows.Forms.Timer
|
||||||
|
$exportTimer.Interval = 200
|
||||||
|
$exportTimer.Add_Tick({
|
||||||
|
if (-not $script:ExportHandle) { $exportTimer.Stop(); return }
|
||||||
|
if (-not $script:ExportHandle.IsCompleted) { return }
|
||||||
|
$exportTimer.Stop()
|
||||||
|
|
||||||
|
$written = $null; $err = $null
|
||||||
|
try {
|
||||||
|
$script:ExportPowerShell.EndInvoke($script:ExportHandle) | Out-Null
|
||||||
|
if ($script:ExportShared) { $written = $script:ExportShared.Written; $err = $script:ExportShared.Error }
|
||||||
|
}
|
||||||
|
catch { $err = $_.Exception.Message }
|
||||||
|
finally {
|
||||||
|
if ($script:ExportPowerShell) { $script:ExportPowerShell.Dispose() }
|
||||||
|
if ($script:ExportRunspace) { $script:ExportRunspace.Close(); $script:ExportRunspace.Dispose() }
|
||||||
|
$script:ExportPowerShell = $null; $script:ExportRunspace = $null; $script:ExportHandle = $null
|
||||||
|
$progressBar.Visible = $false
|
||||||
|
$btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true
|
||||||
|
$btnExport.Enabled = ($null -ne $script:LastScan)
|
||||||
|
}
|
||||||
|
|
||||||
|
$htmlPath = $script:ExportHtmlPath
|
||||||
|
if ($err) {
|
||||||
|
$statusLbl.Text = "Échec de l'export."
|
||||||
|
[System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$err", 'Filer Manager', 'OK', 'Error') | Out-Null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$written = @($written)
|
||||||
|
if ($written.Count -eq 0) {
|
||||||
|
$statusLbl.Text = 'Aucun fichier généré.'
|
||||||
|
[System.Windows.Forms.MessageBox]::Show('Aucun fichier généré (catégories CSV sans données).',
|
||||||
|
'Filer Manager', 'OK', 'Information') | Out-Null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$statusLbl.Text = "Export terminé : $($written.Count) fichier(s)."
|
||||||
|
$list = ($written -join "`n")
|
||||||
|
$prompt = if ($htmlPath) { "`n`nOuvrir le rapport HTML maintenant ?" } else { "`n`nOuvrir le dossier maintenant ?" }
|
||||||
|
if ([System.Windows.Forms.MessageBox]::Show("Fichier(s) enregistré(s) :`n$list$prompt", 'Filer Manager',
|
||||||
|
'YesNo', 'Question') -eq 'Yes') {
|
||||||
|
if ($htmlPath) { Start-Process $htmlPath }
|
||||||
|
else { Start-Process (Split-Path -Path $written[0] -Parent) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# ---- events ----
|
# ---- events ----
|
||||||
$btnAdd.Add_Click({
|
$btnAdd.Add_Click({
|
||||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||||
@@ -1189,28 +1688,71 @@ $Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -Permissi
|
|||||||
$btnExport.Add_Click({
|
$btnExport.Add_Click({
|
||||||
if (-not $script:LastScan) { return }
|
if (-not $script:LastScan) { return }
|
||||||
|
|
||||||
$categories = Show-ExportDialog -Scan $script:LastScan
|
$choice = Show-ExportDialog -Scan $script:LastScan
|
||||||
if (-not $categories) { return } # cancelled or nothing selected
|
if (-not $choice) { return } # cancelled or nothing selected
|
||||||
|
$categories = $choice.Categories
|
||||||
|
|
||||||
# Build a filename hint reflecting the chosen scope.
|
# Build a filename hint reflecting the chosen scope.
|
||||||
$scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() }
|
$scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() }
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$baseName = "filer-report-$scope-$stamp"
|
||||||
|
|
||||||
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
$dlg = New-Object System.Windows.Forms.SaveFileDialog
|
||||||
|
if ($choice.Html -and $choice.Csv) {
|
||||||
|
$dlg.Title = 'Enregistrer les rapports (nom de base) - HTML + CSV'
|
||||||
|
$dlg.Filter = 'Rapports HTML + CSV|*.html'
|
||||||
|
$dlg.FileName = "$baseName.html"
|
||||||
|
}
|
||||||
|
elseif ($choice.Html) {
|
||||||
|
$dlg.Title = 'Enregistrer le rapport HTML'
|
||||||
$dlg.Filter = 'Rapport HTML (*.html)|*.html'
|
$dlg.Filter = 'Rapport HTML (*.html)|*.html'
|
||||||
$dlg.FileName = "filer-report-$scope-$(Get-Date -Format 'yyyyMMdd-HHmmss').html"
|
$dlg.FileName = "$baseName.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
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
$dlg.Title = 'Enregistrer le(s) rapport(s) CSV (nom de base)'
|
||||||
|
$dlg.Filter = 'Rapport CSV (*.csv)|*.csv'
|
||||||
|
$dlg.FileName = "$baseName.csv"
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
[System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null
|
if ($dlg.ShowDialog() -ne 'OK') { return }
|
||||||
|
|
||||||
|
# Generate the report(s) on a background runspace so the GUI stays responsive;
|
||||||
|
# rendering a large scan to HTML/CSV can take several seconds.
|
||||||
|
$htmlPath = if ($choice.Html) { [System.IO.Path]::ChangeExtension($dlg.FileName, '.html') } else { $null }
|
||||||
|
$csvBase = $dlg.FileName
|
||||||
|
$script:ExportHtmlPath = $htmlPath
|
||||||
|
|
||||||
|
$btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false
|
||||||
|
$progressBar.Visible = $true
|
||||||
|
$statusLbl.Text = 'Export en cours...'
|
||||||
|
|
||||||
|
$script:ExportShared = [hashtable]::Synchronized(@{ Written = $null; Error = $null })
|
||||||
|
$script:ExportRunspace = [runspacefactory]::CreateRunspace()
|
||||||
|
$script:ExportRunspace.ApartmentState = 'STA'
|
||||||
|
$script:ExportRunspace.Open()
|
||||||
|
|
||||||
|
$script:ExportPowerShell = [powershell]::Create()
|
||||||
|
$script:ExportPowerShell.Runspace = $script:ExportRunspace
|
||||||
|
[void]$script:ExportPowerShell.AddScript($CoreFunctions)
|
||||||
|
[void]$script:ExportPowerShell.AddScript(@'
|
||||||
|
param($Scan, $HtmlPath, $WantHtml, $WantCsv, $CsvBase, $Categories, $HidePerm, $HideSys, $Shared)
|
||||||
|
try {
|
||||||
|
$written = New-Object System.Collections.ArrayList
|
||||||
|
if ($WantHtml) {
|
||||||
|
$out = ConvertTo-FilerHtmlReport -Scan $Scan -Path $HtmlPath -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys
|
||||||
|
[void]$written.Add($out)
|
||||||
}
|
}
|
||||||
|
if ($WantCsv) {
|
||||||
|
$csvFiles = @(ConvertTo-FilerCsvReport -Scan $Scan -Path $CsvBase -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys)
|
||||||
|
foreach ($f in $csvFiles) { [void]$written.Add($f) }
|
||||||
}
|
}
|
||||||
|
$Shared.Written = $written.ToArray()
|
||||||
|
}
|
||||||
|
catch { $Shared.Error = $_.Exception.Message }
|
||||||
|
'@).AddArgument($script:LastScan).AddArgument($htmlPath).AddArgument([bool]$choice.Html).AddArgument([bool]$choice.Csv).AddArgument($csvBase).AddArgument($categories).AddArgument([bool]$script:PermHideInherited).AddArgument([bool]$script:PermHideSystem).AddArgument($script:ExportShared)
|
||||||
|
|
||||||
|
$script:ExportHandle = $script:ExportPowerShell.BeginInvoke()
|
||||||
|
$exportTimer.Start()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Pre-fill folders if -Path was supplied without -Output
|
# Pre-fill folders if -Path was supplied without -Output
|
||||||
|
|||||||
Reference in New Issue
Block a user