Litsl · Ban appeals

How the log grab works

When you appeal a ban on a Java account, your ticket offers a command that finds a Minecraft log on your computer and sends it to the staff looking at your appeal. This page explains, step by step, exactly what that command does. The full script is at the bottom, so you can check every line.

You don't have to use it. Your appeal is looked at the same way whether you use it, attach your log yourself, or send no log at all.

Only Minecraft logs

It only opens Minecraft log files in launcher folders. Nothing else on your computer.

Asks you first

It shows you the file it found. Nothing is sent unless you type y.

Private bits removed

Login tokens, your computer's username, emails and IP addresses are taken out of the copy.

One file, deleted later

One log goes to your ticket. Our copy is deleted after 90 days.

Step by step

  1. You run the command from your ticket. On Windows you paste it into the Run box (Win + R). On Mac or Linux you paste it into Terminal. It downloads a script, which is just a text file of instructions, from this site and runs it.
  2. It looks for your Minecraft logs. Minecraft writes a log file (a record of what the game did) every time you play. The script looks in the folders where common launchers keep their game files, and only at files that end in .log or .log.gz inside a folder called logs.
  3. It picks one log. First it looks for the newest log that shows your ban: one with your Ban ID in it (like #1234) or the Litsl ban screen. If none of them shows the ban, it picks the newest log where you connected to Litsl instead, so staff can still see your most recent Litsl session.
  4. It shows you what it found. You see the file's full location, its size, and which kind of log it is: shows your ban or your most recent Litsl session. Staff see the same label next to the file.
  5. It asks [y/N]. Type y and press Enter to send it. Anything else, pressing Enter on its own, or closing the window cancels, and nothing is sent.
  6. It cleans a copy and sends it. The script takes out private details (see below), squeezes the copy smaller (gzip, like a zip file), and uploads that one file. Your own log on your computer is not changed.
  7. It shows up in your ticket. This site checks that the file really is a Minecraft log, stores it, and the Litsl ticket bot posts it into your ticket, usually within a couple of minutes.

The link in your command only belongs to your ticket. It works for one upload and stops working after 7 days. Bedrock players aren't offered it, because Bedrock doesn't keep the same kind of log.

What it reads, and what it never touches

What it reads

  • The names and dates of files in launcher folders, to find the logs.
  • The text inside Minecraft log files, to search them for your ban or a Litsl session.
  • Your computer's username, only so it can remove it from the copy.

What it never touches

  • Your worlds, screenshots, mods, resource packs or game settings.
  • Your passwords, browser, Discord, documents or anything outside the launcher folders.
  • Your files: it doesn't change, move or delete any of them.
  • Admin rights: it never asks for them, installs nothing and doesn't stay on your computer.

It knows where these launchers keep their logs:

If your launcher keeps its game folder somewhere else, the script can't see it, and you can attach your log by hand instead. On Windows the script writes no files at all. On Mac and Linux it makes a temporary folder for the cleaned copy and deletes it when it finishes.

What is removed before anything is sent

These are taken out of the copy that is sent. Your own file is left as it was.

Login tokens
Minecraft access tokens and session tokens, which could be used to log in as you, including the launcher lines that carry them.
Your computer's username
It shows up in folder paths. For example C:\Users\you becomes C:\Users\[user].
Email addresses
Replaced with [email].
IP addresses
An IP address is the number that identifies an internet connection. Every one is replaced with [ip], except the Litsl server's own address.

The script finds these by looking for patterns they always have. A log can still contain other things from that game session, such as chat messages you saw or the names of other servers you joined. If you'd rather read the file yourself first, attach it to your ticket by hand.

Exactly what is sent and stored

WhatWhy
The cleaned copy of that one log fileSo staff can see what happened around your ban.
The file's name, the launcher it came from (like "Prism Launcher") and whether you're on Windows, macOS or LinuxShown to staff next to the file.
Your ticket number, Discord user ID, Minecraft name and Ban IDThese come from your ticket, not from your computer. They make sure the log lands in the right ticket.

Your IP address is used for a moment to stop one connection from calling this site too often. It isn't stored with your log and isn't written to our logs. A file bigger than 25 MB of text (5 MB squeezed) is refused.

Who sees it, and how long it's kept

Asking for it to be deleted

Ask in your ticket, or open a new ticket in the Litsl Discord, and say you want your uploaded log deleted. Staff will remove the copy on this site and the file in your ticket.

More about what Litsl keeps and why: Litsl privacy policy.

Check it yourself before you run it

You never have to trust this page. You can read the exact script your command will run:

  1. Copy the web address out of your command. It's the part that starts with https://logs.litsl.net/w/ (Windows) or https://logs.litsl.net/u/ (Mac and Linux).
  2. Paste it into your browser's address bar. You'll see the script as plain text. Opening it like this doesn't run anything and doesn't use up your link.
  3. Compare it with the scripts below. The only differences are four values filled in for you (see below).

Here's what each part of the commands means. Your ticket shows them with your own link in place of <your-link>.

Windows (press Win + R, paste, press Enter):

powershell -NoProfile -Command "[Net.ServicePointManager]::SecurityProtocol=3072;irm https://logs.litsl.net/w/<your-link>|iex"

Mac and Linux (paste into Terminal):

curl -fsSL https://logs.litsl.net/u/<your-link> | sh

The scripts, in full

These are the exact files this site sends. In your copy, four placeholders are filled in: __LG_BASE__ becomes logs.litsl.net, __LG_ID__ your one-time link, __LG_BAN__ your Ban ID and __LG_TICKET__ your ticket number. Nothing else is different.

Windows: loggrab.ps1 (open as plain text)

# Litsl log grab (Windows)
#
# What this script does, in order:
#   1. Looks in the usual Minecraft launcher folders in your user folder for log files.
#   2. Picks the newest log that shows your Litsl ban. If no log shows it (normal when you were
#      banned while offline), picks the log of your most recent Litsl session instead.
#   3. Shows you which file it picked and asks "y/N". Nothing is sent unless you answer y.
#   4. Makes a copy in memory with access tokens, your Windows username, emails and
#      IP addresses (except Litsl's) removed.
#   5. Sends that one copy to Litsl for your ban appeal ticket.
#
# It needs no admin rights, installs nothing, writes no files and changes none of yours.
# The same source is shown at __LG_BASE__/ with the values below left as placeholders.
# Written for Windows PowerShell 5.1 and newer.

$LgBase = '__LG_BASE__'       # where the log is sent
$LgId = '__LG_ID__'           # your one-time upload link
$LgBan = '__LG_BAN__'         # your Ban ID (digits), or empty when the ticket has none
$LgTicket = '__LG_TICKET__'   # your ticket number, only shown to you below

# Litsl's own server addresses (current, and the one used until September 2026). These stay in the log.
$LgLitslIps = @('170.205.24.78', '135.148.136.134')
$LgMaxText = 26214400         # 25 MB of text
$LgMaxGzip = 5242880          # 5 MB compressed
$LgMaxFiles = 400             # newest log files looked at

# Folders that never hold logs. Skipping them keeps the search fast.
$LgSkipDirs = @('assets', 'libraries', 'versions', 'natives', 'cache', 'caches', 'saves', 'mods', 'config',
    'resourcepacks', 'shaderpacks', 'screenshots', 'backups', 'crash-reports', 'jre', 'runtime', 'java', 'meta',
    'webcache', 'node_modules', '.git')

function Get-LgRoots {
    $roots = New-Object System.Collections.Generic.List[string]
    $appData = $env:APPDATA
    $local = $env:LOCALAPPDATA
    $userHome = $env:USERPROFILE
    if ($appData) {
        foreach ($n in @('.minecraft', 'ModrinthApp', 'com.modrinth.theseus', 'PrismLauncher', 'PolyMC', 'PollyMC',
                'FjordLauncher', 'ElyPrismLauncher', 'MultiMC', 'ATLauncher', 'gdlauncher_next', 'gdlauncher_carbon',
                'gdlauncher', '.feather', 'LabyMod', '.tlauncher', '.technic', '.ftba', 'Badlion Client')) {
            $roots.Add((Join-Path $appData $n))
        }
    }
    if ($local) {
        foreach ($n in @('.ftba', 'FTBA', 'ATLauncher', 'Programs/PrismLauncher', 'Programs/MultiMC')) {
            $roots.Add((Join-Path $local $n))
        }
    }
    if ($userHome) {
        foreach ($n in @('.lunarclient', '.feather', '.tlauncher', 'curseforge/minecraft',
                'Documents/curseforge/minecraft', 'ATLauncher', 'MultiMC', 'Desktop/MultiMC', 'Downloads/MultiMC',
                'scoop/persist/prismlauncher', 'scoop/persist/multimc', 'scoop/persist/atlauncher')) {
            $roots.Add((Join-Path $userHome $n))
        }
    }
    $found = New-Object System.Collections.Generic.List[string]
    foreach ($r in $roots) {
        if ((Test-Path -LiteralPath $r -PathType Container) -and -not $found.Contains($r)) { $found.Add($r) }
    }
    return , $found
}

# Every *.log and *.log.gz inside a "logs" folder under the launcher folders, newest first.
function Get-LgCandidates {
    param($Roots)
    $files = New-Object System.Collections.Generic.List[object]
    $seen = New-Object System.Collections.Generic.HashSet[string]
    foreach ($root in $Roots) {
        $queue = New-Object System.Collections.Generic.Queue[object]
        $queue.Enqueue(@($root, 0, $false))
        while ($queue.Count -gt 0) {
            $cur = $queue.Dequeue()
            $path = $cur[0]; $depth = $cur[1]; $inLogs = $cur[2]
            $entries = @(Get-ChildItem -LiteralPath $path -Force -ErrorAction SilentlyContinue)
            foreach ($e in $entries) {
                if ($e.PSIsContainer) {
                    if ($depth -ge 9) { continue }
                    $name = $e.Name.ToLowerInvariant()
                    if ($LgSkipDirs -contains $name) { continue }
                    $queue.Enqueue(@($e.FullName, ($depth + 1), ($inLogs -or $name -eq 'logs')))
                    continue
                }
                if (-not $inLogs) { continue }
                $fn = $e.Name.ToLowerInvariant()
                if ($fn.StartsWith('debug')) { continue }
                if (-not ($fn.EndsWith('.log') -or $fn.EndsWith('.log.gz'))) { continue }
                if ($e.Length -le 0) { continue }
                if ($seen.Add($e.FullName)) { $files.Add($e) }
            }
        }
    }
    return , @($files | Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First $LgMaxFiles)
}

# The log as text. Handles .gz, and logs that an older Java wrote in the Windows code page.
function Read-LgText {
    param([string]$Path)
    # Opened so that it also works while Minecraft is running and still writing the log.
    $fs = New-Object System.IO.FileStream($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read,
        [System.IO.FileShare]::ReadWrite)
    try {
        $mem = New-Object System.IO.MemoryStream
        $fs.CopyTo($mem)
        $bytes = $mem.ToArray()
        $mem.Dispose()
    } finally {
        $fs.Dispose()
    }
    if ($Path.ToLowerInvariant().EndsWith('.gz')) {
        $in = New-Object System.IO.MemoryStream(, $bytes)
        $gz = New-Object System.IO.Compression.GZipStream($in, [System.IO.Compression.CompressionMode]::Decompress)
        $out = New-Object System.IO.MemoryStream
        try {
            $buf = New-Object byte[] 65536
            while ($true) {
                $n = $gz.Read($buf, 0, $buf.Length)
                if ($n -le 0) { break }
                $out.Write($buf, 0, $n)
                if ($out.Length -gt (2 * $LgMaxText)) { throw 'log too large' }
            }
            $bytes = $out.ToArray()
        } finally {
            $gz.Dispose(); $in.Dispose(); $out.Dispose()
        }
    }
    try {
        $strict = New-Object System.Text.UTF8Encoding($false, $true)
        return $strict.GetString($bytes)
    } catch {
        return [System.Text.Encoding]::Default.GetString($bytes)
    }
}

# Does this log show the ban with this Ban ID? "#1234" not followed by another digit.
function Test-LgBanId {
    param([string]$Text)
    if (-not $LgBan) { return $false }
    return [regex]::IsMatch($Text, '#' + [regex]::Escape($LgBan) + '(?!\d)')
}

# Does this log show a ban screen from Litsl? It has to mention Litsl, a disconnect, and a ban.
function Test-LgLitslBan {
    param([string]$Text)
    $i = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
    if (-not [regex]::IsMatch($Text, 'litsl', $i)) { return $false }
    if (-not [regex]::IsMatch($Text, 'disconnect|connection lost|lost connection|kicked', $i)) { return $false }
    return [regex]::IsMatch($Text,
        'you are (ip[- ]?)?banned|you have been (ip[- ]?)?banned|banned from|ban id|ban appeal|has been banned', $i)
}

# Did the player connect to Litsl in this log? The client writes "Connecting to <host>, <port>".
function Test-LgLitslSession {
    param([string]$Text)
    $ips = ($LgLitslIps | ForEach-Object { [regex]::Escape($_) }) -join '|'
    return [regex]::IsMatch($Text, 'Connecting to (?:(?:[a-z0-9-]+\.)*litsl\.net\.?|' + $ips + ')(?=[, \r\n]|$)', 'IgnoreCase')
}

function Get-LgLauncher {
    param([string]$Path)
    $p = $Path.ToLowerInvariant()
    if ($p -match 'modrinth|theseus') { return 'Modrinth App' }
    if ($p -match 'elyprism|fjordlauncher|prismlauncher') { return 'Prism Launcher' }
    if ($p -match 'pollymc|polymc') { return 'PolyMC' }
    if ($p -match 'multimc') { return 'MultiMC' }
    if ($p -match 'atlauncher') { return 'ATLauncher' }
    if ($p -match 'gdlauncher') { return 'GDLauncher' }
    if ($p -match 'curseforge') { return 'CurseForge' }
    if ($p -match 'lunarclient') { return 'Lunar Client' }
    if ($p -match 'blclient|badlion') { return 'Badlion Client' }
    if ($p -match 'feather') { return 'Feather Client' }
    if ($p -match 'labymod') { return 'LabyMod' }
    if ($p -match 'tlauncher') { return 'TLauncher' }
    if ($p -match 'technic') { return 'Technic Launcher' }
    if ($p -match 'ftba') { return 'FTB App' }
    if ($p -match 'minecraft') { return 'Minecraft Launcher' }
    return 'Unknown launcher'
}

# Pick the log, newest first. Three kinds of find, best first:
#   1. a log with your Ban ID in it
#   2. a log that shows a Litsl ban screen
#   3. the log of your most recent Litsl session (no ban screen in it)
function Find-LgLog {
    param($Candidates)
    $banScreen = $null
    $session = $null
    foreach ($f in $Candidates) {
        if ($f.Length -gt (2 * $LgMaxText)) { continue }
        $text = $null
        try { $text = Read-LgText -Path $f.FullName } catch { continue }
        if (Test-LgBanId -Text $text) {
            return @{ File = $f; Text = $text; Kind = 'ban'; HasId = $true }
        }
        if (($null -eq $banScreen) -and (Test-LgLitslBan -Text $text)) {
            $banScreen = @{ File = $f; Text = $text; Kind = 'ban'; HasId = $false }
            # Without a Ban ID to look for, the newest log with a Litsl ban screen is the answer.
            if (-not $LgBan) { return $banScreen }
        }
        if (($null -eq $session) -and ($null -eq $banScreen) -and (Test-LgLitslSession -Text $text)) {
            $session = @{ File = $f; Text = $text; Kind = 'session'; HasId = $false }
        }
    }
    if ($null -ne $banScreen) { return $banScreen }
    return $session
}

# The text with private details removed.
function Protect-LgText {
    param([string]$Text)
    $t = $Text
    $t = [regex]::Replace($t, '(--accessToken[=, ]+"?)[^\] ,"]+', '$1[removed]', 'IgnoreCase')
    $t = [regex]::Replace($t, '(--session[=, ]+"?)[^\] ,"]+', '$1[removed]', 'IgnoreCase')
    $t = [regex]::Replace($t, '(--(xuid|clientId|userProperties|profileProperties)[=, ]+"?)[^\] ,"]+', '$1[removed]', 'IgnoreCase')
    $t = [regex]::Replace($t, '(((refresh|access|id|client)_?token|session_?id)\\?"?[ ]*[:=][ ]*\\?"?)[^ ,"}\\\r\n]+', '$1[removed]', 'IgnoreCase')
    $t = [regex]::Replace($t, '(token:)[A-Za-z0-9._-]{6,}(:[0-9A-Fa-f-]{32,36})', '$1[removed]$2')
    $t = [regex]::Replace($t, '(Session ID is token:)[^:) ]+', '$1[removed]')
    $t = [regex]::Replace($t, '(Bearer[ ]+)[A-Za-z0-9._~+/=-]{8,}', '$1[removed]', 'IgnoreCase')
    $t = [regex]::Replace($t, 'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*', '[removed-token]')
    # Your user folder, however the log wrote it (C:\Users\You, C:\\Users\\You, C:/Users/You).
    $userHome = $env:USERPROFILE
    if ($userHome) {
        $parent = Split-Path -Parent $userHome
        if (-not $parent) { $parent = $userHome }
        $shown = (Join-Path $parent '[user]')
        foreach ($variant in @($userHome, $userHome.Replace('\', '\\'), $userHome.Replace('\', '/'))) {
            $t = [regex]::Replace($t, [regex]::Escape($variant), $shown.Replace('$', '$$'), 'IgnoreCase')
        }
    }
    $t = [regex]::Replace($t, '([A-Za-z]:[\\/]+Users[\\/]+)[^\\/:"''\r\n]+', '$1[user]', 'IgnoreCase')
    $t = [regex]::Replace($t, '(/Users/|/home/|/run/media/|/media/|/Volumes/)[^/\s:"'']+', '$1[user]')
    $t = [regex]::Replace($t, '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}', '[email]')
    $t = [regex]::Replace($t, '([0-9A-Fa-f]{1,4}:){5,7}[0-9A-Fa-f]{1,4}', '[ip]')
    $t = [regex]::Replace($t, '(?<![A-Za-z0-9_:.])([0-9A-Fa-f]{1,4}:){1,7}:([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,6})?', '[ip]')
    # IPv4 addresses become [ip]. Left alone: the Litsl addresses, and numbers that are versions and not
    # addresses (mod list lines, "version 1.20.4.2", "foo-1.2.3.4.jar", "1.2.3.4+build", a part over 255).
    $keep = ($LgLitslIps | ForEach-Object { [regex]::Escape($_) }) -join '|'
    $first = '(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d?)'
    $part = '(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)'
    $ipv4 = '(?<![0-9A-Za-z.+_-])' +
        '(?<!(?:version|ver|java|jdk|jre|openjdk|build|release|loader|lwjgl|opengl|driver)[ :=]{0,3})' +
        '(?<!^[ \t]+[-|\\][^\n]*)' +
        '(?!(?:' + $keep + ')(?!\d))' +
        $first + '(?:\.' + $part + '){3}' +
        '(?![0-9A-Za-z+_-]|\.[0-9A-Za-z])'
    $t = [regex]::Replace($t, $ipv4, '[ip]', 'IgnoreCase, Multiline')
    return $t
}

# UTF-8 text, gzip-compressed, as bytes.
function Compress-LgText {
    param([string]$Text)
    $utf8 = New-Object System.Text.UTF8Encoding($false)
    $raw = $utf8.GetBytes($Text)
    $out = New-Object System.IO.MemoryStream
    $gz = New-Object System.IO.Compression.GZipStream($out, [System.IO.Compression.CompressionMode]::Compress)
    $gz.Write($raw, 0, $raw.Length)
    $gz.Dispose()
    $bytes = $out.ToArray()
    $out.Dispose()
    return @{ Gzip = $bytes; TextLength = $raw.Length }
}

function Format-LgSize {
    param([long]$Bytes)
    if ($Bytes -lt 1024) { return "$Bytes bytes" }
    if ($Bytes -lt 1048576) { return ('{0:N0} KB' -f [math]::Floor($Bytes / 1024)) }
    return ('{0:N1} MB' -f ($Bytes / 1048576))
}

function Get-LgBanLines {
    param([string]$Text)
    $pattern = 'banned|ban id|ban appeal'
    if ($LgBan -and (Test-LgBanId -Text $Text)) { $pattern = '#' + [regex]::Escape($LgBan) + '(?!\d)' }
    $lines = New-Object System.Collections.Generic.List[string]
    foreach ($line in ($Text -split "`r?`n")) {
        if ([regex]::IsMatch($line, $pattern, 'IgnoreCase')) {
            if ($line.Length -gt 200) { $line = $line.Substring(0, 200) }
            $lines.Add($line)
            if ($lines.Count -ge 3) { break }
        }
    }
    return , $lines
}

function Get-LgSessionLines {
    param([string]$Text)
    $lines = New-Object System.Collections.Generic.List[string]
    foreach ($line in ($Text -split "`r?`n")) {
        if (Test-LgLitslSession -Text $line) {
            if ($line.Length -gt 200) { $line = $line.Substring(0, 200) }
            $lines.Add($line)
            if ($lines.Count -ge 2) { break }
        }
    }
    return , $lines
}

# Sends the compressed copy. Returns @{ Ok; Message }.
function Send-LgLog {
    param([byte[]]$Gzip, [string]$FileName, [string]$Launcher, [string]$Kind)
    $client = New-Object System.Net.WebClient
    try {
        $client.Headers['Content-Type'] = 'application/gzip'
        $client.Headers['X-Log-File'] = ($FileName -replace '[^A-Za-z0-9 ._()+-]', '_')
        $client.Headers['X-Log-Launcher'] = $Launcher
        $client.Headers['X-Log-OS'] = 'Windows'
        $client.Headers['X-Log-Kind'] = $Kind
        [void]$client.UploadData("$LgBase/up/$LgId", 'POST', $Gzip)
        return @{ Ok = $true; Message = '' }
    } catch {
        $ex = $_.Exception
        while (($null -ne $ex) -and -not ($ex -is [System.Net.WebException])) { $ex = $ex.InnerException }
        $message = "could not reach $LgBase"
        if (($null -ne $ex) -and ($null -ne $ex.Response)) {
            try {
                $reader = New-Object System.IO.StreamReader($ex.Response.GetResponseStream())
                $body = $reader.ReadToEnd()
                $reader.Dispose()
                if ($body) {
                    if ($body.Length -gt 300) { $body = $body.Substring(0, 300) }
                    $message = $body
                }
            } catch { }
        }
        return @{ Ok = $false; Message = $message }
    } finally {
        $client.Dispose()
    }
}

function Write-LgManualHelp {
    Write-Host ''
    Write-Host 'You can still send a log yourself: press Win + R, type %appdata%\.minecraft\logs and press Enter,'
    Write-Host 'then drag "latest.log" (or the dated .log.gz from the day of the ban) into your ticket.'
    Write-Host 'The log grab is optional. Your appeal is reviewed either way.'
}

function Invoke-LitslLogGrab {
    # Older Windows 10 does not use TLS 1.2 by default, and the upload needs it.
    try {
        [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
    } catch { }
    Write-Host ''
    Write-Host '  Litsl log grab'
    if ($LgTicket) { Write-Host "  for ban appeal ticket #$LgTicket" }
    Write-Host ''
    Write-Host "  How it works and the full source of this script: $LgBase/"
    Write-Host ''
    Write-Host 'Looking for Minecraft logs in the usual launcher folders...'

    $candidates = Get-LgCandidates -Roots (Get-LgRoots)
    if ($candidates.Count -eq 0) {
        Write-Host ''
        Write-Host 'No Minecraft log files were found in the usual launcher folders.'
        Write-Host 'If your launcher keeps its game folder somewhere else, this script cannot see it.'
        Write-Host 'Nothing was sent.'
        Write-LgManualHelp
        return
    }

    $pick = Find-LgLog -Candidates $candidates
    if ($null -eq $pick) {
        Write-Host ''
        Write-Host "Looked at $($candidates.Count) log file(s). None of them shows your ban or a visit to Litsl."
        Write-Host 'If you play Litsl with a launcher that keeps its game folder somewhere else,'
        Write-Host 'this script cannot see it.'
        Write-Host 'Nothing was sent.'
        Write-LgManualHelp
        return
    }

    $file = $pick.File
    $launcher = Get-LgLauncher -Path $file.FullName
    $clean = Protect-LgText -Text $pick.Text
    $packed = Compress-LgText -Text $clean

    Write-Host ''
    if ($pick.Kind -eq 'ban') {
        if ($pick.HasId) { Write-Host "This log shows your ban (#$LgBan):" }
        elseif ($LgBan) { Write-Host "This log shows a Litsl ban screen (no log contains Ban ID #$LgBan):" }
        else { Write-Host 'This log shows your ban:' }
    } else {
        Write-Host "No log shows the ban screen (that's normal if you were banned while offline)."
        Write-Host 'This is your most recent Litsl session:'
    }
    Write-Host ''
    Write-Host "  File:      $($file.FullName)"
    Write-Host "  Date:      $($file.LastWriteTime.ToString('yyyy-MM-dd HH:mm'))"
    Write-Host "  Launcher:  $launcher"
    Write-Host "  Size:      $(Format-LgSize $file.Length) on disk, $(Format-LgSize $packed.Gzip.Length) to send (compressed)"
    Write-Host ''
    if ($pick.Kind -eq 'ban') {
        Write-Host '  Lines that show the ban (as they would be sent):'
        foreach ($line in (Get-LgBanLines -Text $clean)) { Write-Host "    $line" }
    } else {
        Write-Host '  Where it joins Litsl (as it would be sent):'
        foreach ($line in (Get-LgSessionLines -Text $clean)) { Write-Host "    $line" }
    }
    Write-Host ''
    Write-Host '  Removed from the copy: access tokens, your Windows username, email addresses,'
    Write-Host '  and IP addresses other than the Litsl server. Your file on disk is not changed.'
    Write-Host ''

    if (($packed.TextLength -gt $LgMaxText) -or ($packed.Gzip.Length -gt $LgMaxGzip)) {
        Write-Host 'This log is too big to send this way (limit: 25 MB of text, 5 MB compressed).'
        Write-Host 'Nothing was sent.'
        Write-LgManualHelp
        return
    }

    $answer = Read-Host 'Send this one file to Litsl staff for your ban appeal? [y/N]'
    if ($answer -notmatch '^\s*(y|yes)\s*$') {
        Write-Host ''
        Write-Host 'Cancelled. Nothing was sent.'
        return
    }

    Write-Host 'Sending...'
    $result = Send-LgLog -Gzip $packed.Gzip -FileName $file.Name -Launcher $launcher -Kind $pick.Kind
    Write-Host ''
    if ($result.Ok) {
        Write-Host 'Done. Your log was sent and will show up in your ticket within a couple of minutes.'
        return
    }
    Write-Host "The log was not sent: $($result.Message)"
    Write-LgManualHelp
}

# Set $LitslLogGrabNoRun = $true before loading this file to load the functions without running them (tests).
if (-not $LitslLogGrabNoRun) {
    try {
        Invoke-LitslLogGrab
    } catch {
        Write-Host ''
        Write-Host "Something went wrong: $($_.Exception.Message)"
        Write-Host 'Nothing more was sent.'
        Write-LgManualHelp
    }
    Write-Host ''
    [void](Read-Host 'Press Enter to close this window')
}

Mac and Linux: loggrab.sh (open as plain text)

#!/bin/sh
# Litsl log grab (Mac and Linux)
#
# What this script does, in order:
#   1. Looks in the usual Minecraft launcher folders in your home folder for log files.
#   2. Picks the newest log that shows your Litsl ban. If no log shows it (normal when you were
#      banned while offline), picks the log of your most recent Litsl session instead.
#   3. Shows you which file it picked and asks "y/N". Nothing is sent unless you answer y.
#   4. Makes a temporary copy with access tokens, your computer username, emails and
#      IP addresses (except Litsl's) removed.
#   5. Sends that one copy to Litsl for your ban appeal ticket, then deletes its temporary files.
#
# It needs no admin rights, installs nothing and changes none of your files.
# The same source is shown at __LG_BASE__/ with the three values below left as placeholders.
# shellcheck shell=sh
set -u

LG_BASE='__LG_BASE__'       # where the log is sent
LG_ID='__LG_ID__'           # your one-time upload link
LG_BAN='__LG_BAN__'         # your Ban ID (digits), or empty when the ticket has none
LG_TICKET='__LG_TICKET__'   # your ticket number, only shown to you below

# Litsl's own server addresses (current, and the one used until September 2026). These stay in the log.
LG_LITSL_IPS='170.205.24.78 135.148.136.134'
LG_MAX_TEXT=26214400        # 25 MB of text
LG_MAX_FILE_KB=51200        # log files over 50 MB on disk are not looked at
LG_MAX_GZIP=5242880         # 5 MB compressed
LG_MAX_FILES=400            # newest log files looked at
# Where the y/N answer is read from. Piped from curl, the script itself is on stdin,
# so the answer has to come from the terminal.
LG_TTY="${LITSL_LOGGRAB_TTY:-/dev/tty}"

say() { printf '%s\n' "$*" >&2; }

manual_help() {
  say ''
  say 'You can still send a log yourself: open your Minecraft folder, then "logs",'
  say 'and drag "latest.log" (or the dated .log.gz from the day of the ban) into your ticket.'
  say 'The log grab is optional. Your appeal is reviewed either way.'
}

LG_TMP=''
cleanup() {
  if [ -n "$LG_TMP" ] && [ -d "$LG_TMP" ]; then rm -rf "$LG_TMP"; fi
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM HUP

# ---------------------------------------------------------------- where logs live

lg_roots() {
  _h="${HOME:-}"
  [ -n "$_h" ] || return 0
  case "$(uname -s 2>/dev/null || echo unknown)" in
    Darwin)
      _a="$_h/Library/Application Support"
      printf '%s\n' \
        "$_a/minecraft" \
        "$_a/ModrinthApp" \
        "$_a/com.modrinth.theseus" \
        "$_a/PrismLauncher" \
        "$_a/PolyMC" \
        "$_a/PollyMC" \
        "$_a/FjordLauncher" \
        "$_a/ElyPrismLauncher" \
        "$_a/MultiMC" \
        "/Applications/MultiMC.app/Data" \
        "$_a/ATLauncher" \
        "$_a/gdlauncher_next" \
        "$_a/gdlauncher_carbon" \
        "$_a/gdlauncher" \
        "$_a/Badlion Client" \
        "$_a/.feather" \
        "$_a/feather" \
        "$_a/LabyMod" \
        "$_a/tlauncher" \
        "$_a/.tlauncher" \
        "$_a/technic" \
        "$_a/.ftba" \
        "$_a/ftblauncher" \
        "$_h/.lunarclient" \
        "$_h/.feather" \
        "$_h/.tlauncher" \
        "$_h/Documents/curseforge/minecraft" \
        "$_h/curseforge/minecraft"
      ;;
    *)
      _d="${XDG_DATA_HOME:-$_h/.local/share}"
      _c="${XDG_CONFIG_HOME:-$_h/.config}"
      _f="$_h/.var/app"
      printf '%s\n' \
        "$_h/.minecraft" \
        "$_d/ModrinthApp" \
        "$_d/com.modrinth.theseus" \
        "$_d/PrismLauncher" \
        "$_d/PolyMC" \
        "$_d/PollyMC" \
        "$_d/FjordLauncher" \
        "$_d/ElyPrismLauncher" \
        "$_d/multimc" \
        "$_d/MultiMC" \
        "$_d/ATLauncher" \
        "$_d/atlauncher" \
        "$_d/gdlauncher_next" \
        "$_d/gdlauncher_carbon" \
        "$_d/labymod" \
        "$_c/gdlauncher_next" \
        "$_c/gdlauncher_carbon" \
        "$_c/Badlion Client" \
        "$_h/.lunarclient" \
        "$_h/.feather" \
        "$_h/.tlauncher" \
        "$_h/.technic" \
        "$_h/.ftba" \
        "$_h/.atlauncher" \
        "$_h/Documents/curseforge/minecraft" \
        "$_h/curseforge/minecraft" \
        "$_f/com.mojang.Minecraft/.minecraft" \
        "$_f/com.mojang.Minecraft/data/minecraft" \
        "$_f/org.prismlauncher.PrismLauncher/data/PrismLauncher" \
        "$_f/org.polymc.PolyMC/data/PolyMC" \
        "$_f/org.multimc.MultiMC/data/multimc" \
        "$_f/com.modrinth.ModrinthApp/data/ModrinthApp" \
        "$_f/com.atlauncher.ATLauncher/data" \
        "$_f/io.gdevs.GDLauncher/config/gdlauncher_next" \
        "$_f/com.lunarclient.LunarClient/.lunarclient" \
        "$_f/net.badlion.Client/.minecraft" \
        "$_h/snap/mc-installer/current/.minecraft" \
        "$_h/snap/minecraft-launcher/common/.minecraft"
      ;;
  esac
}

# Modification time in seconds, then the path. BSD (Mac) and GNU stat differ.
lg_mtime_line() {
  if stat -c '%Y' / >/dev/null 2>&1; then
    stat -c '%Y %n' "$1" 2>/dev/null
  else
    stat -f '%m %N' "$1" 2>/dev/null
  fi
}

# Every *.log and *.log.gz inside a "logs" folder under the launcher folders. Big folders that
# never hold logs (assets, libraries, saves, mods ...) are skipped. debug logs and files over
# 50 MB are skipped.
lg_candidates() {
  lg_roots | while IFS= read -r _root; do
    [ -d "$_root" ] || continue
    # -L: a launcher folder that is a link to another disk is followed. -maxdepth stops link loops;
    # a file reached twice through a loop sorts shortest path first below.
    find -L "$_root" -maxdepth 9 \
      \( -type d \( -name assets -o -name libraries -o -name versions -o -name natives \
         -o -name cache -o -name caches -o -name saves -o -name mods -o -name config \
         -o -name resourcepacks -o -name shaderpacks -o -name screenshots -o -name backups \
         -o -name crash-reports -o -name jre -o -name runtime -o -name java -o -name meta \
         -o -name webcache -o -name node_modules -o -name .git \) -prune \) -o \
      \( -type f \( -name '*.log' -o -name '*.log.gz' \) -path '*/logs/*' ! -name 'debug*' \
         -size "-${LG_MAX_FILE_KB}k" -print \) \
      2>/dev/null
  done | while IFS= read -r _f; do
    [ -r "$_f" ] && [ -s "$_f" ] && lg_mtime_line "$_f"
  done | sort -t' ' -k1,1nr -k2 | awk '!seen[$0]++' | head -n "$LG_MAX_FILES" | cut -d' ' -f2-
}

lg_cat() {
  case "$1" in
    *.gz) gzip -dc -- "$1" 2>/dev/null ;;
    *) cat -- "$1" 2>/dev/null ;;
  esac
}

# Does this log show the ban with this Ban ID? "#1234" not followed by another digit.
lg_has_ban_id() {
  lg_cat "$1" | LC_ALL=C grep -Eq "#${LG_BAN}([^0-9]|\$)"
}

# Does this log show a ban screen from Litsl? It has to mention Litsl, a disconnect, and a ban.
lg_has_litsl_ban() {
  lg_cat "$1" | LC_ALL=C awk '
    { l = tolower($0) }
    l ~ /litsl/ { a = 1 }
    l ~ /disconnect|connection lost|lost connection|kicked/ { b = 1 }
    l ~ /you are (ip[- ]?)?banned|you have been (ip[- ]?)?banned|banned from|ban id|ban appeal|has been banned/ { c = 1 }
    END { exit !(a && b && c) }'
}

# Did the player connect to Litsl in this log? The client writes "Connecting to <host>, <port>".
lg_has_litsl_session() {
  lg_cat "$1" | LC_ALL=C awk -v ips="$LG_LITSL_IPS" '
    BEGIN { n = split(ips, ip, " ") }
    {
      l = tolower($0)
      at = index(l, "connecting to ")
      if (!at) next
      host = substr(l, at + 14)
      sub(/[, ].*$/, "", host)
      if (host ~ /(^|\.)litsl\.net\.?$/) { found = 1; exit }
      for (i = 1; i <= n; i++) if (host == ip[i]) { found = 1; exit }
    }
    END { exit !found }'
}

lg_launcher() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    *modrinth*|*theseus*) echo 'Modrinth App' ;;
    *elyprism*|*fjordlauncher*|*prismlauncher*) echo 'Prism Launcher' ;;
    *pollymc*|*polymc*) echo 'PolyMC' ;;
    *multimc*) echo 'MultiMC' ;;
    *atlauncher*) echo 'ATLauncher' ;;
    *gdlauncher*) echo 'GDLauncher' ;;
    *curseforge*) echo 'CurseForge' ;;
    *lunarclient*) echo 'Lunar Client' ;;
    *blclient*|*badlion*) echo 'Badlion Client' ;;
    *feather*) echo 'Feather Client' ;;
    *labymod*) echo 'LabyMod' ;;
    *tlauncher*) echo 'TLauncher' ;;
    *technic*) echo 'Technic Launcher' ;;
    *ftba*|*ftblauncher*) echo 'FTB App' ;;
    *minecraft*) echo 'Minecraft Launcher' ;;
    *) echo 'Unknown launcher' ;;
  esac
}

lg_size() {
  _b="$1"
  if [ "$_b" -lt 1024 ]; then printf '%s bytes' "$_b"
  elif [ "$_b" -lt 1048576 ]; then printf '%s KB' "$((_b / 1024))"
  else printf '%s.%s MB' "$((_b / 1048576))" "$(((_b % 1048576) * 10 / 1048576))"
  fi
}

# Escape a literal string for use inside a sed -E pattern (delimiter #).
lg_sed_escape() { printf '%s' "$1" | sed -e 's/[][\\.^$*+?(){}|#/]/\\&/g'; }

# Copy the log to stdout with private details removed.
lg_redact() {
  _home_re="$(lg_sed_escape "${HOME:-/nonexistent-home}")"
  lg_cat "$1" | LC_ALL=C sed -E \
    -e 's#(--[Aa]ccess[Tt]oken[=, ]+"?)[^] ,"]+#\1[removed]#g' \
    -e 's#(--[Ss]ession[=, ]+"?)[^] ,"]+#\1[removed]#g' \
    -e 's#(--(xuid|clientId|userProperties|profileProperties)[=, ]+"?)[^] ,"]+#\1[removed]#g' \
    -e 's#((([Rr]efresh|[Aa]ccess|[Ii]d|[Cc]lient)_?[Tt]oken|[Ss]ession_?[Ii]d)\\?"?[ ]*[:=][ ]*\\?"?)[^ ,"}\\]+#\1[removed]#g' \
    -e 's#(token:)[A-Za-z0-9._-]{6,}(:[0-9A-Fa-f-]{32,36})#\1[removed]\2#g' \
    -e 's#(Session ID is token:)[^:) ]+#\1[removed]#g' \
    -e 's#([Bb]earer[ ]+)[A-Za-z0-9._~+/=-]{8,}#\1[removed]#g' \
    -e 's#eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*#[removed-token]#g' \
    -e "s#${_home_re}#~#g" \
    -e 's#(/Users/|/home/|/run/media/|/media/|/Volumes/)[^/ :"'"'"']+#\1[user]#g' \
    -e 's#([A-Za-z]:[\\/]+Users[\\/]+)[^\\/:"'"'"']+#\1[user]#g' \
    -e 's#[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}#[email]#g' \
    -e 's#([0-9A-Fa-f]{1,4}:){5,7}[0-9A-Fa-f]{1,4}#[ip]#g' \
    -e 's#(^|[^A-Za-z0-9_:.])([0-9A-Fa-f]{1,4}:){1,7}:([0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){0,6})?#\1[ip]#g' \
    | LC_ALL=C awk -v keep="$LG_LITSL_IPS" '
      # IPv4 addresses become [ip]. Left alone: the Litsl addresses, and numbers that are versions and not
      # addresses (mod list lines, "version 1.20.4.2", "foo-1.2.3.4.jar", "1.2.3.4+build", a part over 255).
      BEGIN { n = split(keep, k, " "); for (i = 1; i <= n; i++) litsl[k[i]] = 1 }
      {
        if ($0 ~ /^[ \t]+[-|\\]/ || $0 !~ /[0-9]\.[0-9]/) { print; next }
        out = ""; rest = $0
        while (match(rest, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) {
          pre = out substr(rest, 1, RSTART - 1)
          m = substr(rest, RSTART, RLENGTH)
          rest = substr(rest, RSTART + RLENGTH)
          ok = !(m in litsl)
          split(m, a, ".")
          for (i = 1; i <= 4; i++) if (length(a[i]) > 3 || a[i] + 0 > 255) ok = 0
          if (a[1] + 0 == 0) ok = 0
          if (pre ~ /[0-9A-Za-z.+_-]$/) ok = 0
          if (rest ~ /^[0-9A-Za-z+_-]/ || rest ~ /^\.[0-9A-Za-z]/) ok = 0
          if (tolower(pre) ~ /(version|ver|java|jdk|jre|openjdk|build|release|loader|lwjgl|opengl|driver)[ :=]*$/) ok = 0
          out = pre (ok ? "[ip]" : m)
        }
        print out rest
      }'
}

# ---------------------------------------------------------------- start

say ''
say '  Litsl log grab'
if [ -n "$LG_TICKET" ]; then say "  for ban appeal ticket #$LG_TICKET"; fi
say ''
say "  How it works and the full source of this script: $LG_BASE/"
say ''

for _need in curl gzip find sed awk; do
  if ! command -v "$_need" >/dev/null 2>&1; then
    say "This needs \"$_need\", which is not installed here. Nothing was sent."
    manual_help
    exit 1
  fi
done

LG_TMP="$(mktemp -d "${TMPDIR:-/tmp}/litsl-loggrab.XXXXXX")" || {
  say 'Could not create a temporary folder. Nothing was sent.'
  manual_help
  exit 1
}

say 'Looking for Minecraft logs in the usual launcher folders...'
lg_candidates >"$LG_TMP/candidates"
LG_COUNT="$(wc -l <"$LG_TMP/candidates" | tr -d ' ')"

if [ "$LG_COUNT" -eq 0 ]; then
  say ''
  say 'No Minecraft log files were found in the usual launcher folders.'
  say 'If your launcher keeps its game folder somewhere else, this script cannot see it.'
  say 'Nothing was sent.'
  manual_help
  exit 1
fi

# Three kinds of find, best first. Files are looked at newest first.
#   1. a log with your Ban ID in it
#   2. a log that shows a Litsl ban screen
#   3. the log of your most recent Litsl session (no ban screen in it)
LG_PICK=''
LG_KIND=''
LG_BANSCREEN=''
LG_SESSION=''
while IFS= read -r _f; do
  [ -n "$_f" ] || continue
  if [ -n "$LG_BAN" ] && lg_has_ban_id "$_f"; then
    LG_PICK="$_f"
    LG_KIND='ban'
    break
  fi
  if [ -z "$LG_BANSCREEN" ] && lg_has_litsl_ban "$_f"; then
    LG_BANSCREEN="$_f"
    # Without a Ban ID to look for, the newest log with a Litsl ban screen is the answer.
    [ -n "$LG_BAN" ] || break
  fi
  if [ -z "$LG_SESSION" ] && [ -z "$LG_BANSCREEN" ] && lg_has_litsl_session "$_f"; then
    LG_SESSION="$_f"
  fi
done <"$LG_TMP/candidates"

if [ -z "$LG_PICK" ] && [ -n "$LG_BANSCREEN" ]; then
  LG_PICK="$LG_BANSCREEN"
  LG_KIND='ban'
elif [ -z "$LG_PICK" ] && [ -n "$LG_SESSION" ]; then
  LG_PICK="$LG_SESSION"
  LG_KIND='session'
fi

if [ -z "$LG_PICK" ]; then
  say ''
  say "Looked at $LG_COUNT log file(s). None of them shows your ban or a visit to Litsl."
  say 'If you play Litsl with a launcher that keeps its game folder somewhere else,'
  say 'this script cannot see it.'
  say 'Nothing was sent.'
  manual_help
  exit 1
fi

LG_NAME="$(basename "$LG_PICK")"
LG_LAUNCHER="$(lg_launcher "$LG_PICK")"
LG_ORIG_BYTES="$(wc -c <"$LG_PICK" | tr -d ' ')"

# How big is it as text? Checked before any copy is made (a .log.gz can unpack to far more).
LG_RAW_BYTES="$(lg_cat "$LG_PICK" | head -c "$((LG_MAX_TEXT + 1))" | wc -c | tr -d ' ')"
if [ "$LG_RAW_BYTES" -gt "$LG_MAX_TEXT" ]; then
  say ''
  say "The log that was found is too big to send this way (over 25 MB of text):"
  say "  $LG_PICK"
  say 'Nothing was sent.'
  manual_help
  exit 1
fi

if stat -c '%Y' / >/dev/null 2>&1; then
  LG_DATE="$(date -d "@$(stat -c '%Y' "$LG_PICK")" '+%Y-%m-%d %H:%M' 2>/dev/null || true)"
else
  LG_DATE="$(stat -f '%Sm' -t '%Y-%m-%d %H:%M' "$LG_PICK" 2>/dev/null || true)"
fi

# The copy that would be sent: private details removed, then made valid UTF-8 text.
lg_redact "$LG_PICK" >"$LG_TMP/redacted.log"
if command -v iconv >/dev/null 2>&1 && ! iconv -f UTF-8 -t UTF-8 "$LG_TMP/redacted.log" >/dev/null 2>&1; then
  iconv -c -f UTF-8 -t UTF-8 "$LG_TMP/redacted.log" >"$LG_TMP/clean.log" 2>/dev/null || true
  if [ -s "$LG_TMP/clean.log" ]; then mv "$LG_TMP/clean.log" "$LG_TMP/redacted.log"; fi
fi
LG_TEXT_BYTES="$(wc -c <"$LG_TMP/redacted.log" | tr -d ' ')"
gzip -9 -c "$LG_TMP/redacted.log" >"$LG_TMP/upload.log.gz"
LG_GZ_BYTES="$(wc -c <"$LG_TMP/upload.log.gz" | tr -d ' ')"

say ''
if [ "$LG_KIND" = 'ban' ]; then
  if [ -n "$LG_BAN" ] && LC_ALL=C grep -Eq "#${LG_BAN}([^0-9]|\$)" "$LG_TMP/redacted.log"; then
    say "This log shows your ban (#$LG_BAN):"
  elif [ -n "$LG_BAN" ]; then
    say "This log shows a Litsl ban screen (no log contains Ban ID #$LG_BAN):"
  else
    say 'This log shows your ban:'
  fi
else
  say "No log shows the ban screen (that's normal if you were banned while offline)."
  say 'This is your most recent Litsl session:'
fi
say ''
say "  File:      $LG_PICK"
say "  Date:      ${LG_DATE:-unknown}"
say "  Launcher:  $LG_LAUNCHER"
say "  Size:      $(lg_size "$LG_ORIG_BYTES") on disk, $(lg_size "$LG_GZ_BYTES") to send (compressed)"
say ''
if [ "$LG_KIND" = 'ban' ]; then
  say '  Lines that show the ban (as they would be sent):'
  if [ -n "$LG_BAN" ] && LC_ALL=C grep -Eq "#${LG_BAN}([^0-9]|\$)" "$LG_TMP/redacted.log"; then
    LC_ALL=C grep -E "#${LG_BAN}([^0-9]|\$)" "$LG_TMP/redacted.log" | head -n 3 | cut -c1-200 | sed 's/^/    /' >&2
  else
    LC_ALL=C grep -Ei 'banned|ban id|ban appeal' "$LG_TMP/redacted.log" | head -n 3 | cut -c1-200 | sed 's/^/    /' >&2
  fi
else
  say '  Where it joins Litsl (as it would be sent):'
  LC_ALL=C grep -Ei 'connecting to .*(litsl|170\.205\.24\.78|135\.148\.136\.134)' "$LG_TMP/redacted.log" | head -n 2 | cut -c1-200 | sed 's/^/    /' >&2
fi
say ''
say '  Removed from the copy: access tokens, your computer username, email addresses,'
say '  and IP addresses other than the Litsl server. Your file on disk is not changed.'
say ''

if [ "$LG_TEXT_BYTES" -gt "$LG_MAX_TEXT" ] || [ "$LG_GZ_BYTES" -gt "$LG_MAX_GZIP" ]; then
  say 'This log is too big to send this way (limit: 25 MB of text, 5 MB compressed).'
  say 'Nothing was sent.'
  manual_help
  exit 1
fi

if [ ! -r "$LG_TTY" ]; then
  say 'Cannot ask you to confirm here (no terminal), so nothing was sent.'
  manual_help
  exit 1
fi

printf 'Send this one file to Litsl staff for your ban appeal? [y/N] ' >&2
LG_ANSWER=''
IFS= read -r LG_ANSWER <"$LG_TTY" || LG_ANSWER=''
case "$LG_ANSWER" in
  y|Y|yes|Yes|YES) ;;
  *)
    say ''
    say 'Cancelled. Nothing was sent.'
    exit 0
    ;;
esac

case "$(uname -s 2>/dev/null || echo unknown)" in
  Darwin) LG_OS='macOS' ;;
  Linux) LG_OS='Linux' ;;
  *) LG_OS='Unix' ;;
esac

say 'Sending...'
LG_CODE="$(curl -sS --max-time 180 -o "$LG_TMP/response" -w '%{http_code}' -X POST \
  -H 'Content-Type: application/gzip' \
  -H "X-Log-File: $LG_NAME" \
  -H "X-Log-Launcher: $LG_LAUNCHER" \
  -H "X-Log-OS: $LG_OS" \
  -H "X-Log-Kind: $LG_KIND" \
  --data-binary @"$LG_TMP/upload.log.gz" \
  "$LG_BASE/up/$LG_ID" 2>"$LG_TMP/curl-error")" || LG_CODE='000'

say ''
if [ "$LG_CODE" = '200' ] || [ "$LG_CODE" = '201' ]; then
  say 'Done. Your log was sent and will show up in your ticket within a couple of minutes.'
  say 'You can close this window.'
  exit 0
fi

if [ -s "$LG_TMP/response" ]; then
  say "The log was not sent: $(head -c 300 "$LG_TMP/response")"
else
  say "The log was not sent (could not reach $LG_BASE)."
fi
manual_help
exit 1