This commit is contained in:
Kosztyk 2026-01-26 18:05:22 +01:00 committed by GitHub
commit b382b89ec6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2421 additions and 190 deletions

View file

@ -152,6 +152,20 @@ Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summar
<img src="https://contrib.rocks/image?repo=C4illin/ConvertX" alt="Image with all contributors"/>
</a>
## 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

View file

@ -18,5 +18,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

View file

@ -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");
// public/results.js
//
// Handles live progress updates for /results/:jobId and Share via Erugo modal actions.
// IMPORTANT: /progress/:jobId re-renders the <main> content, so we must not keep stale
// element references. Use event delegation + (re)query elements when needed.
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));
(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");
setTimeout(refreshData, 1000);
// -----------------------------
// 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);
}
}
progressElem = document.querySelector("progress");
};
// Poll every second while job is running
setInterval(refreshData, 1000);
// Run once immediately so the page updates without waiting 1s.
refreshData();
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"),
window.downloadAll = function () {
// Get all download links
const downloadLinks = document.querySelectorAll("tbody a[download]");
emailEl: document.getElementById("cxShareEmail"),
nameEl: document.getElementById("cxShareName"),
descEl: document.getElementById("cxShareDescription"),
// 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);
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();
}
function closeModal() {
const els = getEls();
if (!els.modal) return;
els.modal.classList.add("hidden");
els.modal.classList.remove("flex");
currentJobId = null;
currentFileName = null;
}
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();
}
});
})();

View file

@ -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 = `
<span class="cx-theme-toggle__label">${
initialTheme === "dark" ? "Dark" : "Light"
}</span>
<button
type="button"
class="cx-switch ${initialTheme === "dark" ? "cx-switch--on" : ""}"
aria-label="Toggle dark mode"
>
<span class="cx-switch__thumb"></span>
</button>
`;
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);
});

21
public/theme-init.js Normal file
View file

@ -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");
}
})();

View file

@ -15,23 +15,59 @@ export const BaseHtml = ({
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="webroot" content={webroot} />
<title safe>{title}</title>
{/* Inline theme bootstrap: runs BEFORE CSS & painting, prevents FOUC */}
<script>
{`
(function () {
var STORAGE_KEY = "convertx-theme";
try {
var theme = localStorage.getItem(STORAGE_KEY);
if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme");
}
} catch (e) {
document.documentElement.removeAttribute("data-theme");
}
})();
`}
</script>
{/* CSS */}
<link rel="stylesheet" href={`${webroot}/generated.css`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${webroot}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${webroot}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${webroot}/favicon-16x16.png`} />
{/* Icons */}
<link
rel="apple-touch-icon"
sizes="180x180"
href={`${webroot}/apple-touch-icon.png`}
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href={`${webroot}/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${webroot}/favicon-16x16.png`}
/>
<link rel="manifest" href={`${webroot}/site.webmanifest`} />
</head>
<body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}>
<body class="flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200">
{children}
<footer class="w-full">
<div class="p-4 text-center text-sm text-neutral-500">
<span>Powered by </span>
<a
href="https://github.com/C4illin/ConvertX"
class={`
text-neutral-400
hover:text-accent-500
`}
class="text-neutral-400 hover:text-accent-500"
>
ConvertX{" "}
</a>

View file

@ -31,12 +31,25 @@ PRAGMA user_version = 1;`);
}
const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version;
// existing migration: add status column to file_names
if (dbVersion === 0) {
db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';");
db.exec("PRAGMA user_version = 1;");
console.log("Updated database to version 1.");
}
/**
* Ensure `role` column exists on users table.
* This works for both fresh installs and existing DBs, without touching user_version.
*/
const userColumns = db.query("PRAGMA table_info(users)").all() as { name: string }[];
const hasRoleColumn = userColumns.some((col) => col.name === "role");
if (!hasRoleColumn) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';");
console.log("Added 'role' column to users table.");
}
// enable WAL mode
db.exec("PRAGMA journal_mode = WAL;");

View file

@ -20,4 +20,6 @@ export class User {
id!: number;
email!: string;
password!: string;
role!: string; // 'admin' | 'user'
}

44
src/helpers/avToggle.ts Normal file
View file

@ -0,0 +1,44 @@
// src/helpers/avToggle.ts
//
// Simple in-memory antivirus toggle.
// Default behaviour: enabled by default when ClamAV is configured, unless explicitly overridden.
import { ANTIVIRUS_ENABLED_DEFAULT, CLAMAV_CONFIGURED } from "./env";
let antivirusEnabled: boolean = CLAMAV_CONFIGURED ? ANTIVIRUS_ENABLED_DEFAULT : false;
/**
* Is ClamAV configured at all (CLAMAV_URL set)?
*/
export function isAntivirusAvailable(): boolean {
return CLAMAV_CONFIGURED;
}
/**
* Current effective antivirus enabled state.
* If ClamAV is not configured, this always returns false.
*/
export function isAntivirusEnabled(): boolean {
if (!CLAMAV_CONFIGURED) return false;
return 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 = Boolean(enabled);
}
/**
* Useful if env / configuration changes at runtime (tests, hot reload).
*/
export function resetAntivirusEnabledToDefault(): void {
antivirusEnabled = CLAMAV_CONFIGURED ? ANTIVIRUS_ENABLED_DEFAULT : false;
}

View file

@ -1,7 +1,9 @@
// ConvertX core settings
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 +12,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 ?? "";
@ -22,6 +25,30 @@ export const MAX_CONVERT_PROCESS =
: 0;
export const UNAUTHENTICATED_USER_SHARING =
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;
process.env.UNAUTHENTICICATED_USER_SHARING?.toLowerCase() === "true" ||
false;
// ------------------------------
// Antivirus (ConvertX original)
// ------------------------------
export const CLAMAV_URL = process.env.CLAMAV_URL ?? "";
export const CLAMAV_CONFIGURED = CLAMAV_URL.length > 0;
export const ANTIVIRUS_ENABLED_DEFAULT =
process.env.ANTIVIRUS_ENABLED_DEFAULT === undefined
? true
: process.env.ANTIVIRUS_ENABLED_DEFAULT.toLowerCase() === "true";
// ------------------------------
// Erugo integration
// ------------------------------
export const ERUGO_BASE_URL = process.env.ERUGO_BASE_URL ?? "";
export const ERUGO_API_TOKEN = process.env.ERUGO_API_TOKEN ?? "";
export const ERUGO_DEFAULT_EXPIRY_HOURS = process.env.ERUGO_DEFAULT_EXPIRY_HOURS
? Number(process.env.ERUGO_DEFAULT_EXPIRY_HOURS)
: 168;
export const ERUGO_CONFIGURED =
ERUGO_BASE_URL.length > 0 && ERUGO_API_TOKEN.length > 0;
export const TIMEZONE = process.env.TZ || undefined;

129
src/helpers/erugo.ts Normal file
View file

@ -0,0 +1,129 @@
import {
ERUGO_BASE_URL,
ERUGO_API_TOKEN,
ERUGO_DEFAULT_EXPIRY_HOURS,
ERUGO_CONFIGURED,
} from "./env";
/**
* Send a local file to Erugo using Bun FormData.
* If recipient_email is provided, Erugo will send the share link via email
* (same behavior as Erugo UI).
*/
export async function sendFileToErugo(options: {
fullPath: string;
filename: string;
shareName?: string;
description?: string;
recipientEmail?: string;
recipientName?: string;
expiryHours?: number;
}) {
if (!ERUGO_CONFIGURED) {
throw new Error("Erugo integration is not configured");
}
const {
fullPath,
filename,
shareName,
description,
recipientEmail,
recipientName,
expiryHours,
} = options;
const url =
`${ERUGO_BASE_URL.replace(/\/$/, "")}` +
`/api/integrations/convertx/share`;
const form = new FormData();
// File
const bunFile = Bun.file(fullPath);
form.append("file", bunFile, filename);
// Share name (Erugo UI uses "name")
form.append("name", (shareName?.trim() || filename).toString());
// Optional description
if (description && description.trim()) {
form.append("description", description.trim());
}
// Recipient (THIS is what triggers email in Erugo)
if (recipientEmail && recipientEmail.trim()) {
form.append("recipient_email", recipientEmail.trim());
if (recipientName && recipientName.trim()) {
form.append("recipient_name", recipientName.trim());
}
}
// Expiry (support both variants used in different Erugo versions)
const finalExpiry =
typeof expiryHours === "number"
? expiryHours
: ERUGO_DEFAULT_EXPIRY_HOURS;
form.append("expires_in_hours", String(finalExpiry));
form.append("expiry_hours", String(finalExpiry));
// 🔍 LOG BEFORE REQUEST
console.log("[ConvertX] Erugo upload ->", {
url,
filename,
shareName,
hasRecipient: Boolean(recipientEmail),
recipientMasked: recipientEmail
? recipientEmail.replace(/(.{2}).+(@.*)/, "$1***$2")
: null,
});
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${ERUGO_API_TOKEN}`,
// DO NOT set Content-Type manually for FormData
},
body: form,
});
const text = await res.text();
// 🔍 LOG RESPONSE
console.log("[ConvertX] Erugo response <-", {
status: res.status,
contentType: res.headers.get("content-type"),
bodyPreview: text.slice(0, 800),
});
if (!res.ok) {
throw new Error(
`Erugo share failed: HTTP ${res.status} ${text.slice(0, 800)}`,
);
}
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = { raw: text };
}
return {
...json,
share_url:
json?.share_url ||
json?.share_link ||
json?.data?.url ||
json?.data?.share?.url ||
json?.data?.share_url ||
json?.data?.share_link ||
null,
};
}

27
src/icons/share.tsx Normal file
View file

@ -0,0 +1,27 @@
// src/icons/share.tsx
export function ShareIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
class={`size-6`}
>
{/* Tray */}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 14.25v4.5A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75v-4.5"
/>
{/* Arrow up (share) */}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v11.25m0-11.25L7.5 7.5M12 3l4.5 4.5"
/>
</svg>
);
}

View file

@ -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

View file

@ -5,6 +5,7 @@
@theme {
--color-contrast: var(--contrast);
--color-neutral-900: var(--neutral-900);
--color-neutral-800: var(--neutral-800);
--color-neutral-700: var(--neutral-700);
@ -14,19 +15,24 @@
--color-neutral-300: var(--neutral-300);
--color-neutral-200: var(--neutral-200);
--color-neutral-100: var(--neutral-100);
--color-accent-600: var(--accent-600);
--color-accent-500: var(--accent-500);
--color-accent-400: var(--accent-400);
}
/* Article container (Convert box, history rows, etc.) */
@utility article {
@apply px-2 sm:px-4 py-4 mb-4 bg-neutral-800/40 w-full mx-auto max-w-4xl rounded-sm;
}
/* Primary button (Convert) */
@utility btn-primary {
@apply bg-accent-500 text-contrast rounded-sm p-2 sm:p-4 hover:bg-accent-400 cursor-pointer transition-colors;
}
/* Secondary button */
@utility btn-secondary {
@apply bg-neutral-400 text-contrast rounded-sm p-2 sm:p-4 hover:bg-neutral-300 cursor-pointer transition-colors;
}

97
src/pages/antivirus.tsx Normal file
View file

@ -0,0 +1,97 @@
// src/pages/antivirus.tsx
import { Elysia, t } from "elysia";
import { userService } from "./user";
import {
isAntivirusAvailable,
isAntivirusEnabled,
setAntivirusEnabled,
} from "../helpers/avToggle";
/**
* Antivirus toggle API
*
* GET /api/antivirus (auth required)
* -> { available: boolean, enabled: boolean }
*
* POST /api/antivirus (auth required)
* body: { enabled: boolean }
* -> { available: boolean, enabled: boolean }
*
* - `available` reflects CLAMAV_URL (via isAntivirusAvailable()).
* - `enabled` is the global effective flag used by upload.tsx.
*/
export const antivirus = new Elysia()
.use(userService)
// Read current antivirus state
.get(
"/api/antivirus",
() => {
const available = isAntivirusAvailable();
const enabled = isAntivirusEnabled();
console.log(
"[Antivirus API][GET] available:",
available,
"enabled:",
enabled,
);
return { available, enabled };
},
{
// 🔒 Only logged-in users should see global AV state
auth: true,
},
)
// Update antivirus state (enable/disable)
.post(
"/api/antivirus",
({ body }) => {
const requested = Boolean(body.enabled);
const available = isAntivirusAvailable();
console.log(
"[Antivirus API][POST] requested enabled=",
requested,
"available=",
available,
);
// If AV is not available (CLAMAV_URL missing), force disabled
if (!available) {
console.warn(
"[Antivirus API][POST] CLAMAV_URL not configured. Refusing to enable antivirus.",
);
return {
available: false,
enabled: false,
};
}
// Persist the new state
setAntivirusEnabled(requested);
const effectiveEnabled = isAntivirusEnabled();
console.log(
"[Antivirus API][POST] effective enabled=",
effectiveEnabled,
);
return {
available: true,
enabled: effectiveEnabled,
};
},
{
body: t.Object({
enabled: t.Boolean(),
}),
// 🔒 Only logged-in users can change global AV setting
auth: true,
},
);

View file

@ -1,4 +1,6 @@
import { Elysia } from "elysia";
// @ts-nocheck
import { Elysia, t } from "elysia";
import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import db from "../db/db";
@ -7,7 +9,10 @@ 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 { userService } from "./user";
import { outputDir } from "..";
import { sendFileToErugo } from "../helpers/erugo";
function ResultsArticle({
job,
@ -18,36 +23,54 @@ function ResultsArticle({
files: Filename[];
outputPath: string;
}) {
const maxFiles = Number((job as any).num_files ?? 0);
const doneFiles = Number(files.filter((f: any) => String((f as any).status || '').toLowerCase() === 'done').length);
const isDone = doneFiles === maxFiles;
const disabledLinkClass = "pointer-events-none opacity-50";
const busyAttrs = { disabled: true, "aria-busy": "true" } as const;
return (
<article class="article">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<div class="flex flex-row gap-4">
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
class="flex btn-secondary flex-row gap-2 text-contrast"
class={`flex btn-secondary flex-row gap-2 text-contrast ${
!isDone ? disabledLinkClass : ""
}`}
href={`${WEBROOT}/delete/${job.id}`}
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
{...(!isDone ? busyAttrs : {})}
>
<DeleteIcon /> <p>Delete</p>
</a>
<a
style={files.length !== job.num_files ? "pointer-events: none;" : ""}
class={`flex btn-primary flex-row gap-2 text-contrast ${
!isDone ? disabledLinkClass : ""
}`}
href={`${WEBROOT}/archive/${job.id}`}
download={`converted_files_${job.id}.tar`}
class="flex btn-primary flex-row gap-2 text-contrast"
{...(files.length !== job.num_files ? { disabled: true, "aria-busy": "true" } : "")}
{...(!isDone ? busyAttrs : {})}
>
<DownloadIcon /> <p>Tar</p>
</a>
<button class="flex btn-primary flex-row gap-2 text-contrast" onclick="downloadAll()">
<button
id="cxDownloadAll"
type="button"
class="flex btn-primary flex-row gap-2 text-contrast"
{...(!isDone ? busyAttrs : {})}
>
<DownloadIcon /> <p>All</p>
</button>
</div>
</div>
<progress
max={job.num_files}
{...(files.length === job.num_files ? { value: files.length } : "")}
max={maxFiles}
value={doneFiles}
class={`
mb-4 inline-block h-2 w-full appearance-none overflow-hidden rounded-full border-0
bg-neutral-700 bg-none text-accent-500 accent-accent-500
@ -57,6 +80,7 @@ function ResultsArticle({
[&[value]::-webkit-progress-value]:transition-[inline-size]
`}
/>
<table
class={`
w-full table-auto rounded bg-neutral-900 text-left
@ -66,77 +90,381 @@ function ResultsArticle({
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Converted File Name
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Status
</th>
<th
class={`
px-2 py-2
sm:px-4
`}
>
Actions
</th>
<th class="px-2 py-2 sm:px-4">Converted File Name</th>
<th class="px-2 py-2 sm:px-4">Status</th>
<th class="px-2 py-2 sm:px-4">Actions</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr>
<tr key={file.output_file_name}>
<td safe class="max-w-[20vw] truncate">
{file.output_file_name}
</td>
<td safe>{file.status}</td>
<td class="flex flex-row gap-4">
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
class="text-accent-500 hover:text-accent-400"
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
>
<EyeIcon />
</a>
<a
class={`
text-accent-500 underline
hover:text-accent-400
`}
class="text-accent-500 hover:text-accent-400"
href={`${WEBROOT}/download/${outputPath}${file.output_file_name}`}
download={file.output_file_name}
>
<DownloadIcon />
</a>
<button
type="button"
class="text-accent-500 hover:text-accent-400"
data-share="true"
data-job-id={String(job.id)}
data-file-name={file.output_file_name}
aria-label="Share via Erugo"
>
<ShareIcon />
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Share Modal (hidden by default) */}
<div
id="cxShareModal"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60"
>
<div
class="w-[92vw] max-w-[560px] rounded-lg border border-neutral-700 bg-neutral-900 p-4 text-neutral-100 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="cxShareModalTitle"
>
<div class="mb-3 flex items-center justify-between">
<h2 id="cxShareModalTitle" class="text-lg font-semibold">
Share via Erugo
</h2>
<button
type="button"
id="cxShareClose"
class="rounded px-2 py-1 text-neutral-300 hover:bg-neutral-800 hover:text-white"
>
</button>
</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm text-neutral-300">
Recipient Email
</label>
<input
id="cxShareEmail"
type="email"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
placeholder="name@example.com"
/>
<p class="mt-1 text-xs text-neutral-400">
If provided, Erugo will send the share link via email.
</p>
</div>
<div>
<label class="mb-1 block text-sm text-neutral-300">
Share Name
</label>
<input
id="cxShareName"
type="text"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
placeholder="My converted file"
/>
</div>
<div>
<label class="mb-1 block text-sm text-neutral-300">
Description (optional)
</label>
<textarea
id="cxShareDescription"
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-accent-500"
rows={3}
placeholder="Message to share recipients (optional)"
></textarea>
</div>
<div class="flex items-center gap-2">
<button
id="cxShareSubmit"
type="button"
class="btn-primary flex items-center gap-2"
>
Send
</button>
<button id="cxShareCancel" type="button" class="btn-secondary">
Cancel
</button>
<span id="cxShareStatus" class="text-sm text-neutral-300"></span>
</div>
<div id="cxShareLinkBlock" class="hidden">
<label class="mb-1 block text-sm text-neutral-300">
Share Link
</label>
<div class="flex gap-2">
<input
id="cxShareLink"
type="text"
readonly
class="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-neutral-100"
/>
<button id="cxShareCopy" type="button" class="btn-secondary">
Copy
</button>
</div>
<p class="mt-1 text-xs text-neutral-400">
You can copy the link even if you also email it.
</p>
</div>
</div>
</div>
</div>
</article>
);
}
/**
* IMPORTANT FIX:
* Results HTML is re-rendered via /progress/:jobId and replaces the article + modal.
* So we MUST NOT keep stale DOM references. We re-query elements on each action.
*/
const shareJs = `
(function () {
const WEBROOT = ${JSON.stringify(WEBROOT)};
function getEl(id) { return document.getElementById(id); }
function getRefs() {
return {
modal: getEl("cxShareModal"),
closeBtn: getEl("cxShareClose"),
cancelBtn: getEl("cxShareCancel"),
submitBtn: getEl("cxShareSubmit"),
statusEl: getEl("cxShareStatus"),
emailEl: getEl("cxShareEmail"),
nameEl: getEl("cxShareName"),
descEl: getEl("cxShareDescription"),
linkBlock: getEl("cxShareLinkBlock"),
linkEl: getEl("cxShareLink"),
copyBtn: getEl("cxShareCopy"),
};
}
let currentJobId = null;
let currentFileName = null;
function openModal(jobId, fileName) {
const r = getRefs();
if (!r.modal || !r.emailEl || !r.nameEl || !r.descEl || !r.statusEl || !r.linkBlock || !r.linkEl) {
console.warn("[ConvertX] Share modal elements not found (DOM may be mid-refresh).");
return;
}
currentJobId = jobId;
currentFileName = fileName;
r.nameEl.value = fileName || "";
r.emailEl.value = "";
r.descEl.value = "";
r.linkBlock.classList.add("hidden");
r.linkEl.value = "";
r.statusEl.textContent = "";
r.modal.classList.remove("hidden");
r.modal.classList.add("flex");
r.emailEl.focus();
}
function closeModal() {
const r = getRefs();
if (!r.modal) return;
r.modal.classList.add("hidden");
r.modal.classList.remove("flex");
currentJobId = null;
currentFileName = null;
}
// Delegated: always works even after /progress replaces the article
document.addEventListener("click", (e) => {
const t = e.target;
const btn = t && (t.closest ? t.closest('[data-share="true"]') : null);
if (!btn) return;
// kill old handlers (e.g. alert() from older results.js)
e.preventDefault();
e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
const jobId = btn.getAttribute("data-job-id");
const fileName = btn.getAttribute("data-file-name");
openModal(jobId, fileName);
}, true);
// Delegated close (because modal DOM is replaced during progress polling)
document.addEventListener("click", (e) => {
const id = e.target && e.target.id;
if (id === "cxShareClose" || id === "cxShareCancel") {
e.preventDefault();
closeModal();
}
if (id === "cxShareModal") {
// click outside dialog closes
closeModal();
}
}, true);
document.addEventListener("keydown", (e) => {
const r = getRefs();
if (e.key === "Escape" && r.modal && !r.modal.classList.contains("hidden")) closeModal();
});
// Delegated copy
document.addEventListener("click", async (e) => {
const t = e.target;
if (!t || t.id !== "cxShareCopy") return;
e.preventDefault();
const r = getRefs();
if (!r.linkEl || !r.statusEl) return;
try {
await navigator.clipboard.writeText(r.linkEl.value || "");
r.statusEl.textContent = "Copied.";
} catch (err) {
r.linkEl.focus();
r.linkEl.select();
r.statusEl.textContent = "Select + copy (Ctrl/Cmd+C).";
}
}, true);
// Delegated submit
document.addEventListener("click", async (e) => {
const t = e.target;
if (!t || t.id !== "cxShareSubmit") return;
e.preventDefault();
const r = getRefs();
if (!r.submitBtn || !r.statusEl || !r.emailEl || !r.nameEl || !r.descEl || !r.linkBlock || !r.linkEl) return;
if (!currentJobId || !currentFileName) return;
r.submitBtn.disabled = true;
r.submitBtn.setAttribute("aria-busy", "true");
r.statusEl.textContent = "Sending...";
try {
const email = (r.emailEl.value || "").trim();
const shareName = (r.nameEl.value || "").trim();
const description = (r.descEl.value || "").trim();
const payload = {
fileName: currentFileName,
...(email ? { recipientEmail: email } : {}),
...(shareName ? { shareName } : {}),
...(description ? { description } : {}),
};
const res = await fetch(\`\${WEBROOT}/share-to-erugo/\${currentJobId}\`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch (_) { json = { raw: text }; }
if (!res.ok) {
console.error("[ConvertX] share-to-erugo error", res.status, json);
r.statusEl.textContent = "Failed. See logs.";
return;
}
const url =
json?.share_url ||
json?.share_link ||
json?.data?.url ||
json?.data?.share?.url ||
null;
if (url) {
r.linkEl.value = url;
r.linkBlock.classList.remove("hidden");
}
r.statusEl.textContent = email
? (url ? "Sent. Link also shown below." : "Sent. (No link returned.)")
: (url ? "Created. Copy the link below." : "Created, but no link returned.");
} catch (err) {
console.error(err);
r.statusEl.textContent = "Failed. See logs.";
} finally {
r.submitBtn.disabled = false;
r.submitBtn.removeAttribute("aria-busy");
}
}, true);
// Download All: delegated, because button is replaced during progress polling
document.addEventListener("click", (e) => {
const t = e.target;
const btn = t && (t.closest ? t.closest("#cxDownloadAll") : null);
if (!btn) return;
e.preventDefault();
try {
if (typeof window.downloadAll === "function") {
window.downloadAll();
} else {
console.warn("[ConvertX] downloadAll() not found. Ensure results.js is loaded.");
}
} catch (err) {
console.error("[ConvertX] downloadAll() failed", err);
}
}, true);
})();
`.trim();
export const results = new Elysia()
.use(userService)
.get(
"/results-share.js",
() =>
new Response(shareJs, {
headers: {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-store",
},
}),
{ auth: true },
)
.get(
"/results/:jobId",
async ({ params, set, cookie: { job_id }, user }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
if (job_id?.value) job_id.remove();
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@ -145,9 +473,7 @@ export const results = new Elysia()
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
return { message: "Job not found." };
}
const outputPath = `${user.id}/${params.jobId}/`;
@ -160,29 +486,32 @@ export const results = new Elysia()
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Result">
<>
<Header webroot={WEBROOT} allowUnauthenticated={ALLOW_UNAUTHENTICATED} loggedIn />
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<Header
webroot={WEBROOT}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
loggedIn
/>
<main class="w-full flex-1 px-2 sm:px-4">
<ResultsArticle job={job} files={files} outputPath={outputPath} />
</main>
{/* keep existing file */}
<script src={`${WEBROOT}/results.js`} defer />
{/* our override must also load */}
<script src={`${WEBROOT}/results-share.js`} defer />
</>
</BaseHtml>
);
},
{ auth: true },
)
.post(
"/progress/:jobId",
async ({ set, params, cookie: { job_id }, user }) => {
if (job_id?.value) {
// Clear the job_id cookie since we are viewing the results
job_id.remove();
}
if (job_id?.value) job_id.remove();
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
@ -191,9 +520,7 @@ export const results = new Elysia()
if (!job) {
set.status = 404;
return {
message: "Job not found.",
};
return { message: "Job not found." };
}
const outputPath = `${user.id}/${params.jobId}/`;
@ -206,4 +533,65 @@ export const results = new Elysia()
return <ResultsArticle job={job} files={files} outputPath={outputPath} />;
},
{ auth: true },
)
.post(
"/share-to-erugo/:jobId",
async ({ params, body, user, set }) => {
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
set.status = 404;
return { message: "Job not found." };
}
const file = db
.query(
"SELECT * FROM file_names WHERE job_id = ? AND output_file_name = ?",
)
.as(Filename)
.get(params.jobId, body.fileName);
if (!file) {
set.status = 404;
return { message: "File not found." };
}
const fullPath = `${outputDir}${user.id}/${params.jobId}/${file.output_file_name}`;
try {
const payload = {
fullPath,
filename: file.output_file_name,
shareName: body.shareName?.trim() || file.output_file_name,
...(body.description?.trim()
? { description: body.description.trim() }
: {}),
...(body.recipientEmail?.trim()
? { recipientEmail: body.recipientEmail.trim() }
: {}),
};
const result = await sendFileToErugo(payload);
return result;
} catch (err: any) {
console.error(err);
set.status = 500;
return { message: "Failed to share with Erugo" };
}
},
{
auth: true,
body: t.Object({
fileName: t.String(),
recipientEmail: t.Optional(t.String()),
shareName: t.Optional(t.String()),
description: t.Optional(t.String()),
}),
},
);

View file

@ -5,7 +5,6 @@ import { BaseHtml } from "../components/base";
import { Header } from "../components/header";
import { getAllTargets } from "../converters/main";
import db from "../db/db";
import { User } from "../db/types";
import {
ACCOUNT_REGISTRATION,
ALLOW_UNAUTHENTICATED,
@ -16,6 +15,8 @@ import {
} from "../helpers/env";
import { FIRST_RUN, userService } from "./user";
type JwtUser = { id: string; role: string } & JWTPayloadSpec;
export const root = new Elysia().use(userService).get(
"/",
async ({ jwt, redirect, cookie: { auth, jobId } }) => {
@ -30,18 +31,23 @@ export const root = new Elysia().use(userService).get(
}
// validate jwt
let user: ({ id: string } & JWTPayloadSpec) | false = false;
let user: JwtUser | null = null;
if (ALLOW_UNAUTHENTICATED) {
// unauthenticated / guest mode
const newUserId = String(
UNAUTHENTICATED_USER_SHARING
? 0
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
);
const accessToken = await jwt.sign({
id: newUserId,
role: "user",
});
user = { id: newUserId };
user = { id: newUserId, role: "user" } as JwtUser;
if (!auth) {
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
@ -57,15 +63,19 @@ export const root = new Elysia().use(userService).get(
sameSite: "strict",
});
} else if (auth?.value) {
user = await jwt.verify(auth.value);
const decoded = await jwt.verify(auth.value);
if (decoded && typeof decoded === "object" && "id" in decoded && "role" in decoded) {
user = decoded as JwtUser;
}
if (
user !== false &&
user &&
user.id &&
(Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED)
) {
// Make sure user exists in db
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
const existingUser = db.query("SELECT * FROM users WHERE id = ?").get(user.id);
if (!existingUser) {
if (auth?.value) {
@ -82,27 +92,27 @@ export const root = new Elysia().use(userService).get(
// create a new job
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
user.id,
Number.parseInt(user.id),
new Date().toISOString(),
);
const { id } = db
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
.get(user.id) as { id: number };
const newJob = db.query("SELECT last_insert_rowid() AS id").get() as { id: number };
if (!jobId) {
return { message: "Cookies should be enabled to use this app." };
}
jobId.set({
value: id,
value: newJob.id.toString(),
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});
console.log("jobId set to:", id);
console.log("jobId set to:", newJob.id);
const converters = await getAllTargets();
return (
<BaseHtml webroot={WEBROOT}>
@ -174,7 +184,7 @@ export const root = new Elysia().use(userService).get(
sm:h-[30vh]
`}
>
{Object.entries(getAllTargets()).map(([converter, targets]) => (
{Object.entries(converters).map(([converter, targets]) => (
<article
class={`
convert_to_group flex w-full flex-col border-b border-neutral-700 p-4
@ -187,7 +197,7 @@ export const root = new Elysia().use(userService).get(
<ul class={`convert_to_target flex flex-row flex-wrap gap-1`}>
{targets.map((target) => (
<button
// https://stackoverflow.com/questions/121499/when-a-blur-event-occurs-how-can-i-find-out-which-element-focus-went-to#comment82388679_33325953
// https://stackoverflow.com/quest...-i-find-out-which-element-focus-went-to#comment82388679_33325953
tabindex={0}
class={`
target rounded bg-neutral-700 p-1 text-base
@ -212,7 +222,7 @@ export const root = new Elysia().use(userService).get(
<option selected disabled value="">
Convert to
</option>
{Object.entries(getAllTargets()).map(([converter, targets]) => (
{Object.entries(converters).map(([converter, targets]) => (
<optgroup label={converter}>
{targets.map((target) => (
<option value={`${target},${converter}`} safe>
@ -247,3 +257,4 @@ export const root = new Elysia().use(userService).get(
}),
},
);

View file

@ -1,42 +1,233 @@
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";
import { isAntivirusEnabled } from "../helpers/avToggle";
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: File, 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, but antivirus was considered enabled. Skipping scan.",
);
return {
infected: false,
viruses: [] as string[],
};
}
console.log("[ClamAV] Scanning file:", fileName, "via", CLAMAV_URL);
const formData = new FormData();
formData.append("FILES", file, fileName);
let rawText = "";
let status = 0;
try {
const res = await fetch(CLAMAV_URL, {
method: "POST",
body: formData,
});
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 existingJob = await db
.query("SELECT * FROM jobs WHERE id = ? AND user_id = ?")
.get(jobId.value, user.id);
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 (!existingJob) {
return redirect(`${WEBROOT}/`, 302);
}
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 userUploadsDir = `${uploadsDir}${user.id}/${jobId.value}/`;
const infectedItems = result.filter((item) => item.is_infected);
const viruses = infectedItems.flatMap((item) => item.viruses ?? []);
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);
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 as File[]) {
const originalName = file.name ?? "upload";
// Sanitize first; if result is empty, generate a unique fallback
const baseSanitized = sanitize(originalName);
const sanitizedFileName =
baseSanitized && baseSanitized.trim().length > 0
? baseSanitized
: `file_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
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 (or unique-fallback) 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,
},
);

View file

@ -21,6 +21,7 @@ export const userService = new Elysia({ name: "user/service" })
name: "jwt",
schema: t.Object({
id: t.String(),
role: t.String(), // user role in JWT
}),
secret: process.env.JWT_SECRET ?? randomUUID(),
exp: "7d",
@ -183,7 +184,10 @@ export const user = new Elysia()
.post(
"/register",
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
if (!ACCOUNT_REGISTRATION && !FIRST_RUN) {
// first user allowed even if ACCOUNT_REGISTRATION=false
const isFirstUser = FIRST_RUN;
if (!ACCOUNT_REGISTRATION && !isFirstUser) {
return redirect(`${WEBROOT}/login`, 302);
}
@ -200,11 +204,17 @@ export const user = new Elysia()
}
const savedPassword = await Bun.password.hash(password);
db.query("INSERT INTO users (email, password) VALUES (?, ?)").run(email, savedPassword);
const role = isFirstUser ? "admin" : "user";
const user = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
email,
savedPassword,
role,
);
if (!user) {
const userRow = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email);
if (!userRow) {
set.status = 500;
return {
message: "Failed to create user.",
@ -212,7 +222,8 @@ export const user = new Elysia()
}
const accessToken = await jwt.sign({
id: String(user.id),
id: String(userRow.id),
role: userRow.role,
});
if (!auth) {
@ -318,7 +329,9 @@ export const user = new Elysia()
.post(
"/login",
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email);
const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(
body.email,
);
if (!existingUser) {
set.status = 403;
@ -338,6 +351,7 @@ export const user = new Elysia()
const accessToken = await jwt.sign({
id: String(existingUser.id),
role: existingUser.role ?? "user",
});
if (!auth) {
@ -387,6 +401,13 @@ export const user = new Elysia()
return redirect(`${WEBROOT}/`, 302);
}
let otherUsers: { id: number; email: string; role: string }[] = [];
if (userData.role === "admin") {
otherUsers = db
.query("SELECT id, email, role FROM users WHERE id != ? ORDER BY email ASC")
.all(userData.id) as { id: number; email: string; role: string }[];
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Account">
<>
@ -445,6 +466,176 @@ export const user = new Elysia()
</div>
</form>
</article>
{userData.role === "admin" && (
<>
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Add new user</h2>
<p class="text-sm text-neutral-400">
Create additional users for this ConvertX instance. Admins can create other
admins or normal users.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/add-user`}
class="flex flex-col gap-4"
>
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
name="newUserEmail"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Email"
autocomplete="email"
required
/>
</label>
<label class="flex flex-col gap-1">
Password
<input
type="password"
name="newUserPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Password"
autocomplete="new-password"
required
/>
</label>
<label class="flex flex-col gap-1">
Role
<select
name="newUserRole"
class="rounded-sm bg-neutral-800 p-3"
required
>
<option value="user">Normal user</option>
<option value="admin">Admin</option>
</select>
</label>
</fieldset>
<div role="group">
<input type="submit" value="Add user" class="w-full btn-secondary" />
</div>
</form>
</article>
{otherUsers.length > 0 && (
<article class="article mt-6">
<header class="mb-4">
<h2 class="text-xl font-semibold">Manage users</h2>
<p class="text-sm text-neutral-400">
Edit or delete users from this instance. You cannot delete yourself or the
last remaining admin.
</p>
</header>
<div class="scrollbar-thin max-h-[50vh] overflow-y-auto">
<table
class={`
w-full table-auto rounded bg-neutral-900
[&_td]:border-b [&_td]:border-neutral-800 [&_td]:p-2
[&_th]:border-b [&_th]:border-neutral-800 [&_th]:p-2
`}
>
<thead>
<tr>
<th class="text-left">Email</th>
<th class="text-left">Role</th>
<th class="text-left">Actions</th>
</tr>
</thead>
<tbody>
{otherUsers.map((u) => (
<tr>
<td>{u.email}</td>
<td class="capitalize">{u.role}</td>
<td>
<div class="flex items-center gap-6">
{/* Edit / details icon */}
<form
method="get"
action={`${WEBROOT}/account/edit-user`}
>
<input
type="hidden"
name="userId"
value={String(u.id)}
/>
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5s8.268 2.943 9.542 7c-1.274 4.057-5.065 7-9.542 7s-8.268-2.943-9.542-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</form>
{/* Delete icon */}
<form
method="post"
action={`${WEBROOT}/account/delete-user`}
onsubmit="return confirm('Are you sure you want to delete this user?');"
>
<input
type="hidden"
name="deleteUserId"
value={String(u.id)}
/>
<button
type="submit"
class={`
inline-flex items-center justify-center text-accent-400
hover:text-accent-500
`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-6 w-6"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" />
<path d="M9 4h6a1 1 0 0 1 1 1v2H8V5a1 1 0 0 1 1-1z" />
</svg>
</button>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
)}
</>
)}
</main>
</>
</BaseHtml>
@ -461,11 +652,13 @@ export const user = new Elysia()
return redirect(`${WEBROOT}/login`, 302);
}
const user = await jwt.verify(auth.value);
if (!user) {
const tokenUser = await jwt.verify(auth.value);
if (!tokenUser) {
return redirect(`${WEBROOT}/login`, 302);
}
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(
tokenUser.id,
);
if (!existingUser) {
if (auth?.value) {
@ -483,15 +676,16 @@ export const user = new Elysia()
};
}
const fields = [];
const values = [];
const fields: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const values: any[] = [];
if (body.email) {
const existingUser = await db
const existingUserWithEmail = await db
.query("SELECT id FROM users WHERE email = ?")
.as(User)
.get(body.email);
if (existingUser && existingUser.id.toString() !== user.id) {
if (existingUserWithEmail && existingUserWithEmail.id.toString() !== tokenUser.id) {
set.status = 409;
return { message: "Email already in use." };
}
@ -506,7 +700,7 @@ export const user = new Elysia()
if (fields.length > 0) {
db.query(
`UPDATE users SET ${fields.map((field) => `${field}=?`).join(", ")} WHERE id=?`,
).run(...values, user.id);
).run(...values, tokenUser.id);
}
return redirect(`${WEBROOT}/`, 302);
@ -519,4 +713,342 @@ export const user = new Elysia()
}),
cookie: "session",
},
)
.post(
"/account/add-user",
async ({ body, set, redirect, jwt, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const tokenUser = await jwt.verify(auth.value);
if (!tokenUser) {
return redirect(`${WEBROOT}/login`, 302);
}
const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id);
if (!actingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
if (actingUser.role !== "admin") {
set.status = 403;
return {
message: "Only admins can create new users.",
};
}
const { newUserEmail, newUserPassword, newUserRole } = body as {
newUserEmail: string;
newUserPassword: string;
newUserRole: string;
};
if (!newUserEmail || !newUserPassword) {
set.status = 400;
return {
message: "Missing email or password.",
};
}
const existingNewUser = db.query("SELECT id FROM users WHERE email = ?").get(newUserEmail);
if (existingNewUser) {
set.status = 400;
return {
message: "A user with this email already exists.",
};
}
const hashedPassword = await Bun.password.hash(newUserPassword);
const role = newUserRole === "admin" ? "admin" : "user";
db.query("INSERT INTO users (email, password, role) VALUES (?, ?, ?)").run(
newUserEmail,
hashedPassword,
role,
);
return redirect(`${WEBROOT}/account`, 302);
},
{
body: t.Object({
newUserEmail: t.String(),
newUserPassword: t.String(),
newUserRole: t.String(),
}),
cookie: "session",
},
)
.get(
"/account/edit-user",
async ({ query, user, redirect }) => {
if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}
const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);
if (!actingUser || actingUser.role !== "admin") {
return redirect(`${WEBROOT}/account`, 302);
}
const targetId = Number.parseInt(query.userId, 10);
if (!Number.isFinite(targetId) || targetId === actingUser.id) {
return redirect(`${WEBROOT}/account`, 302);
}
const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId);
if (!targetUser) {
return redirect(`${WEBROOT}/account`, 302);
}
return (
<BaseHtml webroot={WEBROOT} title="ConvertX | Edit user">
<>
<Header
webroot={WEBROOT}
accountRegistration={ACCOUNT_REGISTRATION}
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
hideHistory={HIDE_HISTORY}
loggedIn
/>
<main
class={`
w-full flex-1 px-2
sm:px-4
`}
>
<article class="article">
<header class="mb-4">
<h1 class="text-xl font-semibold">Edit user</h1>
<p class="text-sm text-neutral-400">
Change this user&apos;s role or set a new password. Leave password blank to keep
it unchanged.
</p>
</header>
<form
method="post"
action={`${WEBROOT}/account/edit-user`}
class={`
flex flex-col gap-4
`}
>
<input type="hidden" name="userId" value={String(targetUser.id)} />
<fieldset class="mb-4 flex flex-col gap-4">
<label class="flex flex-col gap-1">
Email
<input
type="email"
value={targetUser.email}
class="rounded-sm bg-neutral-800 p-3"
disabled
/>
</label>
<label class="flex flex-col gap-1">
Role
<select
name="role"
class="rounded-sm bg-neutral-800 p-3 capitalize"
required
>
<option value="user" selected={targetUser.role === "user"}>
Normal user
</option>
<option value="admin" selected={targetUser.role === "admin"}>
Admin
</option>
</select>
</label>
<label class="flex flex-col gap-1">
New password (optional)
<input
type="password"
name="newPassword"
class="rounded-sm bg-neutral-800 p-3"
placeholder="Leave blank to keep current password"
autocomplete="new-password"
/>
</label>
</fieldset>
<div class="flex flex-row gap-4">
<a
href={`${WEBROOT}/account`}
class="w-full btn-secondary text-center"
>
Cancel
</a>
<button type="submit" class="w-full btn-primary">
Save changes
</button>
</div>
</form>
</article>
</main>
</>
</BaseHtml>
);
},
{
auth: true,
query: t.Object({
userId: t.String(),
}),
},
)
.post(
"/account/edit-user",
async ({ body, set, redirect, jwt, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const tokenUser = await jwt.verify(auth.value);
if (!tokenUser) {
return redirect(`${WEBROOT}/login`, 302);
}
const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id);
if (!actingUser || actingUser.role !== "admin") {
set.status = 403;
return { message: "Only admins can edit users." };
}
const { userId, role, newPassword } = body as {
userId: string;
role: string;
newPassword?: string;
};
const targetId = Number.parseInt(userId, 10);
if (!Number.isFinite(targetId) || targetId === actingUser.id) {
return redirect(`${WEBROOT}/account`, 302);
}
const targetUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(targetId);
if (!targetUser) {
return redirect(`${WEBROOT}/account`, 302);
}
// Prevent demoting the last admin
if (targetUser.role === "admin" && role !== "admin") {
const adminCountRow = db
.query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'")
.get() as { cnt: number };
if (adminCountRow.cnt <= 1) {
set.status = 400;
return { message: "You cannot demote the last remaining admin." };
}
}
const fields: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const values: any[] = [];
if (role === "admin" || role === "user") {
fields.push("role");
values.push(role);
}
if (newPassword && newPassword.trim().length > 0) {
fields.push("password");
values.push(await Bun.password.hash(newPassword));
}
if (fields.length > 0) {
db.query(
`UPDATE users SET ${fields.map((f) => `${f}=?`).join(", ")} WHERE id=?`,
).run(...values, targetId);
}
return redirect(`${WEBROOT}/account`, 302);
},
{
body: t.Object({
userId: t.String(),
role: t.String(),
newPassword: t.MaybeEmpty(t.String()),
}),
cookie: "session",
},
)
.post(
"/account/delete-user",
async ({ body, set, redirect, jwt, cookie: { auth } }) => {
if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
const tokenUser = await jwt.verify(auth.value);
if (!tokenUser) {
return redirect(`${WEBROOT}/login`, 302);
}
const actingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(tokenUser.id);
if (!actingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
if (actingUser.role !== "admin") {
set.status = 403;
return { message: "Only admins can delete users." };
}
const { deleteUserId } = body as { deleteUserId: string };
const targetId = Number.parseInt(deleteUserId, 10);
if (!Number.isFinite(targetId)) {
set.status = 400;
return { message: "Invalid user id." };
}
if (targetId === actingUser.id) {
set.status = 400;
return { message: "You cannot delete your own account from here." };
}
const targetUser = db
.query("SELECT * FROM users WHERE id = ?")
.as(User)
.get(targetId as unknown as number);
if (!targetUser) {
return redirect(`${WEBROOT}/account`, 302);
}
if (targetUser.role === "admin") {
const adminCountRow = db
.query("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'")
.get() as { cnt: number };
if (adminCountRow.cnt <= 1) {
set.status = 400;
return { message: "You cannot delete the last remaining admin." };
}
}
// delete this user's jobs and files (to avoid FK issues) in a single transaction
const deleteUserTx = db.transaction((id: number) => {
db.query(
"DELETE FROM file_names WHERE job_id IN (SELECT id FROM jobs WHERE user_id = ?)",
).run(id);
db.query("DELETE FROM jobs WHERE user_id = ?").run(id);
db.query("DELETE FROM users WHERE id = ?").run(id);
});
deleteUserTx(targetId);
return redirect(`${WEBROOT}/account`, 302);
},
{
body: t.Object({
deleteUserId: t.String(),
}),
cookie: "session",
},
);

View file

@ -1,6 +1,7 @@
:root {
/* Light mode */
/* Light mode (original ConvertX) */
--contrast: oklch(100% 0 0);
/* Neutral colors - Gray */
--neutral-950: oklch(98.5% 0.002 247.839);
--neutral-900: oklch(96.7% 0.003 264.542);
@ -13,6 +14,7 @@
--neutral-200: oklch(26.9% 0 0);
--neutral-100: oklch(21% 0.034 264.665);
--neutral-50: oklch(13% 0.028 261.692);
/* lime-700 */
--accent-600: oklch(53.2% 0.157 131.589);
/* lime-600 */
@ -22,10 +24,13 @@
}
@media (prefers-color-scheme: dark) {
/* Dark mode */
/* Dark mode (system preference only) */
:root {
/* In original file this was 0%; we keep value but our explicit
data-theme="dark" override below will control real dark mode. */
--contrast: oklch(0% 0 0);
/* Neutral colors - Gray */
/* Neutral colors - Gray (darker backgrounds) */
--neutral-950: oklch(13% 0.028 261.692);
--neutral-900: oklch(21% 0.034 264.665);
--neutral-800: oklch(27.8% 0.033 256.848);
@ -37,6 +42,7 @@
--neutral-200: oklch(92.8% 0.006 264.531);
--neutral-100: oklch(96.7% 0.003 264.542);
--neutral-50: oklch(98.5% 0.002 247.839);
/* lime-600 */
--accent-600: oklch(64.8% 0.2 131.684);
/* lime-500 */
@ -45,3 +51,92 @@
--accent-400: oklch(84.1% 0.238 128.85);
}
}
/* ---------- Explicit app-controlled dark mode ---------- */
/* Light = default (:root above).
Dark = when JS sets <html data-theme="dark"> */
:root[data-theme="dark"] {
/* White text on dark backgrounds */
--contrast: oklch(100% 0 0);
/* Same dark neutrals as in the @media block above */
--neutral-950: oklch(13% 0.028 261.692);
--neutral-900: oklch(21% 0.034 264.665);
--neutral-800: oklch(27.8% 0.033 256.848);
--neutral-700: oklch(37.3% 0.034 259.733);
--neutral-600: oklch(44.6% 0.03 256.802);
--neutral-500: oklch(55.1% 0.027 264.364);
--neutral-400: oklch(70.7% 0.022 261.325);
--neutral-300: oklch(87.2% 0.01 258.338);
--neutral-200: oklch(92.8% 0.006 264.531);
--neutral-100: oklch(96.7% 0.003 264.542);
--neutral-50: oklch(98.5% 0.002 247.839);
/* Slightly brighter accents in dark mode */
--accent-600: oklch(64.8% 0.2 131.684);
--accent-500: oklch(76.8% 0.233 130.85);
--accent-400: oklch(84.1% 0.238 128.85);
}
/* ---------- Toggle styling (small, header-only) ---------- */
.cx-theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: 1.25rem; /* space from logo area */
margin-right: 1.75rem; /* NEW: space before "History" */
font-size: 0.85rem;
font-weight: 500;
color: var(--neutral-900);
}
:root[data-theme="dark"] .cx-theme-toggle {
color: var(--neutral-50);
}
.cx-theme-toggle__label {
white-space: nowrap;
}
/* Switch body */
.cx-switch {
position: relative;
width: 64px;
height: 28px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.25);
background: #e5e7eb;
display: inline-flex;
align-items: center;
padding: 3px;
box-sizing: border-box;
cursor: pointer;
outline: none;
transition:
background-color 150ms ease-out,
border-color 150ms ease-out;
}
/* Thumb */
.cx-switch__thumb {
width: 22px;
height: 22px;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
transform: translateX(0);
transition: transform 150ms ease-out;
}
/* ON state */
.cx-switch.cx-switch--on {
background: #2563eb;
border-color: #2563eb;
}
.cx-switch.cx-switch--on .cx-switch__thumb {
transform: translateX(32px);
}