From 2b2e511a15414f6c28f15990b75442424f9cc3b9 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 31 May 2026 11:29:58 -0400 Subject: refactor: initial hardening tweaks store images in localStorage --- src/static/main.js | 527 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 465 insertions(+), 62 deletions(-) (limited to 'src/static/main.js') diff --git a/src/static/main.js b/src/static/main.js index 9d217cb..180bb91 100644 --- a/src/static/main.js +++ b/src/static/main.js @@ -2,99 +2,501 @@ const convertButton = document.getElementById("convert-button"); const uploadButton = document.getElementById("upload-button"); const markdownInput = document.getElementById("markdown"); const imageInput = document.getElementById("image"); -const mdFileInput = document.getElementById("md-file"); +const convertForm = document.getElementById("convert-form"); +const loadingIndicator = document.getElementById("loading"); +const resultContainer = document.getElementById("result"); +const uploadResultContainer = document.getElementById("upload-result"); +const PERSISTENCE_KEY = "likha-pdf:form-state:v1"; +const IMAGE_DB_NAME = "likha-pdf:image-db:v1"; +const IMAGE_DB_VERSION = 1; +const IMAGE_STORE_NAME = "images"; +const LOCAL_IMAGE_SCHEME = "local-image://"; +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; +const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024; +const MAX_CONVERT_REQUEST_BYTES = 512 * 1024 * 1024; +const LOCAL_IMAGE_TOKEN_PATTERN = /local-image:\/\/([a-zA-Z0-9-]+)/g; +const ALLOWED_IMAGE_EXT_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i; -// stack overflow session state js -const convertForm=document.getElementById("convert-form"),PERSISTENCE_KEY="likha-pdf:form-state:v1";function readPersistedState(){try{const e=localStorage.getItem(PERSISTENCE_KEY);return e?JSON.parse(e):null}catch{return null}}function writePersistedState(e){try{localStorage.setItem(PERSISTENCE_KEY,JSON.stringify(e))}catch{}}function collectFormState(){if(!(convertForm instanceof HTMLFormElement))return;const e={},t=Array.from(convertForm.elements);for(const n of t){if(!(n instanceof HTMLElement))continue;const t=n.getAttribute("name");t&&"markdown"!==t&&(n instanceof HTMLInputElement?"radio"===n.type?n.checked&&(e[t]=n.value):"checkbox"===n.type&&(e[t]=n.checked):n instanceof HTMLSelectElement&&(e[t]=n.value))}writePersistedState({markdown:markdownInput?.value??"",fields:e})}function restoreFormState(){if(!(convertForm instanceof HTMLFormElement))return;const e=readPersistedState();if(!e||"object"!=typeof e)return;markdownInput&&"string"==typeof e.markdown&&(markdownInput.value=e.markdown,markdownInput.readOnly=!1),imageInput&&(imageInput.disabled=!1),uploadButton&&(uploadButton.disabled=!1);const t=e.fields;if(t&&"object"==typeof t)for(const[e,n]of Object.entries(t)){const t=convertForm.elements.namedItem(e);t&&(t instanceof RadioNodeList?t.value=String(n):t instanceof HTMLElement&&(t instanceof HTMLInputElement&&"checkbox"===t.type?t.checked=Boolean(n):(t instanceof HTMLInputElement||t instanceof HTMLSelectElement)&&(t.value=String(n))))}}restoreFormState(); +function readPersistedState() { + try { + const raw = localStorage.getItem(PERSISTENCE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writePersistedState(value) { + try { + localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(value)); + } catch { + // ignore persistence failures in restricted storage contexts + } +} -document.body.addEventListener("htmx:beforeRequest", (event) => { - const elt = event.detail?.elt; - if (!elt) { +function collectFormState() { + if (!(convertForm instanceof HTMLFormElement)) { return; } - if (elt.id === "convert-form" && convertButton) { - convertButton.disabled = true; - convertButton.textContent = "generating..."; + const fields = {}; + const elements = Array.from(convertForm.elements); + + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const name = element.getAttribute("name"); + if (!name || name === "markdown") { + continue; + } + + if (element instanceof HTMLInputElement) { + if (element.type === "radio") { + if (element.checked) { + fields[name] = element.value; + } + } else if (element.type === "checkbox") { + fields[name] = element.checked; + } + continue; + } + + if (element instanceof HTMLSelectElement) { + fields[name] = element.value; + } } - if (elt.id === "upload-button" && uploadButton) { - uploadButton.disabled = true; - uploadButton.textContent = "uploading..."; + writePersistedState({ + markdown: markdownInput?.value ?? "", + fields, + }); +} + +function restoreFormState() { + if (!(convertForm instanceof HTMLFormElement)) { + return; } -}); -document.body.addEventListener("htmx:afterRequest", (event) => { - const elt = event.detail?.elt; - if (!elt) { + const state = readPersistedState(); + if (!state || typeof state !== "object") { return; } - if (elt.id === "convert-form" && convertButton) { - convertButton.disabled = false; - convertButton.textContent = "generate pdf"; + if (markdownInput && typeof state.markdown === "string") { + markdownInput.value = state.markdown; + markdownInput.readOnly = false; + } + + if (imageInput) { + imageInput.disabled = false; } - if (elt.id === "upload-button" && uploadButton) { + if (uploadButton) { uploadButton.disabled = false; - uploadButton.textContent = "upload image"; } -}); -if (convertForm instanceof HTMLFormElement) { - convertForm.addEventListener("input", collectFormState); - convertForm.addEventListener("change", collectFormState); + const fields = state.fields; + if (!fields || typeof fields !== "object") { + return; + } + + for (const [name, value] of Object.entries(fields)) { + const element = convertForm.elements.namedItem(name); + if (!element) { + continue; + } + + if (element instanceof RadioNodeList) { + element.value = String(value); + continue; + } + + if ( + element instanceof HTMLInputElement && + element.type === "checkbox" + ) { + element.checked = Boolean(value); + continue; + } + + if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { + element.value = String(value); + } + } } -document.body.addEventListener("htmx:afterSwap", (event) => { - const target = event.detail?.target; - const requestElt = event.detail?.requestConfig?.elt; +function setConvertLoadingState(isLoading) { + if (convertButton instanceof HTMLButtonElement) { + convertButton.disabled = isLoading; + convertButton.textContent = isLoading ? "generating..." : "generate pdf"; + } - if (!(target instanceof HTMLElement) || target.id !== "result") { - return; + if (loadingIndicator instanceof HTMLElement) { + loadingIndicator.hidden = !isLoading; } +} - if (!(requestElt instanceof HTMLElement) || requestElt.id !== "convert-form") { - return; +function setUploadLoadingState(isLoading) { + if (uploadButton instanceof HTMLButtonElement) { + uploadButton.disabled = isLoading; + uploadButton.textContent = isLoading ? "preparing..." : "insert image"; } +} - target.scrollIntoView({ behavior: "smooth", block: "start" }); -}); +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} -if (mdFileInput) { - mdFileInput.addEventListener("change", () => { - const file = mdFileInput.files?.[0]; +function buildLocalImageSnippet(name, id) { + const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); + return `![${cleanName}](${LOCAL_IMAGE_SCHEME}${id})`; +} - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - if (markdownInput) { - markdownInput.value = /** @type {string} */ (e.target.result); - markdownInput.readOnly = true; - } - if (imageInput) { - imageInput.disabled = true; +function isAllowedImageFile(file) { + if (file.type.startsWith("image/")) { + return true; + } + + return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); +} + +function makeImageId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + +function requestToPromise(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("indexeddb request failed")); + }); +} + +function transactionDone(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject(transaction.error || new Error("indexeddb transaction failed")); + transaction.onabort = () => + reject(transaction.error || new Error("indexeddb transaction aborted")); + }); +} + +function openImageDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IMAGE_DB_NAME, IMAGE_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { + db.createObjectStore(IMAGE_STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("failed to open indexeddb")); + }); +} + +async function saveImageRecord(record) { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readwrite"); + transaction.objectStore(IMAGE_STORE_NAME).put(record); + await transactionDone(transaction); + } finally { + db.close(); + } +} + +async function getImageRecord(imageId) { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); + const request = transaction.objectStore(IMAGE_STORE_NAME).get(imageId); + const record = await requestToPromise(request); + await transactionDone(transaction); + return record; + } finally { + db.close(); + } +} + +async function getImageUsageStats() { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + let count = 0; + let totalBytes = 0; + + await new Promise((resolve, reject) => { + const request = store.openCursor(); + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; } - if (uploadButton) { - uploadButton.disabled = true; + + count += 1; + let sizeBytes = Number(cursor.value?.sizeBytes); + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) { + sizeBytes = Number(cursor.value?.blob?.size) || 0; } - collectFormState(); + totalBytes += sizeBytes; + cursor.continue(); }; - reader.readAsText(file); - } else { - if (markdownInput) { - markdownInput.value = ""; - markdownInput.readOnly = false; - } - if (imageInput) { - imageInput.disabled = false; - } - if (uploadButton) { - uploadButton.disabled = false; - } - collectFormState(); + + request.onerror = () => reject(request.error || new Error("failed to read image usage")); + }); + + await transactionDone(transaction); + return { count, totalBytes }; + } finally { + db.close(); + } +} + +function getUniqueLocalImageIds(markdown) { + const ids = new Set(); + let match = null; + + while ((match = LOCAL_IMAGE_TOKEN_PATTERN.exec(markdown)) !== null) { + const imageId = match[1]; + if (imageId) { + ids.add(imageId); + } + } + + LOCAL_IMAGE_TOKEN_PATTERN.lastIndex = 0; + return Array.from(ids); +} + +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("failed to read image data")); + reader.readAsDataURL(blob); + }); +} + +async function resolveLocalImageTokens(markdown) { + const ids = getUniqueLocalImageIds(markdown); + if (ids.length === 0) { + return { resolvedMarkdown: markdown, missingIds: [] }; + } + + let resolvedMarkdown = markdown; + const missingIds = []; + + for (const imageId of ids) { + const record = await getImageRecord(imageId); + if (!record || !(record.blob instanceof Blob)) { + missingIds.push(imageId); + continue; + } + + const dataUrl = await blobToDataUrl(record.blob); + resolvedMarkdown = resolvedMarkdown + .split(`${LOCAL_IMAGE_SCHEME}${imageId}`) + .join(dataUrl); + } + + return { resolvedMarkdown, missingIds }; +} + +let activePreviewUrl = ""; + +function showUploadError(message) { + if (!(uploadResultContainer instanceof HTMLElement)) { + return; + } + + uploadResultContainer.innerHTML = ` +
+

image upload failed

+
${escapeHtml(message)}
+
+ `; +} + +function showUploadResult(record) { + if (!(uploadResultContainer instanceof HTMLElement)) { + return; + } + + if (activePreviewUrl) { + URL.revokeObjectURL(activePreviewUrl); + activePreviewUrl = ""; + } + + const snippet = buildLocalImageSnippet(record.name, record.id); + activePreviewUrl = URL.createObjectURL(record.blob); + + uploadResultContainer.innerHTML = ` +
+

image ready for insert

+

${escapeHtml(record.name)}

+

${escapeHtml(snippet)}

+ +
+ `; +} + +async function handleInsertImage() { + if (!(imageInput instanceof HTMLInputElement)) { + return; + } + + const file = imageInput.files?.[0]; + if (!file) { + showUploadError("image file is required."); + return; + } + + if (!isAllowedImageFile(file)) { + showUploadError("unsupported image type."); + return; + } + + if (file.size > MAX_IMAGE_BYTES) { + showUploadError("image is too large. maximum size per image is 25MB."); + return; + } + + setUploadLoadingState(true); + + try { + const { totalBytes } = await getImageUsageStats(); + if (totalBytes + file.size > MAX_STORAGE_BYTES) { + showUploadError("image upload limit reached. maximum total image capacity is 25GB."); + return; + } + + const record = { + id: makeImageId(), + name: file.name || "image", + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + createdAt: Date.now(), + blob: file, + }; + + await saveImageRecord(record); + showUploadResult(record); + imageInput.value = ""; + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "image upload failed."; + showUploadError(message); + } finally { + setUploadLoadingState(false); + } +} + +function showConvertError(message) { + if (!(resultContainer instanceof HTMLElement)) { + return; + } + + resultContainer.innerHTML = ` +
+

Conversion failed

+
${escapeHtml(message)}
+
+ `; + resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +async function handleConvertSubmit(event) { + event.preventDefault(); + + if (!(convertForm instanceof HTMLFormElement)) { + return; + } + + const formData = new FormData(convertForm); + const markdownValue = formData.get("markdown"); + const markdown = typeof markdownValue === "string" ? markdownValue : ""; + + if (!markdown.trim()) { + showConvertError("Markdown content is required."); + return; + } + + setConvertLoadingState(true); + + try { + const { resolvedMarkdown, missingIds } = await resolveLocalImageTokens(markdown); + if (missingIds.length > 0) { + showConvertError("one or more local images are missing from browser storage."); + return; + } + + const markdownBytes = new Blob([resolvedMarkdown]).size; + if (markdownBytes > MAX_CONVERT_REQUEST_BYTES) { + showConvertError( + "resolved markdown is too large to send for conversion. reduce inserted images or set a higher MAX_CONTENT_LENGTH on the server." + ); + return; } + + formData.set("markdown", resolvedMarkdown); + + const response = await fetch("/convert", { + method: "POST", + body: formData, + }); + + const responseHtml = await response.text(); + if (resultContainer instanceof HTMLElement) { + resultContainer.innerHTML = responseHtml; + resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "failed to generate pdf."; + showConvertError(message); + } finally { + setConvertLoadingState(false); + } +} + +restoreFormState(); + +if (convertForm instanceof HTMLFormElement) { + convertForm.addEventListener("input", collectFormState); + convertForm.addEventListener("change", collectFormState); + convertForm.addEventListener("submit", (event) => { + void handleConvertSubmit(event); + }); +} + +if (uploadButton instanceof HTMLButtonElement) { + uploadButton.addEventListener("click", () => { + void handleInsertImage(); }); } @@ -120,3 +522,4 @@ document.body.addEventListener("click", (event) => { markdownInput.focus(); collectFormState(); }); + -- cgit v1.2.3