upload, returns the download URL as text * GET / redirect to the full download URL * GET // download (?meta=1 returns JSON metadata) * HEAD // metadata only * GET /d// delete confirmation page * DELETE /d// delete the file */ const DATA_DIR = __DIR__ . '/data'; const MAX_FILE_BYTES = 512 * 1024 * 1024; // 512 MB per file const MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB across the whole drop const DEFAULT_DAYS = 3; const MAX_DAYS = 30; const MAX_PER_IP_HOUR = 60; // uploads per IP per hour const GC_CHANCE = 20; // 1-in-N requests sweep expired files const CHUNK = 262144; ignore_user_abort(true); @set_time_limit(0); // ---------------------------------------------------------------- helpers --- function h(?string $s): string { return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } /** Absolute base URL of this folder, e.g. https://tool.medowar.de/drop */ function base_url(): string { $https = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https' || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443; $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/'); return ($https ? 'https' : 'http') . '://' . $host . $dir; } /** * The path below this folder, as segments. Works with the .htaccess rewrite * (?_p=…), with PATH_INFO, and as a last resort straight off REQUEST_URI. * * @return string[] */ function route_segments(): array { $path = ''; if (isset($_GET['_p']) && is_string($_GET['_p'])) { $path = $_GET['_p']; } elseif (!empty($_SERVER['PATH_INFO'])) { $path = (string) $_SERVER['PATH_INFO']; } else { $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); $uri = explode('?', $uri, 2)[0]; $uri = rawurldecode($uri); $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/'); if ($dir !== '' && str_starts_with($uri, $dir)) { $uri = substr($uri, strlen($dir)); } $path = preg_replace('#^/index\.php#', '', $uri) ?? ''; } $segments = []; foreach (explode('/', trim($path, '/')) as $seg) { if ($seg === '' || $seg === '.' || $seg === '..') { continue; } $segments[] = $seg; } return $segments; } /** * Last path component of the request, or null when the request addresses this * folder itself. Needed because a PUT to an existing file (`/drop/index.php`) * is served by Apache directly and never reaches the rewrite rule. */ function request_uri_tail(): ?string { $path = rawurldecode(explode('?', (string) ($_SERVER['REQUEST_URI'] ?? ''), 2)[0]); $path = rtrim(str_replace('\\', '/', $path), '/'); $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/'); if ($dir === '' || !str_starts_with($path, $dir . '/')) { return null; } $tail = substr($path, strlen($dir) + 1); return str_contains($tail, '/') ? null : ($tail !== '' ? $tail : null); } /** Reduce an arbitrary client-supplied name to a safe, single path component. */ function clean_name(string $name): string { $name = str_replace('\\', '/', $name); $name = basename($name); $name = (string) preg_replace('/[\x00-\x1F\x7F]/', '', $name); $name = trim($name); if ($name === '' || $name === '.' || $name === '..') { return 'upload.bin'; } if (strlen($name) > 200) { $ext = pathinfo($name, PATHINFO_EXTENSION); $ext = $ext !== '' ? '.' . substr($ext, 0, 20) : ''; $name = substr($name, 0, 200 - strlen($ext)) . $ext; } return $name; } function human_bytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $n = (float) $bytes; while ($n >= 1024 && $i < count($units) - 1) { $n /= 1024; $i++; } return ($i === 0 ? (string) (int) $n : number_format($n, $n >= 100 ? 0 : 1)) . ' ' . $units[$i]; } function is_valid_id(string $id): bool { return (bool) preg_match('/^[a-f0-9]{12}$/', $id); } function entry_dir(string $id): string { return DATA_DIR . '/' . $id; } /** @return array|null */ function read_meta(string $id): ?array { if (!is_valid_id($id)) { return null; } $file = entry_dir($id) . '/meta.json'; if (!is_file($file)) { return null; } $meta = json_decode((string) file_get_contents($file), true); if (!is_array($meta) || !isset($meta['name'], $meta['expires'])) { return null; } return $meta; } /** @param array $meta */ function write_meta(string $id, array $meta): void { file_put_contents( entry_dir($id) . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX ); } /** @param array $meta */ function is_expired(array $meta): bool { if ((int) $meta['expires'] <= time()) { return true; } $max = (int) ($meta['max_downloads'] ?? 0); return $max > 0 && (int) ($meta['downloads'] ?? 0) >= $max; } function rrmdir(string $dir): void { if (!is_dir($dir)) { return; } foreach (scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir . '/' . $entry; is_dir($path) ? rrmdir($path) : @unlink($path); } @rmdir($dir); } /** Drop everything that has expired, plus half-finished uploads older than an hour. */ function gc_sweep(): void { foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) { $meta = read_meta(basename($dir)); if ($meta === null) { if ((int) @filemtime($dir) < time() - 3600) { rrmdir($dir); } continue; } if (is_expired($meta)) { rrmdir($dir); } } } /** @return array{count:int,bytes:int} */ function store_stats(): array { $count = 0; $bytes = 0; foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) { $meta = read_meta(basename($dir)); if ($meta === null || is_expired($meta)) { continue; } $count++; $bytes += (int) ($meta['size'] ?? 0); } return ['count' => $count, 'bytes' => $bytes]; } function client_ip(): string { return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); } /** Sliding one-hour upload budget per IP. Returns false when the budget is used up. */ function rate_limit_ok(): bool { $file = DATA_DIR . '/rate.json'; $fh = @fopen($file, 'c+'); if ($fh === false) { return true; // never block uploads because the counter is unwritable } try { if (!flock($fh, LOCK_EX)) { return true; } $raw = stream_get_contents($fh); $data = json_decode((string) $raw, true); $data = is_array($data) ? $data : []; $now = time(); $key = hash('sha256', client_ip()); foreach ($data as $k => $stamps) { $data[$k] = array_values(array_filter((array) $stamps, static fn($t) => (int) $t > $now - 3600)); if ($data[$k] === []) { unset($data[$k]); } } if (count($data[$key] ?? []) >= MAX_PER_IP_HOUR) { return false; } $data[$key][] = $now; ftruncate($fh, 0); rewind($fh); fwrite($fh, (string) json_encode($data)); fflush($fh); return true; } finally { flock($fh, LOCK_UN); fclose($fh); } } function fail(int $status, string $message): never { http_response_code($status); header('Content-Type: text/plain; charset=utf-8'); echo $message . "\n"; exit; } // ------------------------------------------------------------- the upload --- /** * Store an upload. $source is either a stream to read from or a local file to * move. Returns [id, meta]. * * @param resource|null $stream * @return array{0:string,1:array} */ function store_upload(string $name, $stream, ?string $movePath, int $days, int $maxDownloads): array { $stats = store_stats(); if ($stats['bytes'] >= MAX_TOTAL_BYTES) { fail(507, 'The drop is full — try again later.'); } $id = bin2hex(random_bytes(6)); $dir = entry_dir($id); if (!@mkdir($dir, 0770, true) && !is_dir($dir)) { fail(500, 'Could not create storage directory.'); } $blob = $dir . '/blob'; if ($movePath !== null) { if (!@move_uploaded_file($movePath, $blob) && !@rename($movePath, $blob)) { rrmdir($dir); fail(500, 'Could not store the uploaded file.'); } $size = (int) filesize($blob); } else { $out = @fopen($blob, 'wb'); if ($out === false) { rrmdir($dir); fail(500, 'Could not open storage file.'); } $size = 0; $allowed = min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - $stats['bytes']); while (!feof($stream)) { $chunk = fread($stream, CHUNK); if ($chunk === false) { break; } if ($chunk === '') { continue; } $size += strlen($chunk); if ($size > $allowed) { fclose($out); rrmdir($dir); fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.'); } if (fwrite($out, $chunk) === false) { fclose($out); rrmdir($dir); fail(500, 'Write failed.'); } } fclose($out); } if ($size === 0) { rrmdir($dir); fail(400, 'Refusing to store an empty file.'); } $meta = [ 'id' => $id, 'name' => $name, 'size' => $size, 'created' => time(), 'expires' => time() + $days * 86400, 'max_downloads' => $maxDownloads, 'downloads' => 0, 'delete_token' => bin2hex(random_bytes(16)), ]; write_meta($id, $meta); return [$id, $meta]; } /** Read Max-Days / Max-Downloads request headers. @return array{0:int,1:int} */ function upload_options(): array { $days = (int) ($_SERVER['HTTP_MAX_DAYS'] ?? $_POST['days'] ?? DEFAULT_DAYS); $days = max(1, min(MAX_DAYS, $days ?: DEFAULT_DAYS)); $max = (int) ($_SERVER['HTTP_MAX_DOWNLOADS'] ?? $_POST['max_downloads'] ?? 0); $max = max(0, min(10000, $max)); return [$days, $max]; } /** @param array $meta */ function download_url(string $id, array $meta): string { return base_url() . '/' . $id . '/' . rawurlencode((string) $meta['name']); } /** @param array $meta */ function delete_url(string $id, array $meta): string { return base_url() . '/d/' . $id . '/' . $meta['delete_token']; } // ----------------------------------------------------------- the download --- /** @param array $meta */ function send_file(string $id, array $meta, bool $headOnly): never { $blob = entry_dir($id) . '/blob'; $fh = @fopen($blob, 'rb'); if ($fh === false) { fail(404, 'Not found.'); } $size = (int) $meta['size']; $name = (string) $meta['name']; $ascii = (string) preg_replace('/[^\x20-\x7E]/', '_', $name); $ascii = str_replace('"', '', $ascii); // Count the download first, so an aborted transfer cannot be used to // squeeze extra downloads out of a one-shot link. if (!$headOnly) { $meta['downloads'] = (int) $meta['downloads'] + 1; write_meta($id, $meta); $max = (int) $meta['max_downloads']; if ($max > 0 && $meta['downloads'] >= $max) { // The open handle stays valid after the directory is gone. rrmdir(entry_dir($id)); } } $start = 0; $end = $size - 1; $range = (string) ($_SERVER['HTTP_RANGE'] ?? ''); if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m)) { if ($m[1] === '' && $m[2] === '') { http_response_code(416); header('Content-Range: bytes */' . $size); exit; } if ($m[1] === '') { $start = max(0, $size - (int) $m[2]); } else { $start = (int) $m[1]; if ($m[2] !== '') { $end = min($size - 1, (int) $m[2]); } } if ($start > $end || $start >= $size) { http_response_code(416); header('Content-Range: bytes */' . $size); exit; } http_response_code(206); header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size); } $length = $end - $start + 1; // Always an opaque download: this host serves other tools, so never let an // uploaded file render in the browser under this origin. header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $ascii . '"; ' . "filename*=UTF-8''" . rawurlencode($name)); header('Content-Length: ' . $length); header('Accept-Ranges: bytes'); header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow'); header('Cache-Control: private, no-store'); if ($headOnly) { exit; } fseek($fh, $start); $remaining = $length; while ($remaining > 0 && !feof($fh)) { $chunk = fread($fh, (int) min(CHUNK, $remaining)); if ($chunk === false || $chunk === '') { break; } echo $chunk; $remaining -= strlen($chunk); flush(); } fclose($fh); exit; } // ------------------------------------------------------------------ setup --- if (!is_dir(DATA_DIR) && !@mkdir(DATA_DIR, 0770, true) && !is_dir(DATA_DIR)) { fail(500, 'Storage directory ' . basename(DATA_DIR) . ' is missing and cannot be created.'); } if (random_int(1, GC_CHANCE) === 1) { gc_sweep(); } $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); $segments = route_segments(); // ---------------------------------------------------------------- routing --- // PUT — the curl --upload-file path. if ($method === 'PUT') { if (count($segments) > 1) { fail(400, "Upload to the root of this folder:\n curl --upload-file ./file.txt " . base_url() . "/\n"); } if (!rate_limit_ok()) { fail(429, 'Too many uploads from your address — try again later.'); } $declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0); if ($declared > MAX_FILE_BYTES) { fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.'); } // Segments are already URL-decoded; do not decode them again. $name = $segments[0] ?? request_uri_tail(); // `curl --upload-file f https://host/drop` (no trailing slash) sends no // filename at all, so fall back to a header and then to a generic name. $name = clean_name($name ?? (string) ($_SERVER['HTTP_X_FILENAME'] ?? 'upload.bin')); [$days, $maxDownloads] = upload_options(); $in = fopen('php://input', 'rb'); if ($in === false) { fail(500, 'Could not read the request body.'); } [$id, $meta] = store_upload($name, $in, null, $days, $maxDownloads); fclose($in); header('Content-Type: text/plain; charset=utf-8'); header('X-Url-Delete: ' . delete_url($id, $meta)); header('X-Expires: ' . gmdate('D, d M Y H:i:s', (int) $meta['expires']) . ' GMT'); echo download_url($id, $meta) . "\n"; exit; } // POST — browser upload (JS uses PUT; this is the no-JS fallback). if ($method === 'POST' && $segments === []) { if (!rate_limit_ok()) { fail(429, 'Too many uploads from your address — try again later.'); } if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $code = (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE); $msg = match ($code) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file exceeds the server upload limit.', UPLOAD_ERR_NO_FILE => 'No file selected.', default => 'Upload failed (error ' . $code . ').', }; fail(400, $msg); } [$days, $maxDownloads] = upload_options(); $name = clean_name((string) $_FILES['file']['name']); [$id, $meta] = store_upload($name, null, (string) $_FILES['file']['tmp_name'], $days, $maxDownloads); $uploaded = ['url' => download_url($id, $meta), 'delete' => delete_url($id, $meta), 'meta' => $meta]; // falls through to the UI below, which renders $uploaded } // /d// — delete. if (count($segments) === 3 && $segments[0] === 'd') { [, $id, $token] = $segments; $meta = read_meta($id); if ($meta === null || !hash_equals((string) $meta['delete_token'], $token)) { fail(404, 'Unknown or already deleted file.'); } if ($method === 'DELETE' || ($method === 'POST' && ($_POST['confirm'] ?? '') === 'yes')) { rrmdir(entry_dir($id)); if ($method === 'DELETE') { header('Content-Type: text/plain; charset=utf-8'); echo "Deleted.\n"; exit; } $notice = 'Deleted “' . $meta['name'] . '”.'; } else { // A GET on the delete link only asks — link prefetchers must not delete. $confirm = ['id' => $id, 'token' => $token, 'meta' => $meta]; } } // / — no filename given, redirect to the canonical URL. if ($method === 'GET' && count($segments) === 1 && is_valid_id($segments[0])) { $meta = read_meta($segments[0]); if ($meta !== null && !is_expired($meta)) { header('Location: ' . download_url($segments[0], $meta), true, 302); exit; } fail(410, 'This file does not exist any more.'); } // // — download. if (($method === 'GET' || $method === 'HEAD') && count($segments) === 2 && is_valid_id($segments[0])) { $id = $segments[0]; $meta = read_meta($id); if ($meta === null || is_expired($meta)) { fail(410, "This file does not exist any more.\n"); } if (isset($_GET['meta'])) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'name' => $meta['name'], 'size' => $meta['size'], 'created' => gmdate('c', (int) $meta['created']), 'expires' => gmdate('c', (int) $meta['expires']), 'downloads' => $meta['downloads'], 'max_downloads' => $meta['max_downloads'], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; exit; } send_file($id, $meta, $method === 'HEAD'); } // Anything else that is not the UI root is a miss. if ($segments !== [] && !isset($confirm) && !isset($notice)) { fail(404, "Not found.\n"); } // --------------------------------------------------------------- the page --- $stats = store_stats(); $base = base_url(); header('X-Robots-Tag: noindex, nofollow'); ?> File Drop

📦 File Drop

Delete this file?

Name
Size
Uploaded
Downloads
Cancel

Deleting is immediate and cannot be undone.

Drop a file here and get a temporary link. Files are deleted automatically after days (max ), or earlier if you set a download limit. Max per file. Anyone with the link can download the file — links are unguessable, but they are not access-controlled.

Upload from the command line

curl --upload-file ./hello.txt /

The response is the download URL. Optional request headers: Max-Days: 3 and Max-Downloads: 1 (a one-shot link). The delete URL comes back in the X-Url-Delete response header:

curl -H 'Max-Downloads: 1' -H 'Max-Days: 3' -D- --upload-file ./secret.zip /

Upload from the browser

Uploaded
Download URL
Delete URL
Expires
Drop files here or click to choose — multiple files are uploaded one by one.
0 = unlimited

Notes

  • Downloads are always served as an attachment (application/octet-stream), so nothing uploaded here can run in your browser under this domain.
  • Expired files are removed by a sweep that runs on roughly every th request.
  • Currently stored: files, of .
  • Upload budget: files per IP per hour.