diff --git a/README.md b/README.md index aa4e6802..4c6b5eb4 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,20 @@ Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summar Image with all contributors +## Files updated: + +src/db/types.ts +src/db/db.ts +src/pages/root.tsx +src/pages/user.tsx +src/pages/upload.tsx +src/pages/antivirus.tsx +src/components/base.tsx +public/script.js +public/theme-init.js +public/theme.css +src/main.css + ![Alt](https://repobeats.axiom.co/api/embed/dcdabd0564fcdcccbf5680c1bdc2efad54a3d4d9.svg "Repobeats analytics image") ## Star History diff --git a/compose.yaml b/compose.yaml index 5b26ff8e..ca29d2ba 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,5 +16,32 @@ services: # - HIDE_HISTORY=true # hides the history tab in the web interface, defaults to false - TZ=Europe/Stockholm # set your timezone, defaults to UTC # - UNAUTHENTICATED_USER_SHARING=true # for use with ALLOW_UNAUTHENTICATED=true to share history with all unauthenticated users / devices + - CLAMAV_URL=http://clam_av_api:3000/api/v1/scan ports: - - 3000:3000 + - 8080:3000 + + clamav-rest-api: + image: benzino77/clamav-rest-api:latest + container_name: clamav-rest-api + restart: unless-stopped + environment: + - NODE_ENV=production + # field name expected in the multipart form + - APP_FORM_KEY=FILES + # talk to your existing ClamAV daemon + - CLAMD_IP=CLAMAV_server_IP + - CLAMD_PORT=3310 + # max allowed file size (here: 250 MB) + - APP_MAX_FILE_SIZE=262144000 + ports: + # outside:inside + - "3000:3000" + + clamav: + image: clamav/clamav:latest + container_name: clamav + restart: unless-stopped + ports: + - "3310:3310" + environment: + - CLAMAV_NO_FRESHCLAMD=false diff --git a/public/results.js b/public/results.js index 60c07eb5..23493c26 100644 --- a/public/results.js +++ b/public/results.js @@ -1,38 +1,222 @@ -const webroot = document.querySelector("meta[name='webroot']").content; -const jobId = window.location.pathname.split("/").pop(); -const main = document.querySelector("main"); -let progressElem = document.querySelector("progress"); - -const refreshData = () => { - // console.log("Refreshing data...", progressElem.value, progressElem.max); - if (progressElem.value !== progressElem.max) { - fetch(`${webroot}/progress/${jobId}`, { - method: "POST", - }) - .then((res) => res.text()) - .then((html) => { - main.innerHTML = html; - }) - .catch((err) => console.log(err)); - - setTimeout(refreshData, 1000); +// public/results.js +// +// Handles live progress updates for /results/:jobId and Share via Erugo modal actions. +// IMPORTANT: /progress/:jobId re-renders the
content, so we must not keep stale +// element references. Use event delegation + (re)query elements when needed. + +(function () { + const webrootMeta = document.querySelector("meta[name='webroot']"); + const webroot = webrootMeta ? webrootMeta.content : ""; + const jobId = window.location.pathname.split("/").pop(); + const main = document.querySelector("main"); + + // ----------------------------- + // Progress refresh + // ----------------------------- + async function refreshData() { + if (!main || !jobId) return; + + const progressElem = main.querySelector("progress"); + if (!progressElem) return; + + const max = Number(progressElem.getAttribute("max") || "0"); + const val = Number(progressElem.getAttribute("value") || "0"); + + // Only refresh while still processing + if (max > 0 && val >= max) return; + + try { + const res = await fetch(`${webroot}/progress/${jobId}`, { method: "POST", cache: "no-store" }); + const html = await res.text(); + main.innerHTML = html; + } catch (err) { + console.error("[ConvertX] progress refresh failed", err); + } + } + + // Poll every second while job is running + setInterval(refreshData, 1000); + // Run once immediately so the page updates without waiting 1s. + refreshData(); + + // ----------------------------- + // Share modal helpers + // ----------------------------- + function getEls() { + return { + modal: document.getElementById("cxShareModal"), + closeBtn: document.getElementById("cxShareClose"), + cancelBtn: document.getElementById("cxShareCancel"), + submitBtn: document.getElementById("cxShareSubmit"), + statusEl: document.getElementById("cxShareStatus"), + + emailEl: document.getElementById("cxShareEmail"), + nameEl: document.getElementById("cxShareName"), + descEl: document.getElementById("cxShareDescription"), + + linkBlock: document.getElementById("cxShareLinkBlock"), + linkEl: document.getElementById("cxShareLink"), + copyBtn: document.getElementById("cxShareCopy"), + }; + } + + let currentJobId = null; + let currentFileName = null; + + function openModal(jobIdValue, fileNameValue) { + const els = getEls(); + if (!els.modal) return; + + currentJobId = jobIdValue; + currentFileName = fileNameValue; + + if (els.nameEl) els.nameEl.value = fileNameValue || ""; + if (els.emailEl) els.emailEl.value = ""; + if (els.descEl) els.descEl.value = ""; + + if (els.linkBlock) els.linkBlock.classList.add("hidden"); + if (els.linkEl) els.linkEl.value = ""; + if (els.statusEl) els.statusEl.textContent = ""; + + els.modal.classList.remove("hidden"); + els.modal.classList.add("flex"); + + if (els.emailEl) els.emailEl.focus(); } - progressElem = document.querySelector("progress"); -}; + function closeModal() { + const els = getEls(); + if (!els.modal) return; -refreshData(); + els.modal.classList.add("hidden"); + els.modal.classList.remove("flex"); -window.downloadAll = function () { - // Get all download links - const downloadLinks = document.querySelectorAll("tbody a[download]"); + currentJobId = null; + currentFileName = null; + } - // Trigger download for each link - downloadLinks.forEach((link, index) => { - // We add a delay for each download to prevent them from starting at the same time - setTimeout(() => { - const event = new MouseEvent("click"); - link.dispatchEvent(event); - }, index * 300); + async function shareFile(jobIdValue, fileNameValue) { + const els = getEls(); + if (!els.submitBtn || !els.statusEl) return; + + const recipientEmail = (els.emailEl && els.emailEl.value ? els.emailEl.value.trim() : "") || undefined; + const shareName = (els.nameEl && els.nameEl.value ? els.nameEl.value.trim() : "") || undefined; + const description = (els.descEl && els.descEl.value ? els.descEl.value.trim() : "") || undefined; + + els.submitBtn.disabled = true; + els.submitBtn.setAttribute("aria-busy", "true"); + els.statusEl.textContent = "Sending..."; + + try { + const payload = { fileName: fileNameValue, recipientEmail, shareName, description }; + + const res = await fetch(`${webroot}/share-to-erugo/${jobIdValue}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text }; + } + + if (!res.ok) { + console.error("[ConvertX] share-to-erugo failed", res.status, json); + els.statusEl.textContent = "Failed. See logs."; + return; + } + + const url = + (json && (json.share_url || json.share_link)) || + (json && json.data && json.data.url) || + (json && json.data && json.data.share && json.data.share.url) || + null; + + if (url && els.linkEl && els.linkBlock) { + els.linkEl.value = url; + els.linkBlock.classList.remove("hidden"); + } + + if (recipientEmail) { + els.statusEl.textContent = url ? "Sent. Link also shown below." : "Sent."; + } else { + els.statusEl.textContent = url ? "Created. Copy the link below." : "Created."; + } + } catch (err) { + console.error(err); + els.statusEl.textContent = "Failed. See logs."; + } finally { + els.submitBtn.disabled = false; + els.submitBtn.removeAttribute("aria-busy"); + } + } + + // ----------------------------- + // Global event handlers (delegated) + // ----------------------------- + document.addEventListener("click", async (event) => { + const target = event.target; + + // Share icon buttons inside the table + const shareBtn = target && target.closest ? target.closest("button[data-share='true']") : null; + if (shareBtn) { + const jobIdAttr = shareBtn.getAttribute("data-job-id"); + const fileNameAttr = shareBtn.getAttribute("data-file-name"); + if (!jobIdAttr || !fileNameAttr) return; + openModal(jobIdAttr, fileNameAttr); + return; + } + + // Modal close/cancel + if (target && target.id === "cxShareClose") { + closeModal(); + return; + } + if (target && target.id === "cxShareCancel") { + closeModal(); + return; + } + + // Click outside modal (on overlay) + const els = getEls(); + if (els.modal && target === els.modal) { + closeModal(); + return; + } + + // Submit + if (target && target.id === "cxShareSubmit") { + if (!currentJobId || !currentFileName) return; + await shareFile(currentJobId, currentFileName); + return; + } + + // Copy + if (target && target.id === "cxShareCopy") { + const e = getEls(); + if (!e.linkEl || !e.statusEl) return; + try { + await navigator.clipboard.writeText(e.linkEl.value); + e.statusEl.textContent = "Copied."; + } catch { + e.linkEl.focus(); + e.linkEl.select(); + e.statusEl.textContent = "Select + copy (Ctrl/Cmd+C)."; + } + return; + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + const els = getEls(); + if (els.modal && !els.modal.classList.contains("hidden")) { + closeModal(); + } }); -}; +})(); + diff --git a/public/script.js b/public/script.js index 8b89194f..86974809 100644 --- a/public/script.js +++ b/public/script.js @@ -7,6 +7,195 @@ let fileType; let pendingFiles = 0; let formatSelected = false; +// ───────────────────────────────────── +// Antivirus toggle UI (custom slider) +// ───────────────────────────────────── + +let avToggleButton = null; +let avToggleLabel = null; + +// Inject minimal CSS so the toggle looks like a real slider +function injectAvToggleStyles() { + if (document.getElementById("av-toggle-styles")) return; + + const style = document.createElement("style"); + style.id = "av-toggle-styles"; + style.textContent = ` + .av-toggle-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 0.9rem; + } + + /* Light theme (default) */ + .av-toggle-label { + color: #04070e; + transition: color 0.15s ease; + } + + /* Dark theme: make label white */ + html[data-theme="dark"] .av-toggle-label { + color: #ffffff; + } + + .av-toggle-switch { + position: relative; + width: 42px; + height: 22px; + border-radius: 999px; + border: none; + background-color: #4b5563; + padding: 0; + cursor: pointer; + transition: background-color 0.2s ease; + display: inline-flex; + align-items: center; + } + .av-toggle-switch::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + border-radius: 999px; + background-color: #ffffff; + box-shadow: 0 1px 3px rgba(0,0,0,0.35); + left: 2px; + transition: transform 0.2s ease; + } + .av-toggle-switch.av-on { + background-color: #22c55e; + } + .av-toggle-switch.av-on::before { + transform: translateX(20px); + } + .av-toggle-switch.av-disabled { + opacity: 0.4; + cursor: not-allowed; + } + `; + document.head.appendChild(style); +} + +function setAntivirusToggleVisual(enabled, available) { + if (!avToggleButton) return; + + avToggleButton.classList.remove("av-on", "av-disabled"); + + if (!available) { + avToggleButton.classList.add("av-disabled"); + avToggleButton.setAttribute("aria-disabled", "true"); + avToggleButton.setAttribute("aria-pressed", "false"); + if (avToggleLabel) { + avToggleLabel.textContent = + "Antivirus scan unavailable (CLAMAV_URL not set)"; + } + return; + } + + avToggleButton.setAttribute("aria-disabled", "false"); + + if (enabled) { + avToggleButton.classList.add("av-on"); + avToggleButton.setAttribute("aria-pressed", "true"); + } else { + avToggleButton.setAttribute("aria-pressed", "false"); + } + + if (avToggleLabel) { + avToggleLabel.textContent = "Enable antivirus scan"; + } +} + +function initAntivirusToggleState() { + if (!avToggleButton) return; + + fetch(`${webroot}/api/antivirus`) + .then((res) => res.json()) + .then((data) => { + console.log("Antivirus state from server:", data); + const available = !!data.available; + const enabled = !!data.enabled; + setAntivirusToggleVisual(enabled, available); + }) + .catch((err) => { + console.error("Failed to get antivirus state:", err); + setAntivirusToggleVisual(false, false); + if (avToggleLabel) { + avToggleLabel.textContent = "Antivirus scan status unavailable"; + } + }); + + avToggleButton.addEventListener("click", () => { + const isDisabled = avToggleButton.classList.contains("av-disabled"); + if (isDisabled) return; + + const currentlyOn = avToggleButton.classList.contains("av-on"); + const newValue = !currentlyOn; + + fetch(`${webroot}/api/antivirus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: newValue }), + }) + .then((res) => res.json()) + .then((data) => { + console.log("Antivirus updated state:", data); + const available = !!data.available; + const enabled = !!data.enabled; + setAntivirusToggleVisual(enabled, available); + }) + .catch((err) => { + console.error("Failed to update antivirus state:", err); + // On error, do not visually toggle + }); + }); +} + +function createAntivirusToggle() { + injectAvToggleStyles(); + + const form = document.querySelector("form"); + if (!form) return; + if (document.getElementById("av-toggle-wrapper")) return; + + const wrapper = document.createElement("div"); + wrapper.id = "av-toggle-wrapper"; + wrapper.className = "av-toggle-wrapper"; + + const labelSpan = document.createElement("span"); + labelSpan.id = "av-toggle-label"; + labelSpan.className = "av-toggle-label"; + labelSpan.textContent = "Enable antivirus scan"; + + const button = document.createElement("button"); + button.type = "button"; + button.id = "av-toggle"; + button.className = "av-toggle-switch"; + button.setAttribute("role", "switch"); + button.setAttribute("aria-pressed", "false"); + button.setAttribute("aria-disabled", "true"); + + wrapper.appendChild(labelSpan); + wrapper.appendChild(button); + + // Insert at top of form so it's visible above dropzone + form.insertBefore(wrapper, form.firstChild); + + avToggleButton = button; + avToggleLabel = labelSpan; + + initAntivirusToggleState(); +} + +// Create the antivirus toggle as soon as script runs +createAntivirusToggle(); + +// ───────────────────────────────────── +// Existing upload UI logic +// ───────────────────────────────────── + dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); @@ -33,7 +222,6 @@ dropZone.addEventListener("drop", (e) => { } }); -// Extracted handleFile function for reusability in drag-and-drop and file input function handleFile(file) { const fileList = document.querySelector("#file-list"); @@ -128,14 +316,11 @@ const updateSearchBar = () => { }); convertToInput.addEventListener("search", () => { - // when the user clears the search bar using the 'x' button convertButton.disabled = true; formatSelected = false; }); convertToInput.addEventListener("blur", (e) => { - // Keep the popup open even when clicking on a target button - // for a split second to allow the click to go through if (e?.relatedTarget?.classList?.contains("target")) { convertToPopup.classList.add("hidden"); convertToPopup.classList.remove("flex"); @@ -152,7 +337,6 @@ const updateSearchBar = () => { }); }; -// Add a 'change' event listener to the file input element fileInput.addEventListener("change", (e) => { const files = e.target.files; for (const file of files) { @@ -165,21 +349,19 @@ const setTitle = () => { title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`; }; -// 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; row.remove(); - // remove from fileNames const index = fileNames.indexOf(filename); - fileNames.splice(index, 1); + if (index !== -1) { + fileNames.splice(index, 1); + } - // reset fileInput fileInput.value = ""; - // if fileNames is empty, reset fileType if (fileNames.length === 0) { fileType = null; fileInput.removeAttribute("accept"); @@ -209,20 +391,104 @@ const uploadFile = (file) => { xhr.open("POST", `${webroot}/upload`, true); xhr.onload = () => { - let data = JSON.parse(xhr.responseText); - pendingFiles -= 1; + + // 🔍 1) Log exactly what the browser got + console.log("Upload raw response:", xhr.status, xhr.responseText); + + let data = {}; + try { + data = JSON.parse(xhr.responseText || "{}"); + } catch (e) { + console.error("Failed to parse upload response:", e, xhr.responseText); + } + + // 🔍 2) Compute an "infected" flag as robustly as possible + const isInfected = + (typeof data === "object" && + data !== null && + (data.infected === true || + data.infected === "true" || + (typeof data.message === "string" && + data.message.toLowerCase().includes("infected file found")))) || + (typeof xhr.responseText === "string" && + xhr.responseText.toLowerCase().includes("infected file found")); + + // 🔴 3) If backend reports infection, show popup and stop + if (xhr.status >= 200 && xhr.status < 300 && isInfected) { + const infectedFiles = data.infectedFiles || []; + const details = infectedFiles + .map((f) => + `${f.name}: ${ + Array.isArray(f.viruses) && f.viruses.length + ? f.viruses.join(", ") + : "malware detected" + }`, + ) + .join("\n"); + + alert( + "⚠️ Infected file found. Conversion will be aborted.\n\n" + + (details ? "Details:\n" + details : ""), + ); + + // Remove row for this file + if (file.htmlRow && file.htmlRow.remove) { + file.htmlRow.remove(); + } + + // Remove from internal list + const idx = fileNames.indexOf(file.name); + if (idx !== -1) { + fileNames.splice(idx, 1); + } + + if (fileNames.length === 0) { + fileType = null; + fileInput.removeAttribute("accept"); + setTitle(); + convertButton.disabled = true; + } else if (pendingFiles === 0 && formatSelected) { + convertButton.disabled = false; + } + + convertButton.textContent = "Convert"; + + const progressbar = file.htmlRow?.getElementsByTagName("progress"); + if (progressbar && progressbar[0]?.parentElement) { + progressbar[0].parentElement.remove(); + } + + return; + } + + // Generic HTTP error + if (xhr.status !== 200) { + console.error("Upload failed:", xhr.status, xhr.responseText); + alert("Upload failed. Please try again."); + convertButton.disabled = false; + convertButton.textContent = "Upload failed"; + + const progressbar = file.htmlRow.getElementsByTagName("progress"); + if (progressbar[0]?.parentElement) { + progressbar[0].parentElement.remove(); + } + return; + } + + // Clean upload if (pendingFiles === 0) { - if (formatSelected) { + if (formatSelected && fileNames.length > 0) { convertButton.disabled = false; } convertButton.textContent = "Convert"; } - //Remove the progress bar when upload is done - let progressbar = file.htmlRow.getElementsByTagName("progress"); - progressbar[0].parentElement.remove(); - console.log(data); + const progressbar = file.htmlRow.getElementsByTagName("progress"); + if (progressbar[0]?.parentElement) { + progressbar[0].parentElement.remove(); + } + console.log("Upload parsed response:", data); }; xhr.upload.onprogress = (e) => { @@ -231,11 +497,16 @@ const uploadFile = (file) => { console.log(`upload progress (${file.name}):`, (100 * sent) / total); let progressbar = file.htmlRow.getElementsByTagName("progress"); - progressbar[0].value = (100 * sent) / total; + if (progressbar[0]) { + progressbar[0].value = (100 * sent) / total; + } }; xhr.onerror = (e) => { - console.log(e); + console.log("XHR error:", e); + alert("Upload failed due to a network error."); + convertButton.disabled = false; + convertButton.textContent = "Upload failed"; }; xhr.send(formData); @@ -243,9 +514,117 @@ const uploadFile = (file) => { const formConvert = document.querySelector(`form[action='${webroot}/convert']`); -formConvert.addEventListener("submit", () => { - const hiddenInput = document.querySelector("input[name='file_names']"); - hiddenInput.value = JSON.stringify(fileNames); -}); +if (formConvert) { + formConvert.addEventListener("submit", () => { + console.log("Submitting convert form with files:", fileNames); + const hiddenInput = document.querySelector("input[name='file_names']"); + if (hiddenInput) { + hiddenInput.value = JSON.stringify(fileNames); + } else { + console.warn( + "Hidden input 'file_names' not found – form will submit without it.", + ); + } + }); +} updateSearchBar(); + +/* ------------------------------------------------------------------ + * Theme toggle (Light / Dark) in header, before "History" + * ------------------------------------------------------------------ */ + +const THEME_STORAGE_KEY = "convertx-theme"; + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === "dark") { + root.setAttribute("data-theme", "dark"); + } else { + root.removeAttribute("data-theme"); // original light theme + } +} + +function initThemeFromPreference() { + let stored = null; + try { + stored = localStorage.getItem(THEME_STORAGE_KEY); + } catch (_e) { + // ignore + } + + const initial = + stored === "dark" || stored === "light" ? stored : "light"; + + applyTheme(initial); + return initial; +} + +function createThemeToggle(initialTheme) { + if (document.querySelector(".cx-theme-toggle")) return; + + const container = document.createElement("span"); + container.className = "cx-theme-toggle"; + + container.innerHTML = ` + ${ + initialTheme === "dark" ? "Dark" : "Light" + } + + `; + + const switchEl = container.querySelector(".cx-switch"); + const labelEl = container.querySelector(".cx-theme-toggle__label"); + + switchEl.addEventListener("click", () => { + const isDark = + document.documentElement.getAttribute("data-theme") === "dark"; + const newTheme = isDark ? "light" : "dark"; + + applyTheme(newTheme); + + if (newTheme === "dark") { + switchEl.classList.add("cx-switch--on"); + labelEl.textContent = "Dark"; + } else { + switchEl.classList.remove("cx-switch--on"); + labelEl.textContent = "Light"; + } + + try { + localStorage.setItem(THEME_STORAGE_KEY, newTheme); + } catch (_e) { + // ignore + } + }); + + // Insert in header before the "History" link + const links = Array.from(document.querySelectorAll("header a, nav a")); + const historyLink = links.find( + (a) => a.textContent.trim() === "History", + ); + + if (historyLink && historyLink.parentNode) { + historyLink.parentNode.insertBefore(container, historyLink); + } else { + const header = document.querySelector("header"); + if (header) { + header.appendChild(container); + } else { + document.body.appendChild(container); + } + } +} + +// Initialise theme toggle once DOM is ready +document.addEventListener("DOMContentLoaded", () => { + const initialTheme = initThemeFromPreference(); + createThemeToggle(initialTheme); +}); + diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 00000000..53b80814 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,21 @@ +// public/theme-init.js +// Runs on every page and applies the saved theme *before* the page renders. + +(function () { + var STORAGE_KEY = "convertx-theme"; + + try { + var theme = localStorage.getItem(STORAGE_KEY); + + // default to light if nothing stored or value is invalid + if (theme === "dark") { + document.documentElement.setAttribute("data-theme", "dark"); + } else { + document.documentElement.removeAttribute("data-theme"); + } + } catch (_e) { + // If localStorage is blocked, just fall back to light theme + document.documentElement.removeAttribute("data-theme"); + } +})(); + diff --git a/src/components/base.tsx b/src/components/base.tsx index c44d1f65..51669e9e 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -15,23 +15,59 @@ export const BaseHtml = ({ {title} + + {/* Inline theme bootstrap: runs BEFORE CSS & painting, prevents FOUC */} + + + {/* CSS */} - - - + + {/* Icons */} + + + - + + {children} +