Powered by
ConvertX{" "}
diff --git a/src/db/db.ts b/src/db/db.ts
index de572685..2c4f6f00 100644
--- a/src/db/db.ts
+++ b/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;");
diff --git a/src/db/types.ts b/src/db/types.ts
index 48257119..3993b810 100644
--- a/src/db/types.ts
+++ b/src/db/types.ts
@@ -20,4 +20,6 @@ export class User {
id!: number;
email!: string;
password!: string;
+ role!: string; // 'admin' | 'user'
}
+
diff --git a/src/helpers/avToggle.ts b/src/helpers/avToggle.ts
new file mode 100644
index 00000000..90718560
--- /dev/null
+++ b/src/helpers/avToggle.ts
@@ -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;
+}
+
diff --git a/src/helpers/env.ts b/src/helpers/env.ts
index 53f6a8fc..b7523270 100644
--- a/src/helpers/env.ts
+++ b/src/helpers/env.ts
@@ -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;
diff --git a/src/helpers/erugo.ts b/src/helpers/erugo.ts
new file mode 100644
index 00000000..fad17d13
--- /dev/null
+++ b/src/helpers/erugo.ts
@@ -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,
+ };
+}
+
diff --git a/src/icons/share.tsx b/src/icons/share.tsx
new file mode 100644
index 00000000..dff382d5
--- /dev/null
+++ b/src/icons/share.tsx
@@ -0,0 +1,27 @@
+// src/icons/share.tsx
+export function ShareIcon() {
+ return (
+
+ );
+}
+
diff --git a/src/index.tsx b/src/index.tsx
index c163e744..c93e7ebc 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -18,6 +18,7 @@ import { root } from "./pages/root";
import { upload } from "./pages/upload";
import { user } from "./pages/user";
import { healthcheck } from "./pages/healthcheck";
+import { antivirus } from "./pages/antivirus"; // 👈 NEW
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
@@ -50,6 +51,7 @@ const app = new Elysia({
.use(listConverters)
.use(chooseConverter)
.use(healthcheck)
+ .use(antivirus) // 👈 register the antivirus toggle API
.onError(({ error }) => {
console.error(error);
});
@@ -67,13 +69,19 @@ if (process.env.NODE_ENV !== "production") {
app.listen(3000);
-console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
+console.log(
+ `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`,
+);
const clearJobs = () => {
const jobs = db
.query("SELECT * FROM jobs WHERE date_created < ?")
.as(Jobs)
- .all(new Date(Date.now() - AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000).toISOString());
+ .all(
+ new Date(
+ Date.now() - AUTO_DELETE_EVERY_N_HOURS * 60 * 60 * 1000,
+ ).toISOString(),
+ );
for (const job of jobs) {
// delete the directories
diff --git a/src/main.css b/src/main.css
index 72ded0e1..ce90f78d 100644
--- a/src/main.css
+++ b/src/main.css
@@ -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;
}
+
diff --git a/src/pages/antivirus.tsx b/src/pages/antivirus.tsx
new file mode 100644
index 00000000..3330d59b
--- /dev/null
+++ b/src/pages/antivirus.tsx
@@ -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,
+ },
+ );
+
diff --git a/src/pages/results.tsx b/src/pages/results.tsx
index 3a57c489..4bda22f6 100644
--- a/src/pages/results.tsx
+++ b/src/pages/results.tsx
@@ -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 (
+
+
- |
- Converted File Name
- |
-
- Status
- |
-
- Actions
- |
+ Converted File Name |
+ Status |
+ Actions |
+
{files.map((file) => (
-
+
|
{file.output_file_name}
|
{file.status} |
+
+
+
+
|
))}
+
+ {/* Share Modal (hidden by default) */}
+
+
+
+
+ Share via Erugo
+
+
+
+
+
+
+
+
+
+
+ If provided, Erugo will send the share link via email.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can copy the link even if you also email it.
+
+
+
+
+
);
}
+/**
+ * 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 (
<>
-
-
+
+
+
+
+ {/* keep existing file */}
+
+ {/* our override must also load */}
+
>
);
},
{ 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
;
},
{ 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()),
+ }),
+ },
);
+
+
diff --git a/src/pages/root.tsx b/src/pages/root.tsx
index f2a83dec..01d66e4a 100644
--- a/src/pages/root.tsx
+++ b/src/pages/root.tsx
@@ -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 (
@@ -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]) => (
{targets.map((target) => (
+
+ {userData.role === "admin" && (
+ <>
+