Merge 3e03af3bc5 into 081f62de95
This commit is contained in:
commit
b382b89ec6
20 changed files with 2421 additions and 190 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
|
|
|||
29
compose.yaml
29
compose.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
|
|
|||
425
public/script.js
425
public/script.js
|
|
@ -7,6 +7,195 @@ let fileType;
|
|||
let pendingFiles = 0;
|
||||
let formatSelected = false;
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Antivirus toggle UI (custom slider)
|
||||
// ─────────────────────────────────────
|
||||
|
||||
let avToggleButton = null;
|
||||
let avToggleLabel = null;
|
||||
|
||||
// Inject minimal CSS so the toggle looks like a real slider
|
||||
function injectAvToggleStyles() {
|
||||
if (document.getElementById("av-toggle-styles")) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "av-toggle-styles";
|
||||
style.textContent = `
|
||||
.av-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Light theme (default) */
|
||||
.av-toggle-label {
|
||||
color: #04070e;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Dark theme: make label white */
|
||||
html[data-theme="dark"] .av-toggle-label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.av-toggle-switch {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: #4b5563;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.av-toggle-switch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||
left: 2px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.av-toggle-switch.av-on {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
.av-toggle-switch.av-on::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
.av-toggle-switch.av-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function setAntivirusToggleVisual(enabled, available) {
|
||||
if (!avToggleButton) return;
|
||||
|
||||
avToggleButton.classList.remove("av-on", "av-disabled");
|
||||
|
||||
if (!available) {
|
||||
avToggleButton.classList.add("av-disabled");
|
||||
avToggleButton.setAttribute("aria-disabled", "true");
|
||||
avToggleButton.setAttribute("aria-pressed", "false");
|
||||
if (avToggleLabel) {
|
||||
avToggleLabel.textContent =
|
||||
"Antivirus scan unavailable (CLAMAV_URL not set)";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
avToggleButton.setAttribute("aria-disabled", "false");
|
||||
|
||||
if (enabled) {
|
||||
avToggleButton.classList.add("av-on");
|
||||
avToggleButton.setAttribute("aria-pressed", "true");
|
||||
} else {
|
||||
avToggleButton.setAttribute("aria-pressed", "false");
|
||||
}
|
||||
|
||||
if (avToggleLabel) {
|
||||
avToggleLabel.textContent = "Enable antivirus scan";
|
||||
}
|
||||
}
|
||||
|
||||
function initAntivirusToggleState() {
|
||||
if (!avToggleButton) return;
|
||||
|
||||
fetch(`${webroot}/api/antivirus`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("Antivirus state from server:", data);
|
||||
const available = !!data.available;
|
||||
const enabled = !!data.enabled;
|
||||
setAntivirusToggleVisual(enabled, available);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get antivirus state:", err);
|
||||
setAntivirusToggleVisual(false, false);
|
||||
if (avToggleLabel) {
|
||||
avToggleLabel.textContent = "Antivirus scan status unavailable";
|
||||
}
|
||||
});
|
||||
|
||||
avToggleButton.addEventListener("click", () => {
|
||||
const isDisabled = avToggleButton.classList.contains("av-disabled");
|
||||
if (isDisabled) return;
|
||||
|
||||
const currentlyOn = avToggleButton.classList.contains("av-on");
|
||||
const newValue = !currentlyOn;
|
||||
|
||||
fetch(`${webroot}/api/antivirus`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: newValue }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
console.log("Antivirus updated state:", data);
|
||||
const available = !!data.available;
|
||||
const enabled = !!data.enabled;
|
||||
setAntivirusToggleVisual(enabled, available);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to update antivirus state:", err);
|
||||
// On error, do not visually toggle
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createAntivirusToggle() {
|
||||
injectAvToggleStyles();
|
||||
|
||||
const form = document.querySelector("form");
|
||||
if (!form) return;
|
||||
if (document.getElementById("av-toggle-wrapper")) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.id = "av-toggle-wrapper";
|
||||
wrapper.className = "av-toggle-wrapper";
|
||||
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.id = "av-toggle-label";
|
||||
labelSpan.className = "av-toggle-label";
|
||||
labelSpan.textContent = "Enable antivirus scan";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.id = "av-toggle";
|
||||
button.className = "av-toggle-switch";
|
||||
button.setAttribute("role", "switch");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
|
||||
wrapper.appendChild(labelSpan);
|
||||
wrapper.appendChild(button);
|
||||
|
||||
// Insert at top of form so it's visible above dropzone
|
||||
form.insertBefore(wrapper, form.firstChild);
|
||||
|
||||
avToggleButton = button;
|
||||
avToggleLabel = labelSpan;
|
||||
|
||||
initAntivirusToggleState();
|
||||
}
|
||||
|
||||
// Create the antivirus toggle as soon as script runs
|
||||
createAntivirusToggle();
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Existing upload UI logic
|
||||
// ─────────────────────────────────────
|
||||
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("dragover");
|
||||
|
|
@ -33,7 +222,6 @@ dropZone.addEventListener("drop", (e) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Extracted handleFile function for reusability in drag-and-drop and file input
|
||||
function handleFile(file) {
|
||||
const fileList = document.querySelector("#file-list");
|
||||
|
||||
|
|
@ -128,14 +316,11 @@ const updateSearchBar = () => {
|
|||
});
|
||||
|
||||
convertToInput.addEventListener("search", () => {
|
||||
// when the user clears the search bar using the 'x' button
|
||||
convertButton.disabled = true;
|
||||
formatSelected = false;
|
||||
});
|
||||
|
||||
convertToInput.addEventListener("blur", (e) => {
|
||||
// Keep the popup open even when clicking on a target button
|
||||
// for a split second to allow the click to go through
|
||||
if (e?.relatedTarget?.classList?.contains("target")) {
|
||||
convertToPopup.classList.add("hidden");
|
||||
convertToPopup.classList.remove("flex");
|
||||
|
|
@ -152,7 +337,6 @@ const updateSearchBar = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Add a 'change' event listener to the file input element
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
const files = e.target.files;
|
||||
for (const file of files) {
|
||||
|
|
@ -165,21 +349,19 @@ const setTitle = () => {
|
|||
title.textContent = `Convert ${fileType ? `.${fileType}` : ""}`;
|
||||
};
|
||||
|
||||
// Add a onclick for the delete button
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const deleteRow = (target) => {
|
||||
const filename = target.parentElement.parentElement.children[0].textContent;
|
||||
const row = target.parentElement.parentElement;
|
||||
row.remove();
|
||||
|
||||
// remove from fileNames
|
||||
const index = fileNames.indexOf(filename);
|
||||
fileNames.splice(index, 1);
|
||||
if (index !== -1) {
|
||||
fileNames.splice(index, 1);
|
||||
}
|
||||
|
||||
// reset fileInput
|
||||
fileInput.value = "";
|
||||
|
||||
// if fileNames is empty, reset fileType
|
||||
if (fileNames.length === 0) {
|
||||
fileType = null;
|
||||
fileInput.removeAttribute("accept");
|
||||
|
|
@ -209,20 +391,104 @@ const uploadFile = (file) => {
|
|||
xhr.open("POST", `${webroot}/upload`, true);
|
||||
|
||||
xhr.onload = () => {
|
||||
let data = JSON.parse(xhr.responseText);
|
||||
|
||||
pendingFiles -= 1;
|
||||
|
||||
// 🔍 1) Log exactly what the browser got
|
||||
console.log("Upload raw response:", xhr.status, xhr.responseText);
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText || "{}");
|
||||
} catch (e) {
|
||||
console.error("Failed to parse upload response:", e, xhr.responseText);
|
||||
}
|
||||
|
||||
// 🔍 2) Compute an "infected" flag as robustly as possible
|
||||
const isInfected =
|
||||
(typeof data === "object" &&
|
||||
data !== null &&
|
||||
(data.infected === true ||
|
||||
data.infected === "true" ||
|
||||
(typeof data.message === "string" &&
|
||||
data.message.toLowerCase().includes("infected file found")))) ||
|
||||
(typeof xhr.responseText === "string" &&
|
||||
xhr.responseText.toLowerCase().includes("infected file found"));
|
||||
|
||||
// 🔴 3) If backend reports infection, show popup and stop
|
||||
if (xhr.status >= 200 && xhr.status < 300 && isInfected) {
|
||||
const infectedFiles = data.infectedFiles || [];
|
||||
const details = infectedFiles
|
||||
.map((f) =>
|
||||
`${f.name}: ${
|
||||
Array.isArray(f.viruses) && f.viruses.length
|
||||
? f.viruses.join(", ")
|
||||
: "malware detected"
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
alert(
|
||||
"⚠️ Infected file found. Conversion will be aborted.\n\n" +
|
||||
(details ? "Details:\n" + details : ""),
|
||||
);
|
||||
|
||||
// Remove row for this file
|
||||
if (file.htmlRow && file.htmlRow.remove) {
|
||||
file.htmlRow.remove();
|
||||
}
|
||||
|
||||
// Remove from internal list
|
||||
const idx = fileNames.indexOf(file.name);
|
||||
if (idx !== -1) {
|
||||
fileNames.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (fileNames.length === 0) {
|
||||
fileType = null;
|
||||
fileInput.removeAttribute("accept");
|
||||
setTitle();
|
||||
convertButton.disabled = true;
|
||||
} else if (pendingFiles === 0 && formatSelected) {
|
||||
convertButton.disabled = false;
|
||||
}
|
||||
|
||||
convertButton.textContent = "Convert";
|
||||
|
||||
const progressbar = file.htmlRow?.getElementsByTagName("progress");
|
||||
if (progressbar && progressbar[0]?.parentElement) {
|
||||
progressbar[0].parentElement.remove();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic HTTP error
|
||||
if (xhr.status !== 200) {
|
||||
console.error("Upload failed:", xhr.status, xhr.responseText);
|
||||
alert("Upload failed. Please try again.");
|
||||
convertButton.disabled = false;
|
||||
convertButton.textContent = "Upload failed";
|
||||
|
||||
const progressbar = file.htmlRow.getElementsByTagName("progress");
|
||||
if (progressbar[0]?.parentElement) {
|
||||
progressbar[0].parentElement.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean upload
|
||||
if (pendingFiles === 0) {
|
||||
if (formatSelected) {
|
||||
if (formatSelected && fileNames.length > 0) {
|
||||
convertButton.disabled = false;
|
||||
}
|
||||
convertButton.textContent = "Convert";
|
||||
}
|
||||
|
||||
//Remove the progress bar when upload is done
|
||||
let progressbar = file.htmlRow.getElementsByTagName("progress");
|
||||
progressbar[0].parentElement.remove();
|
||||
console.log(data);
|
||||
const progressbar = file.htmlRow.getElementsByTagName("progress");
|
||||
if (progressbar[0]?.parentElement) {
|
||||
progressbar[0].parentElement.remove();
|
||||
}
|
||||
console.log("Upload parsed response:", data);
|
||||
};
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
|
|
@ -231,11 +497,16 @@ const uploadFile = (file) => {
|
|||
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
|
||||
|
||||
let progressbar = file.htmlRow.getElementsByTagName("progress");
|
||||
progressbar[0].value = (100 * sent) / total;
|
||||
if (progressbar[0]) {
|
||||
progressbar[0].value = (100 * sent) / total;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = (e) => {
|
||||
console.log(e);
|
||||
console.log("XHR error:", e);
|
||||
alert("Upload failed due to a network error.");
|
||||
convertButton.disabled = false;
|
||||
convertButton.textContent = "Upload failed";
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
|
|
@ -243,9 +514,117 @@ const uploadFile = (file) => {
|
|||
|
||||
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
|
||||
|
||||
formConvert.addEventListener("submit", () => {
|
||||
const hiddenInput = document.querySelector("input[name='file_names']");
|
||||
hiddenInput.value = JSON.stringify(fileNames);
|
||||
});
|
||||
if (formConvert) {
|
||||
formConvert.addEventListener("submit", () => {
|
||||
console.log("Submitting convert form with files:", fileNames);
|
||||
const hiddenInput = document.querySelector("input[name='file_names']");
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = JSON.stringify(fileNames);
|
||||
} else {
|
||||
console.warn(
|
||||
"Hidden input 'file_names' not found – form will submit without it.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSearchBar();
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Theme toggle (Light / Dark) in header, before "History"
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const THEME_STORAGE_KEY = "convertx-theme";
|
||||
|
||||
function applyTheme(theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") {
|
||||
root.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
root.removeAttribute("data-theme"); // original light theme
|
||||
}
|
||||
}
|
||||
|
||||
function initThemeFromPreference() {
|
||||
let stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const initial =
|
||||
stored === "dark" || stored === "light" ? stored : "light";
|
||||
|
||||
applyTheme(initial);
|
||||
return initial;
|
||||
}
|
||||
|
||||
function createThemeToggle(initialTheme) {
|
||||
if (document.querySelector(".cx-theme-toggle")) return;
|
||||
|
||||
const container = document.createElement("span");
|
||||
container.className = "cx-theme-toggle";
|
||||
|
||||
container.innerHTML = `
|
||||
<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
21
public/theme-init.js
Normal 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");
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
13
src/db/db.ts
13
src/db/db.ts
|
|
@ -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;");
|
||||
|
||||
|
|
|
|||
|
|
@ -20,4 +20,6 @@ export class User {
|
|||
id!: number;
|
||||
email!: string;
|
||||
password!: string;
|
||||
role!: string; // 'admin' | 'user'
|
||||
}
|
||||
|
||||
|
|
|
|||
44
src/helpers/avToggle.ts
Normal file
44
src/helpers/avToggle.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -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
129
src/helpers/erugo.ts
Normal 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
27
src/icons/share.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
97
src/pages/antivirus.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -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()),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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",
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue