index.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Temporary file drop — transfer.sh style.
  5. *
  6. * Upload with a plain curl PUT:
  7. * curl --upload-file ./hello.txt https://tool.medowar.de/drop/
  8. *
  9. * The response body is the download URL. Files expire after DEFAULT_DAYS (or
  10. * whatever the Max-Days / Max-Downloads request headers ask for) and are then
  11. * removed by a probabilistic garbage-collection sweep.
  12. *
  13. * Routes (all handled by this single script via .htaccess rewrite):
  14. * GET / web UI
  15. * POST / multipart upload (browser form fallback)
  16. * PUT /<name> upload, returns the download URL as text
  17. * GET /<id> redirect to the full download URL
  18. * GET /<id>/<name> download (?meta=1 returns JSON metadata)
  19. * HEAD /<id>/<name> metadata only
  20. * GET /d/<id>/<token> delete confirmation page
  21. * DELETE /d/<id>/<token> delete the file
  22. */
  23. const DATA_DIR = __DIR__ . '/data';
  24. const MAX_FILE_BYTES = 512 * 1024 * 1024; // 512 MB per file
  25. const MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB across the whole drop
  26. const DEFAULT_DAYS = 3;
  27. const MAX_DAYS = 30;
  28. const MAX_PER_IP_HOUR = 60; // uploads per IP per hour
  29. const GC_CHANCE = 20; // 1-in-N requests sweep expired files
  30. const CHUNK = 262144;
  31. ignore_user_abort(true);
  32. @set_time_limit(0);
  33. // ---------------------------------------------------------------- helpers ---
  34. function h(?string $s): string
  35. {
  36. return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  37. }
  38. /** Absolute base URL of this folder, e.g. https://tool.medowar.de/drop */
  39. function base_url(): string
  40. {
  41. $https = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
  42. || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
  43. || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443;
  44. $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
  45. $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
  46. return ($https ? 'https' : 'http') . '://' . $host . $dir;
  47. }
  48. /**
  49. * The path below this folder, as segments. Works with the .htaccess rewrite
  50. * (?_p=…), with PATH_INFO, and as a last resort straight off REQUEST_URI.
  51. *
  52. * @return string[]
  53. */
  54. function route_segments(): array
  55. {
  56. $path = '';
  57. if (isset($_GET['_p']) && is_string($_GET['_p'])) {
  58. $path = $_GET['_p'];
  59. } elseif (!empty($_SERVER['PATH_INFO'])) {
  60. $path = (string) $_SERVER['PATH_INFO'];
  61. } else {
  62. $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
  63. $uri = explode('?', $uri, 2)[0];
  64. $uri = rawurldecode($uri);
  65. $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
  66. if ($dir !== '' && str_starts_with($uri, $dir)) {
  67. $uri = substr($uri, strlen($dir));
  68. }
  69. $path = preg_replace('#^/index\.php#', '', $uri) ?? '';
  70. }
  71. $segments = [];
  72. foreach (explode('/', trim($path, '/')) as $seg) {
  73. if ($seg === '' || $seg === '.' || $seg === '..') {
  74. continue;
  75. }
  76. $segments[] = $seg;
  77. }
  78. return $segments;
  79. }
  80. /**
  81. * Last path component of the request, or null when the request addresses this
  82. * folder itself. Needed because a PUT to an existing file (`/drop/index.php`)
  83. * is served by Apache directly and never reaches the rewrite rule.
  84. */
  85. function request_uri_tail(): ?string
  86. {
  87. $path = rawurldecode(explode('?', (string) ($_SERVER['REQUEST_URI'] ?? ''), 2)[0]);
  88. $path = rtrim(str_replace('\\', '/', $path), '/');
  89. $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
  90. if ($dir === '' || !str_starts_with($path, $dir . '/')) {
  91. return null;
  92. }
  93. $tail = substr($path, strlen($dir) + 1);
  94. return str_contains($tail, '/') ? null : ($tail !== '' ? $tail : null);
  95. }
  96. /** Reduce an arbitrary client-supplied name to a safe, single path component. */
  97. function clean_name(string $name): string
  98. {
  99. $name = str_replace('\\', '/', $name);
  100. $name = basename($name);
  101. $name = (string) preg_replace('/[\x00-\x1F\x7F]/', '', $name);
  102. $name = trim($name);
  103. if ($name === '' || $name === '.' || $name === '..') {
  104. return 'upload.bin';
  105. }
  106. if (strlen($name) > 200) {
  107. $ext = pathinfo($name, PATHINFO_EXTENSION);
  108. $ext = $ext !== '' ? '.' . substr($ext, 0, 20) : '';
  109. $name = substr($name, 0, 200 - strlen($ext)) . $ext;
  110. }
  111. return $name;
  112. }
  113. function human_bytes(int $bytes): string
  114. {
  115. $units = ['B', 'KB', 'MB', 'GB', 'TB'];
  116. $i = 0;
  117. $n = (float) $bytes;
  118. while ($n >= 1024 && $i < count($units) - 1) {
  119. $n /= 1024;
  120. $i++;
  121. }
  122. return ($i === 0 ? (string) (int) $n : number_format($n, $n >= 100 ? 0 : 1)) . ' ' . $units[$i];
  123. }
  124. function is_valid_id(string $id): bool
  125. {
  126. return (bool) preg_match('/^[a-f0-9]{12}$/', $id);
  127. }
  128. function entry_dir(string $id): string
  129. {
  130. return DATA_DIR . '/' . $id;
  131. }
  132. /** @return array<string,mixed>|null */
  133. function read_meta(string $id): ?array
  134. {
  135. if (!is_valid_id($id)) {
  136. return null;
  137. }
  138. $file = entry_dir($id) . '/meta.json';
  139. if (!is_file($file)) {
  140. return null;
  141. }
  142. $meta = json_decode((string) file_get_contents($file), true);
  143. if (!is_array($meta) || !isset($meta['name'], $meta['expires'])) {
  144. return null;
  145. }
  146. return $meta;
  147. }
  148. /** @param array<string,mixed> $meta */
  149. function write_meta(string $id, array $meta): void
  150. {
  151. file_put_contents(
  152. entry_dir($id) . '/meta.json',
  153. json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
  154. LOCK_EX
  155. );
  156. }
  157. /** @param array<string,mixed> $meta */
  158. function is_expired(array $meta): bool
  159. {
  160. if ((int) $meta['expires'] <= time()) {
  161. return true;
  162. }
  163. $max = (int) ($meta['max_downloads'] ?? 0);
  164. return $max > 0 && (int) ($meta['downloads'] ?? 0) >= $max;
  165. }
  166. function rrmdir(string $dir): void
  167. {
  168. if (!is_dir($dir)) {
  169. return;
  170. }
  171. foreach (scandir($dir) ?: [] as $entry) {
  172. if ($entry === '.' || $entry === '..') {
  173. continue;
  174. }
  175. $path = $dir . '/' . $entry;
  176. is_dir($path) ? rrmdir($path) : @unlink($path);
  177. }
  178. @rmdir($dir);
  179. }
  180. /** Drop everything that has expired, plus half-finished uploads older than an hour. */
  181. function gc_sweep(): void
  182. {
  183. foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
  184. $meta = read_meta(basename($dir));
  185. if ($meta === null) {
  186. if ((int) @filemtime($dir) < time() - 3600) {
  187. rrmdir($dir);
  188. }
  189. continue;
  190. }
  191. if (is_expired($meta)) {
  192. rrmdir($dir);
  193. }
  194. }
  195. }
  196. /** @return array{count:int,bytes:int} */
  197. function store_stats(): array
  198. {
  199. $count = 0;
  200. $bytes = 0;
  201. foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
  202. $meta = read_meta(basename($dir));
  203. if ($meta === null || is_expired($meta)) {
  204. continue;
  205. }
  206. $count++;
  207. $bytes += (int) ($meta['size'] ?? 0);
  208. }
  209. return ['count' => $count, 'bytes' => $bytes];
  210. }
  211. function client_ip(): string
  212. {
  213. return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
  214. }
  215. /** Sliding one-hour upload budget per IP. Returns false when the budget is used up. */
  216. function rate_limit_ok(): bool
  217. {
  218. $file = DATA_DIR . '/rate.json';
  219. $fh = @fopen($file, 'c+');
  220. if ($fh === false) {
  221. return true; // never block uploads because the counter is unwritable
  222. }
  223. try {
  224. if (!flock($fh, LOCK_EX)) {
  225. return true;
  226. }
  227. $raw = stream_get_contents($fh);
  228. $data = json_decode((string) $raw, true);
  229. $data = is_array($data) ? $data : [];
  230. $now = time();
  231. $key = hash('sha256', client_ip());
  232. foreach ($data as $k => $stamps) {
  233. $data[$k] = array_values(array_filter((array) $stamps, static fn($t) => (int) $t > $now - 3600));
  234. if ($data[$k] === []) {
  235. unset($data[$k]);
  236. }
  237. }
  238. if (count($data[$key] ?? []) >= MAX_PER_IP_HOUR) {
  239. return false;
  240. }
  241. $data[$key][] = $now;
  242. ftruncate($fh, 0);
  243. rewind($fh);
  244. fwrite($fh, (string) json_encode($data));
  245. fflush($fh);
  246. return true;
  247. } finally {
  248. flock($fh, LOCK_UN);
  249. fclose($fh);
  250. }
  251. }
  252. function fail(int $status, string $message): never
  253. {
  254. http_response_code($status);
  255. header('Content-Type: text/plain; charset=utf-8');
  256. echo $message . "\n";
  257. exit;
  258. }
  259. // ------------------------------------------------------------- the upload ---
  260. /**
  261. * Store an upload. $source is either a stream to read from or a local file to
  262. * move. Returns [id, meta].
  263. *
  264. * @param resource|null $stream
  265. * @return array{0:string,1:array<string,mixed>}
  266. */
  267. function store_upload(string $name, $stream, ?string $movePath, int $days, int $maxDownloads): array
  268. {
  269. $stats = store_stats();
  270. if ($stats['bytes'] >= MAX_TOTAL_BYTES) {
  271. fail(507, 'The drop is full — try again later.');
  272. }
  273. $id = bin2hex(random_bytes(6));
  274. $dir = entry_dir($id);
  275. if (!@mkdir($dir, 0770, true) && !is_dir($dir)) {
  276. fail(500, 'Could not create storage directory.');
  277. }
  278. $blob = $dir . '/blob';
  279. if ($movePath !== null) {
  280. if (!@move_uploaded_file($movePath, $blob) && !@rename($movePath, $blob)) {
  281. rrmdir($dir);
  282. fail(500, 'Could not store the uploaded file.');
  283. }
  284. $size = (int) filesize($blob);
  285. } else {
  286. $out = @fopen($blob, 'wb');
  287. if ($out === false) {
  288. rrmdir($dir);
  289. fail(500, 'Could not open storage file.');
  290. }
  291. $size = 0;
  292. $allowed = min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - $stats['bytes']);
  293. while (!feof($stream)) {
  294. $chunk = fread($stream, CHUNK);
  295. if ($chunk === false) {
  296. break;
  297. }
  298. if ($chunk === '') {
  299. continue;
  300. }
  301. $size += strlen($chunk);
  302. if ($size > $allowed) {
  303. fclose($out);
  304. rrmdir($dir);
  305. fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.');
  306. }
  307. if (fwrite($out, $chunk) === false) {
  308. fclose($out);
  309. rrmdir($dir);
  310. fail(500, 'Write failed.');
  311. }
  312. }
  313. fclose($out);
  314. }
  315. if ($size === 0) {
  316. rrmdir($dir);
  317. fail(400, 'Refusing to store an empty file.');
  318. }
  319. $meta = [
  320. 'id' => $id,
  321. 'name' => $name,
  322. 'size' => $size,
  323. 'created' => time(),
  324. 'expires' => time() + $days * 86400,
  325. 'max_downloads' => $maxDownloads,
  326. 'downloads' => 0,
  327. 'delete_token' => bin2hex(random_bytes(16)),
  328. ];
  329. write_meta($id, $meta);
  330. return [$id, $meta];
  331. }
  332. /** Read Max-Days / Max-Downloads request headers. @return array{0:int,1:int} */
  333. function upload_options(): array
  334. {
  335. $days = (int) ($_SERVER['HTTP_MAX_DAYS'] ?? $_POST['days'] ?? DEFAULT_DAYS);
  336. $days = max(1, min(MAX_DAYS, $days ?: DEFAULT_DAYS));
  337. $max = (int) ($_SERVER['HTTP_MAX_DOWNLOADS'] ?? $_POST['max_downloads'] ?? 0);
  338. $max = max(0, min(10000, $max));
  339. return [$days, $max];
  340. }
  341. /** @param array<string,mixed> $meta */
  342. function download_url(string $id, array $meta): string
  343. {
  344. return base_url() . '/' . $id . '/' . rawurlencode((string) $meta['name']);
  345. }
  346. /** @param array<string,mixed> $meta */
  347. function delete_url(string $id, array $meta): string
  348. {
  349. return base_url() . '/d/' . $id . '/' . $meta['delete_token'];
  350. }
  351. // ----------------------------------------------------------- the download ---
  352. /** @param array<string,mixed> $meta */
  353. function send_file(string $id, array $meta, bool $headOnly): never
  354. {
  355. $blob = entry_dir($id) . '/blob';
  356. $fh = @fopen($blob, 'rb');
  357. if ($fh === false) {
  358. fail(404, 'Not found.');
  359. }
  360. $size = (int) $meta['size'];
  361. $name = (string) $meta['name'];
  362. $ascii = (string) preg_replace('/[^\x20-\x7E]/', '_', $name);
  363. $ascii = str_replace('"', '', $ascii);
  364. // Count the download first, so an aborted transfer cannot be used to
  365. // squeeze extra downloads out of a one-shot link.
  366. if (!$headOnly) {
  367. $meta['downloads'] = (int) $meta['downloads'] + 1;
  368. write_meta($id, $meta);
  369. $max = (int) $meta['max_downloads'];
  370. if ($max > 0 && $meta['downloads'] >= $max) {
  371. // The open handle stays valid after the directory is gone.
  372. rrmdir(entry_dir($id));
  373. }
  374. }
  375. $start = 0;
  376. $end = $size - 1;
  377. $range = (string) ($_SERVER['HTTP_RANGE'] ?? '');
  378. if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m)) {
  379. if ($m[1] === '' && $m[2] === '') {
  380. http_response_code(416);
  381. header('Content-Range: bytes */' . $size);
  382. exit;
  383. }
  384. if ($m[1] === '') {
  385. $start = max(0, $size - (int) $m[2]);
  386. } else {
  387. $start = (int) $m[1];
  388. if ($m[2] !== '') {
  389. $end = min($size - 1, (int) $m[2]);
  390. }
  391. }
  392. if ($start > $end || $start >= $size) {
  393. http_response_code(416);
  394. header('Content-Range: bytes */' . $size);
  395. exit;
  396. }
  397. http_response_code(206);
  398. header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
  399. }
  400. $length = $end - $start + 1;
  401. // Always an opaque download: this host serves other tools, so never let an
  402. // uploaded file render in the browser under this origin.
  403. header('Content-Type: application/octet-stream');
  404. header('Content-Disposition: attachment; filename="' . $ascii . '"; '
  405. . "filename*=UTF-8''" . rawurlencode($name));
  406. header('Content-Length: ' . $length);
  407. header('Accept-Ranges: bytes');
  408. header('X-Content-Type-Options: nosniff');
  409. header('X-Robots-Tag: noindex, nofollow');
  410. header('Cache-Control: private, no-store');
  411. if ($headOnly) {
  412. exit;
  413. }
  414. fseek($fh, $start);
  415. $remaining = $length;
  416. while ($remaining > 0 && !feof($fh)) {
  417. $chunk = fread($fh, (int) min(CHUNK, $remaining));
  418. if ($chunk === false || $chunk === '') {
  419. break;
  420. }
  421. echo $chunk;
  422. $remaining -= strlen($chunk);
  423. flush();
  424. }
  425. fclose($fh);
  426. exit;
  427. }
  428. // ------------------------------------------------------------------ setup ---
  429. if (!is_dir(DATA_DIR) && !@mkdir(DATA_DIR, 0770, true) && !is_dir(DATA_DIR)) {
  430. fail(500, 'Storage directory ' . basename(DATA_DIR) . ' is missing and cannot be created.');
  431. }
  432. if (random_int(1, GC_CHANCE) === 1) {
  433. gc_sweep();
  434. }
  435. $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
  436. $segments = route_segments();
  437. // ---------------------------------------------------------------- routing ---
  438. // PUT — the curl --upload-file path.
  439. if ($method === 'PUT') {
  440. if (count($segments) > 1) {
  441. fail(400, "Upload to the root of this folder:\n curl --upload-file ./file.txt " . base_url() . "/\n");
  442. }
  443. if (!rate_limit_ok()) {
  444. fail(429, 'Too many uploads from your address — try again later.');
  445. }
  446. $declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
  447. if ($declared > MAX_FILE_BYTES) {
  448. fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.');
  449. }
  450. // Segments are already URL-decoded; do not decode them again.
  451. $name = $segments[0] ?? request_uri_tail();
  452. // `curl --upload-file f https://host/drop` (no trailing slash) sends no
  453. // filename at all, so fall back to a header and then to a generic name.
  454. $name = clean_name($name ?? (string) ($_SERVER['HTTP_X_FILENAME'] ?? 'upload.bin'));
  455. [$days, $maxDownloads] = upload_options();
  456. $in = fopen('php://input', 'rb');
  457. if ($in === false) {
  458. fail(500, 'Could not read the request body.');
  459. }
  460. [$id, $meta] = store_upload($name, $in, null, $days, $maxDownloads);
  461. fclose($in);
  462. header('Content-Type: text/plain; charset=utf-8');
  463. header('X-Url-Delete: ' . delete_url($id, $meta));
  464. header('X-Expires: ' . gmdate('D, d M Y H:i:s', (int) $meta['expires']) . ' GMT');
  465. echo download_url($id, $meta) . "\n";
  466. exit;
  467. }
  468. // POST — browser upload (JS uses PUT; this is the no-JS fallback).
  469. if ($method === 'POST' && $segments === []) {
  470. if (!rate_limit_ok()) {
  471. fail(429, 'Too many uploads from your address — try again later.');
  472. }
  473. if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
  474. $code = (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE);
  475. $msg = match ($code) {
  476. UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file exceeds the server upload limit.',
  477. UPLOAD_ERR_NO_FILE => 'No file selected.',
  478. default => 'Upload failed (error ' . $code . ').',
  479. };
  480. fail(400, $msg);
  481. }
  482. [$days, $maxDownloads] = upload_options();
  483. $name = clean_name((string) $_FILES['file']['name']);
  484. [$id, $meta] = store_upload($name, null, (string) $_FILES['file']['tmp_name'], $days, $maxDownloads);
  485. $uploaded = ['url' => download_url($id, $meta), 'delete' => delete_url($id, $meta), 'meta' => $meta];
  486. // falls through to the UI below, which renders $uploaded
  487. }
  488. // /d/<id>/<token> — delete.
  489. if (count($segments) === 3 && $segments[0] === 'd') {
  490. [, $id, $token] = $segments;
  491. $meta = read_meta($id);
  492. if ($meta === null || !hash_equals((string) $meta['delete_token'], $token)) {
  493. fail(404, 'Unknown or already deleted file.');
  494. }
  495. if ($method === 'DELETE' || ($method === 'POST' && ($_POST['confirm'] ?? '') === 'yes')) {
  496. rrmdir(entry_dir($id));
  497. if ($method === 'DELETE') {
  498. header('Content-Type: text/plain; charset=utf-8');
  499. echo "Deleted.\n";
  500. exit;
  501. }
  502. $notice = 'Deleted “' . $meta['name'] . '”.';
  503. } else {
  504. // A GET on the delete link only asks — link prefetchers must not delete.
  505. $confirm = ['id' => $id, 'token' => $token, 'meta' => $meta];
  506. }
  507. }
  508. // /<id> — no filename given, redirect to the canonical URL.
  509. if ($method === 'GET' && count($segments) === 1 && is_valid_id($segments[0])) {
  510. $meta = read_meta($segments[0]);
  511. if ($meta !== null && !is_expired($meta)) {
  512. header('Location: ' . download_url($segments[0], $meta), true, 302);
  513. exit;
  514. }
  515. fail(410, 'This file does not exist any more.');
  516. }
  517. // /<id>/<name> — download.
  518. if (($method === 'GET' || $method === 'HEAD') && count($segments) === 2 && is_valid_id($segments[0])) {
  519. $id = $segments[0];
  520. $meta = read_meta($id);
  521. if ($meta === null || is_expired($meta)) {
  522. fail(410, "This file does not exist any more.\n");
  523. }
  524. if (isset($_GET['meta'])) {
  525. header('Content-Type: application/json; charset=utf-8');
  526. echo json_encode([
  527. 'name' => $meta['name'],
  528. 'size' => $meta['size'],
  529. 'created' => gmdate('c', (int) $meta['created']),
  530. 'expires' => gmdate('c', (int) $meta['expires']),
  531. 'downloads' => $meta['downloads'],
  532. 'max_downloads' => $meta['max_downloads'],
  533. ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
  534. exit;
  535. }
  536. send_file($id, $meta, $method === 'HEAD');
  537. }
  538. // Anything else that is not the UI root is a miss.
  539. if ($segments !== [] && !isset($confirm) && !isset($notice)) {
  540. fail(404, "Not found.\n");
  541. }
  542. // --------------------------------------------------------------- the page ---
  543. $stats = store_stats();
  544. $base = base_url();
  545. header('X-Robots-Tag: noindex, nofollow');
  546. ?>
  547. <!DOCTYPE html>
  548. <html lang="en">
  549. <head>
  550. <meta charset="UTF-8">
  551. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  552. <meta name="robots" content="noindex, nofollow">
  553. <title>File Drop</title>
  554. <style>
  555. body {
  556. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  557. max-width: 1000px;
  558. margin: 40px auto;
  559. padding: 0 20px;
  560. background: #f5f5f5;
  561. color: #333;
  562. }
  563. h1 { color: #333; }
  564. h2 { color: #333; margin-top: 30px; }
  565. .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
  566. .info code, p code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 3px; }
  567. .btn {
  568. display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
  569. text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
  570. }
  571. .btn:hover { background: #1976D2; }
  572. .btn:disabled { background: #90caf9; cursor: default; }
  573. .btn-small { padding: 5px 12px; font-size: 13px; }
  574. .btn-danger { background: #f44336; }
  575. .btn-danger:hover { background: #d32f2f; }
  576. .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
  577. .notice { background: #e8f5e9; color: #2e7d32; padding: 10px; border-radius: 5px; margin: 10px 0; }
  578. .drop {
  579. background: white; border: 2px dashed #90caf9; border-radius: 8px;
  580. padding: 40px 20px; text-align: center; color: #555; cursor: pointer;
  581. transition: background .15s, border-color .15s;
  582. }
  583. .drop.over { background: #e3f2fd; border-color: #2196F3; }
  584. .drop strong { display: block; font-size: 17px; color: #333; margin-bottom: 6px; }
  585. .opts { margin: 14px 0 20px; font-size: 14px; color: #555; display: flex; gap: 20px; flex-wrap: wrap; align-items: center; }
  586. .opts input { width: 80px; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
  587. .record {
  588. background: #263238; color: #aed581; padding: 10px 12px; border-radius: 4px;
  589. font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
  590. white-space: pre-wrap;
  591. }
  592. table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
  593. th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
  594. th { background: #fafafa; color: #555; font-weight: 600; }
  595. td.mono, .mono { font-family: monospace; word-break: break-all; }
  596. progress { width: 100%; height: 10px; }
  597. .muted { color: #777; font-size: 13px; }
  598. #results:empty { display: none; }
  599. </style>
  600. </head>
  601. <body>
  602. <h1>📦 File Drop</h1>
  603. <?php if (isset($notice)): ?>
  604. <div class="notice"><?= h($notice) ?></div>
  605. <?php endif; ?>
  606. <?php if (isset($confirm)): ?>
  607. <h2>Delete this file?</h2>
  608. <table>
  609. <tr><th>Name</th><td class="mono"><?= h((string) $confirm['meta']['name']) ?></td></tr>
  610. <tr><th>Size</th><td><?= h(human_bytes((int) $confirm['meta']['size'])) ?></td></tr>
  611. <tr><th>Uploaded</th><td><?= h(date('Y-m-d H:i:s', (int) $confirm['meta']['created'])) ?></td></tr>
  612. <tr><th>Downloads</th><td><?= (int) $confirm['meta']['downloads'] ?></td></tr>
  613. </table>
  614. <form method="POST" style="margin-top:15px;">
  615. <input type="hidden" name="confirm" value="yes">
  616. <button type="submit" class="btn btn-danger">Delete permanently</button>
  617. <a href="<?= h($base) ?>/" class="btn" style="background:#78909c;">Cancel</a>
  618. </form>
  619. <p class="muted">Deleting is immediate and cannot be undone.</p>
  620. <?php else: ?>
  621. <div class="info">
  622. Drop a file here and get a temporary link. Files are deleted automatically after
  623. <strong><?= DEFAULT_DAYS ?> days</strong> (max <?= MAX_DAYS ?>), or earlier if you set a download limit.
  624. Max <strong><?= h(human_bytes(MAX_FILE_BYTES)) ?></strong> per file.
  625. Anyone with the link can download the file — links are unguessable, but they are not access-controlled.
  626. </div>
  627. <h2>Upload from the command line</h2>
  628. <div class="record">curl --upload-file ./hello.txt <?= h($base) ?>/</div>
  629. <p class="muted">
  630. The response is the download URL. Optional request headers:
  631. <code>Max-Days: 3</code> and <code>Max-Downloads: 1</code> (a one-shot link).
  632. The delete URL comes back in the <code>X-Url-Delete</code> response header:
  633. </p>
  634. <div class="record">curl -H 'Max-Downloads: 1' -H 'Max-Days: 3' -D- --upload-file ./secret.zip <?= h($base) ?>/</div>
  635. <h2>Upload from the browser</h2>
  636. <?php if (isset($uploaded)): ?>
  637. <div class="notice">Uploaded <strong><?= h((string) $uploaded['meta']['name']) ?></strong></div>
  638. <table>
  639. <tr><th>Download URL</th><td class="mono"><a href="<?= h($uploaded['url']) ?>"><?= h($uploaded['url']) ?></a></td></tr>
  640. <tr><th>Delete URL</th><td class="mono"><?= h($uploaded['delete']) ?></td></tr>
  641. <tr><th>Expires</th><td><?= h(date('Y-m-d H:i', (int) $uploaded['meta']['expires'])) ?></td></tr>
  642. </table>
  643. <?php endif; ?>
  644. <form id="form" method="POST" enctype="multipart/form-data">
  645. <div class="drop" id="drop">
  646. <strong>Drop files here</strong>
  647. or click to choose — multiple files are uploaded one by one.
  648. <input type="file" name="file" id="file" multiple style="display:none;">
  649. </div>
  650. <div class="opts">
  651. <label>Keep for <input type="number" name="days" id="days" value="<?= DEFAULT_DAYS ?>" min="1" max="<?= MAX_DAYS ?>"> days</label>
  652. <label>Max downloads <input type="number" name="max_downloads" id="maxdl" value="0" min="0" placeholder="0"></label>
  653. <span class="muted">0 = unlimited</span>
  654. <button type="submit" class="btn" id="submit">Upload</button>
  655. </div>
  656. </form>
  657. <div id="results"></div>
  658. <h2>Notes</h2>
  659. <ul class="muted">
  660. <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>
  661. <li>Expired files are removed by a sweep that runs on roughly every <?= GC_CHANCE ?><sup>th</sup> request.</li>
  662. <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>
  663. <li>Upload budget: <?= MAX_PER_IP_HOUR ?> files per IP per hour.</li>
  664. </ul>
  665. <script>
  666. (function () {
  667. const base = <?= json_encode($base, JSON_UNESCAPED_SLASHES) ?>;
  668. const dropZone = document.getElementById('drop');
  669. const input = document.getElementById('file');
  670. const form = document.getElementById('form');
  671. const results = document.getElementById('results');
  672. const submit = document.getElementById('submit');
  673. dropZone.addEventListener('click', () => input.click());
  674. input.addEventListener('change', () => queue([...input.files]));
  675. ['dragenter', 'dragover'].forEach(ev =>
  676. dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.add('over'); }));
  677. ['dragleave', 'drop'].forEach(ev =>
  678. dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.remove('over'); }));
  679. dropZone.addEventListener('drop', e => queue([...e.dataTransfer.files]));
  680. form.addEventListener('submit', e => {
  681. if (input.files.length) { e.preventDefault(); queue([...input.files]); }
  682. });
  683. let chain = Promise.resolve();
  684. function queue(files) {
  685. files.forEach(f => { chain = chain.then(() => upload(f)); });
  686. chain = chain.then(() => { input.value = ''; });
  687. }
  688. function row(label, value, isLink) {
  689. const td = isLink
  690. ? '<a href="' + escapeAttr(value) + '">' + escapeHtml(value) + '</a>'
  691. : escapeHtml(value);
  692. return '<tr><th>' + escapeHtml(label) + '</th><td class="mono">' + td + '</td></tr>';
  693. }
  694. const escapeHtml = s => String(s).replace(/[&<>"']/g,
  695. c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  696. const escapeAttr = escapeHtml;
  697. function upload(file) {
  698. const box = document.createElement('div');
  699. box.innerHTML = '<p><strong>' + escapeHtml(file.name) + '</strong> '
  700. + '<span class="muted">' + fmt(file.size) + '</span></p>'
  701. + '<progress max="100" value="0"></progress>';
  702. results.prepend(box);
  703. const bar = box.querySelector('progress');
  704. submit.disabled = true;
  705. return new Promise(resolve => {
  706. const xhr = new XMLHttpRequest();
  707. xhr.open('PUT', base + '/' + encodeURIComponent(file.name));
  708. xhr.setRequestHeader('Max-Days', document.getElementById('days').value || '<?= DEFAULT_DAYS ?>');
  709. xhr.setRequestHeader('Max-Downloads', document.getElementById('maxdl').value || '0');
  710. xhr.upload.addEventListener('progress', e => {
  711. if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
  712. });
  713. xhr.addEventListener('loadend', () => {
  714. submit.disabled = false;
  715. bar.remove();
  716. if (xhr.status >= 200 && xhr.status < 300) {
  717. const url = xhr.responseText.trim();
  718. const del = xhr.getResponseHeader('X-Url-Delete') || '';
  719. const exp = xhr.getResponseHeader('X-Expires') || '';
  720. const table = document.createElement('table');
  721. table.innerHTML = row('Download URL', url, true)
  722. + (del ? row('Delete URL', del, false) : '')
  723. + (exp ? row('Expires', exp, false) : '');
  724. box.appendChild(table);
  725. const copy = document.createElement('button');
  726. copy.className = 'btn btn-small';
  727. copy.style.marginTop = '8px';
  728. copy.textContent = 'Copy link';
  729. copy.onclick = () => {
  730. navigator.clipboard.writeText(url)
  731. .then(() => { copy.textContent = 'Copied'; })
  732. .catch(() => { copy.textContent = 'Copy failed'; });
  733. };
  734. box.appendChild(copy);
  735. } else {
  736. const err = document.createElement('div');
  737. err.className = 'error';
  738. err.textContent = xhr.responseText.trim() || ('Upload failed (HTTP ' + xhr.status + ')');
  739. box.appendChild(err);
  740. }
  741. resolve();
  742. });
  743. xhr.send(file);
  744. });
  745. }
  746. function fmt(b) {
  747. const u = ['B', 'KB', 'MB', 'GB'];
  748. let i = 0;
  749. while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
  750. return (i ? b.toFixed(1) : b) + ' ' + u[i];
  751. }
  752. })();
  753. </script>
  754. <?php endif; ?>
  755. </body>
  756. </html>