Files
dev/powershell/ImgBB_Uploader/ImgBB_Uploader.ps1
T

368 lines
16 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
Upload JPG, JPEG, and PNG files to ImgBB sequentially.
.DESCRIPTION
Uses the official HTTPS API without external module dependencies. Files are
streamed through the pipeline without a configured file-count limit.
Requests are spaced 3-6 seconds apart by default. Transient failures get at most three
retries with exponential backoff and jitter; Retry-After is respected.
Exhausted transient failures stop the run to avoid sustained server load.
Automation is not disguised and service-side limits still apply.
Console colors: green for success, cyan for status/dry run, yellow for
retries/skips, and red for errors. Text labels remain readable without color.
.PARAMETER DirectoryPath
An existing directory. Treated as a literal path, including brackets.
Required unless FilePath is supplied. Ignored when FilePath is supplied.
.PARAMETER FilePath
One existing JPG, JPEG, or PNG file, interpreted literally. Takes precedence
over DirectoryPath. An invalid FilePath fails without falling back to a
directory scan. Recurse has no effect in single-file mode.
.PARAMETER ApiKey
ImgBB API key. A nonblank value overrides IMGBB_API_KEY. Prefer the environment
variable to avoid saving a key in shell history. The key is sent in the POST
body, never in the URL or script-generated messages.
.PARAMETER Recurse
Include subdirectories. Disabled by default. Hidden files are not included.
Ignored when FilePath is supplied.
.PARAMETER WaitTime
Random (default) enables a random 3-6 second pause between requests.
Only Off disables this ordinary pause. Omitted, empty, or unrecognized
values retain random waiting. Values are case-insensitive. Retry backoff
and server Retry-After waits remain enabled even with Off.
.PARAMETER Silent
Show errors only; suppress file status, retry notices, and summaries.
Errors are plain messages on standard error, without PowerShell error blocks.
.PARAMETER DryRun
Enumerate, validate size/readability, and Base64-encode files without any
network requests or delays. Requires a key but cannot validate it remotely.
Honors Recurse and Silent. Does not validate actual image contents.
.EXAMPLE
$env:IMGBB_API_KEY = 'YOUR_API_KEY'
.\Upload-ImagesToImgBB.ps1 -DirectoryPath 'C:\Images'
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -DirectoryPath './images' -ApiKey $key -Recurse
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -DirectoryPath './images' -Recurse -DryRun
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -DirectoryPath './images' -Silent
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -DirectoryPath './images' -WaitTime Off
Disables ordinary request delays while retaining retry waits.
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -FilePath 'C:\Images\photo.jpg' -DryRun
.EXAMPLE
.\Upload-ImagesToImgBB.ps1 -FilePath 'C:\Images\photo.jpg' -DirectoryPath 'C:\OtherImages'
Uploads only photo.jpg; DirectoryPath is ignored.
.NOTES
Intended for Windows PowerShell 5.1 and PowerShell 7.
Exit codes: 0 = success (including no matching files); 1 = any failure.
A run stopped early reports only files processed before the stop.
No automatic expiration is requested. No files are modified locally.
Re-running uploads files again. A lost response followed by a retry can also
create a duplicate: the API does not provide an idempotency key here.
Do not publish real keys, private images, or transcripts containing secrets.
.LINK
https://api.imgbb.com/
#>
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Upload-ImagesToImgBB contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
[CmdletBinding()]
param (
[string]$DirectoryPath,
[string]$FilePath,
[string]$ApiKey,
[AllowNull()]
[AllowEmptyString()]
[string]$WaitTime = 'Random',
[switch]$Recurse,
[switch]$Silent,
[switch]$DryRun
)
function Write-Status {
param (
[string]$Message,
[ConsoleColor]$Color = [ConsoleColor]::Cyan
)
if (-not $Silent) { Write-Host $Message -ForegroundColor $Color }
}
function Write-UploadError {
param (
[string]$Message,
[ConsoleColor]$Color = [ConsoleColor]::Red
)
# Do not print raw exceptions, request bodies, or server response bodies.
# Preserve standard error and plain messages without PowerShell error blocks.
# Redirected errors receive no color sequences; unsupported hosts use plain text.
$restoreColor = $false
try {
if (-not [Console]::IsErrorRedirected) {
$previousColor = [Console]::ForegroundColor
[Console]::ForegroundColor = $Color
$restoreColor = $true
}
}
catch { }
try {
[Console]::Error.WriteLine("[ERROR] $Message")
}
finally {
if ($restoreColor) {
try { [Console]::ForegroundColor = $previousColor }
catch { }
}
}
}
function Get-RetryAfterSeconds {
param ($HttpResponse)
if ($null -eq $HttpResponse) { return 0.0 }
try {
# Both WebHeaderCollection (5.1) and HttpResponseHeaders (7) support this.
$headerValues = @($HttpResponse.Headers.GetValues('Retry-After'))
if ($headerValues.Count -eq 0) { return 0.0 }
$header = [string]$headerValues[0]
$seconds = 0L
if ([long]::TryParse($header, [ref]$seconds) -and $seconds -ge 0) {
return [double]$seconds
}
$date = [DateTimeOffset]::MinValue
if ([DateTimeOffset]::TryParse($header,
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::AssumeUniversal, [ref]$date)) {
return [Math]::Max(0, [Math]::Ceiling(($date - [DateTimeOffset]::UtcNow).TotalSeconds))
}
}
catch { } # Missing/malformed headers fall back to exponential backoff.
return 0.0
}
function Wait-UploadDelay {
param ([double]$Seconds)
# Chunk long Retry-After values without shortening the requested wait.
while ($Seconds -gt 0) {
$chunk = [int][Math]::Min(60, [Math]::Ceiling($Seconds))
Start-Sleep -Seconds $chunk
$Seconds -= $chunk
}
}
$finalApiKey = if ([string]::IsNullOrWhiteSpace($ApiKey)) {
$env:IMGBB_API_KEY
}
else { $ApiKey }
if ([string]::IsNullOrWhiteSpace($finalApiKey)) {
Write-UploadError 'API key missing. Use -ApiKey or set IMGBB_API_KEY.'
exit 1
}
# Presence, rather than validity, determines precedence. Never silently upload
# a directory when the caller explicitly selected a missing or invalid file.
$useFilePath = $PSBoundParameters.ContainsKey('FilePath')
$supportedExtensions = @('.jpg', '.jpeg', '.png')
$inputError = if ($useFilePath) {
'FilePath must identify an existing, accessible JPG, JPEG, or PNG file. DirectoryPath is ignored.'
}
else { 'DirectoryPath must identify an existing, accessible filesystem directory, or supply -FilePath.' }
try {
if ($useFilePath) {
if ([string]::IsNullOrWhiteSpace($FilePath)) { throw 'Empty file path.' }
$selectedFile = Get-Item -LiteralPath $FilePath -ErrorAction Stop
if ($selectedFile.PSIsContainer -or $selectedFile.PSProvider.Name -ne 'FileSystem' -or
$selectedFile.Extension -notin $supportedExtensions) {
throw 'Unsupported file input.'
}
}
else {
if ([string]::IsNullOrWhiteSpace($DirectoryPath)) { throw 'Missing directory path.' }
$directory = Get-Item -LiteralPath $DirectoryPath -ErrorAction Stop
if (-not $directory.PSIsContainer -or $directory.PSProvider.Name -ne 'FileSystem') {
throw 'A filesystem directory is required.'
}
}
}
catch {
Write-UploadError $inputError
exit 1
}
$processed = 0L
$succeeded = 0L
$failed = 0L
$runFailed = $false
$requestSent = $false
# Deliberately avoid ValidateSet: unknown values must fall back to random waits.
$useRandomWait = $WaitTime -ine 'Off'
$maxAttempts = 4 # One initial request plus three retries.
$transientStatusCodes = @(408, 429, 500, 502, 503, 504)
$endpoint = 'https://api.imgbb.com/1/upload'
$originalSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol
try {
if (-not $DryRun -and $PSVersionTable.PSVersion.Major -eq 5) {
[Net.ServicePointManager]::SecurityProtocol = $originalSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
$mode = if ($DryRun) { 'Dry run' } else { 'Upload' }
if ($useFilePath) {
Write-Status "$mode started. Single file: $($selectedFile.FullName)."
}
else {
Write-Status "$mode started. Recursive search: $([bool]$Recurse)."
}
if (-not $DryRun) {
$waitMode = if ($useRandomWait) { 'Random (3-6 seconds)' } else { 'Off (retry waits still apply)' }
Write-Status "Request delay: $waitMode."
}
# Both modes feed the same preparation/upload pipeline; directory mode
# continues to stream files instead of collecting the full list in memory.
$getSelectedImages = {
if ($useFilePath) { $selectedFile }
else {
Get-ChildItem -LiteralPath $directory.FullName -File -Recurse:$Recurse -ErrorAction Stop |
Where-Object { $_.Extension -in $supportedExtensions }
}
}
& $getSelectedImages | ForEach-Object {
$image = $_
$processed++
$base64Image = $null
try {
# Check again after reading in case a file changed during enumeration.
if ($image.Length -eq 0 -or $image.Length -gt 32MB) {
$failed++
Write-UploadError "[SKIP] '$($image.FullName)': size $($image.Length) bytes; allowed range is 1 byte to 32 MiB." -Color Yellow
return
}
$fileBytes = [IO.File]::ReadAllBytes($image.FullName)
if ($fileBytes.Length -eq 0 -or $fileBytes.Length -gt 32MB) {
$failed++
Write-UploadError "[SKIP] '$($image.FullName)': size after reading is $($fileBytes.Length) bytes; allowed range is 1 byte to 32 MiB." -Color Yellow
return
}
$base64Image = [Convert]::ToBase64String($fileBytes)
}
catch {
$failed++
$reason = $_.Exception.GetBaseException().GetType().Name
Write-UploadError "[SKIP] '$($image.FullName)': cannot read or encode the file ($reason)." -Color Yellow
return # Skip this pipeline item; continue with the next file.
}
finally { $fileBytes = $null }
if ($DryRun) {
$succeeded++
Write-Status "[DRY RUN] Would upload: $($image.FullName)"
$base64Image = $null
return
}
$body = @{ key = $finalApiKey; image = $base64Image; name = $image.BaseName }
try {
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
if ($requestSent -and $useRandomWait) {
Wait-UploadDelay -Seconds (Get-Random -Minimum 3 -Maximum 7)
}
Write-Status "[UPLOAD $attempt/$maxAttempts] $($image.FullName)" -Color White
$statusCode = 0
$retryAfter = 0.0
$requestSent = $true
try {
$response = Invoke-RestMethod -Uri $endpoint -Method Post -Body $body `
-ContentType 'application/x-www-form-urlencoded' -TimeoutSec 60 `
-MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop -Verbose:$false -Debug:$false
if ($response.success -eq $true) {
$succeeded++
Write-Status "[SUCCESS] $($image.FullName) -> $($response.data.url)" -Color Green
break
}
# An unsuccessful HTTP 2xx response must also terminate or retry.
if ($null -ne $response.status) {
$parsedStatus = 0
if ([int]::TryParse([string]$response.status, [ref]$parsedStatus)) {
$statusCode = $parsedStatus
}
}
if ($statusCode -eq 0) { $statusCode = 400 }
}
catch {
$httpResponse = $_.Exception.Response
if ($null -ne $httpResponse) {
$statusCode = [int]$httpResponse.StatusCode
$retryAfter = Get-RetryAfterSeconds -HttpResponse $httpResponse
}
# Status 0 represents a transport failure with no HTTP response.
}
$transient = $statusCode -eq 0 -or $statusCode -in $transientStatusCodes
$label = if ($statusCode -eq 0) { 'transport failure' } else { "HTTP/API $statusCode" }
Write-UploadError "Attempt $attempt/$maxAttempts failed for '$($image.FullName)' ($label)."
if ($transient -and $attempt -lt $maxAttempts) {
$backoff = 5 * [Math]::Pow(2, $attempt - 1) + (Get-Random -Minimum 0 -Maximum 4)
$wait = [Math]::Max($backoff, $retryAfter)
Write-Status "[RETRY] Waiting at least $wait seconds." -Color Yellow
Wait-UploadDelay -Seconds $wait
continue
}
$failed++
if ($statusCode -in @(401, 403) -or $transient) {
throw 'Authentication/access failure or exhausted transient retries.'
}
# A permanent per-file rejection should not retry unchanged input.
break
}
}
finally {
$body.Clear()
$base64Image = $null
$response = $null
}
}
}
catch {
$runFailed = $true
Write-UploadError 'Run stopped. Check the selected input and its permissions; if requests failed, check the API key, connection, or service availability before restarting.'
}
finally {
if ($PSVersionTable.PSVersion.Major -eq 5) {
[Net.ServicePointManager]::SecurityProtocol = $originalSecurityProtocol
}
$finalApiKey = $null
}
if ($DryRun) {
Write-Status "Dry run finished. Processed: $processed; ready: $succeeded; failed: $failed."
}
else {
Write-Status "Upload finished. Processed: $processed; uploaded: $succeeded; failed: $failed."
}
if ($runFailed -or $failed -gt 0) { exit 1 }
exit 0