aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorkj_sh6042026-06-01 14:37:28 -0400
committerkj_sh6042026-06-01 14:37:28 -0400
commit6fd3f732dd6430a7e5644524b27ea6d60f5e2a45 (patch)
tree33b28188e2d5b92a259cbaf149a7171a77a42b3f /src
parent738de85c9b646ebd68b1677538581267cf1fb515 (diff)
refactor: upload photos
Diffstat (limited to 'src')
-rw-r--r--src/app.py353
-rw-r--r--src/static/main.js523
2 files changed, 631 insertions, 245 deletions
diff --git a/src/app.py b/src/app.py
index 47a8865..61889b5 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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"![{clean_image_name(name)}]({SESSION_IMAGE_SCHEME}{image_id})"
+
+
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
@@ -184,252 +179,222 @@ function escapeHtml(value) {
.replaceAll("'", "&#39;");
}
-function buildLocalImageSnippet(name, id) {
- const cleanName = String(name || "image").replace(/[\]\r\n]/g, "");
- return `![${cleanName}](${LOCAL_IMAGE_SCHEME}${id})`;
-}
-
-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 `![${cleanName}](${SESSION_IMAGE_SCHEME}${id})`;
}
-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();
-