# s200-upload.ps1 v1: sends one file from this computer to a Status 200 Uploads upload link (TUS 1.0.0, resumable). # Status 200 Uploads, https://status200uploads.com - docs: https://status200uploads.com/docs/api # Windows PowerShell 5.1 or PowerShell 7, nothing to install. Every run continues from the byte storage already has. # A published version never changes: any fix is a new folder (v2), so the SHA-256 that the command checks stays true. # # Input, from the environment only (the command Status 200 Uploads gives fills in everything but S200_FILE): # S200_UPLOAD_URL the upload address: only the signed TUS route of yglvofckrdutesfzxwyb.storage.supabase.co # S200_UPLOAD_TOKEN the signed upload token, sent as x-signature and nowhere else # S200_CHECK first 8 hex of SHA-256(S200_UPLOAD_URL + "\n" + S200_UPLOAD_TOKEN): the copy is exact # S200_MAX_SECONDS how long this run may take (default 100); it always ends within that + 10 s # S200_FILE the full path of the file # powershell -NoProfile -ExecutionPolicy Bypass -File s200-upload.ps1 # # Output: "progress N% (X.X of Y.Y MiB)" every 15 s and at the end, then exactly one JSON line. # Exit: 0 done | 1 failed (network, after retries) | 2 refused (a rerun cannot help) | 3 partial (time used, run again) # 4 token_rejected (get a fresh token, run again) | 5 busy (another uploader here is sending this file) # 6 bad_command (the command was not copied exactly) # Constrained Language Mode (AppLocker, WDAC) blocks the .NET calls below, so say it before making any. if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { Write-Output '{"state":"refused","message":"PowerShell runs in Constrained Language Mode on this computer, so this uploader cannot run here. Ask for the link again with runner node."}' exit 2 } # The time budget counts from here: the JSON line and the exit come within S200_MAX_SECONDS + 10 s. $clock = [Diagnostics.Stopwatch]::StartNew() Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' # The only place the token may go: the pattern the server checks before it hands one out. \z, not $: in .NET a $ # also matches before a final newline. The token has the shape of the signed upload token (a JWT). $URL_PATTERN = '^https://yglvofckrdutesfzxwyb\.storage\.supabase\.co/storage/v1/upload/resumable/sign/[A-Za-z0-9_-]+\z' $TOKEN_PATTERN = '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\z' $CHUNK = 6291456 # storage takes resumable uploads in 6 MiB parts; the last one may be shorter $inv = [Globalization.CultureInfo]::InvariantCulture $onWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT $url = [string]$env:S200_UPLOAD_URL $token = [string]$env:S200_UPLOAD_TOKEN $maxSeconds = 100 [long]$size = -1 # the local file's size, known once the file is open [long]$offset = -1 # the bytes storage has, as it last said; -1 until a HEAD answered [long]$printed = -1 # the offset the last progress line showed $lastError = '' # why the last request got no answer, for the "failed" line $fs = $null $lock = $null $client = $null # --------------------------------------------------------------------------------------------------------------- # Output. Everything goes to [Console]::Out, never down the pipeline: a line written inside a function whose output # is being assigned would otherwise be swallowed by that assignment when the script exits there. # --------------------------------------------------------------------------------------------------------------- # A JSON string, pure ASCII whatever the console's code page. function ConvertTo-JsonString([string]$text) { $sb = New-Object System.Text.StringBuilder foreach ($ch in $text.ToCharArray()) { $n = [int]$ch if ($n -eq 34 -or $n -eq 92) { [void]$sb.Append('\').Append($ch) } elseif ($n -lt 32 -or $n -gt 126) { [void]$sb.Append('\u').Append($n.ToString('x4', $inv)) } else { [void]$sb.Append($ch) } } '"' + $sb.ToString() + '"' } # The one JSON line: string values escaped, numbers written the same in every culture. function ConvertTo-JsonLine($fields) { $parts = foreach ($key in $fields.Keys) { $value = $fields[$key] if ($value -is [string]) { '"' + $key + '":' + (ConvertTo-JsonString $value) } else { '"' + $key + '":' + ([long]$value).ToString($inv) } } '{' + ($parts -join ',') + '}' } # Server or .NET text for the JSON line: never the token or the upload address (nor its id on its own), one line, # at most 300 characters. function Get-SafeText([string]$text) { if ($token) { $text = $text.Replace($token, '[token]') } if ($url) { $text = $text.Replace($url, '[upload address]') } $id = $url.Substring($url.LastIndexOf('/') + 1) if ($id.Length -ge 16) { $text = $text.Replace($id, '[upload id]') } $text = ($text -replace '\s+', ' ').Trim() if ($text.Length -gt 300) { $text = $text.Substring(0, 300) } $text } function Write-ProgressLine { $pct = 100 if ($size -gt 0) { $pct = [long][Math]::Floor([double]$offset * 100.0 / [double]$size) } $done = ([double]$offset / 1048576.0).ToString('F1', $inv) $all = ([double]$size / 1048576.0).ToString('F1', $inv) [Console]::Out.WriteLine('progress ' + $pct.ToString($inv) + '% (' + $done + ' of ' + $all + ' MiB)') $script:printed = $offset } # Ends the run: a last progress line if storage's count moved since the last one, the JSON line, the exit code. # exit leaves the whole script from inside any function; the finally block at the bottom still runs. function Stop-Upload([int]$code, $fields) { if ($offset -ge 0 -and $offset -ne $printed) { Write-ProgressLine } [Console]::Out.WriteLine((ConvertTo-JsonLine $fields)) exit $code } # What storage has and what the file is, for the lines that stop mid-upload. function Get-Where($fields) { if ($offset -ge 0) { $fields['bytes_sent'] = $offset } if ($size -ge 0) { $fields['size'] = $size } $fields } function Stop-Refused([string]$message) { Stop-Upload 2 ([ordered]@{ state = 'refused'; message = $message }) } function Stop-Failed([string]$message) { $fields = Get-Where ([ordered]@{ state = 'failed' }) $fields['message'] = $message Stop-Upload 1 $fields } function Stop-TokenRejected { $fields = Get-Where ([ordered]@{ state = 'token_rejected' }) $fields['message'] = 'the token expired or was refused; get a fresh one and run again' Stop-Upload 4 $fields } # --------------------------------------------------------------------------------------------------------------- # Time. Every request gets min(its normal limit, what is left - 5 s); under 10 s left, the run stops with exit 3. # --------------------------------------------------------------------------------------------------------------- function Get-Remaining { $maxSeconds - $clock.Elapsed.TotalSeconds } # The run's time is used: exit 3, "run the same command again". function Stop-Partial { $fields = Get-Where ([ordered]@{ state = 'partial' }) $fields['message'] = 'the time budget was used; run the same command again to continue' Stop-Upload 3 $fields } function Assert-Time { if ((Get-Remaining) -lt 10) { Stop-Partial } } # Backoff after the n-th failure in a row: 1, 2, 4, 8, then 15 s. Capped at 15 s so that 8 refused HEADs take about a # minute: a blocked network ends with exit 1 inside the default 100 s (exit 1 twice = stop), not with an exit 3 that # says "run again" for ever. A 30 s Wi-Fi drop is still bridged. A wait never reaches into the last 10 s of the run: # one that does not fit ends the run now with exit 3, as it would end after a shortened wait. Shortened waits are not # used: at the very end they would send one try after another with almost no pause. function Wait-Backoff([int]$failure) { $s = [Math]::Min(15, [Math]::Pow(2, $failure - 1)) if ((Get-Remaining) - $s -lt 10) { Stop-Partial } Start-Sleep -Milliseconds ([int][Math]::Ceiling($s * 1000)) } # --------------------------------------------------------------------------------------------------------------- # Small helpers # --------------------------------------------------------------------------------------------------------------- function Get-Sha256Hex([string]$text) { $sha = $null try { $sha = [Security.Cryptography.SHA256]::Create() } catch { $sha = New-Object Security.Cryptography.SHA256CryptoServiceProvider } try { $hash = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text)) } finally { $sha.Dispose() } -join ($hash | ForEach-Object { $_.ToString('x2', $inv) }) } # True when a file could not be opened because another process holds it (the lock of another uploader). function Test-SharingViolation($err) { for ($e = $err.Exception; $null -ne $e; $e = $e.InnerException) { if ($e -is [IO.FileNotFoundException] -or $e -is [IO.DirectoryNotFoundException] -or $e -is [IO.PathTooLongException]) { return $false } if ($e -is [IO.IOException]) { # .NET on Linux and macOS takes FileShare.None as flock, and a refused flock is a plain IOException. if (-not $onWindows) { return $true } $win32 = $e.HResult -band 0xFFFF return ($win32 -eq 32 -or $win32 -eq 33) # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION } } $false } # The message of the innermost exception; a cancelled request is our own time limit. function Get-ErrorText($err) { $text = '' for ($e = $err.Exception; $null -ne $e; $e = $e.InnerException) { if ($e -is [OperationCanceledException]) { return 'no answer in time' } $text = $e.Message } Get-SafeText $text } # --------------------------------------------------------------------------------------------------------------- # TUS requests # --------------------------------------------------------------------------------------------------------------- function New-TusRequest([string]$method) { $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($method), $url) [void]$req.Headers.TryAddWithoutValidation('Tus-Resumable', '1.0.0') [void]$req.Headers.TryAddWithoutValidation('x-signature', $token) $req } # Sends one request with its own deadline (HttpClient.Timeout is infinite, so this is the only clock). # Returns the response, or $null when there was no answer; the body is already read when it returns. function Send-Tus($req, [double]$normalSeconds) { $cts = New-Object System.Threading.CancellationTokenSource try { # Never below 1 s: CancelAfter(-1 ms) would mean "never". $limit = [Math]::Max(1.0, [Math]::Min($normalSeconds, (Get-Remaining) - 5)) $cts.CancelAfter([TimeSpan]::FromSeconds($limit)) return $client.SendAsync($req, $cts.Token).GetAwaiter().GetResult() } catch { $script:lastError = Get-ErrorText $_ return $null } finally { $cts.Dispose() } } function Get-Header($resp, [string]$name) { $values = $null if ($resp.Headers.TryGetValues($name, [ref]$values)) { return @($values)[0] } if ($null -ne $resp.Content -and $resp.Content.Headers.TryGetValues($name, [ref]$values)) { return @($values)[0] } return $null } function Read-Text($resp) { try { return (Get-SafeText ($resp.Content.ReadAsStringAsync().GetAwaiter().GetResult())) } catch { return '' } } # Upload-Offset as a number. A missing or odd value must never read as "finished". function Read-Offset($resp) { $v = Get-Header $resp 'Upload-Offset' if ($null -eq $v -or $v -notmatch '^[0-9]{1,18}$' -or [long]$v -gt $size) { Stop-Failed 'the server answered without a valid Upload-Offset' } [long]$v } # How many bytes storage really has (HEAD). Stops on the answers a retry cannot change. function Get-ServerOffset { for ($attempt = 1; ; $attempt++) { Assert-Time $req = New-TusRequest 'HEAD' $resp = Send-Tus $req 30 $req.Dispose() if ($null -ne $resp) { try { $code = [int]$resp.StatusCode if ($code -ge 200 -and $code -lt 300) { $length = Get-Header $resp 'Upload-Length' if ($null -ne $length) { if ($length -notmatch '^[0-9]{1,18}$') { Stop-Failed 'the server answered without a valid Upload-Length' } if ([long]$length -ne $size) { Stop-Refused ('this link expects ' + $length + ' bytes, the file has ' + $size.ToString($inv)) } } return (Read-Offset $resp) } # A HEAD answer has no body to read: storage answers 400 to an expired, invalid or mistyped token. if ($code -eq 400) { Stop-TokenRejected } if (@(401, 403, 404, 410) -contains $code) { Stop-Upload 2 ([ordered]@{ state = 'refused'; status = $code; message = 'storage refused this upload (HTTP ' + $code.ToString($inv) + '): it may be finished, cancelled or older than 24 hours' }) } $script:lastError = 'HTTP ' + $code } finally { $resp.Dispose() } } if ($attempt -ge 8) { Stop-Failed ('the upload server cannot be reached (' + $lastError + ')') } Wait-Backoff $attempt } } # --------------------------------------------------------------------------------------------------------------- # The run # --------------------------------------------------------------------------------------------------------------- try { # 1. The copy check, before anything else: a slip anywhere in the command (a letter of the address or the token, # a lost line) ends here with exit 6, "copy it again", and never reads as a refusal: the copy checks (exit 6) come # before the address and the file (exit 2). The check binds the address to the token, so an address that passes # it and is still not ours was changed on purpose, together with its check: step 2 refuses that one. $check = ([string]$env:S200_CHECK).ToLowerInvariant() if ($check -cne (Get-Sha256Hex ($url + "`n" + $token)).Substring(0, 8) -or $token -cnotmatch $TOKEN_PATTERN -or $token.Length -gt 4096) { $message = 'the command was not copied exactly; copy it again and change only ' if (-not $url) { $message = 'the command was not copied exactly: the upload address did not reach the uploader (in sh, every S200_ line must end with a space and a backslash); copy it again and change only ' } Stop-Upload 6 ([ordered]@{ state = 'bad_command'; message = $message }) } $raw = [string]$env:S200_MAX_SECONDS if ($raw) { if ($raw -cnotmatch '^[1-9][0-9]{0,5}\z') { Stop-Upload 6 ([ordered]@{ state = 'bad_command'; message = 'the command was not copied exactly: S200_MAX_SECONDS must be a whole number of seconds' }) } $maxSeconds = [int]$raw } # 2. Our storage host and its signed TUS route only, so the token can go nowhere else. Like every check up to the # lock, it comes before any request. if ($url -cnotmatch $URL_PATTERN) { Stop-Refused 'this is not a Status 200 Uploads upload address' } # 3. The file. Opened so that nobody can write to it or delete it while it is sent (readers are fine); a # program that is still writing it makes the open fail. $path = [string]$env:S200_FILE if (-not $path -or $path -ceq '') { Stop-Refused 'file not found: S200_FILE must hold the full path of the file' } $full = $null try { $full = [IO.Path]::GetFullPath($path) } catch { Stop-Refused 'file not found' } if (-not [IO.File]::Exists($full)) { Stop-Refused 'file not found' } try { $fs = [IO.File]::Open($full, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) } catch { Stop-Refused ('the file cannot be read (another program may be writing it): ' + (Get-ErrorText $_)) } $size = $fs.Length if ($size -eq 0) { Stop-Refused 'the file is empty' } # 4. One uploader per upload on this computer. The OS lets go of the lock when the process ends, however it ends. # The Node uploader keeps the same file open while it runs, so on Windows the two exclude each other too. # On Windows the file goes away with the handle (DeleteOnClose), so nothing can ever delete another holder's # file. Not elsewhere: .NET takes FileShare.None as flock there, and unlinking a locked name lets two uploaders # lock two different files of the same name. # Where no lock can be made (a sandbox with a read-only profile folder) the temp folder is tried next, and without # either the run goes on unlocked, as the other uploaders do: the lock spares wasted retries, it does not protect # the bytes (storage serves one request per upload at a time; the SHA-256 check after the upload does the rest). $key = (Get-Sha256Hex $url).Substring(0, 16) $appData = $env:LOCALAPPDATA if (-not $appData) { $appData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) } $lockOptions = [IO.FileOptions]::None if ($onWindows) { $lockOptions = [IO.FileOptions]::DeleteOnClose } $noLock = 'no folder for it' foreach ($base in @($appData, [IO.Path]::GetTempPath())) { if (-not $base) { continue } $dir = [IO.Path]::Combine($base, 's200-upload') try { [void][IO.Directory]::CreateDirectory($dir) } catch { $noLock = Get-ErrorText $_ continue } try { $lock = [IO.FileStream]::new([IO.Path]::Combine($dir, 'lock-' + $key), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None, 4096, $lockOptions) } catch { if (Test-SharingViolation $_) { Stop-Upload 5 ([ordered]@{ state = 'busy'; message = 'another uploader on this computer is already sending this file' }) } $noLock = Get-ErrorText $_ continue } break } if ($null -eq $lock) { [Console]::Error.WriteLine('note: running without the lock (' + $noLock + ')') } Add-Type -AssemblyName System.Net.Http if ($PSVersionTable.PSEdition -eq 'Desktop') { # Windows PowerShell can still default to TLS 1.0 on older setups; storage needs 1.2. SystemDefault (0) # already allows 1.2 and 1.3, so it is left alone. $protocols = [Net.ServicePointManager]::SecurityProtocol if ([int]$protocols -ne 0 -and ([int]$protocols -band 3072) -eq 0) { [Net.ServicePointManager]::SecurityProtocol = $protocols -bor [Net.SecurityProtocolType]::Tls12 } [Net.ServicePointManager]::Expect100Continue = $false } $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $false # a redirect would carry x-signature to another address $client = New-Object System.Net.Http.HttpClient -ArgumentList @($handler) $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan $client.DefaultRequestHeaders.ExpectContinue = $false $client.MaxResponseContentBufferSize = 1048576 # HEAD first: storage's offset, and a refusal if this link was made for a file of another size. $offset = Get-ServerOffset $buf = New-Object byte[] $CHUNK $failures = 0 $lastReport = -15.0 while ($offset -lt $size) { # The link was made for this exact size; a file whose size changes now cannot match it any more. (On Windows # nobody can write to the file while it is open here; elsewhere another program still can.) $now = $fs.Length if ($now -lt $size) { Stop-Refused 'the file got shorter while it was being uploaded' } if ($now -gt $size) { Stop-Refused ('the file changed while it was being uploaded (it has ' + $now.ToString($inv) + ' bytes now, the link expects ' + $size.ToString($inv) + ')') } $want = [int][Math]::Min([long]$CHUNK, $size - $offset) # The chunk at storage's offset. Fewer bytes than the file had at the start: it got shorter meanwhile. $fs.Position = $offset $got = 0 while ($got -lt $want) { $n = $fs.Read($buf, $got, $want - $got) if ($n -le 0) { Stop-Refused 'the file got shorter while it was being uploaded' } $got += $n } Assert-Time $req = New-TusRequest 'PATCH' [void]$req.Headers.TryAddWithoutValidation('Upload-Offset', $offset.ToString($inv)) $req.Content = [System.Net.Http.ByteArrayContent]::new($buf, 0, $want) $req.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new('application/offset+octet-stream') $resp = Send-Tus $req 180 $req.Dispose() $code = 0 $moved = $false if ($null -ne $resp) { try { $code = [int]$resp.StatusCode if ($code -eq 204) { $next = Read-Offset $resp if ($next -gt $offset) { $offset = $next $failures = 0 $moved = $true } elseif ($fs.Length -lt $size) { Stop-Refused 'the file got shorter while it was being uploaded' } else { $lastError = 'storage took none of the bytes' } } else { $text = Read-Text $resp # Storage answers 400 to ExpiredSignature, InvalidSignature and InvalidJWT ('"exp" claim timestamp check # failed'). "exp" counts only as a word of its own: a 400 that says "expected" is not about the token. if ($code -eq 400 -and $text -match '\bexp\b|signature|jwt|jws|token') { Stop-TokenRejected } if ((@(400, 401, 403, 404, 410, 413, 415) -contains $code) -or ($code -eq 409 -and $text -match 'exist|duplicate')) { # The same words in every uploader, so the AI app reads "storage refused" whichever one ran. $message = 'storage refused this upload (HTTP ' + $code.ToString($inv) + ')' if ($text) { $message = $message + ': ' + $text } Stop-Upload 2 ([ordered]@{ state = 'refused'; status = $code; message = $message }) } $lastError = 'HTTP ' + $code } } finally { $resp.Dispose() } } if (-not $moved) { $failures++ if ($failures -gt 10) { Stop-Failed ('gave up after 10 failed attempts in a row (' + $lastError + ')') } # A failed PATCH may still have stored part of the chunk, or all of it with the answer lost, so ask storage. # 409 = our offset may be wrong: ask at once. Anything else: back off first. if ($code -ne 409) { Wait-Backoff $failures } $before = $offset $offset = Get-ServerOffset if ($offset -gt $before) { # Storage has more than before: the upload is moving and only answers were lost. The count starts again, # or a line that cuts every PATCH but keeps its bytes would end in "failed" while it makes progress. $failures = 0 } elseif ($code -eq 409) { # The same offset after a 409: not a wrong offset but another request still holding the upload (storage # serves one at a time), such as a PATCH cut on our side that storage has not dropped yet. Wait for it. Wait-Backoff $failures } } if ($clock.Elapsed.TotalSeconds - $lastReport -ge 15 -or $offset -eq $size) { Write-ProgressLine $lastReport = $clock.Elapsed.TotalSeconds } } Stop-Upload 0 ([ordered]@{ state = 'done'; size = $size }) } catch { Stop-Failed (Get-ErrorText $_) } finally { if ($null -ne $client) { $client.Dispose() } if ($null -ne $fs) { $fs.Dispose() } if ($null -ne $lock) { $lock.Dispose() } }