diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 4bda22f..3e3bb29 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -14,48 +14,330 @@ import { userService } from "./user"; import { outputDir } from ".."; import { sendFileToErugo } from "../helpers/erugo"; -function ResultsArticle({ - job, - files, - outputPath, -}: { - job: Jobs; - files: Filename[]; - outputPath: string; -}) { +export const results = new Elysia() + .use(userService) + .get( + "/results/:id", + async ({ params: { id }, user }) => { + if (!ALLOW_UNAUTHENTICATED && !user) { + return new Response(null, { + status: 302, + headers: { Location: `${WEBROOT}/login` }, + }); + } + + const job = db + .query("SELECT * FROM jobs WHERE id = ?") + .as(Jobs) + .get(id); + + if (!job) { + return new Response(null, { + status: 302, + headers: { Location: `${WEBROOT}/history` }, + }); + } + + const files = db + .query("SELECT * FROM file_names WHERE job_id = ?") + .as(Filename) + .all(id); + + if (!files) { + return new Response(null, { + status: 302, + headers: { Location: `${WEBROOT}/history` }, + }); + } + + const isOwner = user?.id === job.user_id; + + // If authentication is enabled, users should only be able to access their own jobs. + // If authentication is disabled, anyone can access job links. + if (!ALLOW_UNAUTHENTICATED && !isOwner) { + return new Response(null, { + status: 302, + headers: { Location: `${WEBROOT}/history` }, + }); + } + + return ( + + +
+
+
+ +
+
+ + + + + ); + }, + { + params: t.Object({ + id: t.Numeric(), + }), + }, + ) + .get( + "/archive/:id", + async ({ params: { id }, user, set }) => { + if (!ALLOW_UNAUTHENTICATED && !user) { + set.status = 302; + set.headers.Location = `${WEBROOT}/login`; + return; + } + + const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); + + if (!job) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const isOwner = user?.id === job.user_id; + + // If authentication is enabled, users should only be able to access their own jobs. + // If authentication is disabled, anyone can access job links. + if (!ALLOW_UNAUTHENTICATED && !isOwner) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const outputTar = Bun.file(`${outputDir}/${job.id}/converted_files_${job.id}.tar`); + + if (!(await outputTar.exists())) { + set.status = 302; + set.headers.Location = `${WEBROOT}/results/${job.id}`; + return; + } + + set.headers["Content-Type"] = "application/x-tar"; + set.headers["Content-Disposition"] = `attachment; filename="converted_files_${job.id}.tar"`; + return outputTar; + }, + { + params: t.Object({ + id: t.Numeric(), + }), + }, + ) + .get( + "/preview/:id", + async ({ params: { id }, user, set }) => { + if (!ALLOW_UNAUTHENTICATED && !user) { + set.status = 302; + set.headers.Location = `${WEBROOT}/login`; + return; + } + + const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); + + if (!job) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const isOwner = user?.id === job.user_id; + + // If authentication is enabled, users should only be able to access their own jobs. + // If authentication is disabled, anyone can access job links. + if (!ALLOW_UNAUTHENTICATED && !isOwner) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const preview = Bun.file(`${outputDir}/${job.id}/preview.pdf`); + + if (!(await preview.exists())) { + set.status = 302; + set.headers.Location = `${WEBROOT}/results/${job.id}`; + return; + } + + set.headers["Content-Type"] = "application/pdf"; + set.headers["Content-Disposition"] = `inline; filename="preview_${job.id}.pdf"`; + return preview; + }, + { + params: t.Object({ + id: t.Numeric(), + }), + }, + ) + .get( + "/delete/:id", + async ({ params: { id }, user, set }) => { + if (!ALLOW_UNAUTHENTICATED && !user) { + set.status = 302; + set.headers.Location = `${WEBROOT}/login`; + return; + } + + const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); + + if (!job) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const isOwner = user?.id === job.user_id; + + // If authentication is enabled, users should only be able to access their own jobs. + // If authentication is disabled, anyone can access job links. + if (!ALLOW_UNAUTHENTICATED && !isOwner) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + db.query("DELETE FROM jobs WHERE id = ?").run(id); + db.query("DELETE FROM file_names WHERE job_id = ?").run(id); + + try { + await Bun.$`rm -rf ${outputDir}/${job.id}`.quiet(); + } catch {} + + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + }, + { + params: t.Object({ + id: t.Numeric(), + }), + }, + ) + .post( + "/erugo/share/:id", + async ({ params: { id }, user, set }) => { + if (!ALLOW_UNAUTHENTICATED && !user) { + set.status = 302; + set.headers.Location = `${WEBROOT}/login`; + return; + } + + const job = db.query("SELECT * FROM jobs WHERE id = ?").as(Jobs).get(id); + + if (!job) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + const isOwner = user?.id === job.user_id; + + // If authentication is enabled, users should only be able to access their own jobs. + // If authentication is disabled, anyone can access job links. + if (!ALLOW_UNAUTHENTICATED && !isOwner) { + set.status = 302; + set.headers.Location = `${WEBROOT}/history`; + return; + } + + // Mark "sharing" state (optional; safe if column exists) + try { + db.query("UPDATE jobs SET is_sharing = 1 WHERE id = ?").run(id); + } catch {} + + try { + await sendFileToErugo(job.id); + } finally { + try { + db.query("UPDATE jobs SET is_sharing = 0 WHERE id = ?").run(id); + } catch {} + } + + set.status = 302; + set.headers.Location = `${WEBROOT}/results/${job.id}`; + }, + { + params: t.Object({ + id: t.Numeric(), + }), + }, + ); + +const ResultsArticle = ({ job, files }: { job: Jobs; files: Filename[] }) => { 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 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"; + // NOTE: is ignored by browsers. If an action is not ready, render + // a non-interactive element (no href) so it cannot be activated by mouse/keyboard. + const disabledLinkClass = "opacity-50 cursor-not-allowed select-none"; const busyAttrs = { disabled: true, "aria-busy": "true" } as const; return (
-

Results

+

Results

-
+ +
+
+
+

Job

+

+ #{job.id} • {job.filename} +

+
+ +
+
+ {isDone ? ( + +

Delete

+
+ ) : ( + +

Delete

+
+ )} + + {isDone ? ( + +

Tar

+
+ ) : ( + +

Tar

+
+ )}
-
+
+
- + /> - - - - - - - - - - - {files.map((file) => ( - - - - - - - ))} - -
Converted File NameStatusActions
- {file.output_file_name} - {file.status} - - - - - - - - - -
- - {/* Share Modal (hidden by default) */} -
); -} - -/** - * 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) job_id.remove(); - - 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 outputPath = `${user.id}/${params.jobId}/`; - - const files = db - .query("SELECT * FROM file_names WHERE job_id = ?") - .as(Filename) - .all(params.jobId); - - return ( - - <> -
- -
- -
- - {/* keep existing file */} -