| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868 |
- <?php
- declare(strict_types=1);
- /**
- * Temporary file drop — transfer.sh style.
- *
- * Upload with a plain curl PUT:
- * curl --upload-file ./hello.txt https://tool.medowar.de/drop/
- *
- * The response body is the download URL. Files expire after DEFAULT_DAYS (or
- * whatever the Max-Days / Max-Downloads request headers ask for) and are then
- * removed by a probabilistic garbage-collection sweep.
- *
- * Routes (all handled by this single script via .htaccess rewrite):
- * GET / web UI
- * POST / multipart upload (browser form fallback)
- * PUT /<name> upload, returns the download URL as text
- * GET /<id> redirect to the full download URL
- * GET /<id>/<name> download (?meta=1 returns JSON metadata)
- * HEAD /<id>/<name> metadata only
- * GET /d/<id>/<token> delete confirmation page
- * DELETE /d/<id>/<token> 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<string,mixed>|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<string,mixed> $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<string,mixed> $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<string,mixed>}
- */
- 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<string,mixed> $meta */
- function download_url(string $id, array $meta): string
- {
- return base_url() . '/' . $id . '/' . rawurlencode((string) $meta['name']);
- }
- /** @param array<string,mixed> $meta */
- function delete_url(string $id, array $meta): string
- {
- return base_url() . '/d/' . $id . '/' . $meta['delete_token'];
- }
- // ----------------------------------------------------------- the download ---
- /** @param array<string,mixed> $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/<id>/<token> — 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];
- }
- }
- // /<id> — 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.');
- }
- // /<id>/<name> — 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');
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta name="robots" content="noindex, nofollow">
- <title>File Drop</title>
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- max-width: 1000px;
- margin: 40px auto;
- padding: 0 20px;
- background: #f5f5f5;
- color: #333;
- }
- h1 { color: #333; }
- h2 { color: #333; margin-top: 30px; }
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
- .info code, p code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 3px; }
- .btn {
- display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
- text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
- }
- .btn:hover { background: #1976D2; }
- .btn:disabled { background: #90caf9; cursor: default; }
- .btn-small { padding: 5px 12px; font-size: 13px; }
- .btn-danger { background: #f44336; }
- .btn-danger:hover { background: #d32f2f; }
- .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
- .notice { background: #e8f5e9; color: #2e7d32; padding: 10px; border-radius: 5px; margin: 10px 0; }
- .drop {
- background: white; border: 2px dashed #90caf9; border-radius: 8px;
- padding: 40px 20px; text-align: center; color: #555; cursor: pointer;
- transition: background .15s, border-color .15s;
- }
- .drop.over { background: #e3f2fd; border-color: #2196F3; }
- .drop strong { display: block; font-size: 17px; color: #333; margin-bottom: 6px; }
- .opts { margin: 14px 0 20px; font-size: 14px; color: #555; display: flex; gap: 20px; flex-wrap: wrap; align-items: center; }
- .opts input { width: 80px; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
- .record {
- background: #263238; color: #aed581; padding: 10px 12px; border-radius: 4px;
- font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
- white-space: pre-wrap;
- }
- table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
- th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
- th { background: #fafafa; color: #555; font-weight: 600; }
- td.mono, .mono { font-family: monospace; word-break: break-all; }
- progress { width: 100%; height: 10px; }
- .muted { color: #777; font-size: 13px; }
- #results:empty { display: none; }
- </style>
- </head>
- <body>
- <h1>📦 File Drop</h1>
- <?php if (isset($notice)): ?>
- <div class="notice"><?= h($notice) ?></div>
- <?php endif; ?>
- <?php if (isset($confirm)): ?>
- <h2>Delete this file?</h2>
- <table>
- <tr><th>Name</th><td class="mono"><?= h((string) $confirm['meta']['name']) ?></td></tr>
- <tr><th>Size</th><td><?= h(human_bytes((int) $confirm['meta']['size'])) ?></td></tr>
- <tr><th>Uploaded</th><td><?= h(date('Y-m-d H:i:s', (int) $confirm['meta']['created'])) ?></td></tr>
- <tr><th>Downloads</th><td><?= (int) $confirm['meta']['downloads'] ?></td></tr>
- </table>
- <form method="POST" style="margin-top:15px;">
- <input type="hidden" name="confirm" value="yes">
- <button type="submit" class="btn btn-danger">Delete permanently</button>
- <a href="<?= h($base) ?>/" class="btn" style="background:#78909c;">Cancel</a>
- </form>
- <p class="muted">Deleting is immediate and cannot be undone.</p>
- <?php else: ?>
- <div class="info">
- Drop a file here and get a temporary link. Files are deleted automatically after
- <strong><?= DEFAULT_DAYS ?> days</strong> (max <?= MAX_DAYS ?>), or earlier if you set a download limit.
- Max <strong><?= h(human_bytes(MAX_FILE_BYTES)) ?></strong> per file.
- Anyone with the link can download the file — links are unguessable, but they are not access-controlled.
- </div>
- <h2>Upload from the command line</h2>
- <div class="record">curl --upload-file ./hello.txt <?= h($base) ?>/</div>
- <p class="muted">
- The response is the download URL. Optional request headers:
- <code>Max-Days: 3</code> and <code>Max-Downloads: 1</code> (a one-shot link).
- The delete URL comes back in the <code>X-Url-Delete</code> response header:
- </p>
- <div class="record">curl -H 'Max-Downloads: 1' -H 'Max-Days: 3' -D- --upload-file ./secret.zip <?= h($base) ?>/</div>
- <h2>Upload from the browser</h2>
- <?php if (isset($uploaded)): ?>
- <div class="notice">Uploaded <strong><?= h((string) $uploaded['meta']['name']) ?></strong></div>
- <table>
- <tr><th>Download URL</th><td class="mono"><a href="<?= h($uploaded['url']) ?>"><?= h($uploaded['url']) ?></a></td></tr>
- <tr><th>Delete URL</th><td class="mono"><?= h($uploaded['delete']) ?></td></tr>
- <tr><th>Expires</th><td><?= h(date('Y-m-d H:i', (int) $uploaded['meta']['expires'])) ?></td></tr>
- </table>
- <?php endif; ?>
- <form id="form" method="POST" enctype="multipart/form-data">
- <div class="drop" id="drop">
- <strong>Drop files here</strong>
- or click to choose — multiple files are uploaded one by one.
- <input type="file" name="file" id="file" multiple style="display:none;">
- </div>
- <div class="opts">
- <label>Keep for <input type="number" name="days" id="days" value="<?= DEFAULT_DAYS ?>" min="1" max="<?= MAX_DAYS ?>"> days</label>
- <label>Max downloads <input type="number" name="max_downloads" id="maxdl" value="0" min="0" placeholder="0"></label>
- <span class="muted">0 = unlimited</span>
- <button type="submit" class="btn" id="submit">Upload</button>
- </div>
- </form>
- <div id="results"></div>
- <h2>Notes</h2>
- <ul class="muted">
- <li>Downloads are always served as an attachment (<code>application/octet-stream</code>), so nothing uploaded here can run in your browser under this domain.</li>
- <li>Expired files are removed by a sweep that runs on roughly every <?= GC_CHANCE ?><sup>th</sup> request.</li>
- <li>Currently stored: <strong><?= (int) $stats['count'] ?></strong> files, <strong><?= h(human_bytes((int) $stats['bytes'])) ?></strong> of <?= h(human_bytes(MAX_TOTAL_BYTES)) ?>.</li>
- <li>Upload budget: <?= MAX_PER_IP_HOUR ?> files per IP per hour.</li>
- </ul>
- <script>
- (function () {
- const base = <?= json_encode($base, JSON_UNESCAPED_SLASHES) ?>;
- const dropZone = document.getElementById('drop');
- const input = document.getElementById('file');
- const form = document.getElementById('form');
- const results = document.getElementById('results');
- const submit = document.getElementById('submit');
- dropZone.addEventListener('click', () => input.click());
- input.addEventListener('change', () => queue([...input.files]));
- ['dragenter', 'dragover'].forEach(ev =>
- dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.add('over'); }));
- ['dragleave', 'drop'].forEach(ev =>
- dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.remove('over'); }));
- dropZone.addEventListener('drop', e => queue([...e.dataTransfer.files]));
- form.addEventListener('submit', e => {
- if (input.files.length) { e.preventDefault(); queue([...input.files]); }
- });
- let chain = Promise.resolve();
- function queue(files) {
- files.forEach(f => { chain = chain.then(() => upload(f)); });
- chain = chain.then(() => { input.value = ''; });
- }
- function row(label, value, isLink) {
- const td = isLink
- ? '<a href="' + escapeAttr(value) + '">' + escapeHtml(value) + '</a>'
- : escapeHtml(value);
- return '<tr><th>' + escapeHtml(label) + '</th><td class="mono">' + td + '</td></tr>';
- }
- const escapeHtml = s => String(s).replace(/[&<>"']/g,
- c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- const escapeAttr = escapeHtml;
- function upload(file) {
- const box = document.createElement('div');
- box.innerHTML = '<p><strong>' + escapeHtml(file.name) + '</strong> '
- + '<span class="muted">' + fmt(file.size) + '</span></p>'
- + '<progress max="100" value="0"></progress>';
- results.prepend(box);
- const bar = box.querySelector('progress');
- submit.disabled = true;
- return new Promise(resolve => {
- const xhr = new XMLHttpRequest();
- xhr.open('PUT', base + '/' + encodeURIComponent(file.name));
- xhr.setRequestHeader('Max-Days', document.getElementById('days').value || '<?= DEFAULT_DAYS ?>');
- xhr.setRequestHeader('Max-Downloads', document.getElementById('maxdl').value || '0');
- xhr.upload.addEventListener('progress', e => {
- if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
- });
- xhr.addEventListener('loadend', () => {
- submit.disabled = false;
- bar.remove();
- if (xhr.status >= 200 && xhr.status < 300) {
- const url = xhr.responseText.trim();
- const del = xhr.getResponseHeader('X-Url-Delete') || '';
- const exp = xhr.getResponseHeader('X-Expires') || '';
- const table = document.createElement('table');
- table.innerHTML = row('Download URL', url, true)
- + (del ? row('Delete URL', del, false) : '')
- + (exp ? row('Expires', exp, false) : '');
- box.appendChild(table);
- const copy = document.createElement('button');
- copy.className = 'btn btn-small';
- copy.style.marginTop = '8px';
- copy.textContent = 'Copy link';
- copy.onclick = () => {
- navigator.clipboard.writeText(url)
- .then(() => { copy.textContent = 'Copied'; })
- .catch(() => { copy.textContent = 'Copy failed'; });
- };
- box.appendChild(copy);
- } else {
- const err = document.createElement('div');
- err.className = 'error';
- err.textContent = xhr.responseText.trim() || ('Upload failed (HTTP ' + xhr.status + ')');
- box.appendChild(err);
- }
- resolve();
- });
- xhr.send(file);
- });
- }
- function fmt(b) {
- const u = ['B', 'KB', 'MB', 'GB'];
- let i = 0;
- while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
- return (i ? b.toFixed(1) : b) + ' ' + u[i];
- }
- })();
- </script>
- <?php endif; ?>
- </body>
- </html>
|