download wf update

This commit is contained in:
Anaïs Saraiva
2026-08-25 11:44:16 +02:00
parent c6a96b08c3
commit ccc29212cd
8 changed files with 239 additions and 33 deletions
+22 -9
View File
@@ -10,6 +10,8 @@ database, no object storage, and no queue. Expired files are swept automatically
## Features ## Features
- Drag & drop / file-picker uploads with progress, straight from the browser. - Drag & drop / file-picker uploads with progress, straight from the browser.
- **Media files render inline** (image/video/audio/PDF/text) as direct, embeddable
links, with one-click **Direct / HTML / BBCode / Markdown** link formats.
- **Optional per-file password**: set a password to gate downloads behind a prompt. - **Optional per-file password**: set a password to gate downloads behind a prompt.
- Storage on the local filesystem; files survive restarts. - Storage on the local filesystem; files survive restarts.
- A cryptographically secure, unguessable download key for every file. - A cryptographically secure, unguessable download key for every file.
@@ -131,10 +133,11 @@ curl -F "password=hunter2" -F "[email protected]" http://localhost:3000/api/uploa
{ {
"id": "u8wcfcm7", "id": "u8wcfcm7",
"key": "u8wcfcm7", "key": "u8wcfcm7",
"url": "/u8wcfcm7", "url": "/u8wcfcm7.zip",
"originalName": "example.zip", "originalName": "example.zip",
"downloadName": "u8wcfcm7.zip", "downloadName": "u8wcfcm7.zip",
"size": 123456, "size": 123456,
"mimeType": "application/zip",
"protected": true "protected": true
} }
``` ```
@@ -161,9 +164,10 @@ curl -sS -F "[email protected]" "$BASE/api/upload"
{ {
"id": "u8wcfcm7", "id": "u8wcfcm7",
"key": "u8wcfcm7", "key": "u8wcfcm7",
"url": "/u8wcfcm7", "url": "/u8wcfcm7.zip",
"originalName": "myfile.zip", "originalName": "myfile.zip",
"size": 123456, "size": 123456,
"mimeType": "application/zip",
"protected": false "protected": false
} }
``` ```
@@ -210,13 +214,20 @@ Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id`
### `GET /:key` (also `HEAD`) ### `GET /:key` (also `HEAD`)
The download URL is just the key at the site root (`https://host/u8wcfcm7`). The download URL is the key at the site root, optionally with the file extension
The legacy `/d/:key` form still works as an alias. A file without a password appended for a Catbox-style link (`https://host/u8wcfcm7` or
streams immediately; if a password is set, the server shows a password prompt `https://host/u8wcfcm7.zip`). The legacy `/d/:key` form still works as an alias. A
before streaming. A wrong or non-existent key returns a generic `404`. file without a password streams immediately; if a password is set, the server shows
a password prompt before streaming. A wrong or non-existent key returns a generic
`404`.
Media files (images, video, audio, PDF, plain text) are served `inline` so they
render directly in the browser and can be embedded (`<img>`, Discord, etc.).
Everything else — and any URL with `?dl=1` — is sent as an `attachment` download.
```bash ```bash
curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set curl -O http://localhost:3000/u8wcfcm7 # streams, or prompts if password set
curl -O "http://localhost:3000/u8wcfcm7.zip?dl=1" # always force a download
``` ```
### Password protection ### Password protection
@@ -279,9 +290,11 @@ paths or stack traces.
`../`, encoded traversal, null bytes, and symlink injection via user input are `../`, encoded traversal, null bytes, and symlink injection via user input are
not possible. not possible.
Downloads additionally refuse symlinks (`lstat`) as defense in depth. Downloads additionally refuse symlinks (`lstat`) as defense in depth.
- **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers - **MIME / XSS.** Non-media downloads are sent as `Content-Disposition: attachment`
download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed (browsers download rather than render) with `X-Content-Type-Options: nosniff`, so a
`text/html` or `image/svg+xml` cannot execute scripts. spoofed `text/html` or `image/svg+xml` cannot execute scripts. Only a whitelist of
inert media types (image, video, audio, PDF, plain text — never HTML or SVG) is
served `inline` for direct/embed links.
- **Secrecy of URLs.** All responses carry `Referrer-Policy: no-referrer`, and - **Secrecy of URLs.** All responses carry `Referrer-Policy: no-referrer`, and
downloads use `Cache-Control: private, no-store`, so the keyed URL is not leaked downloads use `Cache-Control: private, no-store`, so the keyed URL is not leaked
to referrers or shared caches. to referrers or shared caches.
+76 -3
View File
@@ -20,6 +20,7 @@ const resultUrl = $("resultUrl");
const resultName = $("resultName"); const resultName = $("resultName");
const resultLock = $("resultLock"); const resultLock = $("resultLock");
const copyBtn = $("copyBtn"); const copyBtn = $("copyBtn");
const linkFormats = $("linkFormats");
const notice = $("notice"); const notice = $("notice");
const fileList = $("fileList"); const fileList = $("fileList");
const fileCount = $("fileCount"); const fileCount = $("fileCount");
@@ -111,6 +112,61 @@ function downloadUrl(item) {
return new URL(item.url, window.location.origin).href; return new URL(item.url, window.location.origin).href;
} }
function forcedDownloadUrl(item) {
const url = new URL(downloadUrl(item));
url.searchParams.set("dl", "1");
return url.href;
}
function linkName(item) {
return item.originalName || item.downloadName || "file";
}
function buildLinkFormats(item) {
const url = downloadUrl(item);
const name = linkName(item);
const mime = item.mimeType || "";
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml";
let html;
let bbcode;
let markdown;
if (isImage) {
html = `<img src="${url}" alt="${name}">`;
bbcode = `[img]${url}[/img]`;
markdown = `![${name}](${url})`;
} else {
html = `<a href="${url}" target="_blank" rel="noopener">${name}</a>`;
bbcode = `[url=${url}]${name}[/url]`;
markdown = `[${name}](${url})`;
}
return [
{ label: "Direct", value: url },
{ label: "HTML", value: html },
{ label: "BBCode", value: bbcode },
{ label: "Markdown", value: markdown },
];
}
function createLinkFormatButtons(item) {
return buildLinkFormats(item).map((fmt) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "link-fmt";
btn.textContent = fmt.label;
btn.title = fmt.value;
btn.addEventListener("click", async () => {
const ok = await copyText(fmt.value);
setCopyFeedback(btn, ok ? "Copied" : "Failed");
});
return btn;
});
}
function renderLinkFormats(item) {
linkFormats.innerHTML = "";
for (const btn of createLinkFormatButtons(item)) linkFormats.appendChild(btn);
}
async function copyText(text) { async function copyText(text) {
const fallback = () => { const fallback = () => {
const ta = document.createElement("textarea"); const ta = document.createElement("textarea");
@@ -194,14 +250,16 @@ function renderList() {
dateTd.setAttribute("data-label", "Uploaded"); dateTd.setAttribute("data-label", "Uploaded");
const actionsTd = document.createElement("td"); const actionsTd = document.createElement("td");
const wrap = document.createElement("div");
wrap.className = "actions-wrap";
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "row-actions"; actions.className = "row-actions";
const dl = document.createElement("a"); const dl = document.createElement("a");
dl.className = "btn btn-primary"; dl.className = "btn btn-primary";
dl.href = downloadUrl(item); dl.href = forcedDownloadUrl(item);
dl.textContent = "Download"; dl.textContent = "Download";
dl.title = "Open download link"; dl.title = "Force download (direct link renders inline in the browser)";
const copy = document.createElement("button"); const copy = document.createElement("button");
copy.type = "button"; copy.type = "button";
@@ -221,7 +279,20 @@ function renderList() {
actions.appendChild(dl); actions.appendChild(dl);
actions.appendChild(copy); actions.appendChild(copy);
actions.appendChild(del); actions.appendChild(del);
actionsTd.appendChild(actions);
const links = document.createElement("details");
links.className = "row-links";
const summary = document.createElement("summary");
summary.textContent = "Links";
const panel = document.createElement("div");
panel.className = "row-links-panel";
for (const btn of createLinkFormatButtons(item)) panel.appendChild(btn);
links.appendChild(summary);
links.appendChild(panel);
wrap.appendChild(actions);
wrap.appendChild(links);
actionsTd.appendChild(wrap);
tr.appendChild(nameTd); tr.appendChild(nameTd);
tr.appendChild(sizeTd); tr.appendChild(sizeTd);
@@ -266,12 +337,14 @@ function showResult(data) {
resultLock.textContent = data.protected ? "Protected" : "Public"; resultLock.textContent = data.protected ? "Protected" : "Public";
resultLock.classList.toggle("public", !data.protected); resultLock.classList.toggle("public", !data.protected);
resultUrl.value = downloadUrl(data); resultUrl.value = downloadUrl(data);
renderLinkFormats(data);
addUpload({ addUpload({
id: data.id, id: data.id,
key: data.key, key: data.key,
url: data.url, url: data.url,
originalName: data.originalName, originalName: data.originalName,
downloadName: data.downloadName || data.originalName, downloadName: data.downloadName || data.originalName,
mimeType: data.mimeType || "",
size: data.size, size: data.size,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
protected: !!data.protected, protected: !!data.protected,
+1
View File
@@ -66,6 +66,7 @@
<input id="resultUrl" type="text" readonly aria-label="Download URL" /> <input id="resultUrl" type="text" readonly aria-label="Download URL" />
<button id="copyBtn" type="button" class="btn btn-primary">Copy</button> <button id="copyBtn" type="button" class="btn btn-primary">Copy</button>
</div> </div>
<div id="linkFormats" class="link-formats"></div>
</div> </div>
<div id="notice" class="hidden"></div> <div id="notice" class="hidden"></div>
+83
View File
@@ -303,6 +303,30 @@ button {
font-size: 13px; font-size: 13px;
} }
/* ---- link formats (Catbox-style) ---- */
.link-formats {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.link-fmt {
border: 1px solid var(--border);
background: var(--surface);
color: var(--muted);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
transition: color 0.12s, border-color 0.12s;
}
.link-fmt:hover {
color: var(--text);
border-color: var(--muted);
}
/* ---- files panel ---- */ /* ---- files panel ---- */
.panel-head { .panel-head {
display: flex; display: flex;
@@ -436,6 +460,51 @@ button {
font-size: 12px; font-size: 12px;
} }
.actions-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.row-links {
font-size: 12px;
color: var(--muted);
text-align: right;
}
.row-links summary {
display: inline-block;
list-style: none;
cursor: pointer;
font-weight: 600;
user-select: none;
}
.row-links summary::-webkit-details-marker {
display: none;
}
.row-links summary::before {
content: "▸ ";
}
.row-links[open] summary::before {
content: "▾ ";
}
.row-links summary:hover {
color: var(--text);
}
.row-links-panel {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
margin-top: 6px;
}
.empty { .empty {
text-align: center; text-align: center;
color: var(--muted); color: var(--muted);
@@ -614,4 +683,18 @@ button {
padding: 11px 8px; padding: 11px 8px;
font-size: 13px; font-size: 13px;
} }
.actions-wrap {
flex: 1 1 auto;
min-width: 0;
align-items: stretch;
}
.row-links {
text-align: left;
}
.row-links-panel {
justify-content: flex-start;
}
} }
+11 -7
View File
@@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises";
import { hasValidAuth, renderPasswordForm } from "../auth"; import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http"; import { notFound } from "../http";
import { log } from "../log"; import { log } from "../log";
import { contentDisposition, downloadFileName, fileIdFromKey, KEY_PATTERN, secretMatches } from "../security"; import { contentDisposition, downloadFileName, keyFromPath, secretMatches, shouldInline } from "../security";
import { isExpired, type Storage } from "../storage"; import { isExpired, type Storage } from "../storage";
export async function handleDownload( export async function handleDownload(
@@ -11,8 +11,7 @@ export async function handleDownload(
rawKey: string, rawKey: string,
secret: string, secret: string,
): Promise<Response> { ): Promise<Response> {
if (!KEY_PATTERN.test(rawKey)) return notFound(); const id = keyFromPath(rawKey);
const id = fileIdFromKey(rawKey);
if (!id) return notFound(); if (!id) return notFound();
const meta = await storage.readMetadata(id); const meta = await storage.readMetadata(id);
@@ -22,11 +21,11 @@ export async function handleDownload(
log("info", `download rejected id=${id} expired`); log("info", `download rejected id=${id} expired`);
return notFound(); return notFound();
} }
if (!secretMatches(meta.downloadKeyHash, rawKey)) return notFound(); if (!secretMatches(meta.downloadKeyHash, id)) return notFound();
if (meta.passwordHash && !hasValidAuth(req, secret, rawKey)) { if (meta.passwordHash && !hasValidAuth(req, secret, id)) {
const url = new URL(req.url); const url = new URL(req.url);
return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e")); return renderPasswordForm(id, meta.originalName, url.searchParams.has("e"));
} }
const filePath = storage.filePath(id); const filePath = storage.filePath(id);
@@ -41,7 +40,12 @@ export async function handleDownload(
const headers = new Headers({ const headers = new Headers({
"content-type": meta.mimeType || "application/octet-stream", "content-type": meta.mimeType || "application/octet-stream",
"content-length": String(fileStat.size), "content-length": String(fileStat.size),
"content-disposition": contentDisposition(downloadFileName(rawKey, meta.originalName)), "content-disposition": contentDisposition(
downloadFileName(id, meta.originalName),
new URL(req.url).searchParams.get("dl") === "1" || !shouldInline(meta.mimeType)
? "attachment"
: "inline",
),
"cache-control": "private, no-store", "cache-control": "private, no-store",
"x-content-type-options": "nosniff", "x-content-type-options": "nosniff",
"referrer-policy": "no-referrer", "referrer-policy": "no-referrer",
+4 -2
View File
@@ -89,15 +89,17 @@ export async function handleUpload(
expiresAt: new Date(Date.now() + ttlMs).toISOString(), expiresAt: new Date(Date.now() + ttlMs).toISOString(),
}); });
const url = publicBaseUrl ? `${publicBaseUrl}/${key}` : `/${key}`; const downloadName = downloadFileName(key, originalName);
const url = publicBaseUrl ? `${publicBaseUrl}/${downloadName}` : `/${downloadName}`;
log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`); log("info", `upload ok id=${id} size=${size} name=${JSON.stringify(originalName)}`);
return json({ return json({
id, id,
key, key,
url, url,
originalName, originalName,
downloadName: downloadFileName(key, originalName), downloadName,
size, size,
mimeType: mime,
protected: !!passwordHash, protected: !!passwordHash,
}); });
} catch (err) { } catch (err) {
+33 -2
View File
@@ -46,6 +46,15 @@ export function fileIdFromKey(key: string): string | null {
return key; return key;
} }
// Accepts a bare key ("abc12345") or a key with a cosmetic file extension
// ("abc12345.png", Catbox-style). The key is everything before the first dot;
// the extension is ignored for lookup and never touches the filesystem.
export function keyFromPath(segment: string): string | null {
if (!segment || segment.includes("/")) return null;
const key = segment.split(".")[0]!;
return KEY_PATTERN.test(key) ? key : null;
}
export function sanitizeOriginalName(name: string): string { export function sanitizeOriginalName(name: string): string {
const base = name.replace(/\\/g, "/").split("/").pop() ?? ""; const base = name.replace(/\\/g, "/").split("/").pop() ?? "";
const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "_").trim(); const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, "_").trim();
@@ -57,12 +66,34 @@ export function sanitizeMime(value: string): string {
return cleaned.length > 0 && cleaned.length <= 200 ? cleaned : "application/octet-stream"; return cleaned.length > 0 && cleaned.length <= 200 ? cleaned : "application/octet-stream";
} }
export function contentDisposition(name: string): string { export function contentDisposition(
name: string,
disposition: "inline" | "attachment" = "attachment",
): string {
const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) =>
"%" + c.charCodeAt(0).toString(16).toUpperCase(), "%" + c.charCodeAt(0).toString(16).toUpperCase(),
); );
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}
// Whether a file should be rendered inline in the browser (like a direct
// Catbox link) rather than forced as a download. Restricted to media types
// that cannot execute scripts, so the XSS protection stays intact: HTML,
// SVG, JS, and other active/unknown types are always downloaded.
export function shouldInline(mimeType: string): boolean {
const mime = (mimeType || "").split(";")[0]!.trim().toLowerCase();
if (!mime || mime === "application/octet-stream") return false;
if (mime === "image/svg+xml" || mime === "text/html" || mime === "application/xhtml+xml") {
return false;
}
return (
mime.startsWith("image/") ||
mime.startsWith("video/") ||
mime.startsWith("audio/") ||
mime === "text/plain" ||
mime === "application/pdf"
);
} }
export function downloadFileName(key: string, originalName: string): string { export function downloadFileName(key: string, originalName: string): string {
+8 -9
View File
@@ -7,7 +7,7 @@ import { handleAuth } from "./routes/auth";
import { handleDelete } from "./routes/delete"; import { handleDelete } from "./routes/delete";
import { handleDownload } from "./routes/download"; import { handleDownload } from "./routes/download";
import { handleUpload } from "./routes/upload"; import { handleUpload } from "./routes/upload";
import { KEY_PATTERN, setKeyLength } from "./security"; import { keyFromPath, setKeyLength } from "./security";
import { Storage } from "./storage"; import { Storage } from "./storage";
const cfg = loadConfig(); const cfg = loadConfig();
@@ -121,23 +121,22 @@ async function route(req: Request, srv: Server<undefined>): Promise<Response> {
} }
if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) { if (path.startsWith("/d/") && (method === "GET" || method === "HEAD")) {
const key = keyFromPath(path.slice(3));
if (!key) return notFound();
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests"); return jsonError(429, "too many requests");
} }
return handleDownload(req, storage, path.slice(3), secret); return handleDownload(req, storage, key, secret);
} }
const rootKey = path.slice(1); const rootKey = path.slice(1);
if ( if (rootKey && !rootKey.includes("/") && (method === "GET" || method === "HEAD")) {
rootKey && const key = keyFromPath(rootKey);
!rootKey.includes("/") && if (!key) return notFound();
KEY_PATTERN.test(rootKey) &&
(method === "GET" || method === "HEAD")
) {
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) { if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
return jsonError(429, "too many requests"); return jsonError(429, "too many requests");
} }
return handleDownload(req, storage, rootKey, secret); return handleDownload(req, storage, key, secret);
} }
if (path.startsWith("/api/files/") && method === "DELETE") { if (path.startsWith("/api/files/") && method === "DELETE") {