Private/Write-InstallLog.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Installation logging that exists from the first line of the installer.
 
.DESCRIPTION
Until 1.4.0 the log directory was created in installation step 1, and the
first log call happened after that - so every failure during detection,
Discord entry or the readiness check produced no log at all. Combined with
the window closing on exit, that made the most common failures completely
undiagnosable.
 
Initialize-InstallLog is therefore called before anything else can fail, and
logging never throws: a broken log must not abort an install.
#>


function Initialize-InstallLog {
    <#
    .SYNOPSIS
    Creates the install directory and starts a fresh log session.
 
    .OUTPUTS
    Path to the log file, or $null if it could not be created.
    #>

    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$InstallPath,

        [string]$Version = "unknown"
    )

    try {
        if (-not (Test-Path $InstallPath)) {
            New-Item -ItemType Directory -Path $InstallPath -Force -ErrorAction Stop | Out-Null
        }
        $logPath = Join-Path $InstallPath "install.log"

        # UTF8 (not UTF8NoBOM): the NoBOM variant only exists in PowerShell 6+
        # and silently failed every log write on Windows PowerShell 5.1.
        Add-Content -Path $logPath -Encoding UTF8 -ErrorAction Stop -Value @(
            "",
            "==================================================",
            "Installation session started $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')",
            "BATCRelayBot $Version on PowerShell $($PSVersionTable.PSVersion) / $([System.Environment]::OSVersion.VersionString)",
            "=================================================="
        )
        return $logPath
    } catch {
        Write-Host "WARNING: Could not create the log file under $InstallPath" -ForegroundColor Yellow
        Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
        return $null
    }
}

function Write-InstallLog {
    <#
    .SYNOPSIS
    Appends one line to the installation log. Never throws.
    #>

    param(
        [string]$Message,
        [string]$LogPath,
        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO'
    )

    if ([string]::IsNullOrWhiteSpace($LogPath)) { return }

    try {
        $logDir = Split-Path -Parent $LogPath
        if ($logDir -and -not (Test-Path $logDir)) {
            New-Item -ItemType Directory -Path $logDir -Force -ErrorAction Stop | Out-Null
        }

        $safe = Remove-SensitiveData -Text $Message
        $entry = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $safe
        Add-Content -Path $LogPath -Value $entry -Encoding UTF8 -ErrorAction Stop
    } catch {
        # A log that cannot be written must not take the installation down.
    }
}

function Remove-SensitiveData {
    <#
    .SYNOPSIS
    Redacts bot tokens and Discord IDs before anything reaches the log or the
    console.
 
    .DESCRIPTION
    Discord bot tokens are three base64url segments separated by dots. The
    first segment is the bot's user ID and is not secret, but the remainder
    is, so the whole value is replaced rather than partially shown.
 
    Server, channel and user IDs are redacted too. The rule that forbids
    logging a token forbids logging these in the same sentence, and until
    1.4.1 the installer wrote both to install.log in plain text - a file that
    travels into bug reports, screenshots and support threads.
 
    Every message written by Write-InstallLog passes through here, so a value
    added to the log later is covered without anyone remembering to think
    about it.
    #>

    [OutputType([string])]
    param([string]$Text)

    if ([string]::IsNullOrEmpty($Text)) { return $Text }

    $redacted = [regex]::Replace(
        $Text,
        '[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{20,}',
        '[REDACTED-TOKEN]')

    # Also catch a token that appears behind an Authorization header.
    $redacted = [regex]::Replace($redacted, '(?i)(Bot\s+)[A-Za-z0-9_\-\.]{30,}', '$1[REDACTED-TOKEN]')

    # Snowflakes: 17 to 20 digits, not part of a longer number and not a path
    # or version. Timestamps in this log are formatted with separators, so a
    # bare run of that length is an ID.
    $redacted = [regex]::Replace($redacted, '(?<![\d.\\/])\d{17,20}(?![\d.])', '[REDACTED-ID]')

    return $redacted
}

Export-ModuleMember -Function @('Initialize-InstallLog', 'Write-InstallLog', 'Remove-SensitiveData')