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 = `${name}`; + bbcode = `[img]${url}[/img]`; + markdown = `![${name}](${url})`; + } 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") {