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
|
## 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
@@ -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 = ``;
|
||||||
|
} 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user