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 @@
+
diff --git a/src/public/style.css b/src/public/style.css
index b910a4d..1be7230 100644
--- a/src/public/style.css
+++ b/src/public/style.css
@@ -303,6 +303,30 @@ button {
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 ---- */
.panel-head {
display: flex;
@@ -436,6 +460,51 @@ button {
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 {
text-align: center;
color: var(--muted);
@@ -614,4 +683,18 @@ button {
padding: 11px 8px;
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;
+ }
}
\ No newline at end of file
diff --git a/src/routes/download.ts b/src/routes/download.ts
index e8b9bfe..b1a7b0b 100644
--- a/src/routes/download.ts
+++ b/src/routes/download.ts
@@ -2,7 +2,7 @@ import { lstat } from "node:fs/promises";
import { hasValidAuth, renderPasswordForm } from "../auth";
import { notFound } from "../http";
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";
export async function handleDownload(
@@ -11,8 +11,7 @@ export async function handleDownload(
rawKey: string,
secret: string,
): Promise {
- if (!KEY_PATTERN.test(rawKey)) return notFound();
- const id = fileIdFromKey(rawKey);
+ const id = keyFromPath(rawKey);
if (!id) return notFound();
const meta = await storage.readMetadata(id);
@@ -22,11 +21,11 @@ export async function handleDownload(
log("info", `download rejected id=${id} expired`);
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);
- return renderPasswordForm(rawKey, meta.originalName, url.searchParams.has("e"));
+ return renderPasswordForm(id, meta.originalName, url.searchParams.has("e"));
}
const filePath = storage.filePath(id);
@@ -41,7 +40,12 @@ export async function handleDownload(
const headers = new Headers({
"content-type": meta.mimeType || "application/octet-stream",
"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",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
diff --git a/src/routes/upload.ts b/src/routes/upload.ts
index 9366cd5..743bb82 100644
--- a/src/routes/upload.ts
+++ b/src/routes/upload.ts
@@ -89,15 +89,17 @@ export async function handleUpload(
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)}`);
return json({
id,
key,
url,
originalName,
- downloadName: downloadFileName(key, originalName),
+ downloadName,
size,
+ mimeType: mime,
protected: !!passwordHash,
});
} catch (err) {
diff --git a/src/security.ts b/src/security.ts
index 4944a04..3a25898 100644
--- a/src/security.ts
+++ b/src/security.ts
@@ -46,6 +46,15 @@ export function fileIdFromKey(key: string): string | null {
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 {
const base = name.replace(/\\/g, "/").split("/").pop() ?? "";
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";
}
-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 encoded = encodeURIComponent(name).replace(/['()*]/g, (c) =>
"%" + 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 {
diff --git a/src/server.ts b/src/server.ts
index 32582fc..e69135e 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -7,7 +7,7 @@ import { handleAuth } from "./routes/auth";
import { handleDelete } from "./routes/delete";
import { handleDownload } from "./routes/download";
import { handleUpload } from "./routes/upload";
-import { KEY_PATTERN, setKeyLength } from "./security";
+import { keyFromPath, setKeyLength } from "./security";
import { Storage } from "./storage";
const cfg = loadConfig();
@@ -121,23 +121,22 @@ async function route(req: Request, srv: Server): Promise {
}
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))) {
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);
- if (
- rootKey &&
- !rootKey.includes("/") &&
- KEY_PATTERN.test(rootKey) &&
- (method === "GET" || method === "HEAD")
- ) {
+ if (rootKey && !rootKey.includes("/") && (method === "GET" || method === "HEAD")) {
+ const key = keyFromPath(rootKey);
+ if (!key) return notFound();
if (cfg.rateLimitEnabled && !limiter.check(clientIp(req, srv))) {
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") {