Created
September 25, 2026 16:20
-
-
Save andyg2/e431afad45cc57c1d10505da7ecdc10e to your computer and use it in GitHub Desktop.
ulz is a Bash command that zips files, folders or wildcards and uploads them with basic auth to a single-file PHP receiver you can drop into any Apache directory, asking you to confirm if the zip is over a size limit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * ulz.php - receiver for the `ulz` upload script. | |
| * | |
| * Drop this file into any directory on an Apache/PHP server. Uploaded zips | |
| * are stored in ./uploads next to this file (created automatically, with an | |
| * .htaccess that blocks web access to it). | |
| * | |
| * If PHP runs as CGI/FPM and auth always fails, Apache is stripping the | |
| * Authorization header. Add this to an .htaccess next to this file: | |
| * | |
| * CGIPassAuth On | |
| * # or, on older Apache: | |
| * # RewriteEngine On | |
| * # RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] | |
| * | |
| * Big uploads also need PHP's limits raised (php.ini, or .htaccess with mod_php): | |
| * | |
| * php_value upload_max_filesize 2G | |
| * php_value post_max_size 2G | |
| * php_value max_execution_time 600 | |
| * php_value max_input_time 600 | |
| */ | |
| // ------------------------------------------------------------- config ---- | |
| const AUTH_USER = 'uploader'; | |
| const AUTH_PASS = 'change-me-please'; | |
| const UPLOAD_DIR = __DIR__ . '/uploads'; | |
| const MAX_BYTES = 2 * 1024 * 1024 * 1024; // hard server-side cap: 2 GB | |
| // ------------------------------------------------------------------------- | |
| header('Content-Type: text/plain; charset=utf-8'); | |
| header('X-Robots-Tag: noindex'); | |
| function out(int $code, string $msg): void { | |
| http_response_code($code); | |
| echo $msg, "\n"; | |
| exit; | |
| } | |
| // --- Basic auth (works under mod_php and CGI/FPM) --- | |
| $user = $_SERVER['PHP_AUTH_USER'] ?? null; | |
| $pass = $_SERVER['PHP_AUTH_PW'] ?? null; | |
| if ($user === null) { | |
| $hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; | |
| if (stripos($hdr, 'basic ') === 0) { | |
| $decoded = base64_decode(substr($hdr, 6), true); | |
| if ($decoded !== false && strpos($decoded, ':') !== false) { | |
| [$user, $pass] = explode(':', $decoded, 2); | |
| } | |
| } | |
| } | |
| if ($user === null || !hash_equals(AUTH_USER, (string)$user) || !hash_equals(AUTH_PASS, (string)$pass)) { | |
| header('WWW-Authenticate: Basic realm="ulz"'); | |
| out(401, 'ERROR: authentication required'); | |
| } | |
| if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | |
| out(405, 'ERROR: POST a file as multipart field "file"'); | |
| } | |
| // POST bigger than post_max_size arrives with empty $_FILES/$_POST | |
| if (empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { | |
| out(413, 'ERROR: upload exceeds post_max_size (' . ini_get('post_max_size') . ')'); | |
| } | |
| $f = $_FILES['file'] ?? null; | |
| if (!$f || is_array($f['error'])) { | |
| out(400, 'ERROR: no file received (expected multipart field "file")'); | |
| } | |
| $errors = [ | |
| UPLOAD_ERR_INI_SIZE => 'exceeds upload_max_filesize (' . ini_get('upload_max_filesize') . ')', | |
| UPLOAD_ERR_FORM_SIZE => 'exceeds form MAX_FILE_SIZE', | |
| UPLOAD_ERR_PARTIAL => 'only partially uploaded', | |
| UPLOAD_ERR_NO_FILE => 'no file uploaded', | |
| UPLOAD_ERR_NO_TMP_DIR => 'server has no temp directory', | |
| UPLOAD_ERR_CANT_WRITE => 'server failed to write to disk', | |
| UPLOAD_ERR_EXTENSION => 'blocked by a PHP extension', | |
| ]; | |
| if ($f['error'] !== UPLOAD_ERR_OK) { | |
| $code = in_array($f['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) ? 413 : 500; | |
| out($code, 'ERROR: ' . ($errors[$f['error']] ?? 'upload error ' . $f['error'])); | |
| } | |
| if (!is_uploaded_file($f['tmp_name'])) { | |
| out(400, 'ERROR: invalid upload'); | |
| } | |
| if ($f['size'] > MAX_BYTES) { | |
| out(413, 'ERROR: file larger than server limit'); | |
| } | |
| // --- Only accept real zip files --- | |
| $fh = fopen($f['tmp_name'], 'rb'); | |
| $magic = $fh ? fread($fh, 4) : ''; | |
| if ($fh) fclose($fh); | |
| if (!in_array($magic, ["PK\x03\x04", "PK\x05\x06"], true)) { | |
| out(415, 'ERROR: not a zip file'); | |
| } | |
| // --- Safe, non-clobbering filename --- | |
| $name = basename(str_replace('\\', '/', (string)$f['name'])); | |
| $name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name); | |
| $name = ltrim($name, '.'); | |
| $stem = preg_replace('/\.zip$/i', '', $name); | |
| if ($stem === '' || $stem === null) $stem = 'upload-' . date('Ymd-His'); | |
| // --- Prepare storage dir (locked down against web access / execution) --- | |
| if (!is_dir(UPLOAD_DIR) && !mkdir(UPLOAD_DIR, 0750, true)) { | |
| out(500, 'ERROR: cannot create upload directory'); | |
| } | |
| $ht = UPLOAD_DIR . '/.htaccess'; | |
| if (!file_exists($ht)) { | |
| @file_put_contents($ht, | |
| "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n" . | |
| "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n" . | |
| "Options -Indexes -ExecCGI\n<IfModule mod_php.c>\n php_flag engine off\n</IfModule>\n"); | |
| } | |
| $dest = UPLOAD_DIR . "/$stem.zip"; | |
| for ($i = 1; file_exists($dest); $i++) { | |
| $dest = UPLOAD_DIR . "/$stem-$i.zip"; | |
| } | |
| if (!move_uploaded_file($f['tmp_name'], $dest)) { | |
| out(500, 'ERROR: could not save file (check directory permissions)'); | |
| } | |
| @chmod($dest, 0640); | |
| out(200, sprintf('OK: saved %s (%s bytes, sha256 %s)', | |
| basename($dest), number_format(filesize($dest)), hash_file('sha256', $dest))); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # ulz - zip files/folders and upload them to a receiver endpoind | |
| # | |
| # Usage: | |
| # ulz *.txt | |
| # ulz ./files | |
| # ulz me.txt you.txt ./somedir | |
| # | |
| # Options: | |
| # -y Don't ask for confirmation when over the size limit | |
| # -n NAME Name of the zip to upload (default: derived from input + timestamp) | |
| # -k Keep the local zip after upload (saved in current directory) | |
| # -h Help | |
| # | |
| # Environment overrides: | |
| # ULZ_MAX_MB size (MB) above which you're asked to confirm (default below) | |
| # ULZ_URL / ULZ_USER / ULZ_PASS override the hard-coded values | |
| # | |
| # ---------------------------------------------------------------- config ---- | |
| URL="${ULZ_URL:-https://example.com/probably-some-directory/}" | |
| AUTH_USER="${ULZ_USER:-uploader}" | |
| AUTH_PASS="${ULZ_PASS:-change-me-please}" | |
| MAX_MB="${ULZ_MAX_MB:-100}" | |
| # ---------------------------------------------------------------------------- | |
| set -uo pipefail | |
| die() { echo "ulz: $*" >&2; exit 1; } | |
| usage() { sed -n '3,21p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } | |
| ASSUME_YES=0 | |
| KEEP=0 | |
| NAME="" | |
| while getopts ":yn:kh" opt; do | |
| case "$opt" in | |
| y) ASSUME_YES=1 ;; | |
| n) NAME="$OPTARG" ;; | |
| k) KEEP=1 ;; | |
| h) usage 0 ;; | |
| :) die "option -$OPTARG needs an argument" ;; | |
| *) die "unknown option -$OPTARG (try -h)" ;; | |
| esac | |
| done | |
| shift $((OPTIND - 1)) | |
| [ $# -gt 0 ] || usage 1 | |
| command -v zip >/dev/null 2>&1 || die "'zip' is not installed" | |
| command -v curl >/dev/null 2>&1 || die "'curl' is not installed" | |
| [[ "$MAX_MB" =~ ^[0-9]+$ ]] || die "ULZ_MAX_MB must be a whole number" | |
| # Validate inputs (an unmatched wildcard arrives literally, e.g. '*.txt') | |
| inputs=() | |
| for p in "$@"; do | |
| [ -e "$p" ] || die "no such file or directory: $p" | |
| # stop zip treating names beginning with '-' as options | |
| [[ "$p" == -* ]] && p="./$p" | |
| inputs+=("$p") | |
| done | |
| # Work out zip name | |
| stamp="$(date +%Y%m%d-%H%M%S)" | |
| if [ -z "$NAME" ]; then | |
| if [ ${#inputs[@]} -eq 1 ]; then | |
| base="$(basename -- "${inputs[0]%/}")" | |
| [ "$base" = "." ] && base="$(basename -- "$PWD")" | |
| base="${base%.*}"; [ -n "$base" ] || base="ulz" | |
| NAME="${base}-${stamp}.zip" | |
| else | |
| NAME="ulz-${stamp}.zip" | |
| fi | |
| fi | |
| [[ "$NAME" == *.zip ]] || NAME="${NAME}.zip" | |
| NAME="$(basename -- "$NAME")" | |
| tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t ulz)" || die "can't create temp dir" | |
| trap 'rm -rf "$tmpdir"' EXIT INT TERM | |
| zipfile="$tmpdir/$NAME" | |
| echo "Zipping ${#inputs[@]} item(s) -> $NAME" | |
| zip -r -q -y "$zipfile" "${inputs[@]}" || die "zip failed" | |
| bytes=$(wc -c < "$zipfile" | tr -d ' ') | |
| human=$(awk -v b="$bytes" 'BEGIN{ | |
| split("B KB MB GB TB",u," "); i=1 | |
| while (b>=1024 && i<5) { b/=1024; i++ } | |
| printf (i==1 ? "%d %s" : "%.1f %s"), b, u[i] }') | |
| echo "Zip size: $human" | |
| limit=$((MAX_MB * 1024 * 1024)) | |
| if [ "$bytes" -gt "$limit" ] && [ "$ASSUME_YES" -ne 1 ]; then | |
| echo "WARNING: $human is over the ${MAX_MB} MB limit." >&2 | |
| if [ -t 0 ]; then | |
| read -r -p "Upload anyway? [y/N] " ans | |
| [[ "$ans" =~ ^[Yy]([Ee][Ss])?$ ]] || { echo "Aborted."; exit 2; } | |
| else | |
| die "not a terminal; re-run with -y to upload anyway" | |
| fi | |
| fi | |
| echo "Uploading to $URL" | |
| resp="$tmpdir/response" | |
| code=$(curl --progress-bar --fail-with-body -u "$AUTH_USER:$AUTH_PASS" \ | |
| -F "file=@${zipfile};type=application/zip" \ | |
| -o "$resp" -w '%{http_code}' "$URL") | |
| rc=$? | |
| if [ "$KEEP" -eq 1 ]; then | |
| cp -- "$zipfile" "./$NAME" && echo "Kept local copy: ./$NAME" | |
| fi | |
| if [ $rc -ne 0 ]; then | |
| echo "Upload FAILED (curl exit $rc, HTTP $code)" >&2 | |
| [ -s "$resp" ] && cat "$resp" >&2 && echo >&2 | |
| exit 1 | |
| fi | |
| cat "$resp"; echo |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment