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}