aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorkj_sh6042026-05-31 11:29:58 -0400
committerkj_sh6042026-05-31 11:29:58 -0400
commit2b2e511a15414f6c28f15990b75442424f9cc3b9 (patch)
tree99a6cc4ad40dd2309bb4a0b2c875e1719dbfe449 /src
parent4626e9f40c4678622ef53cc0d9d3fa89768c08f0 (diff)
refactor: initial hardening tweaks
store images in localStorage
Diffstat (limited to 'src')
-rw-r--r--src/.gitignore1
-rw-r--r--src/app.py111
-rw-r--r--src/static/main.js527
-rw-r--r--src/templates/index.html27
-rw-r--r--src/templates/partials/upload_error.html4
-rw-r--r--src/templates/partials/upload_result.html12
6 files changed, 505 insertions, 177 deletions
diff --git a/src/.gitignore b/src/.gitignore
index d2ba364..3ca4078 100644
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -3,5 +3,4 @@ __pycache__/
.venv/
likha-pdf
generated/
-uploads/
launch-likha-ssb
diff --git a/src/app.py b/src/app.py
index 52484ef..72fb78d 100644
--- a/src/app.py
+++ b/src/app.py
@@ -25,16 +25,15 @@ from werkzeug.middleware.proxy_fix import ProxyFix
APP_NAME = "likha-pdf"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 5001
+DEFAULT_MAX_CONTENT_LENGTH = 512 * 1024 * 1024
+DEFAULT_MAX_FORM_MEMORY_SIZE = DEFAULT_MAX_CONTENT_LENGTH
BASE_DIR = Path(__file__).resolve().parent
GENERATED_DIR = BASE_DIR / "generated"
-UPLOADS_DIR = BASE_DIR / "uploads"
TEMPLATES_DIR = BASE_DIR / "templates"
PARTIALS_DIR = TEMPLATES_DIR / "partials"
STATIC_DIR = BASE_DIR / "static"
-ALLOWED_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg"}
-
VALID_PAPER_SIZES = {
"a0paper",
"a1paper",
@@ -133,7 +132,6 @@ def env_bool(name, default=False):
def ensure_runtime_dirs():
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
def random_hex(length=32):
@@ -144,26 +142,6 @@ def pick_option(value, fallback, valid):
return value if value in valid else fallback
-def sanitize_filename(name):
- """keep only safe characters in a filename"""
- name = os.path.basename(name.replace("\\", "/"))
- out = []
- for ch in name:
- if ch.isalnum() or ch in "-_.":
- out.append(ch)
- elif ch == " ":
- out.append("_")
- return "".join(out)
-
-
-def is_allowed_image(filename):
- dot = filename.rfind(".")
- if dot < 1 or dot == len(filename) - 1:
- return False
- ext = filename[dot + 1 :].lower()
- return ext in ALLOWED_IMAGE_EXTS
-
-
def is_safe_relative_path(path_part):
if not path_part or "\\" in path_part:
return False
@@ -186,6 +164,20 @@ def tail_text(value, max_len=1200):
return value[-max_len:]
+def format_bytes(num_bytes):
+ if num_bytes < 1024:
+ return f"{num_bytes} B"
+
+ units = ["KB", "MB", "GB", "TB"]
+ value = float(num_bytes)
+ for unit in units:
+ value /= 1024.0
+ if value < 1024.0:
+ return f"{value:.2f} {unit}"
+
+ return f"{value:.2f} PB"
+
+
# pdf stylesheet generator
def build_pdf_css(
paper_size,
@@ -612,9 +604,15 @@ def create_app():
static_url_path="/static",
)
- app.config["MAX_CONTENT_LENGTH"] = int(
- os.getenv("MAX_CONTENT_LENGTH", str(64 * 1024 * 1024))
+ max_content_length = int(
+ os.getenv("MAX_CONTENT_LENGTH", str(DEFAULT_MAX_CONTENT_LENGTH))
)
+ max_form_memory_size = int(
+ os.getenv("MAX_FORM_MEMORY_SIZE", str(DEFAULT_MAX_FORM_MEMORY_SIZE))
+ )
+
+ app.config["MAX_CONTENT_LENGTH"] = max_content_length
+ app.config["MAX_FORM_MEMORY_SIZE"] = max_form_memory_size
if env_bool("TRUST_PROXY", default=True):
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
@@ -631,11 +629,21 @@ def create_app():
@app.errorhandler(413)
def payload_too_large(_err):
+ content_limit = int(app.config.get("MAX_CONTENT_LENGTH") or 0)
+ form_limit = int(app.config.get("MAX_FORM_MEMORY_SIZE") or 0)
+ content_limit_text = (
+ format_bytes(content_limit) if content_limit else "configured limit"
+ )
+ form_limit_text = format_bytes(form_limit) if form_limit else "unlimited"
return (
read_partial(
- "upload_error.html",
+ "error.html",
{
- "{{ message }}": "request body too large.",
+ "{{ message }}": (
+ "request body too large. "
+ f"max request size is {content_limit_text}; "
+ f"max form field memory is {form_limit_text}."
+ ),
},
),
413,
@@ -728,53 +736,6 @@ def create_app():
},
)
- @app.route("/upload-image", methods=["POST"])
- def upload_image():
- uploaded = request.files.get("image")
- if not uploaded or not uploaded.filename or not uploaded.filename.strip():
- return (
- read_partial(
- "upload_error.html",
- {
- "{{ message }}": "image file is required.",
- },
- ),
- 400,
- )
-
- original = sanitize_filename(uploaded.filename)
- if not original or not is_allowed_image(original):
- return (
- read_partial(
- "upload_error.html",
- {
- "{{ message }}": "unsupported image type.",
- },
- ),
- 400,
- )
-
- ext = original.rsplit(".", 1)[-1].lower()
- stored_name = f"img_{int(time.time())}_{random_hex()}.{ext}"
- image_path = UPLOADS_DIR / stored_name
- uploaded.save(str(image_path))
-
- snippet = f"![](uploads/{stored_name})"
- return read_partial(
- "upload_result.html",
- {
- "{{ filename }}": str(escape(stored_name)),
- "{{ markdown_snippet }}": str(escape(snippet)),
- "{{ preview_url }}": f"/uploads/{stored_name}",
- },
- )
-
- @app.route("/uploads/<path:filename>")
- def serve_upload(filename):
- if not is_safe_relative_path(filename):
- abort(400)
- return send_from_directory(str(UPLOADS_DIR), filename, conditional=True)
-
@app.route("/download/<path:filename>")
def download(filename):
if not is_safe_relative_path(filename):
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("&", "&amp;")
+ .replaceAll("<", "&lt;")
+ .replaceAll(">", "&gt;")
+ .replaceAll('"', "&quot;")
+ .replaceAll("'", "&#39;");
+}
-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 = `
+ <article>
+ <h4>image upload failed</h4>
+ <pre>${escapeHtml(message)}</pre>
+ </article>
+ `;
+}
+
+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 = `
+ <article>
+ <h4>image ready for insert</h4>
+ <p><a href="${escapeHtml(activePreviewUrl)}" target="_blank" rel="noreferrer">${escapeHtml(record.name)}</a></p>
+ <p><code id="uploaded-markdown">${escapeHtml(snippet)}</code></p>
+ <button type="button" data-insert-markdown="${escapeHtml(snippet)}" class="insert-markdown">insert into markdown</button>
+ </article>
+ `;
+}
+
+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 = `
+ <article>
+ <h3>Conversion failed</h3>
+ <pre>${escapeHtml(message)}</pre>
+ </article>
+ `;
+ 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();
});
+
diff --git a/src/templates/index.html b/src/templates/index.html
index 194a2af..3af58ad 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -7,17 +7,6 @@
<title>likha-pdf</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css" />
- <script src="https://unpkg.com/htmx.org@1.9.12"></script>
- <style>
- .htmx-indicator {
- display: none;
- }
-
- .htmx-request .htmx-indicator,
- .htmx-request.htmx-indicator {
- display: inline;
- }
- </style>
</head>
<body>
@@ -25,7 +14,7 @@
<h1>likha-pdf</h1>
<p>simple markdown to pdf export.</p>
- <form id="convert-form" hx-post="/convert" hx-target="#result" hx-swap="innerHTML" hx-indicator="#loading">
+ <form id="convert-form" action="/convert" method="post">
<label for="markdown">
<h3>textarea</h3>
</label>
@@ -34,19 +23,11 @@
<section id="image-upload-section">
<label for="image"><small>image file</small></label>
- <input id="image" name="image" type="file" accept="image/*" />
- <button id="upload-button" type="button" hx-post="/upload-image" hx-target="#upload-result" hx-swap="innerHTML"
- hx-encoding="multipart/form-data" hx-include="#image" hx-indicator="#uploading">upload image</button>
- <small id="uploading" class="htmx-indicator">uploading…</small>
+ <input id="image" type="file" accept="image/*" />
+ <button id="upload-button" type="button">insert image</button>
<div id="upload-result" aria-live="polite"></div>
</section>
- <section id="md-file-section">
- <h3>or upload a markdown file</h3>
- <label for="md-file"><small><em>(overrides textarea)</em></small></label>
- <input id="md-file" type="file" accept=".md,.markdown,text/markdown,text/plain" />
- </section>
-
<section>
<h3>pdf options</h3>
@@ -133,7 +114,7 @@
</section>
<button id="convert-button" type="submit">generate pdf</button>
- <small id="loading" class="htmx-indicator">converting…</small>
+ <small id="loading" hidden>converting...</small>
</form>
<section id="result" aria-live="polite"></section>
diff --git a/src/templates/partials/upload_error.html b/src/templates/partials/upload_error.html
deleted file mode 100644
index d219783..0000000
--- a/src/templates/partials/upload_error.html
+++ /dev/null
@@ -1,4 +0,0 @@
-<article>
- <h4>upload failed</h4>
- <pre>{{ message }}</pre>
-</article>
diff --git a/src/templates/partials/upload_result.html b/src/templates/partials/upload_result.html
deleted file mode 100644
index 116c74c..0000000
--- a/src/templates/partials/upload_result.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<article>
- <h4>image uploaded</h4>
- <p><a href="{{ preview_url }}" target="_blank" rel="noreferrer">{{ filename }}</a></p>
- <p><code id="uploaded-markdown">{{ markdown_snippet }}</code></p>
- <button
- type="button"
- data-insert-markdown="{{ markdown_snippet }}"
- class="insert-markdown"
- >
- insert into markdown
- </button>
-</article>