diff options
| author | kj_sh604 | 2026-06-01 14:37:28 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-06-01 14:37:28 -0400 |
| commit | 6fd3f732dd6430a7e5644524b27ea6d60f5e2a45 (patch) | |
| tree | 33b28188e2d5b92a259cbaf149a7171a77a42b3f /src | |
| parent | 738de85c9b646ebd68b1677538581267cf1fb515 (diff) | |
refactor: upload photos
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.py | 353 | ||||
| -rw-r--r-- | src/static/main.js | 523 |
2 files changed, 631 insertions, 245 deletions
@@ -3,13 +3,16 @@ # likha-pdf — markdown to pdf, no latex required # production-friendly flask app with weasyprint + reportlab fallback -import logging import io +import base64 +import logging import os +import re import secrets import sqlite3 import time from collections import deque +from datetime import timedelta from pathlib import Path from threading import Lock from urllib.parse import urlsplit @@ -18,19 +21,25 @@ from flask import ( Flask, Response, current_app, + jsonify, request, + session, send_from_directory, ) from markupsafe import escape from markdown import markdown from weasyprint import HTML, default_url_fetcher from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.utils import secure_filename APP_NAME = "likha-pdf" DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 5001 DEFAULT_MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 DEFAULT_MAX_FORM_MEMORY_SIZE = DEFAULT_MAX_CONTENT_LENGTH +DEFAULT_MAX_IMAGE_UPLOAD_BYTES = 25 * 1024 * 1024 +DEFAULT_IMAGE_UPLOAD_DIR = "uploads" +DEFAULT_IMAGE_SESSION_TTL_SECONDS = 24 * 60 * 60 DEFAULT_CONVERT_RATE_LIMIT_REQUESTS = 5 DEFAULT_CONVERT_RATE_LIMIT_WINDOW_SECONDS = 60 DEFAULT_CONVERT_RATE_LIMIT_DB_PATH = "/tmp/likha-pdf-rate-limit.sqlite3" @@ -56,6 +65,16 @@ TEMPLATES_DIR = BASE_DIR / "templates" PARTIALS_DIR = TEMPLATES_DIR / "partials" STATIC_DIR = BASE_DIR / "static" +SESSION_IMAGE_SCHEME = "session-image://" +SESSION_IMAGE_TOKEN_PATTERN = re.compile(r"session-image://([a-zA-Z0-9-]+)") + +ALLOWED_IMAGE_EXTENSIONS = { + ".png", + ".jpg", + ".jpeg", + ".webp", +} + VALID_PAPER_SIZES = { "a0paper", "a1paper", @@ -198,6 +217,17 @@ def format_bytes(num_bytes): return f"{value:.2f} PB" +def clean_image_name(name): + cleaned = str(name or "image") + cleaned = cleaned.replace("]", "") + cleaned = cleaned.replace("\r", " ").replace("\n", " ").strip() + return cleaned or "image" + + +def build_session_image_snippet(name, image_id): + return f"" + + def safe_weasy_url_fetcher(url, *args, **kwargs): """allow only data urls, block file/network/relative resources""" scheme = (urlsplit(url).scheme or "").lower() @@ -206,6 +236,232 @@ def safe_weasy_url_fetcher(url, *args, **kwargs): raise ValueError("blocked non-data resource url") +class SessionImageStore: + def __init__(self, base_dir, max_image_upload_bytes, session_ttl_seconds): + self.base_dir = Path(base_dir).expanduser() + self.max_image_upload_bytes = int(max_image_upload_bytes) + self.session_ttl_seconds = float(session_ttl_seconds) + self._cleanup_interval_seconds = 60.0 + + self._session_images = {} + self._session_last_seen = {} + self._next_cleanup_at = 0.0 + self._lock = Lock() + + self.base_dir.mkdir(parents=True, exist_ok=True) + + def _touch_session(self, session_id, now): + self._session_last_seen[session_id] = now + + def _cleanup_expired_locked(self, now): + if now < self._next_cleanup_at: + return + + expire_before = now - self.session_ttl_seconds + expired_sessions = [ + session_id + for session_id, last_seen in self._session_last_seen.items() + if last_seen < expire_before + ] + + for session_id in expired_sessions: + self._session_last_seen.pop(session_id, None) + self._session_images.pop(session_id, None) + + session_dir = self.base_dir / session_id + if session_dir.exists() and session_dir.is_dir(): + for child in session_dir.iterdir(): + if child.is_file(): + try: + child.unlink() + except OSError: + pass + + try: + session_dir.rmdir() + except OSError: + pass + + self._next_cleanup_at = now + self._cleanup_interval_seconds + + def _session_dir(self, session_id): + path = self.base_dir / session_id + path.mkdir(parents=True, exist_ok=True) + return path + + def _record_to_public(self, record): + return { + "id": record["id"], + "name": record["name"], + "mimeType": record["mime_type"], + "sizeBytes": record["size_bytes"], + "createdAt": record["created_at"], + "snippet": build_session_image_snippet(record["name"], record["id"]), + } + + def _remove_missing_record_locked(self, session_id, image_id): + bucket = self._session_images.get(session_id) + if not bucket: + return + + bucket.pop(image_id, None) + if not bucket: + self._session_images.pop(session_id, None) + + def add_image(self, session_id, uploaded_file): + now = time.time() + + original_name = secure_filename(uploaded_file.filename or "") + if not original_name: + original_name = "image" + + mime_type = (uploaded_file.mimetype or "").lower() + suffix = Path(original_name).suffix.lower() + if not mime_type.startswith("image/") and suffix not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError("unsupported image type.") + + if not mime_type.startswith("image/"): + if suffix in {".jpg", ".jpeg"}: + mime_type = "image/jpeg" + elif suffix == ".png": + mime_type = "image/png" + elif suffix == ".gif": + mime_type = "image/gif" + elif suffix == ".webp": + mime_type = "image/webp" + elif suffix == ".svg": + mime_type = "image/svg+xml" + else: + mime_type = "application/octet-stream" + + if suffix not in ALLOWED_IMAGE_EXTENSIONS: + suffix = "" + + image_id = secrets.token_hex(20) + destination = self._session_dir(session_id) / f"{image_id}{suffix}" + uploaded_file.save(str(destination)) + + size_bytes = destination.stat().st_size if destination.exists() else 0 + if size_bytes <= 0: + try: + destination.unlink() + except OSError: + pass + raise ValueError("image file is empty.") + + if size_bytes > self.max_image_upload_bytes: + try: + destination.unlink() + except OSError: + pass + raise ValueError( + "image is too large. " + f"maximum size per image is {format_bytes(self.max_image_upload_bytes)}." + ) + + record = { + "id": image_id, + "name": original_name, + "mime_type": mime_type, + "size_bytes": int(size_bytes), + "created_at": int(now * 1000), + "path": destination, + } + + with self._lock: + self._cleanup_expired_locked(now) + session_bucket = self._session_images.setdefault(session_id, {}) + session_bucket[image_id] = record + self._touch_session(session_id, now) + + return self._record_to_public(record) + + def list_images(self, session_id): + now = time.time() + with self._lock: + self._cleanup_expired_locked(now) + self._touch_session(session_id, now) + session_bucket = self._session_images.get(session_id, {}) + + records = [] + for image_id, record in list(session_bucket.items()): + image_path = Path(record["path"]) + if not image_path.exists(): + self._remove_missing_record_locked(session_id, image_id) + continue + records.append(self._record_to_public(record)) + + records.sort(key=lambda entry: entry["createdAt"], reverse=True) + return records + + def get_image_data_url(self, session_id, image_id): + now = time.time() + + with self._lock: + self._cleanup_expired_locked(now) + self._touch_session(session_id, now) + + session_bucket = self._session_images.get(session_id, {}) + record = session_bucket.get(image_id) + if record is None: + return None + + image_path = Path(record["path"]) + mime_type = record["mime_type"] + + if not image_path.exists(): + with self._lock: + self._remove_missing_record_locked(session_id, image_id) + return None + + try: + payload = image_path.read_bytes() + except OSError: + return None + + encoded = base64.b64encode(payload).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +def resolve_session_image_tokens(source_markdown, session_id, image_store): + image_ids = { + match.group(1) + for match in SESSION_IMAGE_TOKEN_PATTERN.finditer(source_markdown) + if match.group(1) + } + + if not image_ids: + return source_markdown, [] + + resolved_markdown = source_markdown + missing_image_ids = [] + + for image_id in image_ids: + data_url = image_store.get_image_data_url(session_id, image_id) + if data_url is None: + missing_image_ids.append(image_id) + continue + + resolved_markdown = resolved_markdown.replace( + f"{SESSION_IMAGE_SCHEME}{image_id}", + data_url, + ) + + missing_image_ids.sort() + return resolved_markdown, missing_image_ids + + +def get_or_create_session_id(): + session_id = session.get("likha_pdf_session_id") + if isinstance(session_id, str) and session_id: + return session_id + + session_id = secrets.token_hex(24) + session["likha_pdf_session_id"] = session_id + session.permanent = True + return session_id + + class SlidingWindowRateLimiter: def __init__( self, @@ -806,6 +1062,31 @@ def create_app(): app.config["MAX_CONTENT_LENGTH"] = max_content_length app.config["MAX_FORM_MEMORY_SIZE"] = max_form_memory_size + max_image_upload_bytes = env_int( + "MAX_IMAGE_UPLOAD_BYTES", + DEFAULT_MAX_IMAGE_UPLOAD_BYTES, + minimum=1, + ) + image_upload_dir = os.getenv("IMAGE_UPLOAD_DIR", DEFAULT_IMAGE_UPLOAD_DIR).strip() + if not image_upload_dir: + image_upload_dir = DEFAULT_IMAGE_UPLOAD_DIR + + image_session_ttl_seconds = env_int( + "IMAGE_SESSION_TTL_SECONDS", + DEFAULT_IMAGE_SESSION_TTL_SECONDS, + minimum=60, + ) + + image_store = SessionImageStore( + image_upload_dir, + max_image_upload_bytes, + image_session_ttl_seconds, + ) + + app.config["MAX_IMAGE_UPLOAD_BYTES"] = max_image_upload_bytes + app.config["IMAGE_UPLOAD_DIR"] = image_upload_dir + app.config["IMAGE_SESSION_TTL_SECONDS"] = image_session_ttl_seconds + convert_rate_limit_requests = env_int( "CONVERT_RATE_LIMIT_REQUESTS", DEFAULT_CONVERT_RATE_LIMIT_REQUESTS, @@ -871,6 +1152,24 @@ def create_app(): log_level = os.getenv("LOG_LEVEL", "INFO").upper() app.logger.setLevel(log_level) + secret_key = os.getenv("SECRET_KEY", "").strip() + if not secret_key: + secret_key = secrets.token_hex(32) + app.logger.warning( + "SECRET_KEY is not set, generated ephemeral key for this process" + ) + + app.secret_key = secret_key + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + app.config["SESSION_COOKIE_SECURE"] = env_bool( + "SESSION_COOKIE_SECURE", + default=trust_proxy, + ) + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( + seconds=image_session_ttl_seconds + ) + @app.after_request def add_security_headers(resp): resp.headers.setdefault("X-Content-Type-Options", "nosniff") @@ -913,6 +1212,38 @@ def create_app(): def favicon(): return send_from_directory(str(BASE_DIR), "favicon.svg") + @app.route("/upload-image", methods=["POST"]) + def upload_image(): + session_id = get_or_create_session_id() + image_file = request.files.get("image") + if image_file is None: + return jsonify({"error": "image file is required."}), 400 + + if not (image_file.filename or "").strip(): + return jsonify({"error": "image file is required."}), 400 + + try: + image_record = image_store.add_image(session_id, image_file) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except OSError: + app.logger.exception("failed to save uploaded image") + return jsonify({"error": "failed to save image."}), 500 + + response = jsonify({"image": image_record}) + response.status_code = 201 + response.headers["Cache-Control"] = "no-store" + return response + + @app.route("/session-images", methods=["GET"]) + def session_images(): + session_id = get_or_create_session_id() + records = image_store.list_images(session_id) + + response = jsonify({"images": records}) + response.headers["Cache-Control"] = "no-store" + return response + @app.route("/convert", methods=["POST"]) def convert(): rate_limit_key = f"ip:{request.remote_addr or 'unknown'}" @@ -945,6 +1276,26 @@ def create_app(): 400, ) + session_id = get_or_create_session_id() + md, missing_image_ids = resolve_session_image_tokens(md, session_id, image_store) + if missing_image_ids: + app.logger.warning( + "missing session images during convert: %s", + ", ".join(missing_image_ids), + ) + return ( + read_partial( + "error.html", + { + "{{ message }}": ( + "one or more images in markdown are missing from this browser session. " + "please upload the missing image again." + ), + }, + ), + 400, + ) + paper_size = pick_option( request.form.get("paper_size", ""), "letterpaper", diff --git a/src/static/main.js b/src/static/main.js index 43715d5..8523b87 100644 --- a/src/static/main.js +++ b/src/static/main.js @@ -8,19 +8,17 @@ 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 SESSION_IMAGE_CACHE_KEY = "likha-pdf:session-images:v1"; const PDF_FILENAME_KEY = "likha-pdf:last-pdf-filename"; const SNIPPET_DETAILS_OPEN_KEY = "likha-pdf:snippet-details-open:v1"; -const LOCAL_IMAGE_SCHEME = "local-image://"; +const SESSION_IMAGE_SCHEME = "session-image://"; const MAX_IMAGE_BYTES = 25 * 1024 * 1024; -const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024; const MAX_CONVERT_REQUEST_BYTES = 2048 * 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; const TAB_SPACES = " "; + let snippetDetailsIsOpen = readPersistedBoolean(SNIPPET_DETAILS_OPEN_KEY, false); +let sessionImageCache = readSessionImageCache(); function readPersistedState() { try { @@ -143,10 +141,7 @@ function restoreFormState() { continue; } - if ( - element instanceof HTMLInputElement && - element.type === "checkbox" - ) { + if (element instanceof HTMLInputElement && element.type === "checkbox") { element.checked = Boolean(value); continue; } @@ -171,12 +166,12 @@ function setConvertLoadingState(isLoading) { function setUploadLoadingState(isLoading) { if (uploadButton instanceof HTMLButtonElement) { uploadButton.disabled = isLoading; - uploadButton.textContent = isLoading ? "preparing..." : "insert image"; + uploadButton.textContent = isLoading ? "uploading..." : "insert image"; } } function escapeHtml(value) { - return value + return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") @@ -184,252 +179,222 @@ function escapeHtml(value) { .replaceAll("'", "'"); } -function buildLocalImageSnippet(name, id) { - const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); - return ``; -} - -function insertSnippetIntoMarkdown(snippet) { - if (!markdownInput) { - return; +function formatBytes(numBytes) { + const parsed = Number(numBytes); + if (!Number.isFinite(parsed) || parsed <= 0) { + return "0 B"; } - const needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); - const prefix = needsLeadingNewline ? "\n" : ""; - markdownInput.value += `${prefix}${snippet}\n`; - markdownInput.focus(); - collectFormState(); -} + if (parsed < 1024) { + return `${Math.round(parsed)} B`; + } -function isAllowedImageFile(file) { - if (file.type.startsWith("image/")) { - return true; + const units = ["KB", "MB", "GB", "TB"]; + let value = parsed; + for (const unit of units) { + value /= 1024; + if (value < 1024) { + return `${value.toFixed(2)} ${unit}`; + } } - return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); + return `${value.toFixed(2)} PB`; +} + +function buildSessionImageSnippet(name, id) { + const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); + return ``; } -function makeImageId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); +function normalizeImageRecord(raw) { + if (!raw || typeof raw !== "object") { + return null; + } + + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) { + return null; } - return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; + const nameValue = typeof raw.name === "string" ? raw.name.trim() : ""; + const name = nameValue || "image"; + + const mimeValue = typeof raw.mimeType === "string" ? raw.mimeType.trim() : ""; + const mimeType = mimeValue || "application/octet-stream"; + + const sizeValue = Number(raw.sizeBytes); + const sizeBytes = Number.isFinite(sizeValue) && sizeValue >= 0 ? sizeValue : 0; + + const createdAtValue = Number(raw.createdAt); + const createdAt = Number.isFinite(createdAtValue) && createdAtValue > 0 + ? createdAtValue + : Date.now(); + + const snippetValue = typeof raw.snippet === "string" ? raw.snippet.trim() : ""; + const snippet = snippetValue || buildSessionImageSnippet(name, id); + + return { + id, + name, + mimeType, + sizeBytes, + createdAt, + snippet, + }; } -function randomHex(length = 40) { - const byteLen = Math.ceil(length / 2); +function dedupeImageRecords(records) { + const deduped = []; + const seen = new Set(); - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - const bytes = new Uint8Array(byteLen); - crypto.getRandomValues(bytes); - return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")) - .join("") - .slice(0, length); + for (const record of records) { + if (!record || seen.has(record.id)) { + continue; + } + seen.add(record.id); + deduped.push(record); } - let out = ""; - while (out.length < length) { - out += Math.random().toString(16).slice(2); - } - return out.slice(0, length); + return deduped; } -function requestToPromise(request) { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("indexeddb request failed")); - }); +function sortImageRecordsByCreatedAt(records) { + return [...records].sort((a, b) => b.createdAt - a.createdAt); } -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 toCacheImageRecord(record) { + return { + id: record.id, + name: record.name, + mimeType: record.mimeType, + sizeBytes: record.sizeBytes, + createdAt: record.createdAt, + snippet: record.snippet, + }; } -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" }); - } - }; +function readSessionImageCache() { + try { + const raw = sessionStorage.getItem(SESSION_IMAGE_CACHE_KEY); + if (!raw) { + return []; + } - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error || new Error("failed to open indexeddb")); - }); -} + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } -async function saveImageRecord(record) { - const db = await openImageDb(); + const normalized = parsed + .map(normalizeImageRecord) + .filter((record) => record !== null); - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readwrite"); - transaction.objectStore(IMAGE_STORE_NAME).put(record); - await transactionDone(transaction); - } finally { - db.close(); + return dedupeImageRecords(normalized); + } catch { + return []; } } -async function getImageRecord(imageId) { - const db = await openImageDb(); - +function writeSessionImageCache(records) { 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(); + const payload = records.map(toCacheImageRecord); + sessionStorage.setItem(SESSION_IMAGE_CACHE_KEY, JSON.stringify(payload)); + } catch { + // ignore storage write failures in restricted storage contexts } } -async function getImageUsageStats() { - const db = await openImageDb(); +function setSessionImageCache(records) { + const normalized = records + .map(normalizeImageRecord) + .filter((record) => record !== null); - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); - const store = transaction.objectStore(IMAGE_STORE_NAME); + sessionImageCache = dedupeImageRecords(normalized); + writeSessionImageCache(sessionImageCache); +} - let count = 0; - let totalBytes = 0; +function getSessionImageCache() { + return [...sessionImageCache]; +} - await new Promise((resolve, reject) => { - const request = store.openCursor(); +function mergeImageRecordsCachedFirst(cachedRecords, serverRecords) { + const normalizedCache = dedupeImageRecords( + cachedRecords.map(normalizeImageRecord).filter((record) => record !== null) + ); - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(); - return; - } + const normalizedServer = sortImageRecordsByCreatedAt( + dedupeImageRecords( + serverRecords.map(normalizeImageRecord).filter((record) => record !== null) + ) + ); - count += 1; - let sizeBytes = Number(cursor.value?.sizeBytes); - if (!Number.isFinite(sizeBytes) || sizeBytes < 0) { - sizeBytes = Number(cursor.value?.blob?.size) || 0; - } - totalBytes += sizeBytes; - cursor.continue(); - }; + const serverById = new Map(normalizedServer.map((record) => [record.id, record])); + const mergedCache = normalizedCache.map((cachedRecord) => ({ + ...(serverById.get(cachedRecord.id) || {}), + ...cachedRecord, + })); - request.onerror = () => reject(request.error || new Error("failed to read image usage")); - }); + const cacheIds = new Set(mergedCache.map((record) => record.id)); + const serverOnly = normalizedServer.filter((record) => !cacheIds.has(record.id)); - await transactionDone(transaction); - return { count, totalBytes }; - } finally { - db.close(); - } + return [...mergedCache, ...serverOnly]; } -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); - } +function upsertSessionImageCache(record) { + const normalized = normalizeImageRecord(record); + if (!normalized) { + return null; } - LOCAL_IMAGE_TOKEN_PATTERN.lastIndex = 0; - return Array.from(ids); + const withoutRecord = sessionImageCache.filter((entry) => entry.id !== normalized.id); + setSessionImageCache([normalized, ...withoutRecord]); + return normalized; } -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: [] }; +function insertSnippetIntoMarkdown(snippet) { + if (!markdownInput) { + return; } - 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 needsLeadingNewline = markdownInput.value && !markdownInput.value.endsWith("\n"); + const prefix = needsLeadingNewline ? "\n" : ""; + markdownInput.value += `${prefix}${snippet}\n`; + markdownInput.focus(); + collectFormState(); +} - const dataUrl = await blobToDataUrl(record.blob); - resolvedMarkdown = resolvedMarkdown - .split(`${LOCAL_IMAGE_SCHEME}${imageId}`) - .join(dataUrl); +function isAllowedImageFile(file) { + if (file.type.startsWith("image/")) { + return true; } - return { resolvedMarkdown, missingIds }; + return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); } -async function getImageSnippetHistory() { - const db = await openImageDb(); - - try { - const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); - const store = transaction.objectStore(IMAGE_STORE_NAME); - const snippets = []; - - await new Promise((resolve, reject) => { - const request = store.openCursor(); - - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(); - return; - } - - const value = cursor.value || {}; - const id = typeof value.id === "string" ? value.id : ""; - if (id) { - snippets.push({ - createdAt: Number(value.createdAt) || 0, - snippet: buildLocalImageSnippet(value.name, id), - }); - } - - cursor.continue(); - }; +function randomHex(length = 40) { + const byteLen = Math.ceil(length / 2); - request.onerror = () => - reject(request.error || new Error("failed to read image snippet history")); - }); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const bytes = new Uint8Array(byteLen); + crypto.getRandomValues(bytes); + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")) + .join("") + .slice(0, length); + } - await transactionDone(transaction); - snippets.sort((a, b) => b.createdAt - a.createdAt); - return snippets.map((entry) => entry.snippet); - } finally { - db.close(); + let out = ""; + while (out.length < length) { + out += Math.random().toString(16).slice(2); } + return out.slice(0, length); } -function renderSnippetHistory(snippets, heading = "", message = "") { +function renderSnippetHistory(records, heading = "", message = "") { if (!(uploadResultContainer instanceof HTMLElement)) { return; } - if (snippets.length === 0 && !heading && !message) { + if (records.length === 0 && !heading && !message) { uploadResultContainer.innerHTML = ""; return; } @@ -438,20 +403,30 @@ function renderSnippetHistory(snippets, heading = "", message = "") { const messageHtml = message ? `<p>${escapeHtml(message)}</p>` : ""; let detailsHtml = ""; - if (snippets.length > 0) { + if (records.length > 0) { const openAttr = snippetDetailsIsOpen ? " open" : ""; - const snippetItems = snippets - .map( - (snippet) => - `<li style="all: revert;"><code style="all: revert;">${escapeHtml(snippet)}</code></li>` - ) + const detailsItems = records + .map((record) => { + const sizeText = formatBytes(record.sizeBytes); + return ` + <li style="all: revert; margin-bottom: 0.6em;"> + <div style="all: revert;"> + <strong style="all: revert;">${escapeHtml(record.name)}</strong> + <small style="all: revert; color: #555;">${escapeHtml(sizeText)}</small> + </div> + <code style="all: revert; display: inline-block; margin-top: 0.2em;">${escapeHtml(record.snippet)}</code> + </li> + `; + }) .join(""); + detailsHtml = ` <br> <details style="all: revert;" data-snippet-history="true"${openAttr}> - <summary style="all: revert; cursor: pointer;">images (${snippets.length})</summary> - <ol style="all: revert;">${snippetItems}</ol> - </details><br> + <summary style="all: revert; cursor: pointer;">images (${records.length})</summary> + <ol style="all: revert;">${detailsItems}</ol> + </details> + <br> `; } @@ -473,12 +448,70 @@ function renderSnippetHistory(snippets, heading = "", message = "") { } } +async function readApiError(response, fallbackMessage) { + const fallback = String(fallbackMessage || "request failed."); + const contentType = (response.headers.get("content-type") || "").toLowerCase(); + + if (contentType.includes("application/json")) { + try { + const payload = await response.json(); + const errorText = payload && typeof payload.error === "string" ? payload.error : ""; + if (errorText.trim()) { + return errorText.trim(); + } + } catch { + // ignore json parse failures + } + return fallback; + } + + try { + const text = (await response.text()).trim(); + if (text) { + return text.slice(0, 1200); + } + } catch { + // ignore body read failures + } + + return fallback; +} + +async function syncSessionImageCacheFromServer() { + const response = await fetch("/session-images", { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error( + await readApiError(response, `failed to load session images (${response.status}).`) + ); + } + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + const serverRecordsRaw = Array.isArray(payload?.images) ? payload.images : []; + const merged = mergeImageRecordsCachedFirst(getSessionImageCache(), serverRecordsRaw); + setSessionImageCache(merged); + return getSessionImageCache(); +} + async function refreshSnippetHistory(heading = "", message = "") { + renderSnippetHistory(getSessionImageCache(), heading, message); + try { - const snippets = await getImageSnippetHistory(); - renderSnippetHistory(snippets, heading, message); + const mergedRecords = await syncSessionImageCacheFromServer(); + renderSnippetHistory(mergedRecords, heading, message); } catch { - renderSnippetHistory([], "image upload failed", "unable to load image snippet history."); + // keep the cache view when server sync fails } } @@ -487,8 +520,7 @@ function showUploadError(message) { } async function showUploadResult(record) { - const snippet = buildLocalImageSnippet(record.name, record.id); - insertSnippetIntoMarkdown(snippet); + insertSnippetIntoMarkdown(record.snippet); await refreshSnippetHistory("image inserted", record.name || "image"); } @@ -516,23 +548,35 @@ async function handleInsertImage() { 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."); + const uploadFormData = new FormData(); + uploadFormData.set("image", file, file.name || "image"); + + const response = await fetch("/upload-image", { + method: "POST", + body: uploadFormData, + }); + + if (!response.ok) { + showUploadError( + await readApiError(response, `image upload failed (${response.status}).`) + ); 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); - await showUploadResult(record); + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + const uploadedRecord = upsertSessionImageCache(payload?.image || null); + if (!uploadedRecord) { + showUploadError("image upload failed."); + return; + } + + await showUploadResult(uploadedRecord); imageInput.value = ""; } catch (error) { const message = @@ -698,26 +742,18 @@ async function handleConvertSubmit(event) { return; } + const markdownBytes = new Blob([markdown]).size; + if (markdownBytes > MAX_CONVERT_REQUEST_BYTES) { + showConvertError( + "markdown is too large to send for conversion. reduce image usage or increase server limits." + ); + return; + } + setConvertLoadingState(true); prepareForPdfRegeneration(); 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, @@ -802,4 +838,3 @@ if (uploadButton instanceof HTMLButtonElement) { } void refreshSnippetHistory(); - |
