From ccc29212cd0cf51c56662c036cc3ffaf8a503446 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ana=C3=AFs=20Saraiva?=
<53227802+Kisakay@users.noreply.github.com>
Date: Tue, 25 Aug 2026 11:44:16 +0200
Subject: [PATCH] download wf update
---
README.md | 33 ++++++++++++-----
src/public/app.js | 79 ++++++++++++++++++++++++++++++++++++++--
src/public/index.html | 1 +
src/public/style.css | 83 ++++++++++++++++++++++++++++++++++++++++++
src/routes/download.ts | 18 +++++----
src/routes/upload.ts | 6 ++-
src/security.ts | 35 +++++++++++++++++-
src/server.ts | 17 ++++-----
8 files changed, 239 insertions(+), 33 deletions(-)
diff --git a/README.md b/README.md
index b96acf9..fa0619c 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ database, no object storage, and no queue. Expired files are swept automatically
## Features
- 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.
- Storage on the local filesystem; files survive restarts.
- A cryptographically secure, unguessable download key for every file.
@@ -131,10 +133,11 @@ curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/uploa
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
- "url": "/u8wcfcm7",
+ "url": "/u8wcfcm7.zip",
"originalName": "example.zip",
"downloadName": "u8wcfcm7.zip",
"size": 123456,
+ "mimeType": "application/zip",
"protected": true
}
```
@@ -161,9 +164,10 @@ curl -sS -F "file=@myfile.zip" "$BASE/api/upload"
{
"id": "u8wcfcm7",
"key": "u8wcfcm7",
- "url": "/u8wcfcm7",
+ "url": "/u8wcfcm7.zip",
"originalName": "myfile.zip",
"size": 123456,
+ "mimeType": "application/zip",
"protected": false
}
```
@@ -210,13 +214,20 @@ Keep `id`/`key` around to delete the file afterwards via `DELETE /api/files/:id`
### `GET /:key` (also `HEAD`)
-The download URL is just the key at the site root (`https://host/u8wcfcm7`).
-The legacy `/d/:key` form still works as an alias. A 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`.
+The download URL is the key at the site root, optionally with the file extension
+appended for a Catbox-style link (`https://host/u8wcfcm7` or
+`https://host/u8wcfcm7.zip`). The legacy `/d/:key` form still works as an alias. A
+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 (``, Discord, etc.).
+Everything else — and any URL with `?dl=1` — is sent as an `attachment` download.
```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
@@ -279,9 +290,11 @@ paths or stack traces.
`../`, encoded traversal, null bytes, and symlink injection via user input are
not possible.
Downloads additionally refuse symlinks (`lstat`) as defense in depth.
-- **MIME / XSS.** Downloads are sent as `Content-Disposition: attachment` (browsers
- download rather than render) with `X-Content-Type-Options: nosniff`, so a spoofed
- `text/html` or `image/svg+xml` cannot execute scripts.
+- **MIME / XSS.** Non-media downloads are sent as `Content-Disposition: attachment`
+ (browsers download rather than render) with `X-Content-Type-Options: nosniff`, so a
+ 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
downloads use `Cache-Control: private, no-store`, so the keyed URL is not leaked
to referrers or shared caches.
diff --git a/src/public/app.js b/src/public/app.js
index 63b17bb..59152ab 100644
--- a/src/public/app.js
+++ b/src/public/app.js
@@ -20,6 +20,7 @@ const resultUrl = $("resultUrl");
const resultName = $("resultName");
const resultLock = $("resultLock");
const copyBtn = $("copyBtn");
+const linkFormats = $("linkFormats");
const notice = $("notice");
const fileList = $("fileList");
const fileCount = $("fileCount");
@@ -111,6 +112,61 @@ function downloadUrl(item) {
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 = `
`;
+ bbcode = `[img]${url}[/img]`;
+ markdown = ``;
+ } else {
+ html = `${name}`;
+ 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) {
const fallback = () => {
const ta = document.createElement("textarea");
@@ -194,14 +250,16 @@ function renderList() {
dateTd.setAttribute("data-label", "Uploaded");
const actionsTd = document.createElement("td");
+ const wrap = document.createElement("div");
+ wrap.className = "actions-wrap";
const actions = document.createElement("div");
actions.className = "row-actions";
const dl = document.createElement("a");
dl.className = "btn btn-primary";
- dl.href = downloadUrl(item);
+ dl.href = forcedDownloadUrl(item);
dl.textContent = "Download";
- dl.title = "Open download link";
+ dl.title = "Force download (direct link renders inline in the browser)";
const copy = document.createElement("button");
copy.type = "button";
@@ -221,7 +279,20 @@ function renderList() {
actions.appendChild(dl);
actions.appendChild(copy);
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(sizeTd);
@@ -266,12 +337,14 @@ function showResult(data) {
resultLock.textContent = data.protected ? "Protected" : "Public";
resultLock.classList.toggle("public", !data.protected);
resultUrl.value = downloadUrl(data);
+ renderLinkFormats(data);
addUpload({
id: data.id,
key: data.key,
url: data.url,
originalName: data.originalName,
downloadName: data.downloadName || data.originalName,
+ mimeType: data.mimeType || "",
size: data.size,
createdAt: new Date().toISOString(),
protected: !!data.protected,
diff --git a/src/public/index.html b/src/public/index.html
index 69c59ad..158aa01 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -66,6 +66,7 @@
+