download wf update
This commit is contained in:
@@ -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 "[email protected]" 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 "[email protected]" "$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 (`<img>`, 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.
|
||||
|
||||
+76
-3
@@ -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 = `<img src="${url}" alt="${name}">`;
|
||||
bbcode = `[img]${url}[/img]`;
|
||||
markdown = ``;
|
||||
} 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) {
|
||||
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,
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<input id="resultUrl" type="text" readonly aria-label="Download URL" />
|
||||
<button id="copyBtn" type="button" class="btn btn-primary">Copy</button>
|
||||
</div>
|
||||
<div id="linkFormats" class="link-formats"></div>
|
||||
</div>
|
||||
|
||||
<div id="notice" class="hidden"></div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -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<Response> {
|
||||
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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+33
-2
@@ -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 {
|
||||
|
||||
+8
-9
@@ -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<undefined>): Promise<Response> {
|
||||
}
|
||||
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user