VYBECODEDDDDDDDDD

This commit is contained in:
Anaïs Saraiva
2026-08-15 00:20:45 +02:00
parent 1a2c8aaf7c
commit 5f201cf725
23 changed files with 2263 additions and 2 deletions
+74
View File
@@ -0,0 +1,74 @@
import { resolve } from "node:path";
export interface Config {
host: string;
port: number;
storageDir: string;
maxFileSize: number;
rateLimitEnabled: boolean;
rateLimitMax: number;
rateLimitWindowMs: number;
trustProxy: boolean;
}
const SIZE_UNITS: Record<string, number> = {
B: 1,
K: 1 << 10,
KB: 1 << 10,
KIB: 1 << 10,
M: 1 << 20,
MB: 1 << 20,
MIB: 1 << 20,
G: 1 << 30,
GB: 1 << 30,
GIB: 1 << 30,
T: 1 << 40,
TB: 1 << 40,
TIB: 1 << 40,
};
export function parseSize(value: string): number {
const match = /^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$/.exec(value);
if (!match) throw new Error(`Invalid size value: "${value}"`);
const num = parseFloat(match[1]!);
const unit = (match[2] || "B").toUpperCase();
const factor = SIZE_UNITS[unit];
if (factor === undefined) throw new Error(`Invalid size unit in: "${value}"`);
return Math.floor(num * factor);
}
function boolFromEnv(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const port = Number(env.PORT ?? "3000");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid PORT: ${env.PORT}`);
}
const host = env.HOST ?? "0.0.0.0";
const storageDir = resolve(env.STORAGE_DIR ?? "./storage");
const maxFileSize = parseSize(env.MAX_FILE_SIZE ?? "5GB");
if (maxFileSize < 1024) throw new Error("MAX_FILE_SIZE must be at least 1KB");
const rateLimitMax = Number(env.RATE_LIMIT_MAX ?? "300");
if (!Number.isInteger(rateLimitMax) || rateLimitMax < 1) {
throw new Error(`Invalid RATE_LIMIT_MAX: ${env.RATE_LIMIT_MAX}`);
}
const rateLimitWindowMs = Number(env.RATE_LIMIT_WINDOW_MS ?? "60000");
if (!Number.isInteger(rateLimitWindowMs) || rateLimitWindowMs < 1000) {
throw new Error(`Invalid RATE_LIMIT_WINDOW_MS: ${env.RATE_LIMIT_WINDOW_MS}`);
}
return {
host,
port,
storageDir,
maxFileSize,
rateLimitEnabled: boolFromEnv(env.RATE_LIMIT_ENABLED, true),
rateLimitMax,
rateLimitWindowMs,
trustProxy: boolFromEnv(env.TRUST_PROXY, false),
};
}
+21
View File
@@ -0,0 +1,21 @@
export function securityHeaders(): Record<string, string> {
return {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
};
}
export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8", ...securityHeaders(), ...headers },
});
}
export function jsonError(status: number, message: string): Response {
return json({ error: message }, status);
}
export function notFound(): Response {
return jsonError(404, "not found");
}
+10
View File
@@ -0,0 +1,10 @@
export type LogLevel = "info" | "warn" | "error";
export function log(level: LogLevel, message: string): void {
const line = `[${new Date().toISOString()}] [${level}] ${message}`;
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
}
+153
View File
@@ -0,0 +1,153 @@
const CR = 0x0d;
const LF = 0x0a;
const MAX_HEADER_LINE_BYTES = 64 * 1024;
export interface MultipartFilePart {
filename: string;
contentType: string;
chunks: AsyncIterable<Uint8Array>;
}
export class MultipartError extends Error {
constructor(message: string) {
super(message);
this.name = "MultipartError";
}
}
export function getBoundary(contentType: string): string | null {
const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
return match?.[1] ?? match?.[2] ?? null;
}
async function* streamChunks(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
const reader = stream.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return;
if (value && value.byteLength > 0) yield value;
}
} finally {
reader.releaseLock();
}
}
export async function* multipartParts(
body: ReadableStream<Uint8Array>,
boundary: string,
): AsyncGenerator<MultipartFilePart> {
const DELIMITER = Buffer.from(`--${boundary}`);
const CRLF_DELIMITER = Buffer.concat([Buffer.from("\r\n"), DELIMITER]);
const FINAL_MARKER = `--${boundary}--`;
let buffer = Buffer.alloc(0);
let eof = false;
const source = streamChunks(body);
const readMore = async (): Promise<void> => {
if (eof) return;
const { done, value } = await source.next();
if (done) {
eof = true;
return;
}
if (value) buffer = Buffer.concat([buffer, Buffer.from(value)]);
};
const readLine = async (): Promise<Buffer | null> => {
for (;;) {
const lf = buffer.indexOf(LF);
if (lf !== -1) {
if (lf > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long");
let line = buffer.subarray(0, lf);
buffer = buffer.subarray(lf + 1);
if (line.byteLength > 0 && line[line.byteLength - 1] === CR) {
line = line.subarray(0, line.byteLength - 1);
}
return line;
}
if (buffer.byteLength > MAX_HEADER_LINE_BYTES) throw new MultipartError("header line too long");
if (eof) return buffer.byteLength > 0 ? buffer : null;
await readMore();
}
};
const contentChunks = async function* (): AsyncGenerator<Uint8Array> {
for (;;) {
const idx = buffer.indexOf(CRLF_DELIMITER);
if (idx !== -1) {
if (idx > 0) yield buffer.subarray(0, idx);
buffer = buffer.subarray(idx + 2);
return;
}
const keep = Math.min(CRLF_DELIMITER.byteLength - 1, buffer.byteLength);
const emit = buffer.byteLength - keep;
if (emit > 0) {
yield buffer.subarray(0, emit);
buffer = buffer.subarray(emit);
}
if (eof) {
if (buffer.byteLength > 0) yield buffer;
buffer = Buffer.alloc(0);
return;
}
await readMore();
}
};
for (;;) {
const line = await readLine();
if (line === null) throw new MultipartError("no multipart boundary found");
if (line.equals(DELIMITER)) break;
}
for (;;) {
const headers = new Map<string, string>();
for (;;) {
const line = await readLine();
if (line === null) return;
if (line.byteLength === 0) break;
const text = line.toString("utf8");
const colon = text.indexOf(":");
if (colon > 0) {
headers.set(text.slice(0, colon).trim().toLowerCase(), text.slice(colon + 1).trim());
}
}
const disposition = headers.get("content-disposition") ?? "";
let filename: string | undefined;
const star = /filename\*\s*=\s*UTF-8''([^\s;]+)/i.exec(disposition);
if (star?.[1]) {
try {
filename = decodeURIComponent(star[1]);
} catch {
filename = star[1];
}
} else {
const quoted = /filename\s*=\s*"([^"]*)"/i.exec(disposition);
filename = quoted?.[1];
}
if (!filename) {
for await (const _ of contentChunks()) {
void _;
}
} else {
yield {
filename,
contentType: headers.get("content-type") ?? "application/octet-stream",
chunks: contentChunks(),
};
}
for (;;) {
const marker = await readLine();
if (marker === null) return;
const markerText = marker.toString("utf8");
if (markerText === FINAL_MARKER) return;
if (markerText !== `--${boundary}`) throw new MultipartError("malformed multipart boundary");
break;
}
}
}
+384
View File
@@ -0,0 +1,384 @@
"use strict";
const $ = (id) => document.getElementById(id);
const STORAGE_KEY = "filehost.uploads";
const dropzone = $("dropzone");
const fileInput = $("fileInput");
const progress = $("progress");
const progressBar = $("progressBar");
const progressPct = $("progressPct");
const progressName = $("progressName");
const result = $("result");
const resultUrl = $("resultUrl");
const resultName = $("resultName");
const resultSize = $("resultSize");
const copyBtn = $("copyBtn");
const deleteBtn = $("deleteBtn");
const againBtn = $("againBtn");
const notice = $("notice");
const fileListWrap = $("fileListWrap");
const fileList = $("fileList");
const clearListBtn = $("clearListBtn");
let current = null;
let uploading = false;
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes;
let i = -1;
do {
value /= 1024;
i += 1;
} while (value >= 1024 && i < units.length - 1);
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
}
function showNotice(message, kind) {
notice.textContent = message;
notice.className = kind;
}
function clearNotice() {
notice.className = "hidden";
notice.textContent = "";
}
function setProgress(loaded, total) {
const pct = total > 0 ? Math.round((loaded / total) * 100) : 0;
progressBar.style.width = `${pct}%`;
progressPct.textContent = `${pct}%`;
}
function reset() {
uploading = false;
current = null;
result.classList.add("hidden");
progress.classList.add("hidden");
progressBar.style.width = "0%";
progressPct.textContent = "0%";
fileInput.value = "";
clearNotice();
}
// ---- client-side upload history (localStorage) ----
function loadUploads() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveUploads(list) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch {
/* storage unavailable (e.g. private mode) — ignore */
}
}
function addUpload(record) {
const list = loadUploads();
const idx = list.findIndex((item) => item.id === record.id);
if (idx !== -1) list[idx] = record;
else list.unshift(record);
saveUploads(list);
renderList();
}
function removeUpload(id) {
saveUploads(loadUploads().filter((item) => item.id !== id));
renderList();
}
function downloadUrl(item) {
return new URL(item.url, window.location.origin).href;
}
async function copyText(text) {
const fallback = () => {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
ta.remove();
return ok;
};
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return fallback();
}
}
return fallback();
}
function setCopyFeedback(btn, label) {
const original = btn.textContent;
btn.textContent = label;
setTimeout(() => {
btn.textContent = original;
}, 1500);
}
function renderList() {
const list = loadUploads();
if (list.length === 0) {
fileListWrap.classList.add("hidden");
fileList.innerHTML = "";
return;
}
fileListWrap.classList.remove("hidden");
fileList.innerHTML = "";
for (const item of list) {
const li = document.createElement("li");
li.className = "file-item";
const meta = document.createElement("div");
meta.className = "file-item-meta";
const name = document.createElement("span");
name.className = "file-item-name";
name.textContent = item.originalName;
name.title = item.originalName;
const sub = document.createElement("span");
sub.className = "file-item-sub";
let subText = formatBytes(item.size || 0);
if (item.createdAt) {
try {
subText += ` \u00b7 ${new Date(item.createdAt).toLocaleString()}`;
} catch {
/* ignore date formatting */
}
}
sub.textContent = subText;
meta.appendChild(name);
meta.appendChild(sub);
const actions = document.createElement("div");
actions.className = "file-item-actions";
const dl = document.createElement("a");
dl.className = "btn-link";
dl.href = downloadUrl(item);
dl.textContent = "Download";
dl.setAttribute("download", "");
dl.title = "Open download link";
const copy = document.createElement("button");
copy.type = "button";
copy.textContent = "Copy";
copy.addEventListener("click", async () => {
const ok = await copyText(downloadUrl(item));
setCopyFeedback(copy, ok ? "Copied" : "Copy failed");
});
const del = document.createElement("button");
del.type = "button";
del.className = "danger";
del.textContent = "Delete";
del.addEventListener("click", () => deleteFile(item, () => removeUpload(item.id)));
actions.appendChild(dl);
actions.appendChild(copy);
actions.appendChild(del);
li.appendChild(meta);
li.appendChild(actions);
fileList.appendChild(li);
}
}
async function deleteFile(item, onSuccess) {
const ok = window.confirm(`Delete "${item.originalName}" permanently? This cannot be undone.`);
if (!ok) return;
try {
const res = await fetch(`/api/files/${item.id}`, {
method: "DELETE",
headers: { "X-Delete-Key": item.key },
});
if (res.ok) {
onSuccess();
showNotice(`"${item.originalName}" was deleted.`, "info");
} else {
let message = "Deletion failed.";
try {
const data = await res.json();
if (data && data.error) message = data.error;
} catch {
/* keep fallback message */
}
showNotice(message, "error");
}
} catch {
showNotice("Deletion failed: network error.", "error");
}
}
// ---- upload flow ----
function showResult(data) {
progress.classList.add("hidden");
result.classList.remove("hidden");
resultName.textContent = data.originalName;
resultSize.textContent = formatBytes(data.size);
resultUrl.value = downloadUrl(data);
current = data;
addUpload({
id: data.id,
key: data.key,
url: data.url,
originalName: data.originalName,
size: data.size,
createdAt: new Date().toISOString(),
});
}
function startUpload(file) {
if (uploading) return;
clearNotice();
progress.classList.remove("hidden");
progressName.textContent = file.name;
setProgress(0, file.size);
uploading = true;
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append("file", file);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) setProgress(e.loaded, e.total);
});
xhr.addEventListener("load", () => {
uploading = false;
if (xhr.status >= 200 && xhr.status < 300) {
try {
showResult(JSON.parse(xhr.responseText));
} catch {
progress.classList.add("hidden");
showNotice("Invalid server response.", "error");
}
return;
}
progress.classList.add("hidden");
let message = `Upload failed (HTTP ${xhr.status})`;
try {
const data = JSON.parse(xhr.responseText);
if (data && data.error) message = data.error;
} catch {
/* keep fallback message */
}
showNotice(message, "error");
});
xhr.addEventListener("error", () => {
uploading = false;
progress.classList.add("hidden");
showNotice("Network error during upload.", "error");
});
xhr.addEventListener("abort", () => {
uploading = false;
progress.classList.add("hidden");
});
xhr.open("POST", "/api/upload");
xhr.send(form);
}
function handleFile(file) {
if (!file) return;
if (file.size === 0) {
showNotice("Empty files are not supported.", "error");
return;
}
startUpload(file);
}
dropzone.addEventListener("click", () => {
if (!uploading) fileInput.click();
});
dropzone.addEventListener("keydown", (e) => {
if ((e.key === "Enter" || e.key === " ") && !uploading) {
e.preventDefault();
fileInput.click();
}
});
fileInput.addEventListener("change", () => {
const file = fileInput.files ? fileInput.files[0] : null;
handleFile(file);
});
let dragDepth = 0;
document.addEventListener("dragenter", (e) => {
e.preventDefault();
if (!uploading) {
dragDepth += 1;
dropzone.classList.add("drag");
}
});
document.addEventListener("dragover", (e) => e.preventDefault());
document.addEventListener("dragleave", (e) => {
e.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) dropzone.classList.remove("drag");
});
document.addEventListener("drop", (e) => {
e.preventDefault();
dragDepth = 0;
dropzone.classList.remove("drag");
if (uploading) return;
const file = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null;
if (file) handleFile(file);
});
copyBtn.addEventListener("click", async () => {
const ok = await copyText(resultUrl.value);
setCopyFeedback(copyBtn, ok ? "Copied" : "Copy failed");
});
deleteBtn.addEventListener("click", () => {
if (!current) return;
deleteFile(current, () => {
removeUpload(current.id);
reset();
});
});
againBtn.addEventListener("click", reset);
clearListBtn.addEventListener("click", () => {
if (loadUploads().length === 0) return;
const ok = window.confirm(
"Clear this list? This only removes the entries from THIS browser. The uploaded files remain on the server.",
);
if (!ok) return;
saveUploads([]);
renderList();
showNotice("List cleared. Files on the server were not touched.", "info");
});
renderList();
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="data:," />
<title>File drop</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main class="card">
<h1>File drop</h1>
<p class="tagline">Drop a file to get a private download link. Files are kept forever.</p>
<div id="dropzone" class="dropzone" role="button" tabindex="0" aria-label="Upload a file">
<input type="file" id="fileInput" hidden />
<p class="dz-main">Drag &amp; drop a file here</p>
<p class="dz-sub">or click to choose</p>
</div>
<div id="progress" class="progress hidden">
<div class="progress-label">
<span id="progressName"></span>
<span id="progressPct">0%</span>
</div>
<div class="progress-track">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<div id="result" class="result hidden">
<div class="result-meta">
<strong id="resultName"></strong>
<span id="resultSize"></span>
</div>
<div class="url-row">
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
<button id="copyBtn" type="button">Copy</button>
</div>
<div class="result-actions">
<button id="deleteBtn" type="button" class="danger">Delete file</button>
<button id="againBtn" type="button" class="ghost">Upload another</button>
</div>
</div>
<div id="notice" class="hidden"></div>
<section id="fileListWrap" class="files hidden" aria-label="Your uploaded files">
<div class="files-head">
<h2>Your files</h2>
<button id="clearListBtn" type="button" class="ghost">Clear list</button>
</div>
<p class="files-note">
Stored locally in this browser only. Other visitors cannot see them.
</p>
<ul id="fileList"></ul>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
:root {
--bg: #0f1115;
--card: #171a21;
--border: #2a2f3a;
--text: #e6e8ee;
--muted: #9aa3b2;
--accent: #4f8cff;
--accent-hover: #3a74e0;
--danger: #e05252;
--ok: #46b076;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.card {
width: 100%;
max-width: 560px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
h1 {
margin: 0 0 4px;
font-size: 24px;
}
.tagline {
margin: 0 0 20px;
color: var(--muted);
font-size: 14px;
}
.dropzone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
outline: none;
}
.dropzone:hover,
.dropzone.drag,
.dropzone:focus-visible {
border-color: var(--accent);
background: rgba(79, 140, 255, 0.06);
}
.dz-main {
margin: 0;
font-weight: 600;
}
.dz-sub {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.progress {
margin-top: 20px;
}
.progress-label {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.progress-label span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-track {
height: 8px;
background: var(--border);
border-radius: 99px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: var(--accent);
border-radius: 99px;
transition: width 0.15s;
}
.result {
margin-top: 20px;
}
.result-meta {
font-size: 14px;
margin-bottom: 10px;
color: var(--muted);
word-break: break-all;
}
.result-meta strong {
color: var(--text);
font-weight: 600;
}
.url-row {
display: flex;
gap: 8px;
}
.url-row input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.result-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
button {
font: inherit;
cursor: pointer;
border: none;
border-radius: 8px;
padding: 9px 14px;
font-size: 13px;
background: var(--accent);
color: #fff;
transition: background 0.15s;
}
button:hover {
background: var(--accent-hover);
}
button.danger {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
}
button.danger:hover {
background: rgba(224, 82, 82, 0.1);
}
button.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
button.ghost:hover {
color: var(--text);
border-color: var(--muted);
}
#notice {
margin-top: 16px;
font-size: 13px;
border-radius: 8px;
padding: 10px 12px;
}
#notice.error {
color: var(--danger);
background: rgba(224, 82, 82, 0.08);
border: 1px solid rgba(224, 82, 82, 0.3);
}
#notice.info {
color: var(--ok);
background: rgba(70, 176, 118, 0.08);
border: 1px solid rgba(70, 176, 118, 0.3);
}
.hidden {
display: none !important;
}
/* Client-side upload list */
.files {
margin-top: 24px;
border-top: 1px solid var(--border);
padding-top: 18px;
}
.files-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.files-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.files-note {
margin: 6px 0 12px;
font-size: 12px;
color: var(--muted);
}
#fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.file-item-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-item-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item-sub {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.file-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.btn-link {
font: inherit;
font-size: 13px;
color: #fff;
background: var(--accent);
border-radius: 8px;
padding: 8px 12px;
text-decoration: none;
transition: background 0.15s;
}
.btn-link:hover {
background: var(--accent-hover);
}
.file-item-actions button {
font-size: 12px;
padding: 8px 10px;
}
@media (max-width: 480px) {
.card {
padding: 20px;
}
.dropzone {
padding: 28px 16px;
}
.url-row {
flex-direction: column;
}
.file-item {
flex-direction: column;
align-items: stretch;
}
.file-item-actions {
justify-content: flex-end;
}
}
+29
View File
@@ -0,0 +1,29 @@
interface Bucket {
count: number;
resetAt: number;
}
export class RateLimiter {
private buckets = new Map<string, Bucket>();
constructor(
private readonly max: number,
private readonly windowMs: number,
) {}
check(ip: string): boolean {
const now = Date.now();
if (this.buckets.size > 10_000) this.prune(now);
const existing = this.buckets.get(ip);
const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + this.windowMs };
if (!existing || bucket !== existing) this.buckets.set(ip, bucket);
bucket.count++;
return bucket.count <= this.max;
}
private prune(now: number): void {
for (const [ip, bucket] of this.buckets) {
if (bucket.resetAt <= now) this.buckets.delete(ip);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { json, notFound } from "../http";
import { log } from "../log";
import { ID_PATTERN, KEY_PATTERN, secretMatches } from "../security";
import type { Storage } from "../storage";
export async function handleDelete(
req: Request,
storage: Storage,
rawId: string,
): Promise<Response> {
if (!ID_PATTERN.test(rawId)) return notFound();
const meta = await storage.readMetadata(rawId);
if (!meta) return notFound();
const key = req.headers.get("x-delete-key");
if (!key || !KEY_PATTERN.test(key)) return notFound();
if (!secretMatches(meta.downloadKeyHash, key)) return notFound();
await storage.delete(rawId);
log("info", `deleted id=${rawId} name=${JSON.stringify(meta.originalName)}`);
return json({ ok: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { lstat } from "node:fs/promises";
import { notFound } from "../http";
import { log } from "../log";
import { contentDisposition, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security";
import type { Storage } from "../storage";
export async function handleDownload(
req: Request,
storage: Storage,
rawKey: string,
): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound();
const id = fileIdFromKey(rawKey);
if (!id) return notFound();
const meta = await storage.readMetadata(id);
if (!meta) return notFound();
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound();
const filePath = storage.filePath(id);
let fileStat;
try {
fileStat = await lstat(filePath);
} catch {
return notFound();
}
if (!fileStat.isFile()) return notFound();
const headers = new Headers({
"content-type": meta.mimeType || "application/octet-stream",
"content-length": String(fileStat.size),
"content-disposition": contentDisposition(meta.originalName),
"cache-control": "private, no-store",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
});
if (req.method === "HEAD") return new Response(null, { status: 200, headers });
log("info", `download id=${id} size=${fileStat.size}`);
return new Response(Bun.file(filePath).stream(), { status: 200, headers });
}
+66
View File
@@ -0,0 +1,66 @@
import { json, jsonError } from "../http";
import { log } from "../log";
import { getBoundary, MultipartError, multipartParts } from "../multipart";
import { hashSecret, newDownloadKey, newFileId, sanitizeMime, sanitizeOriginalName } from "../security";
import { UploadTooLargeError, type Storage } from "../storage";
export async function handleUpload(
req: Request,
storage: Storage,
maxFileSize: number,
): Promise<Response> {
const contentType = req.headers.get("content-type");
const boundary = contentType ? getBoundary(contentType) : null;
if (!boundary || !req.body) return jsonError(400, "expected a multipart/form-data upload");
const id = newFileId();
const key = id + newDownloadKey();
try {
let filename = "";
let mime = "application/octet-stream";
let size = 0;
let hasFile = false;
for await (const part of multipartParts(req.body, boundary)) {
if (!hasFile) {
hasFile = true;
filename = part.filename;
mime = sanitizeMime(part.contentType);
size = await storage.saveFile(id, part.chunks, maxFileSize);
break;
}
for await (const _ of part.chunks) {
void _;
}
}
if (!hasFile) return jsonError(400, "no file part in the upload");
const originalName = sanitizeOriginalName(filename);
await storage.writeMetadata({
id,
originalName,
size,
mimeType: mime,
downloadKeyHash: hashSecret(key),
createdAt: new Date().toISOString(),
});
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
return json({ id, key, url: `/d/${key}`, originalName, size });
} catch (err) {
await storage.delete(id);
await storage.deletePartial(id);
if (err instanceof UploadTooLargeError) {
log("warn", `upload rejected id=${id} exceeding max size ${maxFileSize}`);
return jsonError(413, "file too large");
}
if (err instanceof MultipartError) {
log("warn", `upload rejected id=${id}: ${err.message}`);
return jsonError(400, "invalid multipart body");
}
log("error", `upload failed id=${id}: ${err instanceof Error ? err.message : String(err)}`);
return jsonError(500, "upload failed");
}
}
+56
View File
@@ -0,0 +1,56 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export const ID_BYTES = 16;
export const KEY_BYTES = 32;
export const ID_LENGTH = 22;
export const KEY_LENGTH = 65;
export const ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
export const KEY_PATTERN = /^[A-Za-z0-9_-]{65}$/;
export function randomToken(byteCount: number): string {
return randomBytes(byteCount).toString("base64url");
}
export function newFileId(): string {
return randomToken(ID_BYTES);
}
export function newDownloadKey(): string {
return randomToken(KEY_BYTES);
}
export function hashSecret(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
export function secretMatches(storedHash: string, candidate: string): boolean {
const stored = Buffer.from(storedHash, "hex");
const computed = Buffer.from(hashSecret(candidate), "hex");
if (stored.length !== computed.length) return false;
return timingSafeEqual(stored, computed);
}
export function fileIdFromKey(key: string): string | null {
if (key.length !== KEY_LENGTH) return null;
return key.slice(0, ID_LENGTH);
}
export function sanitizeOriginalName(name: string): string {
const base = name.replace(/\\/g, "/").split("/").pop() ?? "";
const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "_").trim();
return cleaned.length > 0 ? cleaned : "file";
}
export function sanitizeMime(value: string): string {
const cleaned = value.replace(/[\r\n"\\]/g, "").trim();
return cleaned.length > 0 && cleaned.length <= 200 ? cleaned : "application/octet-stream";
}
export function contentDisposition(name: string): string {
const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) =>
"%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}
+127
View File
@@ -0,0 +1,127 @@
import type { Server } from "bun";
import { loadConfig } from "./config";
import { json, jsonError, notFound, securityHeaders } from "./http";
import { log } from "./log";
import { RateLimiter } from "./ratelimit";
import { handleDelete } from "./routes/delete";
import { handleDownload } from "./routes/download";
import { handleUpload } from "./routes/upload";
import { Storage } from "./storage";
const cfg = loadConfig();
const storage = new Storage(cfg.storageDir);
await storage.init();
const sweep = await storage.sweep();
if (sweep.removedFiles > 0 || sweep.removedMetadata > 0) {
log(
"info",
`startup sweep removed ${sweep.removedFiles} orphaned file(s), ${sweep.removedMetadata} orphaned metadata record(s)`,
);
}
const limiter = new RateLimiter(cfg.rateLimitMax, cfg.rateLimitWindowMs);
const startedAt = Date.now();
const STATIC_ROUTES: Record<string, { file: string; type: string }> = {
"/": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" },
"/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" },
"/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" },
};
const staticContent = new Map<string, { body: string; type: string }>();
for (const [route, info] of Object.entries(STATIC_ROUTES)) {
staticContent.set(route, {
body: await Bun.file(new URL(info.file, import.meta.url)).text(),
type: info.type,
});
}
const server = Bun.serve({
hostname: cfg.host,
port: cfg.port,
maxRequestBodySize: Infinity,
fetch(req, srv) {
return route(req, srv).catch((err) => {
log("error", `unhandled error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
return jsonError(500, "internal error");
});
},
});
function clientIp(req: Request, srv: Server<undefined>): string {
if (cfg.trustProxy) {
const xff = req.headers.get("x-forwarded-for");
if (xff) {
const first = xff.split(",")[0]?.trim();
if (first) return first;
}
const cf = req.headers.get("cf-connecting-ip");
if (cf) return cf;
}
return srv.requestIP(req)?.address ?? "unknown";
}
function serveStatic(path: string): Response {
const entry = staticContent.get(path);
if (!entry) return notFound();
return new Response(entry.body, {
headers: {
"content-type": entry.type,
"cache-control": "no-cache",
"content-security-policy":
"default-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
...securityHeaders(),
},
});
}
async function route(req: Request, srv: Server<undefined>): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method;
if (method === "GET" && staticContent.has(path)) return serveStatic(path);
if (path === "/api/health" && method === "GET") {
return json({ status: "ok", uptimeSeconds: Math.round((Date.now() - startedAt) / 1000) });
}
if (path === "/api/upload" && method === "POST") {
return handleUpload(req, storage, cfg.maxFileSize);
}
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests");
}
return handleDownload(req, storage, path.slice(3));
}
if (path.startsWith("/api/files/") && method === "DELETE") {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests");
}
return handleDelete(req, storage, path.slice("/api/files/".length));
}
return notFound();
}
log(
"info",
`listening on http://${cfg.host}:${cfg.port} storage=${cfg.storageDir} maxUpload=${cfg.maxFileSize}`,
);
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
log("info", `received ${signal}, shutting down`);
server.stop();
process.exit(0);
});
}
process.on("unhandledRejection", (reason) => {
log("error", `unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
});
+142
View File
@@ -0,0 +1,142 @@
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { ID_PATTERN } from "./security";
export interface FileMeta {
id: string;
originalName: string;
size: number;
mimeType: string;
downloadKeyHash: string;
createdAt: string;
}
export class UploadTooLargeError extends Error {
readonly maxBytes: number;
constructor(maxBytes: number) {
super("file exceeds the maximum upload size");
this.name = "UploadTooLargeError";
this.maxBytes = maxBytes;
}
}
export class Storage {
readonly filesDir: string;
readonly metadataDir: string;
constructor(readonly root: string) {
this.filesDir = join(root, "files");
this.metadataDir = join(root, "metadata");
}
async init(): Promise<void> {
await mkdir(this.filesDir, { recursive: true });
await mkdir(this.metadataDir, { recursive: true });
}
filePath(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error("invalid file id");
return join(this.filesDir, id);
}
private metaPath(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error("invalid file id");
return join(this.metadataDir, `${id}.json`);
}
async saveFile(id: string, chunks: AsyncIterable<Uint8Array>, maxBytes: number): Promise<number> {
const partPath = join(this.filesDir, `${id}.part`);
const sink = Bun.file(partPath).writer();
let size = 0;
let sinceFlush = 0;
try {
for await (const chunk of chunks) {
size += chunk.byteLength;
if (size > maxBytes) throw new UploadTooLargeError(maxBytes);
sink.write(chunk);
sinceFlush += chunk.byteLength;
if (sinceFlush >= 4 * 1024 * 1024) {
await sink.flush();
sinceFlush = 0;
}
}
await sink.end();
} catch (err) {
const ended = sink.end();
if (ended instanceof Promise) await ended.catch(() => {});
throw err;
}
await rename(partPath, this.filePath(id));
return size;
}
async writeMetadata(meta: FileMeta): Promise<void> {
const target = this.metaPath(meta.id);
const tmp = `${target}.tmp`;
await writeFile(tmp, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
await rename(tmp, target);
}
async readMetadata(id: string): Promise<FileMeta | null> {
try {
const raw = await readFile(this.metaPath(id), "utf8");
const meta = JSON.parse(raw) as Partial<FileMeta>;
if (typeof meta.id !== "string" || typeof meta.downloadKeyHash !== "string") return null;
return meta as FileMeta;
} catch {
return null;
}
}
async delete(id: string): Promise<void> {
await rm(this.filePath(id), { force: true });
await rm(this.metaPath(id), { force: true });
}
async deletePartial(id: string): Promise<void> {
await rm(join(this.filesDir, `${id}.part`), { force: true });
}
async sweep(): Promise<{ removedFiles: number; removedMetadata: number }> {
let removedFiles = 0;
let removedMetadata = 0;
for (const entry of await readdir(this.filesDir)) {
const full = join(this.filesDir, entry);
if (entry.endsWith(".part") || !ID_PATTERN.test(entry)) {
await rm(full, { force: true });
removedFiles++;
continue;
}
try {
await stat(join(this.metadataDir, `${entry}.json`));
} catch {
await rm(full, { force: true });
removedFiles++;
}
}
for (const entry of await readdir(this.metadataDir)) {
if (!entry.endsWith(".json")) {
await rm(join(this.metadataDir, entry), { force: true });
removedMetadata++;
continue;
}
const id = entry.slice(0, -".json".length);
if (!ID_PATTERN.test(id)) {
await rm(join(this.metadataDir, entry), { force: true });
removedMetadata++;
continue;
}
try {
await stat(join(this.filesDir, id));
} catch {
await rm(join(this.metadataDir, entry), { force: true });
removedMetadata++;
}
}
return { removedFiles, removedMetadata };
}
}