diff --git a/Dockerfile b/Dockerfile index 5dc213d88..57a0c9171 100644 --- a/Dockerfile +++ b/Dockerfile @@ -103,6 +103,7 @@ RUN ARCH=$(uname -m) && \ COPY --from=install /temp/prod/node_modules node_modules COPY --from=prerelease /app/public/ /app/public/ COPY --from=prerelease /app/dist /app/dist +COPY entrypoint.sh /app/entrypoint.sh # COPY . . RUN mkdir data @@ -111,4 +112,4 @@ EXPOSE 3000/tcp # used for calibre ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" ENV NODE_ENV=production -ENTRYPOINT [ "bun", "run", "dist/src/index.js" ] +ENTRYPOINT [ "/app/entrypoint.sh" ] diff --git a/README.md b/README.md index 12aa61aff..0b0054676 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ All are optional, JWT_SECRET is recommended to be set. | ACCOUNT_REGISTRATION | false | Allow users to register accounts | | HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally | | ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally | +| ALLOW_URL_UPLOAD | false | Allow uploading/converting files from URLs | | AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable | | WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" | | BRANDING | ConvertX | Custom string that allows you to change the display name of the website in the header (max 26 characters) | @@ -103,6 +104,8 @@ All are optional, JWT_SECRET is recommended to be set. | LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) | | UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users | | MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. | +| MAGICK_MAX_WIDTH | unset (ImageMagick default) | (Docker only) ImageMagick maximum image width in pixels | +| MAGICK_MAX_HEIGHT | unset (ImageMagick default) | (Docker only) ImageMagick maximum image height in pixels | | PORT | 3000 | Application listen port | ### Docker images diff --git a/bun.lock b/bun.lock index 89947d198..5215372b0 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@elysiajs/static": "^1.4.10", "@kitajs/html": "^4.2.13", "elysia": "1.4.30", + "mime": "^4.1.0", "sanitize-filename": "^1.6.4", "tar": "^7.5.22", }, @@ -266,7 +267,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@24.13.4", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw=="], + "@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -304,7 +305,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -478,6 +479,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -642,7 +645,7 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], @@ -652,8 +655,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], - "bun-types/@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], - "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -670,10 +671,6 @@ "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - - "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "tsconfig-paths-webpack-plugin/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 000000000..cfd58d004 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e + +if [ -n "$MAGICK_MAX_WIDTH" ]; then + if ! [[ "$MAGICK_MAX_WIDTH" =~ ^[0-9]+$ ]]; then + echo "Error: MAGICK_MAX_WIDTH must be a positive integer in pixels" >&2 + exit 1 + fi +fi + +if [ -n "$MAGICK_MAX_HEIGHT" ]; then + if ! [[ "$MAGICK_MAX_HEIGHT" =~ ^[0-9]+$ ]]; then + echo "Error: MAGICK_MAX_HEIGHT must be a positive integer in pixels" >&2 + exit 1 + fi +fi + +if [ -n "$MAGICK_MAX_WIDTH" ] || [ -n "$MAGICK_MAX_HEIGHT" ]; then + POLICY_DIR="/etc/ImageMagick-7" + POLICY_FILE="$POLICY_DIR/policy.xml" + + if [ -f "$POLICY_FILE" ] && grep -q "" "$POLICY_FILE"; then + [ -n "$MAGICK_MAX_WIDTH" ] && sed -i '/| \n|" "$POLICY_FILE" + fi + if [ -n "$MAGICK_MAX_HEIGHT" ]; then + sed -i "s|| \n|" "$POLICY_FILE" + fi + else + mkdir -p "$POLICY_DIR" + echo "" > "$POLICY_FILE" + if [ -n "$MAGICK_MAX_WIDTH" ]; then + echo " " >> "$POLICY_FILE" + fi + if [ -n "$MAGICK_MAX_HEIGHT" ]; then + echo " " >> "$POLICY_FILE" + fi + echo "" >> "$POLICY_FILE" + fi +fi + +exec bun run dist/src/index.js diff --git a/eslint.config.ts b/eslint.config.ts index 8b493b3fd..c077dd664 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -51,6 +51,7 @@ export default defineConfig( "target", "convert_to_target", "job-details-toggle", + "share-btn", ], }, ], diff --git a/package.json b/package.json index a855909f4..edcd7cfab 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@elysiajs/static": "^1.4.10", "@kitajs/html": "^4.2.13", "elysia": "1.4.30", + "mime": "^4.1.0", "sanitize-filename": "^1.6.4", "tar": "^7.5.22" }, diff --git a/public/results.js b/public/results.js index 60c07eb5f..cd35eefff 100644 --- a/public/results.js +++ b/public/results.js @@ -3,6 +3,56 @@ const jobId = window.location.pathname.split("/").pop(); const main = document.querySelector("main"); let progressElem = document.querySelector("progress"); +const supportsWebShare = navigator.share && navigator.canShare; + +const setupShareButtons = () => { + if (!supportsWebShare) { + return; + } + + document.querySelectorAll(".share-btn").forEach((btn) => { + if (btn.dataset.setupComplete) return; + + const filename = btn.dataset.filename; + const mimeType = btn.dataset.mimeType; + const fileUrl = btn.dataset.downloadUrl; + const dummyFile = new File([], filename, { type: mimeType }); + + if (!navigator.canShare({ files: [dummyFile] })) { + return; + } + let cachedFile = null; + let isFetching = false; + + btn.addEventListener("click", async (e) => { + e.preventDefault(); + if (isFetching) return; + try { + if (!cachedFile) { + isFetching = true; + const response = await fetch(fileUrl); + if (!response.ok) { + throw new Error(`Failed to download file (${response.status})`); + } + const blob = await response.blob(); + cachedFile = new File([blob], filename, { type: mimeType }); + } + await navigator.share({ files: [cachedFile] }); + } catch (err) { + if (err.name === "NotAllowedError" && cachedFile) { + alert("File is ready. Please tap the share button again to share."); + } else if (err.name !== "AbortError") { + console.error("Error sharing:", err); + } + } finally { + isFetching = false; + } + }); + btn.style.display = ""; + btn.dataset.setupComplete = true; + }); +}; + const refreshData = () => { // console.log("Refreshing data...", progressElem.value, progressElem.max); if (progressElem.value !== progressElem.max) { @@ -12,6 +62,7 @@ const refreshData = () => { .then((res) => res.text()) .then((html) => { main.innerHTML = html; + setupShareButtons(); }) .catch((err) => console.log(err)); @@ -21,6 +72,7 @@ const refreshData = () => { progressElem = document.querySelector("progress"); }; +setupShareButtons(); refreshData(); window.downloadAll = function () { diff --git a/public/script.js b/public/script.js index 5c2e33762..5d124f5a9 100644 --- a/public/script.js +++ b/public/script.js @@ -1,4 +1,6 @@ const webroot = document.querySelector("meta[name='webroot']").content; +const urlInput = document.querySelector("#url-input"); +const urlSubmit = document.querySelector("#url-submit"); const fileInput = document.querySelector('input[type="file"]'); const dropZone = document.getElementById("dropzone"); const convertButton = document.querySelector("input[type='submit']"); @@ -16,37 +18,174 @@ dropZone.addEventListener("dragleave", () => { dropZone.classList.remove("dragover"); }); -dropZone.addEventListener("drop", (e) => { +const readAllEntries = async (reader) => { + const entries = []; + let batch; + do { + batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + entries.push(...batch); + } while (batch.length > 0); + return entries; +}; + +const entryToFiles = async (entry) => { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + if (entry.fullPath) { + file.filepath = entry.fullPath.replace(/^\/+/, ""); + } + return [file]; + } + const reader = entry.createReader(); + const entries = await readAllEntries(reader); + const out = []; + for (const child of entries) { + out.push(...(await entryToFiles(child))); + } + return out; +}; + +const getFileNameWithSlashes = (file) => { + if (file.filepath) { + return file.filepath; + } + if (file.webkitRelativePath) { + return file.webkitRelativePath; + } + return file.name; +}; + +dropZone.addEventListener("drop", async (e) => { e.preventDefault(); dropZone.classList.remove("dragover"); - const files = e.dataTransfer.files; + const items = e.dataTransfer.items; - if (files.length === 0) { + if (items.length === 0) { console.warn("No files dropped — likely a URL or unsupported source."); return; } - for (const file of files) { - console.log("Handling dropped file:", file.name); - handleFile(file); + for (const item of items) { + const entry = item.webkitGetAsEntry(); + if (!entry) { + return; + } + const files = await entryToFiles(entry); + for (const file of files) { + console.log("Handling dropped file:", getFileNameWithSlashes(file)); + handleFile(file); + } } }); +urlSubmit?.addEventListener("click", (e) => { + e.preventDefault(); + if (urlInput) { + handleUrl(urlInput.value); + } +}); + +function handleUrl(url) { + if (!url || !url.trim()) return; + + if (urlSubmit) urlSubmit.disabled = true; + pendingFiles += 1; + convertButton.disabled = true; + convertButton.textContent = "Uploading..."; + + fetch(`${webroot}/url`, { + method: "POST", + body: JSON.stringify({ url: url.trim() }), + headers: { "Content-Type": "application/json" }, + }) + .then((res) => { + if (!res.ok) { + return res.text().then((text) => { + throw new Error(text || `HTTP ${res.status}`); + }); + } + return res.json(); + }) + .then((res) => { + if (urlInput) urlInput.value = ""; + const fileList = document.querySelector("#file-list"); + + const row = document.createElement("tr"); + + const nameTd = document.createElement("td"); + nameTd.textContent = res.filename; + row.appendChild(nameTd); + + const emptyTd = document.createElement("td"); + row.appendChild(emptyTd); + + const sizeTd = document.createElement("td"); + sizeTd.textContent = `${(res.fileSizeBytes / 1024).toFixed(2)} kB`; + row.appendChild(sizeTd); + + const actionTd = document.createElement("td"); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "text-accent-500 hover:underline"; + removeBtn.textContent = "Remove"; + removeBtn.onclick = function () { + deleteRow(this); + }; + actionTd.appendChild(removeBtn); + row.appendChild(actionTd); + + fileList.appendChild(row); + fileNames.push(res.filename); + + if (!fileType) { + fileType = res.filename.split(".").pop(); + fileInput.setAttribute("accept", `.${fileType}`); + setTitle(); + + fetch(`${webroot}/conversions`, { + method: "POST", + body: JSON.stringify({ fileType }), + headers: { "Content-Type": "application/json" }, + }) + .then((r) => r.text()) + .then((html) => { + selectContainer.innerHTML = html; + updateSearchBar(); + }) + .catch(console.error); + } + }) + .catch((err) => { + console.error(err); + alert(`Failed to add URL: ${err.message || err}`); + }) + .finally(() => { + if (urlSubmit) urlSubmit.disabled = false; + pendingFiles -= 1; + if (pendingFiles === 0) { + convertButton.textContent = "Convert"; + if (formatSelected && fileNames.length > 0) { + convertButton.disabled = false; + } + } + }); +} + // Extracted handleFile function for reusability in drag-and-drop and file input function handleFile(file) { const fileList = document.querySelector("#file-list"); const row = document.createElement("tr"); row.innerHTML = ` - ${file.name} + ${getFileNameWithSlashes(file)} ${(file.size / 1024).toFixed(2)} kB `; if (!fileType) { - fileType = file.name.split(".").pop(); + fileType = getFileNameWithSlashes(file).split(".").pop(); fileInput.setAttribute("accept", `.${fileType}`); setTitle(); @@ -65,7 +204,7 @@ function handleFile(file) { fileList.appendChild(row); file.htmlRow = row; - fileNames.push(file.name); + fileNames.push(getFileNameWithSlashes(file)); uploadFile(file); } @@ -166,7 +305,6 @@ const setTitle = () => { }; // Add a onclick for the delete button -// eslint-disable-next-line @typescript-eslint/no-unused-vars const deleteRow = (target) => { const filename = target.parentElement.parentElement.children[0].textContent; const row = target.parentElement.parentElement; @@ -202,7 +340,7 @@ const uploadFile = (file) => { pendingFiles += 1; const formData = new FormData(); - formData.append("file", file, file.name); + formData.append("file", file, getFileNameWithSlashes(file)); let xhr = new XMLHttpRequest(); @@ -228,7 +366,7 @@ const uploadFile = (file) => { xhr.upload.onprogress = (e) => { let sent = e.loaded; let total = e.total; - console.log(`upload progress (${file.name}):`, (100 * sent) / total); + console.log(`upload progress (${getFileNameWithSlashes(file)}):`, (100 * sent) / total); let progressbar = file.htmlRow.getElementsByTagName("progress"); progressbar[0].value = (100 * sent) / total; diff --git a/src/converters/main.ts b/src/converters/main.ts index 6980cc693..2a089c407 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -1,6 +1,7 @@ import { Cookie } from "elysia"; import db from "../db/db"; import { MAX_CONVERT_PROCESS } from "../helpers/env"; +import { isSafePath } from "../helpers/validatePath"; import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype"; import { convert as convertassimp, properties as propertiesassimp } from "./assimp"; import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre"; @@ -26,6 +27,8 @@ import { convert as convertVtracer, properties as propertiesVtracer } from "./vt import { convert as convertVcf, properties as propertiesVcf } from "./vcf"; import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex"; import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown"; +import path from "node:path"; +import fs from "node:fs/promises"; // This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular @@ -168,23 +171,38 @@ export async function handleConvert( for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) { const toProcess: Promise[] = []; for (const fileName of chunk) { - const filePath = `${userUploadsDir}${fileName}`; - const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : ""; + // `isSafePath` ensures the path as parsed by JS is safe, but if we pass the path to external programs, we don't + // want to be affected by a parser differential so we normalize with JS' parsing + const filePath = path.normalize(`${userUploadsDir}${fileName}`); + if (!isSafePath(userUploadsDir, filePath)) { + throw new Error("Unsafe filename"); + } + + const lastDot = fileName.lastIndexOf("."); + const fileTypeOrig = lastDot === -1 ? "" : fileName.slice(lastDot + 1); const fileType = normalizeFiletype(fileTypeOrig); const newFileExt = normalizeOutputFiletype(convertTo); - let newFileName: string; - if (fileTypeOrig === "") { - newFileName = `${fileName}.${newFileExt}`; - } else { - newFileName = fileName.replace( - new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`), - newFileExt, - ); + const newFileName = + lastDot === -1 + ? `${fileName}.${newFileExt}` + : `${fileName.slice(0, lastDot + 1)}${newFileExt}`; + + // `isSafePath` ensures the path as parsed by JS is safe, but if we pass the path to external programs, we don't + // want to be affected by a parser differential so we normalize with JS' parsing + // This shouldn't be unsafe as we've already validated the original filename is safe, but if a filename is + // provided which *attempts* to be malicious, `targetPath` could theoretically differ to `newFileName` due to + // normalization + const targetPath = path.normalize(`${userOutputDir}${newFileName}`); + if (!isSafePath(userOutputDir, targetPath)) { + throw new Error("Unsafe filename"); } - const targetPath = `${userOutputDir}${newFileName}`; toProcess.push( new Promise((resolve, reject) => { - mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName) + const parent = path.dirname(targetPath); + fs.mkdir(parent, { recursive: true }) + .then(() => { + return mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName); + }) .then((r) => { if (jobId.value) { query.run(jobId.value, fileName, newFileName, r); diff --git a/src/helpers/env.ts b/src/helpers/env.ts index c5207882c..8a076d158 100644 --- a/src/helpers/env.ts +++ b/src/helpers/env.ts @@ -6,6 +6,8 @@ export const HTTP_ALLOWED = process.env.HTTP_ALLOWED?.toLowerCase() === "true" | export const ALLOW_UNAUTHENTICATED = process.env.ALLOW_UNAUTHENTICATED?.toLowerCase() === "true" || false; +export const ALLOW_URL_UPLOAD = process.env.ALLOW_URL_UPLOAD?.toLowerCase() === "true" || false; + export const AUTO_DELETE_EVERY_N_HOURS = process.env.AUTO_DELETE_EVERY_N_HOURS ? Number(process.env.AUTO_DELETE_EVERY_N_HOURS) : 24; diff --git a/src/helpers/getFilename.ts b/src/helpers/getFilename.ts new file mode 100644 index 000000000..37521f20d --- /dev/null +++ b/src/helpers/getFilename.ts @@ -0,0 +1,51 @@ +import mime from "mime"; +import sanitize from "sanitize-filename"; +import { randomUUID } from "node:crypto"; + +export const getFilename = (urlStr: string, headers: Headers): string => { + const contentType = headers.get("content-type")?.split(";")[0]?.trim(); + const extension = contentType ? mime.getExtension(contentType) : null; + + let candidate = ""; + const contentDisposition = headers.get("Content-Disposition"); + if (contentDisposition) { + const utf8Match = /filename\*=UTF-8''([^;\s]+)/i.exec(contentDisposition); + if (utf8Match && utf8Match[1]) { + try { + candidate = decodeURIComponent(utf8Match[1]); + } catch { + // ignore malformed URI + } + } + if (!candidate) { + const match = /filename=(?:"([^"]+)"|([^;\s]+))/i.exec(contentDisposition); + if (match) { + candidate = match[1] || match[2] || ""; + } + } + } + + if (!candidate) { + try { + const path = new URL(urlStr).pathname; + const lastPart = path.split("/").filter(Boolean).at(-1); + if (lastPart) { + candidate = decodeURIComponent(lastPart); + } + } catch { + // ignore invalid URL path + } + } + + const sanitized = sanitize(candidate).trim().replace(/^\.+/, ""); + + if (!sanitized) { + return extension ? `${randomUUID()}.${extension}` : randomUUID(); + } + + if (!sanitized.includes(".") && extension) { + return `${sanitized}.${extension}`; + } + + return sanitized; +}; diff --git a/src/helpers/validatePath.ts b/src/helpers/validatePath.ts new file mode 100644 index 000000000..39e263db1 --- /dev/null +++ b/src/helpers/validatePath.ts @@ -0,0 +1,7 @@ +import path from "node:path"; + +export const isSafePath = (expectedParent: string, userInput: string): boolean => { + const relativePath = path.relative(expectedParent, userInput); + const isEscaping = relativePath === ".." || relativePath.startsWith(".." + path.sep); + return !!relativePath && !isEscaping && !path.isAbsolute(relativePath); +}; diff --git a/src/helpers/validateUrl.ts b/src/helpers/validateUrl.ts new file mode 100644 index 000000000..1b6e6601f --- /dev/null +++ b/src/helpers/validateUrl.ts @@ -0,0 +1,14 @@ +export function validateSafeUrl(urlString: string): URL { + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + throw new Error("Invalid URL format"); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported"); + } + + return parsed; +} diff --git a/src/icons/share.tsx b/src/icons/share.tsx new file mode 100644 index 000000000..83ec207ff --- /dev/null +++ b/src/icons/share.tsx @@ -0,0 +1,18 @@ +export function ShareIcon() { + return ( + + + + ); +} diff --git a/src/index.tsx b/src/index.tsx index 05713a80f..848d9208f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ import { listConverters } from "./pages/listConverters"; import { results } from "./pages/results"; import { root } from "./pages/root"; import { upload } from "./pages/upload"; +import { url } from "./pages/url"; import { user } from "./pages/user"; import { healthcheck } from "./pages/healthcheck"; @@ -41,6 +42,7 @@ const app = new Elysia({ .use(user) .use(root) .use(upload) + .use(url) .use(history) .use(convert) .use(download) diff --git a/src/pages/convert.tsx b/src/pages/convert.tsx index ed367b23f..b781dbafd 100644 --- a/src/pages/convert.tsx +++ b/src/pages/convert.tsx @@ -1,6 +1,5 @@ import { mkdir } from "node:fs/promises"; import { Elysia, t } from "elysia"; -import sanitize from "sanitize-filename"; import { outputDir, uploadsDir } from ".."; import { handleConvert } from "../converters/main"; import db from "../db/db"; @@ -58,10 +57,6 @@ export const convert = new Elysia().use(userService).post( const fileNames = JSON.parse(body.file_names) as string[]; - for (let i = 0; i < fileNames.length; i++) { - fileNames[i] = sanitize(fileNames[i] || ""); - } - if (!Array.isArray(fileNames) || fileNames.length === 0) { return redirect(`${WEBROOT}/`, 302); } diff --git a/src/pages/deleteFile.tsx b/src/pages/deleteFile.tsx index e18caef3c..79ed6db3e 100644 --- a/src/pages/deleteFile.tsx +++ b/src/pages/deleteFile.tsx @@ -3,8 +3,8 @@ import { Elysia, t } from "elysia"; import { uploadsDir } from ".."; import db from "../db/db"; import { WEBROOT } from "../helpers/env"; +import { isSafePath } from "../helpers/validatePath"; import { userService } from "./user"; -import sanitize from "sanitize-filename"; import path from "node:path"; export const deleteFile = new Elysia().use(userService).post( @@ -24,8 +24,10 @@ export const deleteFile = new Elysia().use(userService).post( const userUploadsDir = path.join(uploadsDir, user.id, jobId.value); - const sanitized = sanitize(body.filename); - const targetPath = path.join(userUploadsDir, sanitized); + const targetPath = path.join(userUploadsDir, body.filename); + if (!isSafePath(userUploadsDir, targetPath)) { + throw new Error("Unsafe filename"); + } await unlink(targetPath); diff --git a/src/pages/download.tsx b/src/pages/download.tsx index ffc64e9bb..120061b9b 100644 --- a/src/pages/download.tsx +++ b/src/pages/download.tsx @@ -1,10 +1,10 @@ -import path from "node:path"; import { Elysia } from "elysia"; -import sanitize from "sanitize-filename"; +import path from "node:path"; import * as tar from "tar"; import { outputDir } from ".."; import db from "../db/db"; import { WEBROOT } from "../helpers/env"; +import { isSafePath } from "../helpers/validatePath"; import { userService } from "./user"; export const download = new Elysia() @@ -22,9 +22,12 @@ export const download = new Elysia() } // parse from URL encoded string const jobId = decodeURIComponent(params.jobId); - const fileName = sanitize(decodeURIComponent(params.fileName)); + const jobPath = `${outputDir}${userId}/${jobId}/`; + const filePath = `${jobPath}${decodeURIComponent(params.fileName)}`; + if (!isSafePath(jobPath, filePath)) { + throw new Error("Unsafe filename"); + } - const filePath = `${outputDir}${userId}/${jobId}/${fileName}`; const file = Bun.file(filePath); if (!(await file.exists())) { set.status = 404; diff --git a/src/pages/results.tsx b/src/pages/results.tsx index c30dbdd84..664fb9fc6 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -1,13 +1,15 @@ import { Elysia } from "elysia"; +import mime from "mime"; import { BaseHtml } from "../components/base"; import { Header } from "../components/header"; import db from "../db/db"; import { Filename, Jobs } from "../db/types"; import { buildDownloadUrl } from "../helpers/buildDownloadUrl"; -import { ALLOW_UNAUTHENTICATED, WEBROOT, BRANDING } from "../helpers/env"; -import { DownloadIcon } from "../icons/download"; +import { ALLOW_UNAUTHENTICATED, BRANDING, WEBROOT } from "../helpers/env"; import { DeleteIcon } from "../icons/delete"; +import { DownloadIcon } from "../icons/download"; import { EyeIcon } from "../icons/eye"; +import { ShareIcon } from "../icons/share"; import { userService } from "./user"; function ResultsArticle({ @@ -131,6 +133,24 @@ function ResultsArticle({ > + )} diff --git a/src/pages/root.tsx b/src/pages/root.tsx index eba323849..3c72fccb6 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -9,6 +9,7 @@ import { User } from "../db/types"; import { ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED, + ALLOW_URL_UPLOAD, HIDE_HISTORY, HTTP_ALLOWED, UNAUTHENTICATED_USER_SHARING, @@ -145,7 +146,7 @@ export const root = new Elysia().use(userService).get( `} > - Choose a file or drag it here + Choose a file or drag a file or folder here + {ALLOW_URL_UPLOAD && ( + + )}
{ + if (!ALLOW_URL_UPLOAD) { + set.status = 403; + throw new Error("URL upload is disabled"); + } + + if (!jobId?.value) { + return redirect(`${WEBROOT}/`, 302); + } + + const existingJob = await db + .query("SELECT * FROM jobs WHERE id = ? AND user_id = ?") + .get(jobId.value, user.id); + + if (!existingJob) { + return redirect(`${WEBROOT}/`, 302); + } + + const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`; + + // 30 seconds timeout for the fetch operation + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), 30_000); + + let currentUrl = body.url; + let res: Response | null = null; + let redirects = 0; + + try { + while (redirects < 5) { + await validateSafeUrl(currentUrl); + + res = await fetch(currentUrl, { + signal: abortController.signal, + redirect: "manual", + }); + + if ([301, 302, 303, 307, 308].includes(res.status)) { + const loc = res.headers.get("Location"); + if (!loc) break; + currentUrl = new URL(loc, currentUrl).toString(); + redirects++; + continue; + } + break; + } + } finally { + clearTimeout(timeout); + } + + if (!res || !res.ok) { + throw new Error(`Failed to download URL, received ${res?.status || "unknown"}`); + } + + const filename = getFilename(currentUrl, res.headers); + const targetFilePath = `${userUploadsDir}${filename}`; + if (!isSafePath(userUploadsDir, targetFilePath)) { + throw new Error("Unsafe filename"); + } + + const fileSizeBytes = await Bun.write(targetFilePath, await res.blob()); + + return { + message: "Files downloaded successfully.", + filename, + fileSizeBytes, + }; + }, + { body: t.Object({ url: t.String() }), auth: true }, +); diff --git a/tests/pages/url.test.ts b/tests/pages/url.test.ts new file mode 100644 index 000000000..402166628 --- /dev/null +++ b/tests/pages/url.test.ts @@ -0,0 +1,54 @@ +import { expect, test, describe } from "bun:test"; +import { getFilename } from "../../src/helpers/getFilename"; + +describe("getFilename", () => { + test("extracts filename from simple URL", () => { + const headers = new Headers(); + const result = getFilename("https://example.com/cat.jpg", headers); + expect(result).toBe("cat.jpg"); + }); + + test("extracts filename from Content-Disposition header", () => { + const headers = new Headers({ + "Content-Disposition": 'attachment; filename="cute-dog.png"', + }); + const result = getFilename("https://example.com/download?id=123", headers); + expect(result).toBe("cute-dog.png"); + }); + + test("extracts RFC 5987 encoded filename from Content-Disposition header", () => { + const headers = new Headers({ + "Content-Disposition": "attachment; filename*=UTF-8''my%20document.pdf", + }); + const result = getFilename("https://example.com/file", headers); + expect(result).toBe("my document.pdf"); + }); + + test("appends MIME extension when filename has no extension", () => { + const headers = new Headers({ + "Content-Type": "image/png", + }); + const result = getFilename("https://example.com/myimage", headers); + expect(result).toBe("myimage.png"); + }); + + test("generates fallback filename when remote supplies empty or all-dot filename", () => { + const headers = new Headers({ + "Content-Disposition": 'attachment; filename="...."', + "Content-Type": "application/pdf", + }); + const result = getFilename("https://example.com/...", headers); + expect(result.endsWith(".pdf")).toBe(true); + expect(result.length).toBeGreaterThan(4); + expect(result).not.toContain(".."); + }); + + test("generates fallback UUID when URL has no filename and no Content-Disposition", () => { + const headers = new Headers({ + "Content-Type": "text/plain", + }); + const result = getFilename("https://example.com/", headers); + expect(result.endsWith(".txt")).toBe(true); + expect(result.length).toBeGreaterThan(4); + }); +}); diff --git a/tests/validatePath.test.ts b/tests/validatePath.test.ts new file mode 100644 index 000000000..85d7ab4a3 --- /dev/null +++ b/tests/validatePath.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { isSafePath } from "../src/helpers/validatePath"; + +describe("isSafePath", () => { + test("should allow no slash", () => { + const path = "cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(true); + }); + + test("should allow safe slash", () => { + const path = "dir/cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(true); + }); + + test("should allow leading slash", () => { + const path = "/dir/cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(true); + }); + + test("should allow leading dots", () => { + const path = "dir/..cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(true); + }); + + test("should allow leading dots in parent", () => { + const path = "..cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(true); + }); + + test("should disallow parent", () => { + const path = "../cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(false); + }); + + test("should disallow absolute paths escaping parent", () => { + const result = isSafePath("./job/", "/etc/passwd"); + expect(result).toEqual(false); + }); + + test("should disallow relative paths escaping parent", () => { + const result = isSafePath("./job/", "./job/../etc/passwd"); + expect(result).toEqual(false); + }); +}); diff --git a/tests/validateUrl.test.ts b/tests/validateUrl.test.ts new file mode 100644 index 000000000..e2a7e92c4 --- /dev/null +++ b/tests/validateUrl.test.ts @@ -0,0 +1,36 @@ +import { expect, test, describe } from "bun:test"; +import { validateSafeUrl } from "../src/helpers/validateUrl"; + +describe("validateSafeUrl", () => { + test("allows valid HTTP and HTTPS URLs", () => { + expect(() => validateSafeUrl("https://example.com/cat.jpg")).not.toThrow(); + expect(() => validateSafeUrl("http://example.com/image.png")).not.toThrow(); + }); + + test("allows local IP and LAN addresses for self-hosted usage", () => { + expect(() => validateSafeUrl("http://localhost:3000/test.jpg")).not.toThrow(); + expect(() => validateSafeUrl("http://192.168.1.50/file.png")).not.toThrow(); + expect(() => validateSafeUrl("http://10.0.0.5/doc.pdf")).not.toThrow(); + expect(() => validateSafeUrl("http://nas.local/data.zip")).not.toThrow(); + }); + + test("rejects non-http/https protocols", () => { + expect(() => validateSafeUrl("file:///etc/passwd")).toThrow( + "Only HTTP and HTTPS URLs are supported", + ); + expect(() => validateSafeUrl("ftp://example.com/file")).toThrow( + "Only HTTP and HTTPS URLs are supported", + ); + expect(() => validateSafeUrl("gopher://example.com")).toThrow( + "Only HTTP and HTTPS URLs are supported", + ); + expect(() => validateSafeUrl("javascript:alert(1)")).toThrow( + "Only HTTP and HTTPS URLs are supported", + ); + }); + + test("rejects invalid URL formats", () => { + expect(() => validateSafeUrl("not-a-url")).toThrow("Invalid URL format"); + expect(() => validateSafeUrl("://bad.url")).toThrow("Invalid URL format"); + }); +});