diff --git a/README.md b/README.md index feb894c5..ce517064 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/" | | FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. | diff --git a/src/helpers/env.ts b/src/helpers/env.ts index 53f6a8fc..3d8ed830 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 00000000..37521f20 --- /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/validateUrl.ts b/src/helpers/validateUrl.ts new file mode 100644 index 00000000..1b6e6601 --- /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/tests/pages/url.test.ts b/tests/pages/url.test.ts new file mode 100644 index 00000000..40216662 --- /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/validateUrl.test.ts b/tests/validateUrl.test.ts new file mode 100644 index 00000000..e2a7e92c --- /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"); + }); +});