diff --git a/.claude/get-api-key.sh b/.claude/get-api-key.sh new file mode 100755 index 0000000..2626974 --- /dev/null +++ b/.claude/get-api-key.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Load project-local .env file +set -o allexport +source "$(dirname "$0")/../.env" +set +o allexport +# Output the API key to Claude Code +echo "$ANTHROPIC_API_KEY" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5f7058e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "apiKeyHelper": "./.claude/get-api-key.sh" +} diff --git a/.config/git-hooks/README.md b/.config/git-hooks/README.md new file mode 100644 index 0000000..db2abe9 --- /dev/null +++ b/.config/git-hooks/README.md @@ -0,0 +1,114 @@ +# AI-Blocking Git Hooks + +Git hooks that prevent AI indicators from being committed to your repository. + +## What These Hooks Block + +The hooks scan for AI references including: + +- AI tool names (Claude, Cursor, Copilot, ChatGPT, Gemini, etc.) +- AI-related phrases ("ai-generated", "ai-assisted", "generated with", etc.) +- Model names (GPT-4, Claude Sonnet, Claude Code, etc.) +- Company names (OpenAI, Anthropic, etc.) +- Co-authorship markers (Co-Authored-By: Claude, noreply@anthropic.com, etc.) + +## Prerequisites + +Install [pre-commit](https://pre-commit.com/): + +```bash +pip install pre-commit +``` + +## Installation + +### Single Repository + +1. Navigate to your repository: + + ```bash + cd /path/to/your/repo + ``` + +2. Create or update `.pre-commit-config.yaml`: + + ```yaml + repos: + - repo: local + hooks: + - id: ai-pre-commit + name: Block AI indicators in staged files + entry: python3 .config/ai-hooks/ai_guard.py pre-commit + language: system + pass_filenames: false + stages: [pre-commit] + - id: ai-commit-msg + name: Block AI indicators in commit messages + entry: python3 .config/ai-hooks/ai_guard.py commit-msg + language: system + stages: [commit-msg] + ``` + +3. Install the hooks: + + ```bash + pre-commit install + ``` + +## How It Works + +### commit-msg Hook + +- Runs when you create a commit +- Scans the commit message for AI indicators +- Blocks the commit if any indicators are found + +### pre-commit Hook + +- Runs before the commit is created +- Scans all staged files for AI indicators +- Blocks the commit if any indicators are found in the code + +## Uninstallation + +To remove the hooks from a repository: + +```bash +cd /path/to/your/repo +rm .git/hooks/commit-msg .git/hooks/pre-commit .git/hooks/ai_guard.py +``` + +## Testing + +To verify the hooks are working: + +1. Create a test file with AI indicators: + + ```bash + echo "Generated with Claude Code" > test.txt + git add test.txt + git commit -m "test" + ``` + +2. You should see an error message blocking the commit + +3. Clean up: + ```bash + git reset HEAD test.txt + rm test.txt + ``` + +## Bypassing Hooks When Needed + +In rare cases where you need to commit content with AI indicators, you can bypass the hooks using the `--no-verify` flag: + +```bash +git commit --no-verify -m "your commit message" +``` + +## Notes + +- These hooks are **repository-specific** and must be installed in each repo where you want them active +- Git hooks are not tracked by git itself (they live in `.git/hooks/`) +- If you have existing hooks, the installer will create `.backup` copies +- All pattern matching is case-insensitive diff --git a/.config/git-hooks/ai_guard.py b/.config/git-hooks/ai_guard.py new file mode 100755 index 0000000..d4e8df2 --- /dev/null +++ b/.config/git-hooks/ai_guard.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Python wrapper for AI indicator guard hooks. +Calls the appropriate bash script based on the hook type. +""" + +import subprocess +import sys +from pathlib import Path + + +def main(): + if len(sys.argv) < 2: + print("Usage: ai_guard.py ", file=sys.stderr) + sys.exit(1) + + hook_type = sys.argv[1] + + # Get the directory where this script is located + script_dir = Path(__file__).parent + + # Determine which hook script to call + if hook_type == "pre-commit": + hook_script = script_dir / "pre-commit" + elif hook_type == "commit-msg": + hook_script = script_dir / "commit-msg" + # For commit-msg, we need to pass the commit message file + if len(sys.argv) < 3: + print("Error: commit-msg hook requires message file argument", file=sys.stderr) + sys.exit(1) + commit_msg_file = sys.argv[2] + else: + print(f"Unknown hook type: {hook_type}", file=sys.stderr) + sys.exit(1) + + if not hook_script.exists(): + print(f"Hook script not found: {hook_script}", file=sys.stderr) + sys.exit(1) + + # Call the hook script + try: + if hook_type == "commit-msg": + result = subprocess.run( + [str(hook_script), commit_msg_file], + check=False + ) + else: + result = subprocess.run( + [str(hook_script)], + check=False + ) + sys.exit(result.returncode) + except Exception as e: + print(f"Error running hook: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.config/git-hooks/commit-msg b/.config/git-hooks/commit-msg new file mode 100755 index 0000000..9c4e129 --- /dev/null +++ b/.config/git-hooks/commit-msg @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# Guard commit messages against explicit AI usage indicators. +# The script rejects messages containing phrases that suggest the text +# was generated by an AI assistant or tool (as defined in onboarding docs). + +set -euo pipefail + +MSG_FILE=${1:?} + +# Patterns to block; all matches are case-insensitive (grep -i). +declare -a PATTERNS=( + "generated by cursor" + "cursor ai" + "cursor wrote this" + "cursor suggestion" + "generated by claude" + "claude wrote this" + "claude ai" + "claude opus" + "claude sonnet" + "claude haiku" + "claude code" + "generated with claude code" + "generated with \\[claude code\\]" + "co-authored-by: claude" + "noreply@anthropic\\.com" + "generated by gemini" + "gemini wrote this" + "google gemini" + "gemini ultra" + "gemini pro" + "gemini nano" + "generated by bard" + "bard wrote this" + "google bard" + "as an ai assistant" + "as an ai" + "ai assistant" + "ai suggestion" + "ai suggestions" + "ai-generated" + "ai generated" + "ai-assisted" + "ai assisted" + "ai-powered" + "ai powered" + "powered by ai" + "with ai assistance" + "with the help of ai" + "assisted by ai" + "written with ai" + "written by ai" + "generated with ai" + "generated via ai" + "created with ai" + "created by ai" + "authored by ai" + "co-authored by ai" + "ai help" + "ai helped" + "ai copilot" + "ai co-pilot" + "chatgpt" + "gpt-4" + "gpt4" + "gpt-3" + "gpt3" + "openai" + "anthropic" + "google ai" + "google deepmind" + "deepmind" + "perplexity" + "perplexity ai" + "tabnine" + "tabnine ai" + "replit ghostwriter" + "ghostwriter ai" + "aws codewhisperer" + "amazon codewhisperer" + "code whisperer" + "codewhisperer" + "github copilot" + "copilot" + "amazon q" + "amazon q developer" + "meta ai" + "deepseek" + "phind" + "blackbox ai" + "magic ai" + "ai model" + "llm" + "large language model" + "language model" + "\\[ai\\]" + "\\[ai-generated\\]" + "\\[cursor\\]" + "\\[claude\\]" + "\\[gemini\\]" + "\\[copilot\\]" + "\\[chatgpt\\]" + "\\[llm\\]" +) + +for pattern in "${PATTERNS[@]}"; do + if LC_ALL=C grep -qiE "$pattern" "$MSG_FILE"; then + cat >&2 < "* ]]; then + path=${path##* -> } + fi + if [[ "$added" == "-" || "$deleted" == "-" ]]; then + BINARY_PATHS="${BINARY_PATHS}${path}"$'\n' + fi +done < <(git diff --cached --numstat --diff-filter=ACMRT || true) + +report="" +current_file="" +last_report_file="" +violations=0 + +while IFS= read -r line; do + case "$line" in + "diff --git "*) + current_file="" + ;; + "+++ "*) + current_file=${line#+++ } + if [[ "$current_file" == b/* ]]; then + current_file=${current_file#b/} + elif [[ "$current_file" == a/* ]]; then + current_file=${current_file#a/} + fi + if [[ "$current_file" == "/dev/null" ]]; then + current_file="" + fi + ;; + "Binary files "*) + current_file="" + ;; + *) + if [[ "$line" == +* && "$line" != "+++"* ]]; then + [[ -z "$current_file" ]] && continue + if [[ -n "$BINARY_PATHS" ]] && echo "$BINARY_PATHS" | grep -qxF "$current_file"; then + continue + fi + content=${line:1} + if [[ -n "$content" ]] && LC_ALL=C grep -qiE "$PATTERN_REGEX" <<<"$content"; then + if [[ "$current_file" != "$last_report_file" ]]; then + report+=$'\n'"• $current_file" + last_report_file="$current_file" + fi + report+=$'\n'" ${line}" + violations=1 + fi + fi + ;; + esac +done < <(git diff --cached --diff-filter=ACMRT --unified=0 --no-color) + +if [[ $violations -eq 1 ]]; then + cat >&2 <=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -593,6 +598,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..b9c200f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +if [ -n "$MAGICK_MAX_WIDTH" ] || [ -n "$MAGICK_MAX_HEIGHT" ]; then + POLICY_DIR="/etc/ImageMagick-7" + POLICY_FILE="$POLICY_DIR/policy.xml" + + 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 + +exec bun run dist/src/index.js diff --git a/eslint.config.ts b/eslint.config.ts index 8b493b3..c077dd6 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 0fe130f..2a7daee 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@elysiajs/static": "^1.4.10", "@kitajs/html": "^4.2.13", "elysia": "1.4.22", + "mime": "^4.1.0", "sanitize-filename": "^1.6.4", "tar": "^7.5.22" }, diff --git a/public/results.js b/public/results.js index 60c07eb..468195e 100644 --- a/public/results.js +++ b/public/results.js @@ -3,6 +3,42 @@ 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; + } + btn.addEventListener("click", async (e) => { + e.preventDefault(); + try { + const response = await fetch(fileUrl); + const blob = await response.blob(); + const file = new File([blob], filename, { type: mimeType }); + await navigator.share({ files: [file] }); + } catch (err) { + if (err.name !== "AbortError") { + console.error("Error sharing:", err); + } + } + }); + btn.style.display = ""; + btn.dataset.setupComplete = true; + }); +}; + const refreshData = () => { // console.log("Refreshing data...", progressElem.value, progressElem.max); if (progressElem.value !== progressElem.max) { @@ -12,6 +48,7 @@ const refreshData = () => { .then((res) => res.text()) .then((html) => { main.innerHTML = html; + setupShareButtons(); }) .catch((err) => console.log(err)); @@ -21,6 +58,7 @@ const refreshData = () => { progressElem = document.querySelector("progress"); }; +setupShareButtons(); refreshData(); window.downloadAll = function () { diff --git a/public/script.js b/public/script.js index 5c2e337..6531cbc 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,95 @@ dropZone.addEventListener("dragleave", () => { dropZone.classList.remove("dragover"); }); -dropZone.addEventListener("drop", (e) => { +const entryToFiles = async (entry) => { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + return [file]; + } + const reader = entry.createReader(); + const entries = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + const out = []; + for (const entry of entries) { + out.push(...(await entryToFiles(entry))); + } + return out; +}; + +const getFileNameWithSlashes = (file) => { + if (file.webkitRelativePath) { + return file.webkitRelativePath; + } else { + 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(); + handleUrl(urlInput.value); +}); + +function handleUrl(url) { + fetch(`${webroot}/url`, { + method: "POST", + body: JSON.stringify({ url }), + headers: { "Content-Type": "application/json" }, + }) + .then((res) => res.json()) + .then((res) => { + const fileList = document.querySelector("#file-list"); + + const row = document.createElement("tr"); + row.innerHTML = ` + ${res.filename} + + ${(res.fileSizeBytes / 1024).toFixed(2)} kB + Remove + `; + + fileList.appendChild(row); + fileNames.push(res.filename); + }) + .catch(console.error); +} + // 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 +125,7 @@ function handleFile(file) { fileList.appendChild(row); file.htmlRow = row; - fileNames.push(file.name); + fileNames.push(getFileNameWithSlashes(file)); uploadFile(file); } @@ -202,7 +262,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 +288,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 23839d9..aec8c5e 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"; @@ -25,6 +26,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 @@ -163,23 +166,37 @@ 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 fileTypeOrig = fileName.split(".").pop() ?? ""; 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 = fileName.replace( + new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`), + 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/validatePath.ts b/src/helpers/validatePath.ts new file mode 100644 index 0000000..96b1c4b --- /dev/null +++ b/src/helpers/validatePath.ts @@ -0,0 +1,6 @@ +import path from "node:path"; + +export const isSafePath = (expectedParent: string, userInput: string): boolean => { + const relativePath = path.relative(expectedParent, userInput); + return !!relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +}; diff --git a/src/icons/share.tsx b/src/icons/share.tsx new file mode 100644 index 0000000..83ec207 --- /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 05713a8..848d920 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 ed367b2..b781dba 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 e18caef..79ed6db 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 f10fec4..89aaf35 100644 --- a/src/pages/download.tsx +++ b/src/pages/download.tsx @@ -1,16 +1,16 @@ import path from "node:path"; import { Elysia } from "elysia"; -import sanitize from "sanitize-filename"; 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() .use(userService) .get( - "/download/:userId/:jobId/:fileName", + "/download/:userId/:jobId/*", async ({ params, redirect, user }) => { const userId = user.id; const job = await db @@ -22,9 +22,11 @@ export const download = new Elysia() } // parse from URL encoded string const jobId = decodeURIComponent(params.jobId); - const fileName = sanitize(decodeURIComponent(params.fileName)); - - const filePath = `${outputDir}${userId}/${jobId}/${fileName}`; + const jobPath = `${outputDir}${userId}/${jobId}/`; + const filePath = `${jobPath}${decodeURIComponent(params["*"])}`; + if (!isSafePath(jobPath, filePath)) { + throw new Error("Unsafe filename"); + } return Bun.file(filePath); }, { diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 64dd221..de35ed8 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -8,6 +8,8 @@ import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env"; import { DownloadIcon } from "../icons/download"; import { DeleteIcon } from "../icons/delete"; import { EyeIcon } from "../icons/eye"; +import { ShareIcon } from "../icons/share"; +import mime from "mime"; import { userService } from "./user"; function ResultsArticle({ @@ -122,6 +124,18 @@ function ResultsArticle({ > + ))} diff --git a/src/pages/root.tsx b/src/pages/root.tsx index bf1a73c..d5efcae 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -143,7 +143,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 +
{ + const contentDisposition = headers.get("Content-Disposition"); + if (contentDisposition) { + const match = /filename="([^"]+)"/.exec(contentDisposition); + if (match && match[1]) { + return sanitize(match[1]); + } + } + const path = new URL(url).pathname; + const lastPart = path.split("/").at(-1); + const contentType = headers.get("content-type"); + const extension = contentType ? mime.getExtension(contentType) : null; + if (!lastPart) { + if (extension) { + return `${randomUUID()}.${extension}`; + } + return randomUUID(); + } + if (!lastPart.includes(".") && extension) { + return `${sanitize(lastPart)}.${extension}`; + } + return sanitize(lastPart); +}; + +export const url = new Elysia().use(userService).post( + "/url", + async ({ body, redirect, user, cookie: { jobId } }) => { + 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}/`; + + const res = await fetch(body.url); + if (!res.ok) { + throw new Error(`Failed to download URL, received ${res.status}`); + } + const filename = getFilename(body.url, res.headers); + const fileSizeBytes = await Bun.write(`${userUploadsDir}${filename}`, await res.blob()); + + return { + message: "Files downloaded successfully.", + filename, + fileSizeBytes, + }; + }, + { body: t.Object({ url: t.String() }), auth: true }, +); diff --git a/tests/validatePath.test.ts b/tests/validatePath.test.ts new file mode 100644 index 0000000..1cc7daf --- /dev/null +++ b/tests/validatePath.test.ts @@ -0,0 +1,34 @@ +import { expect, test, describe } 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 disallow parent", () => { + const path = "../cat.jpg"; + const result = isSafePath("./job/", `./job/${path}`); + expect(result).toEqual(false); + }); +});