Show or delete local user profiles

Version: 2.4.20
Creator Name: Guy Leech
Date Created: 2018-12-26
Date Modified: 2020-06-10
Scripting language: PS
Download Count: 84

Finds and optionally deletes local user profiles. The user can provide two arguments - the minimum profile age in days and the minimum profile size. The default operation mode is to display the profiles that fit these criteria. When the "Delete" option is set to "true", the script will delete the profiles it found.
Tags: user profiles,disk space,vdi,rds,citrix

The Script

#required -version 3.0
<#
    Find all user profiles across the local machine selected and allow removal

    @GuyRLeech, 2018

    Modification history:

    09/06/2020 @guyrleech  Cater for OneDrive Files on Demand by showing space consumed and potentially
#>

$VerbosePreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
$DebugPreference = 'SilentlyContinue'

[int]$outputWidth = 400

# Altering the size of the PS Buffer
$PSWindow = (Get-Host).UI.RawUI
$WideDimensions = $PSWindow.BufferSize
$WideDimensions.Width = $outputWidth
$PSWindow.BufferSize = $WideDimensions

if( $args.Count -ne 3 )
{
    Throw "Takes three arguments - last used (days), over size (MB) and delete (true/false)"
}

[int]$lastUsedDays = $args[0]
[int]$overSizeMB = $args[1]
[bool]$delete = $args[2] -eq 'true'
[int]$FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x400000
[long]$INVALID_FILE_SIZE = 0xFFFFFFFF
[string]$sortBy = 'Last Used'
[string[]]$excludeUsers = $null
[string[]]$includeUsers = $null #@( 'g[aeiou]' )
[switch]$excludeLocal = $false

[string]$logName = 'Microsoft-Windows-User Profile Service/Operational'

$columns = [System.Collections.Generic.List[String]]@('User Name','Full Name','Profile Path','Profile Size (MB)','Loaded','Roaming','Last Used','Last Local Login (days)','Last Local Logoff (days)','Last AD Login (days)','Account Disabled','Account Locked','Password Expired','Password Last Set','Last Bad Password' ) 

$nativeDefinitions = @'
    [System.Runtime.InteropServices.DllImport("kernel32.dll", CharSet = System.Runtime.InteropServices.CharSet.Auto, SetLastError = true)]
    public static extern uint GetCompressedFileSizeW( string pFileName , ref uint lpFileSizeHigh );
'@

Add-Type -MemberDefinition $nativeDefinitions -Name 'kernel32' -Namespace 'win32' -UsingNamespace System.Text -Debug:$false

Function Calculate-FolderSize( [string]$folderName , [string]$sid , [string[]]$excludeUsers , [string[]]$includeUsers , [ref]$lastUsed , [ref]$potentialSize )
{
    ## can't do a Get-ChildItem -Recurse as can't seem to stop junction point traversal so do it manually
    [string]$username = if( $sid )
    {
        try
        {
            ([System.Security.Principal.SecurityIdentifier]($sid)).Translate([System.Security.Principal.NTAccount]).Value
        }
        catch
        {
            $null
        }
    }
    else
    {
        $null
    }

    ForEach( $includedUser in $includeUsers )
    {
        [bool]$found = $false
        if( $username -match $includedUser )
        {
            $found = $false
            break
        }
        if( ! $found )
        {
            return $null,$null
        }
    }
    ForEach( $excludedUser in $excludeUsers )
    {
        if( [string]::IsNullOrEmpty( $username ) -or $username -match $excludedUser )
        {
            return $null,$null
        }
    }
    $items = @( $folderName )
    [array]$files = @( While( $items )
    {
        $newitems = @( $items | Get-ChildItem -Force -ErrorAction SilentlyContinue ) ## | Where-Object { ! ( $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint ) }
        $newitems
        $items = @( $newitems.Where( { $_.Attributes -band [System.IO.FileAttributes]::Directory } ) ) ## -and ! ( $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint ) } ))
    })
    if( $files -and $files.Count )
    {
        $lastUsed.Value = (Get-Date).AddYears( -20 )
        [string]$ntuserdotdat = Join-Path -Path $folderName -ChildPath 'ntuser.dat'
        [uint64]$size = 0
        $potentialSize.Value = 0
        $files | . { Process `
        {
            [uint64]$expandedSize = 0
            if( $_.FullName -ne $ntuserdotdat -and $_.LastWriteTime -gt $lastUsed.Value )
            {
                $lastUsed.Value = $_.LastWriteTime
            }
            if( $_.PSObject.Properties -and $_.PSObject.Properties[ 'Length' ] )
            {
                $expandedSize = $_.Length
            }
            if( ($_.Attributes.value__ -band $FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) -eq $FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS ) ## e.g. OneDrive Files On Demand
            {
                [uint64]$filesizeHigh = 0
                [uint64]$fileSizeLow = [win32.kernel32]::GetCompressedFileSizeW( $_.FullName , [ref] $filesizeHigh )
                if( $fileSizeLow -ne $INVALID_FILE_SIZE )
                {
                    [uint64]$actualsize = ( $filesizeHigh -shl 32 ) + $fileSizeLow
                    $size += $actualsize
                }
            }
            else
            {
                $size += $expandedSize
            }
            $potentialSize.Value += $expandedSize
        }}
        $size
    }
    else
    {
        [long]0
    }
    $username
}

[hashtable]$profileObjects = @{}
[long]$totalSize = 0
[datetime]$overAge = (Get-Date).AddDays( -$lastUsedDays )
[bool]$notFullyDownloaded = $false
[uint64]$totalPotentialSize = 0

[array]$userProfiles = @( Get-WmiObject -Class win32_userprofile -ErrorAction SilentlyContinue | Where-Object Special -ne $true | . { Process `
{
    $profile = $_
    [datetime]$lastUsed = Get-Date
    
    try
    {
        $lastUsed = [Management.ManagementDateTimeConverter]::ToDateTime( $profile.LastUseTime )
    }
    catch{}

    ## TODO Rule out on age before we calculate size??

    ## Get size of profile, last used - translate SID on remote machine in case a local account
    [uint64]$potentialSize = 0
    [uint64]$spaceUsed,[string]$username = Calculate-FolderSize -folderName $profile.LocalPath -sid $profile.sid -excludeUsers $excludeUsers -includeUsers $includeUsers -lastUsed ([ref]$lastUsed) -potentialSize ([ref]$potentialSize)
    [uint64]$sizeMB = 0
    if( $spaceUsed )
    {
        $sizeMB = [math]::Round( $spaceUsed / 1MB ) -as [int]
    }
    if( ! [string]::IsNullOrEmpty( $username ) -and ( $sizeMB -ge $overSizeMB -or $lastUsed -lt $overAge ) ) ## username could be null if excluded by called function
    {
        [string]$domainname,[string]$unqualifiedUserName = ( $username -split '\\' )
        ## Look in user profile log for last logon
        $lastLocalLogon = '-'
        $lastLogonTime = Get-WinEvent -FilterHashtable @{ LogName = $logName ; id = 1 ; UserId = $profile.SID } -ErrorAction SilentlyContinue | Select -First 1 -ExpandProperty TimeCreated
        if( $lastLogonTime )
        {
            $lastLocalLogon = [math]::round( (New-TimeSpan -End ([datetime]::Now)  -Start $lastLogonTime).TotalDays , 1 )
        }
        $lastLocalLogoff = '-'
        $lastLogoffTime = Get-WinEvent -FilterHashtable @{ LogName = $logName ; id = 4 ; UserId = $profile.SID } -ErrorAction SilentlyContinue | Select -First 1 -ExpandProperty TimeCreated
        if( $lastLogoffTime )
        {
            $lastLocalLogoff = [math]::round( (New-TimeSpan -End ([datetime]::Now)  -Start $lastLogoffTime).TotalDays , 1 )
        }
        if( $spaceUsed -lt $potentialSize )
        {
            $notFullyDownloaded = $true ## we will add an extra column showing the fully downloaded size
        }
        $totalPotentialSize += $potentialSize
        [hashtable]$properties = @{ 'User Name' = $username ; 'Profile Path' = $profile.LocalPath ; 'Profile Size (MB)' = $sizeMB ; 'Fully Downloaded Size (MB)' = [int]($potentialSize / 1MB)
            'Last Used' = $lastUsed ; 'Roaming' = $profile.RoamingConfigured ; 'Loaded' = $profile.Loaded ; 'Last Local Login (days)' = $lastLocalLogon ; 'Last Local Logoff (days)' = $lastLocalLogoff }
        $totalSize += $properties[ 'Profile Size (MB)' ]
        ## if $unqualifiedUserNmame is null then not a domain\username so won't be in AD
        if( ! [string]::IsNullOrEmpty( $unqualifiedUserName ) -and ! [string]::IsNullOrEmpty( $domainname ) )
        {
            ## we stuff the profile object into a separate hash table so we can call its delete method later if required. 
            $profileObjects.Add( $username , $profile )
            $user = [ADSI]"WinNT://$domainname/$unqualifiedUserName,user"
            if( $user -and $user.Path )
            {
                $lastLogin = 'Never'
                try
                {
                    $lastLogin = [math]::round( (New-TimeSpan -End ([datetime]::Now)  -Start $user.LastLogin.Value).TotalDays , 1 )
                }
                catch{}

                $properties += @{
                    'Full Name' = $user.FullName.Value
                    ##'Description' = $user.Description.Value
                    'Last AD Login (days)' = $lastLogin
                    ##'Password Last Changed' = (Get-Date).AddSeconds( -($user.PasswordAge.Value) )
                    'Password Expired' = if( $user.PasswordExpired )  { 'Yes' } else { 'No' }
                    'Account Disabled' = if( ( $user.UserFlags.Value -band 0x02 ) )  { 'Yes' } else { 'No' }
                    'Account Locked' = if( ( $user.UserFlags.Value -band 0x10 ) ) { 'Yes' } else { 'No' }
                    ##'Bad Passwords' = $user.BadPasswordAttempts.Value
                }
            }
        }
        [pscustomobject]$properties
    }
}})

if( $userProfiles -and $userProfiles.Count )
{
    [string]$header = "Found $($userProfiles.Count) user profiles either not used in the last $lastUsedDays days or in excess of $($overSizeMB)MB which in total are consuming $($totalSize)MB"
    if( $notFullyDownloaded )
    {
        $header += ", fully downloaded size would be $([int]($totalPotentialSize / 1MB))MB"
    }
    Write-Output -InputObject $header

    [datetime]$oldestEvent = Get-WinEvent -FilterHashtable @{ LogName = $logName ; id = 1,4 } -ErrorAction SilentlyContinue -MaxEvents 1 -Oldest | Select -ExpandProperty TimeCreated
    if( $oldestEvent )
    {
        "Oldest recorded logon/logoff event is from $(Get-Date $oldestEvent -Format G) ($([int](New-TimeSpan -Start $oldestEvent -End (Get-Date)).TotalDays) days ago)"
    }
    if( $notFullyDownloaded )
    {
        For( [int]$index = 0 ; $index -lt $columns.Count ; $index++ )
        {
            if( $columns[ $index ] -eq 'Profile Size (MB)' )
            {
                $columns.Insert( $index + 1 , 'Fully Downloaded Size (MB)' )
                break
            }
        }
    }
    $userProfiles | Select-Object -Property $columns | Sort-Object -Property $sortBy -Descending |Format-Table -AutoSize

    if( $delete )
    {
        [long]$totalSizeDeleted = 0
        [int]$deleted = 0
        $userProfiles | Where-Object { ! $_.Loaded } | ForEach-Object `
        {
            $profile = $_
            $profileObject = $profileObjects[ $profile.'User Name' ]
            if( $profileObject )
            {
                Write-Verbose "Deleting profile for $($profile.'User Name')"
                $profileObject.Delete()
                if( $? )
                {
                    $deleted++
                    $totalSizeDeleted += $profile.'Profile Size (MB)'
                }
            }
            else
            {
                Write-Warning "Failed to retrieve cached profile object for user $($profile.'User Name')"
            }
        }
        Write-Output "Deleted $deleted profiles occupying $totalSizeDeleted MB"
    }
}
else
{
    Write-Warning "No local user profiles found either not used in the last $lastUsedDays days or in excess of $($overSizeMB)MB"
}