From 4d8652d98e89111610c57a417f2c97b8c26dc36a Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:35:11 +0200 Subject: [PATCH 01/34] Add files via upload added clav av scanning capabilities --- src/pages/upload.tsx | 231 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 204 insertions(+), 27 deletions(-) diff --git a/src/pages/upload.tsx b/src/pages/upload.tsx index 0c000091..c3b9c969 100644 --- a/src/pages/upload.tsx +++ b/src/pages/upload.tsx @@ -1,42 +1,219 @@ import { Elysia, t } from "elysia"; import db from "../db/db"; -import { WEBROOT } from "../helpers/env"; +import { WEBROOT, CLAMAV_URL } from "../helpers/env"; import { uploadsDir } from "../index"; import { userService } from "./user"; import sanitize from "sanitize-filename"; -export const upload = new Elysia().use(userService).post( - "/upload", - async ({ body, redirect, user, cookie: { jobId } }) => { - if (!jobId?.value) { - return redirect(`${WEBROOT}/`, 302); - } +type ClamAvResultItem = { + name: string; + is_infected: boolean; + viruses: string[]; +}; + +type ClamAvResponse = { + success: boolean; + data?: { + result?: ClamAvResultItem[]; + }; +}; + +/** + * Send a file to ClamAV REST API (benzino77/clamav-rest-api). + * Returns { infected: boolean, viruses: string[] } and logs everything. + */ +async function scanFileWithClamAV(file: any, fileName: string) { + if (!CLAMAV_URL) { + console.error("[ClamAV] CLAMAV_URL is not configured, skipping scan."); + return { + infected: false, + viruses: [] as string[], + }; + } + + const FormDataCtor = (globalThis as any).FormData as + | (new () => { append: (name: string, value: any, fileName?: string) => void }) + | undefined; + + if (!FormDataCtor) { + console.error("[ClamAV] FormData is not available in this runtime, skipping scan."); + return { + infected: false, + viruses: [] as string[], + }; + } + + console.log("[ClamAV] Scanning file:", fileName, "via", CLAMAV_URL); + + const formData = new FormDataCtor(); + formData.append("FILES", file, fileName); - const existingJob = await db - .query("SELECT * FROM jobs WHERE id = ? AND user_id = ?") - .get(jobId.value, user.id); + let rawText = ""; + let status = 0; - if (!existingJob) { - return redirect(`${WEBROOT}/`, 302); + try { + const res = await fetch(CLAMAV_URL, { + method: "POST", + body: formData as any, + }); + + status = res.status; + rawText = await res.text(); + console.log("[ClamAV] HTTP status:", status); + console.log("[ClamAV] Raw response:", rawText); + + if (!res.ok) { + console.error("[ClamAV] Non-OK response from ClamAV:", status, rawText); + // fail-open: treat as clean if AV is misbehaving, to not block all uploads + return { + infected: false, + viruses: [] as string[], + }; } + } catch (error) { + console.error("[ClamAV] Error sending request to ClamAV:", error); + // fail-open + return { + infected: false, + viruses: [] as string[], + }; + } - const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`; + let json: ClamAvResponse | undefined; + try { + json = JSON.parse(rawText) as ClamAvResponse; + console.log("[ClamAV] Parsed JSON:", json); + } catch (error) { + console.error("[ClamAV] Failed to parse JSON from ClamAV:", error); + return { + infected: false, + viruses: [] as string[], + }; + } - if (body?.file) { - if (Array.isArray(body.file)) { - for (const file of body.file) { - const santizedFileName = sanitize(file.name); - await Bun.write(`${userUploadsDir}${santizedFileName}`, file); + const result = json?.data?.result; + if (!json?.success || !Array.isArray(result)) { + console.error("[ClamAV] Unexpected JSON structure from ClamAV."); + return { + infected: false, + viruses: [] as string[], + }; + } + + const infectedItems = result.filter((item) => item.is_infected); + const viruses = infectedItems.flatMap((item) => item.viruses ?? []); + + if (infectedItems.length > 0) { + console.warn( + "[ClamAV] Infection detected for file:", + fileName, + "viruses:", + viruses, + ); + return { + infected: true, + viruses, + }; + } + + console.log("[ClamAV] File is clean:", fileName); + return { + infected: false, + viruses: [] as string[], + }; +} + +export const upload = new Elysia() + .use(userService) + .post( + "/upload", + async ({ body, redirect, user, cookie: { jobId } }) => { + // Ensure we have a valid job + if (!jobId?.value) { + console.warn("[Upload] Missing jobId cookie, redirecting to root."); + return redirect(`${WEBROOT}/`, 302); + } + + console.log("[Upload] Incoming upload for jobId:", jobId.value, "userId:", user.id); + + const existingJob = await db + .query("SELECT * FROM jobs WHERE id = ? AND user_id = ?") + .get(jobId.value, user.id); + + if (!existingJob) { + console.warn( + "[Upload] Job not found or does not belong to user. jobId:", + jobId.value, + "userId:", + user.id, + ); + return redirect(`${WEBROOT}/`, 302); + } + + const userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`; + console.log("[Upload] Upload directory:", userUploadsDir); + + if (body?.file) { + const files = Array.isArray(body.file) ? body.file : [body.file]; + + const infectedFiles: { name: string; viruses: string[] }[] = []; + + for (const file of files) { + const originalName = (file as any).name ?? "upload"; + const sanitizedFileName = sanitize(originalName) || "file"; + console.log("[Upload] Handling file:", originalName, "=> sanitized:", sanitizedFileName); + + // 1) Scan with ClamAV REST API (use original name just for logging / AV metadata) + const scan = await scanFileWithClamAV(file, originalName); + + if (scan.infected) { + infectedFiles.push({ + name: originalName, + viruses: scan.viruses, + }); + console.warn( + "[Upload] File marked as infected, will NOT be saved:", + originalName, + "viruses:", + scan.viruses, + ); + // do NOT save infected file + continue; + } + + // 2) Only save if clean, with sanitized filename + const targetPath = `${userUploadsDir}${sanitizedFileName}`; + console.log("[Upload] Saving clean file to:", targetPath); + await Bun.write(targetPath, file); + } + + // ❗ If any infected file detected: tell frontend (status 200) + if (infectedFiles.length > 0) { + console.warn( + "[Upload] One or more infected files detected, returning infected=true.", + infectedFiles, + ); + return { + message: "Infected file found. Conversion will be aborted.", + infected: true, + infectedFiles, + }; } } else { - const santizedFileName = sanitize(body.file["name"]); - await Bun.write(`${userUploadsDir}${santizedFileName}`, body.file); + console.warn("[Upload] No file found in request body."); } - } - return { - message: "Files uploaded successfully.", - }; - }, - { body: t.Object({ file: t.Files() }), auth: true }, -); + // Normal case: all files clean (or no files) + console.log("[Upload] All files clean, upload successful for jobId:", jobId.value); + return { + message: "Files uploaded successfully.", + }; + }, + { + body: t.Object({ + file: t.Files(), + }), + auth: true, + }, + ); + From a30e543dedd081b51bd41d5875d6c6047d31d3e4 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:35:49 +0200 Subject: [PATCH 02/34] Add files via upload added clamav scanning capabilities --- public/script.js | 121 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/public/script.js b/public/script.js index 8b89194f..f38fdeb9 100644 --- a/public/script.js +++ b/public/script.js @@ -33,7 +33,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 +127,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 +148,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 +160,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 +202,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 +308,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); @@ -249,3 +331,4 @@ formConvert.addEventListener("submit", () => { }); updateSearchBar(); + From e67c1b37aa6724c87d42e9f6396b72a903ae4106 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:36:55 +0200 Subject: [PATCH 03/34] Add files via upload added clamav capabilities --- src/helpers/env.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/helpers/env.ts b/src/helpers/env.ts index 4c8c067e..9d644cbd 100644 --- a/src/helpers/env.ts +++ b/src/helpers/env.ts @@ -23,3 +23,6 @@ export const MAX_CONVERT_PROCESS = export const UNAUTHENTICATED_USER_SHARING = process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false; + +export const CLAMAV_URL = process.env.CLAMAV_URL; + From 4c28300d9587665a3d53d68bde006f8474ef528d Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:43:35 +0200 Subject: [PATCH 04/34] Configure ClamAV REST API and change port mapping Added ClamAV REST API service and updated ports. --- compose.yaml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 From 4697b15b6958d03e6bdf16be2ffbaa10b871c271 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:48:31 +0200 Subject: [PATCH 05/34] Add files via upload --- src/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index c163e744..c93e7ebc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ import { root } from "./pages/root"; import { upload } from "./pages/upload"; import { user } from "./pages/user"; import { healthcheck } from "./pages/healthcheck"; +import { antivirus } from "./pages/antivirus"; // 👈 NEW export const uploadsDir = "./data/uploads/"; export const outputDir = "./data/output/"; @@ -50,6 +51,7 @@ const app = new Elysia({ .use(listConverters) .use(chooseConverter) .use(healthcheck) + .use(antivirus) // 👈 register the antivirus toggle API .onError(({ error }) => { console.error(error); }); @@ -67,13 +69,19 @@ if (process.env.NODE_ENV !== "production") { app.listen(3000); -console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`); +console.log( + `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`, +); const clearJobs = () => { const jobs = db .query("SELECT * FROM jobs WHERE date_created < ?") .as(Jobs) - .all(new Date(Date.now() - AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000).toISOString()); + .all( + new Date( + Date.now() - AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000, + ).toISOString(), + ); for (const job of jobs) { // delete the directories From 718f87ec31c69e27c5189bb8360899c2fa21924a Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:49:21 +0200 Subject: [PATCH 06/34] Add files via upload --- src/helpers/avToggle.ts | 34 ++++++++++++++++++++++++++++++++++ src/helpers/env.ts | 26 +++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/helpers/avToggle.ts diff --git a/src/helpers/avToggle.ts b/src/helpers/avToggle.ts new file mode 100644 index 00000000..79594048 --- /dev/null +++ b/src/helpers/avToggle.ts @@ -0,0 +1,34 @@ +// src/helpers/avToggle.ts + +import { + ANTIVIRUS_ENABLED_DEFAULT, + CLAMAV_CONFIGURED, +} from "./env"; + +let antivirusEnabled = ANTIVIRUS_ENABLED_DEFAULT; + +/** + * Is ClamAV configured at all (CLAMAV_URL set)? + */ +export function isAntivirusAvailable(): boolean { + return CLAMAV_CONFIGURED; +} + +/** + * Is antivirus scanning currently enabled (and available)? + */ +export function isAntivirusEnabled(): boolean { + return CLAMAV_CONFIGURED && antivirusEnabled; +} + +/** + * Change current antivirus enabled/disabled state. + * If CLAMAV is not configured, this is effectively a no-op and remains false. + */ +export function setAntivirusEnabled(enabled: boolean): void { + if (!CLAMAV_CONFIGURED) { + antivirusEnabled = false; + return; + } + antivirusEnabled = enabled; +} diff --git a/src/helpers/env.ts b/src/helpers/env.ts index 9d644cbd..b962cf28 100644 --- a/src/helpers/env.ts +++ b/src/helpers/env.ts @@ -1,7 +1,10 @@ +// src/helpers/env.ts + export const ACCOUNT_REGISTRATION = process.env.ACCOUNT_REGISTRATION?.toLowerCase() === "true" || false; -export const HTTP_ALLOWED = process.env.HTTP_ALLOWED?.toLowerCase() === "true" || false; +export const HTTP_ALLOWED = + process.env.HTTP_ALLOWED?.toLowerCase() === "true" || false; export const ALLOW_UNAUTHENTICATED = process.env.ALLOW_UNAUTHENTICATED?.toLowerCase() === "true" || false; @@ -10,7 +13,8 @@ export const AUTO_DELETE_EVERY_N_HOURS = process.env.AUTO_DELETE_EVERY_N_HOURS ? Number(process.env.AUTO_DELETE_EVERY_N_HOURS) : 24; -export const HIDE_HISTORY = process.env.HIDE_HISTORY?.toLowerCase() === "true" || false; +export const HIDE_HISTORY = + process.env.HIDE_HISTORY?.toLowerCase() === "true" || false; export const WEBROOT = process.env.WEBROOT ?? ""; @@ -24,5 +28,21 @@ export const MAX_CONVERT_PROCESS = export const UNAUTHENTICATED_USER_SHARING = process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false; -export const CLAMAV_URL = process.env.CLAMAV_URL; +// ───────────────────────────────────────────────────────────── +// ClamAV / Antivirus integration +// ───────────────────────────────────────────────────────────── + +// REST endpoint of benzino77/clamav-rest-api, e.g. +// CLAMAV_URL=http://192.168.68.134:3000/api/v1/scan +export const CLAMAV_URL = process.env.CLAMAV_URL ?? ""; + +// True only if CLAMAV_URL is non-empty +export const CLAMAV_CONFIGURED = CLAMAV_URL.length > 0; +// Default AV toggle value: +// - If ANTIVIRUS_ENABLED_DEFAULT=false → force disabled +// - Otherwise: enabled only when CLAMAV is configured +export const ANTIVIRUS_ENABLED_DEFAULT = + process.env.ANTIVIRUS_ENABLED_DEFAULT?.toLowerCase() === "false" + ? false + : CLAMAV_CONFIGURED; From 179abac88db6956b5799742efe9f057250b7ab92 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:50:19 +0200 Subject: [PATCH 07/34] Add files via upload --- src/pages/antivirus.tsx | 45 +++++++++++++++++++++++++++++++++++++++++ src/pages/upload.tsx | 27 ++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/pages/antivirus.tsx diff --git a/src/pages/antivirus.tsx b/src/pages/antivirus.tsx new file mode 100644 index 00000000..8a29eeea --- /dev/null +++ b/src/pages/antivirus.tsx @@ -0,0 +1,45 @@ +// src/pages/antivirus.tsx + +import { Elysia, t } from "elysia"; +import { userService } from "./user"; +import { + isAntivirusAvailable, + isAntivirusEnabled, + setAntivirusEnabled, +} from "../helpers/avToggle"; + +export const antivirus = new Elysia() + .use(userService) + // Get current AV status + .get("/api/antivirus", () => { + const available = isAntivirusAvailable(); + const enabled = isAntivirusEnabled(); + return { available, enabled }; + }) + // Update AV status + .post( + "/api/antivirus", + ({ body }) => { + const { enabled } = body; + + if (!isAntivirusAvailable()) { + // CLAMAV_URL missing: force disabled and report unavailable + return { + available: false, + enabled: false, + }; + } + + setAntivirusEnabled(Boolean(enabled)); + + return { + available: true, + enabled: isAntivirusEnabled(), + }; + }, + { + body: t.Object({ + enabled: t.Boolean(), + }), + }, + ); diff --git a/src/pages/upload.tsx b/src/pages/upload.tsx index c3b9c969..49b91ffe 100644 --- a/src/pages/upload.tsx +++ b/src/pages/upload.tsx @@ -1,9 +1,12 @@ +// src/pages/upload.tsx + import { Elysia, t } from "elysia"; import db from "../db/db"; import { WEBROOT, CLAMAV_URL } from "../helpers/env"; import { uploadsDir } from "../index"; import { userService } from "./user"; import sanitize from "sanitize-filename"; +import { isAntivirusEnabled } from "../helpers/avToggle"; type ClamAvResultItem = { name: string; @@ -23,8 +26,22 @@ type ClamAvResponse = { * Returns { infected: boolean, viruses: string[] } and logs everything. */ async function scanFileWithClamAV(file: any, fileName: string) { + // 🔀 Respect toggle + CLAMAV_URL availability + if (!isAntivirusEnabled()) { + console.log( + "[ClamAV] Antivirus disabled (toggle off or CLAMAV_URL unset). Skipping scan for", + fileName, + ); + return { + infected: false, + viruses: [] as string[], + }; + } + if (!CLAMAV_URL) { - console.error("[ClamAV] CLAMAV_URL is not configured, skipping scan."); + console.error( + "[ClamAV] CLAMAV_URL is not configured, but antivirus was considered enabled. Skipping scan.", + ); return { infected: false, viruses: [] as string[], @@ -161,7 +178,12 @@ export const upload = new Elysia() for (const file of files) { const originalName = (file as any).name ?? "upload"; const sanitizedFileName = sanitize(originalName) || "file"; - console.log("[Upload] Handling file:", originalName, "=> sanitized:", sanitizedFileName); + console.log( + "[Upload] Handling file:", + originalName, + "=> sanitized:", + sanitizedFileName, + ); // 1) Scan with ClamAV REST API (use original name just for logging / AV metadata) const scan = await scanFileWithClamAV(file, originalName); @@ -216,4 +238,3 @@ export const upload = new Elysia() auth: true, }, ); - From 6b2c737fe39f753850b9164dee3da4deeb67f44b Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:51:07 +0200 Subject: [PATCH 08/34] Add files via upload --- public/script.js | 198 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 193 insertions(+), 5 deletions(-) diff --git a/public/script.js b/public/script.js index f38fdeb9..e91a6c9a 100644 --- a/public/script.js +++ b/public/script.js @@ -7,6 +7,186 @@ 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; + } + .av-toggle-label { + color: #04070eff; + } + .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 toggle as soon as script runs +createAntivirusToggle(); + +// ───────────────────────────────────── +// Existing upload UI logic +// ───────────────────────────────────── + dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); @@ -325,10 +505,18 @@ 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(); - From 48d1ce57e04f76f4473f230b4b46a6a23fff7870 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:52:33 +0200 Subject: [PATCH 09/34] Add files via upload From f2f48c9eae78d82e5d0f063ce526b69446b4adc4 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:10:09 +0200 Subject: [PATCH 10/34] Add files via upload --- public/theme-init.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 public/theme-init.js diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 00000000..0924375b --- /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"); + } +})(); + From d9bb3955d20a1ddf89e3878ce08a92170395cab2 Mon Sep 17 00:00:00 2001 From: Kosztyk <36381705+Kosztyk@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:10:42 +0200 Subject: [PATCH 11/34] Add files via upload --- src/components/base.tsx | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/base.tsx b/src/components/base.tsx index c44d1f65..e3c9aa58 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -15,13 +15,36 @@ export const BaseHtml = ({ {title} + + {/* Main stylesheet generated by Tailwind/Bun */} - - - + + {/* Icons / manifest (original ConvertX setup) */} + + + + + {/* NEW: global theme initializer – runs on *all* pages */} + - + {children}