Last active
May 30, 2026 19:07
-
-
Save peterc/e971b5de717654373a88db93c240109f to your computer and use it in GitHub Desktop.
A temporary file dropping/sharing service
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
| // drop | |
| // | |
| // A dependency-free Bun web app for short-lived file sharing. It serves a | |
| // drag-and-drop page, accepts SVG, PNG, PDF, GIF, WEBP, and JPEG uploads after | |
| // detecting the file type from contents, stores each upload in /tmp/images under | |
| // a random UUID filename, and deletes stored files after 5 minutes. The storage | |
| // directory is capped at 1 GB by deleting the oldest files first, and is kept at | |
| // 0700 so only the app's Unix user can inspect stored files directly. | |
| // | |
| // Run: | |
| // bun run drop.ts | |
| // | |
| // Options: | |
| // --port PORT Listening port. Defaults to 8000. | |
| // --host HOST Listening host. Defaults to Bun's default host. | |
| // --public-url URL Public base URL used in upload responses, for example | |
| // https://drop.example.com. Defaults to deriving the base | |
| // URL from the incoming request's scheme and Host header. | |
| // --help, -h Print usage and exit. | |
| // | |
| // Example: | |
| // bun run drop.ts -- --port 9000 --host 0.0.0.0 --public-url https://drop.example.com | |
| // | |
| // Assumes an upstream proxy always sets X-Forwarded-For; rate limiting trusts | |
| // its leftmost value as the client IP. Direct (un-proxied) exposure would let | |
| // clients spoof XFF to bypass the limit. | |
| import { chmod, mkdir, readdir, stat, unlink } from "node:fs/promises"; | |
| import { existsSync } from "node:fs"; | |
| import { extname, join, resolve, sep } from "node:path"; | |
| const DIR = "/tmp/images"; | |
| const TTL_MS = 5 * 60 * 1000; | |
| const DEFAULT_PORT = 8000; | |
| const DEFAULT_HOST = undefined; | |
| const DEFAULT_PUBLIC_URL = undefined; | |
| const MAX_BYTES = 8 * 1024 * 1024; | |
| const MAX_DIR_BYTES = 1024 * 1024 * 1024; | |
| const MAGIC_BYTES = 4096; | |
| function usage(message?: string): never { | |
| if (message) console.error(message); | |
| console.error("usage: bun run drop.ts [--port PORT] [--host HOST] [--public-url URL]"); | |
| process.exit(message ? 1 : 0); | |
| } | |
| function parsePublicUrl(value: string): string { | |
| let url: URL; | |
| try { url = new URL(value); } | |
| catch { usage("--public-url must be an absolute http(s) URL"); } | |
| if (url.protocol !== "http:" && url.protocol !== "https:") { | |
| usage("--public-url must use http or https"); | |
| } | |
| url.pathname = url.pathname.replace(/\/+$/, ""); | |
| url.search = ""; | |
| url.hash = ""; | |
| return url.toString().replace(/\/$/, ""); | |
| } | |
| function parseArgs(args: string[]): { port: number; host?: string; publicUrl?: string } { | |
| let port = DEFAULT_PORT; | |
| let host = DEFAULT_HOST; | |
| let publicUrl = DEFAULT_PUBLIC_URL; | |
| for (let i = 0; i < args.length; i++) { | |
| const arg = args[i]; | |
| if (arg === "--help" || arg === "-h") usage(); | |
| if (arg === "--port") { | |
| const value = args[++i]; | |
| if (!value) usage("--port requires a value"); | |
| port = Number(value); | |
| if (!Number.isInteger(port) || port < 1 || port > 65535) usage("--port must be an integer from 1 to 65535"); | |
| continue; | |
| } | |
| if (arg === "--host") { | |
| host = args[++i]; | |
| if (!host) usage("--host requires a value"); | |
| continue; | |
| } | |
| if (arg === "--public-url") { | |
| const value = args[++i]; | |
| if (!value) usage("--public-url requires a value"); | |
| publicUrl = parsePublicUrl(value); | |
| continue; | |
| } | |
| usage(`unknown argument: ${arg}`); | |
| } | |
| return { port, host, publicUrl }; | |
| } | |
| const { port: PORT, host: HOST, publicUrl: PUBLIC_URL } = parseArgs(Bun.argv.slice(2)); | |
| if (!existsSync(DIR)) await mkdir(DIR, { recursive: true, mode: 0o700 }); | |
| await chmod(DIR, 0o700); | |
| function scheduleUnlink(path: string, atMs: number) { | |
| const delay = Math.max(0, atMs - Date.now()); | |
| setTimeout(() => { unlink(path).catch(() => {}); }, delay); | |
| } | |
| async function storedFiles() { | |
| const files: { path: string; size: number; mtimeMs: number }[] = []; | |
| for (const name of await readdir(DIR).catch(() => [])) { | |
| const path = join(DIR, name); | |
| const s = await stat(path).catch(() => null); | |
| if (s?.isFile()) files.push({ path, size: s.size, mtimeMs: s.mtimeMs }); | |
| } | |
| return files; | |
| } | |
| async function makeRoomFor(bytes: number) { | |
| let files = await storedFiles(); | |
| let total = files.reduce((sum, file) => sum + file.size, 0); | |
| if (total + bytes <= MAX_DIR_BYTES) return; | |
| files = files.sort((a, b) => a.mtimeMs - b.mtimeMs); | |
| for (const file of files) { | |
| await unlink(file.path).catch(() => {}); | |
| total -= file.size; | |
| if (total + bytes <= MAX_DIR_BYTES) return; | |
| } | |
| } | |
| await makeRoomFor(0); | |
| for (const name of await readdir(DIR).catch(() => [])) { | |
| const full = join(DIR, name); | |
| const s = await stat(full).catch(() => null); | |
| if (s?.isFile()) scheduleUnlink(full, s.mtimeMs + TTL_MS); | |
| } | |
| const html = `<!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>drop</title> | |
| <style> | |
| html,body{margin:0;height:100%} | |
| body{display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;background:#111;color:#bbb;transition:background .1s} | |
| body.drag{background:#1a2a3a;color:#fff} | |
| .hint{font-size:1.2rem;opacity:.7;pointer-events:none} | |
| #log{position:fixed;bottom:12px;left:12px;right:12px;font-family:ui-monospace,monospace;font-size:.85rem;color:#7af;word-break:break-all} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="hint">drop SVG, PNG, PDF, GIF, WEBP, or JPEG anywhere</div> | |
| <div id="log"></div> | |
| <script> | |
| const log = document.getElementById('log'); | |
| let depth = 0; | |
| addEventListener('dragenter', e => { e.preventDefault(); if (++depth) document.body.classList.add('drag'); }); | |
| addEventListener('dragover', e => e.preventDefault()); | |
| addEventListener('dragleave', e => { if (--depth <= 0) { depth = 0; document.body.classList.remove('drag'); } }); | |
| addEventListener('drop', async e => { | |
| e.preventDefault(); | |
| depth = 0; | |
| document.body.classList.remove('drag'); | |
| for (const f of e.dataTransfer.files) { | |
| if (f.size > 8 * 1024 * 1024) { log.textContent = 'too big (' + (f.size/1048576).toFixed(1) + ' MB, max 8 MB): ' + f.name; continue; } | |
| const fd = new FormData(); | |
| fd.append('image', f); | |
| log.textContent = 'uploading ' + f.name + '…'; | |
| const res = await fetch('/upload', { method: 'POST', body: fd }); | |
| if (!res.ok) { log.textContent = 'upload failed: ' + res.status + ' ' + await res.text(); continue; } | |
| const url = await res.text(); | |
| try { await navigator.clipboard.writeText(url); log.textContent = 'copied: ' + url; } | |
| catch { log.textContent = url + ' (clipboard blocked — copy manually)'; } | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html>`; | |
| const RATE_WINDOW_MS = 60 * 1000; | |
| const RATE_MAX = 5; | |
| const hits = new Map<string, number[]>(); | |
| function clientIP(req: Request, server: { requestIP(r: Request): { address: string } | null }): string { | |
| const xff = req.headers.get("x-forwarded-for"); | |
| if (xff) return xff.split(",")[0].trim(); | |
| return server.requestIP(req)?.address ?? "unknown"; | |
| } | |
| function checkRate(ip: string): number { | |
| const now = Date.now(); | |
| const arr = (hits.get(ip) ?? []).filter(t => now - t < RATE_WINDOW_MS); | |
| if (arr.length >= RATE_MAX) { | |
| hits.set(ip, arr); | |
| return Math.ceil((RATE_WINDOW_MS - (now - arr[0])) / 1000); | |
| } | |
| arr.push(now); | |
| hits.set(ip, arr); | |
| return 0; | |
| } | |
| setInterval(() => { | |
| const now = Date.now(); | |
| for (const [ip, arr] of hits) { | |
| const kept = arr.filter(t => now - t < RATE_WINDOW_MS); | |
| if (kept.length === 0) hits.delete(ip); | |
| else if (kept.length !== arr.length) hits.set(ip, kept); | |
| } | |
| }, RATE_WINDOW_MS).unref?.(); | |
| type FileType = { ext: string; contentType: string }; | |
| const FILE_TYPES = { | |
| gif: { ext: ".gif", contentType: "image/gif" }, | |
| jpg: { ext: ".jpg", contentType: "image/jpeg" }, | |
| pdf: { ext: ".pdf", contentType: "application/pdf" }, | |
| png: { ext: ".png", contentType: "image/png" }, | |
| svg: { ext: ".svg", contentType: "image/svg+xml; charset=utf-8" }, | |
| webp: { ext: ".webp", contentType: "image/webp" }, | |
| } as const satisfies Record<string, FileType>; | |
| function ascii(bytes: Uint8Array, start: number, text: string): boolean { | |
| if (bytes.length < start + text.length) return false; | |
| for (let i = 0; i < text.length; i++) { | |
| if (bytes[start + i] !== text.charCodeAt(i)) return false; | |
| } | |
| return true; | |
| } | |
| function looksLikeSvg(bytes: Uint8Array): boolean { | |
| let text = new TextDecoder("utf-8").decode(bytes).replace(/^\ufeff/, "").trimStart().toLowerCase(); | |
| if (text.startsWith("<?xml")) { | |
| const end = text.indexOf("?>"); | |
| if (end === -1) return false; | |
| text = text.slice(end + 2).trimStart(); | |
| } | |
| while (text.startsWith("<!--")) { | |
| const end = text.indexOf("-->"); | |
| if (end === -1) return false; | |
| text = text.slice(end + 3).trimStart(); | |
| } | |
| if (text.startsWith("<!doctype")) { | |
| if (!/^<!doctype\s+svg(?:\s|>)/.test(text)) return false; | |
| const end = text.indexOf(">"); | |
| if (end === -1) return false; | |
| text = text.slice(end + 1).trimStart(); | |
| } | |
| return text.startsWith("<svg"); | |
| } | |
| async function detectFileType(file: File): Promise<FileType | null> { | |
| const bytes = new Uint8Array(await file.slice(0, MAGIC_BYTES).arrayBuffer()); | |
| if ( | |
| bytes.length >= 8 && | |
| bytes[0] === 0x89 && | |
| bytes[1] === 0x50 && | |
| bytes[2] === 0x4e && | |
| bytes[3] === 0x47 && | |
| bytes[4] === 0x0d && | |
| bytes[5] === 0x0a && | |
| bytes[6] === 0x1a && | |
| bytes[7] === 0x0a | |
| ) return FILE_TYPES.png; | |
| if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { | |
| return FILE_TYPES.jpg; | |
| } | |
| if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return FILE_TYPES.gif; | |
| if (ascii(bytes, 0, "%PDF-")) return FILE_TYPES.pdf; | |
| if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return FILE_TYPES.webp; | |
| if (looksLikeSvg(bytes)) return FILE_TYPES.svg; | |
| return null; | |
| } | |
| function contentTypeForName(name: string): string { | |
| switch (extname(name).toLowerCase()) { | |
| case ".gif": return FILE_TYPES.gif.contentType; | |
| case ".jpg": | |
| case ".jpeg": return FILE_TYPES.jpg.contentType; | |
| case ".pdf": return FILE_TYPES.pdf.contentType; | |
| case ".png": return FILE_TYPES.png.contentType; | |
| case ".svg": return FILE_TYPES.svg.contentType; | |
| case ".webp": return FILE_TYPES.webp.contentType; | |
| default: return "application/octet-stream"; | |
| } | |
| } | |
| const FILE_HEADERS = { | |
| "x-content-type-options": "nosniff", | |
| "cache-control": "no-store", | |
| "content-security-policy": "default-src 'none'; sandbox", | |
| }; | |
| Bun.serve({ | |
| port: PORT, | |
| hostname: HOST, | |
| idleTimeout: 60, | |
| maxRequestBodySize: MAX_BYTES + 64 * 1024, | |
| async fetch(req, server) { | |
| const url = new URL(req.url); | |
| if (req.method === "GET" && url.pathname === "/") { | |
| return new Response(html, { | |
| headers: { | |
| "content-type": "text/html; charset=utf-8", | |
| "cache-control": "no-store", | |
| }, | |
| }); | |
| } | |
| if (req.method === "POST" && url.pathname === "/upload") { | |
| const ip = clientIP(req, server); | |
| const retry = checkRate(ip); | |
| if (retry > 0) { | |
| return new Response(`rate limit: try again in ${retry}s`, { | |
| status: 429, | |
| headers: { "retry-after": String(retry) }, | |
| }); | |
| } | |
| let form; | |
| try { form = await req.formData(); } | |
| catch { return new Response("too large or malformed", { status: 413 }); } | |
| const file = form.get("image"); | |
| if (!(file instanceof File)) return new Response("no file", { status: 400 }); | |
| if (file.size > MAX_BYTES) return new Response("too large", { status: 413 }); | |
| const type = await detectFileType(file); | |
| if (!type) return new Response("unsupported file type", { status: 415 }); | |
| await makeRoomFor(file.size); | |
| const name = crypto.randomUUID() + type.ext; | |
| const path = join(DIR, name); | |
| await Bun.write(path, file); | |
| await makeRoomFor(0); | |
| scheduleUnlink(path, Date.now() + TTL_MS); | |
| const publicUrl = `${PUBLIC_URL ?? `${url.protocol}//${url.host}`}/i/${name}`; | |
| return new Response(publicUrl, { headers: { "content-type": "text/plain" } }); | |
| } | |
| if (req.method === "GET" && url.pathname.startsWith("/i/")) { | |
| let name: string; | |
| try { name = decodeURIComponent(url.pathname.slice(3)); } | |
| catch { return new Response("bad name", { status: 400 }); } | |
| if (!name || name.includes("/") || name.includes("\\") || name.includes("\0") || name === "." || name === "..") { | |
| return new Response("bad name", { status: 400 }); | |
| } | |
| const full = resolve(DIR, name); | |
| if (full !== join(DIR, name) || !full.startsWith(DIR + sep)) { | |
| return new Response("bad path", { status: 400 }); | |
| } | |
| const f = Bun.file(full); | |
| if (!(await f.exists())) return new Response("not found", { status: 404 }); | |
| return new Response(f, { headers: { ...FILE_HEADERS, "content-type": contentTypeForName(name) } }); | |
| } | |
| return new Response("not found", { status: 404 }); | |
| }, | |
| }); | |
| console.log(`drop server on http://${HOST ?? "localhost"}:${PORT} → ${DIR} (TTL ${TTL_MS / 1000}s)`); | |
| console.log(`public URL: ${PUBLIC_URL ?? "derived from request"}`); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is handy for running on exe.dev VMs (or locally and exposed via Tailscale/similar) for quick image sharing with remote agents.