diff options
| author | kj_sh604 | 2026-06-01 13:13:13 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-06-01 13:13:13 -0400 |
| commit | 6aea2bf6305e6d266f7ec7d54bd1966b050e7f79 (patch) | |
| tree | bbe495406d5b949fcda9ccbb509812e2526b8040 /src | |
| parent | a9abb6d4e0c173e44e42cb6267133ef34c6d023c (diff) | |
refactor: revert back to image upload
just discovered that localStorage has limits
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.py | 49 | ||||
| -rw-r--r-- | src/index.html | 126 | ||||
| -rw-r--r-- | src/requirements.txt | 5 |
3 files changed, 108 insertions, 72 deletions
@@ -3,14 +3,29 @@ import base64 import hashlib import os +import re +import secrets import subprocess import time from pathlib import Path from threading import Lock +import magic from flask import Flask, Response, jsonify, request, send_file, send_from_directory app = Flask(__name__, static_folder=None) +app.config["MAX_CONTENT_LENGTH"] = 25 * 1000 * 1000 # 25 MB upload cap + +UPLOAD_DIR = Path(__file__).parent / "uploads" +UPLOAD_DIR.mkdir(mode=0o755, exist_ok=True) + +ALLOWED_MIME = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +} _CSP = ( "default-src 'self'; " @@ -203,13 +218,37 @@ def nyan_png(): return send_from_directory(app.root_path, "nyan.png") +@app.route("/uploads/<filename>") +def uploads(filename: str): + if Path(filename).suffix.lower() == ".svg": + return Response("not found", status=404) + return send_from_directory(UPLOAD_DIR, filename) + + @app.route("/upload", methods=["POST"]) def upload(): - return jsonify( - { - "error": "server-side uploads are disabled; images stay in browser local storage" - } - ), 410 + if "image" not in request.files: + return jsonify({"error": "no file provided"}), 400 + + f = request.files["image"] + if not f.filename: + return jsonify({"error": "empty filename"}), 400 + + data = f.read() + if not data: + return jsonify({"error": "empty file"}), 400 + + mime = magic.from_buffer(data, mime=True) + if mime not in ALLOWED_MIME: + return jsonify({"error": f"invalid file type: {mime}"}), 400 + + ext = ALLOWED_MIME[mime] + basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] or "image" + filename = f"{basename}_{secrets.token_hex(4)}.{ext}" + + (UPLOAD_DIR / filename).write_bytes(data) + + return jsonify({"filename": filename, "url": f"uploads/{filename}"}) @app.route("/fonts") diff --git a/src/index.html b/src/index.html index cbcc7cd..7f5a476 100644 --- a/src/index.html +++ b/src/index.html @@ -116,7 +116,7 @@ questions?</textarea> <button type="button" onclick="App.downloadSent()">download for local sent usage</button> <button type="button" onclick="App.exportPDF()">export .pdf</button> </div> - <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate · images are local to this browser</p> + <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate · images upload to server and preload before presenting</p> </main> <!-- presentation overlay --> @@ -179,9 +179,7 @@ questions?</textarea> }, init() { - this.restoreImageState(); this.restoreState(); - this.ensureBundledImageInLocalStore(); this.loadFonts(); this.bindEvents(); this.updateColors(); @@ -448,7 +446,27 @@ questions?</textarea> return this.defaultBundledImage.url; } - return canonicalName; + return 'uploads/' + encodeURIComponent(canonicalName); + }, + + listSlideImageUrls(slides) { + const urls = new Set(); + for (const slide of slides) { + if (!slide || !slide.img) continue; + const src = this.resolveSlideImageSrc(slide.img); + if (src) urls.add(src); + } + return [...urls]; + }, + + async preloadSlideImages(slides) { + const urls = this.listSlideImageUrls(slides); + await Promise.all(urls.map(src => new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => resolve(); + img.src = src; + }))); }, buildSentFontStack(primaryFamily) { @@ -548,9 +566,6 @@ questions?</textarea> input.addEventListener('input', () => { localStorage.setItem('sent-web-content', input.value); }); - input.addEventListener('blur', () => { - this.pruneUnusedLocalImages(input.value); - }); input.addEventListener('keydown', e => this.handleEditorKeydown(e)); document.addEventListener('keydown', e => this.handleKeydown(e)); @@ -695,9 +710,8 @@ questions?</textarea> }, // presentation controls - startPresentation() { + async startPresentation() { const text = document.getElementById('input').value; - this.pruneUnusedLocalImages(text); this.slides = this.parseSent(text); if (this.slides.length === 0) { @@ -705,6 +719,22 @@ questions?</textarea> return; } + const btn = document.getElementById('btn-present'); + const originalBtnText = btn ? btn.textContent : 'present'; + if (btn) { + btn.disabled = true; + btn.textContent = 'preloading images...'; + } + + try { + await this.preloadSlideImages(this.slides); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = originalBtnText; + } + } + this.idx = 0; this.presenting = true; document.getElementById('presentation').classList.add('active'); @@ -898,68 +928,48 @@ questions?</textarea> return; } - const rateCheck = this.canStoreUpload(); - if (!rateCheck.ok) { - status.textContent = `error: ${rateCheck.error}`; - done(); - return; - } - - status.textContent = 'storing locally...'; + status.textContent = 'uploading...'; try { - const ta = document.getElementById('input'); - this.pruneUnusedLocalImages(ta.value); - - if (Object.keys(this.imageStore).length >= this.uploadPolicy.maxImages) { - status.textContent = `error: local image limit reached (${this.uploadPolicy.maxImages})`; - return; - } + const fd = new FormData(); + fd.append('image', file); - const filename = this.makeLocalImageName(file, ext); - const dataUrl = await this.fileToDataUrl(file); + const res = await fetch('/upload', { + method: 'POST', + body: fd, + }); - const nextStore = { - ...this.imageStore, - [filename]: { - dataUrl, - mime: file.type, - bytes: file.size, - createdAt: Date.now(), - }, - }; + let data = {}; + try { + data = await res.json(); + } catch (_) {} - const nextTotalBytes = this.imageStoreBytes(nextStore); - if (nextTotalBytes > this.uploadPolicy.maxTotalBytes) { - status.textContent = `error: local quota exceeded (max ${this.formatBytes(this.uploadPolicy.maxTotalBytes)} total)`; + if (!res.ok || data.error) { + const msg = data.error || `upload failed (${res.status})`; + status.textContent = `error: ${msg}`; return; } - this.imageStore = nextStore; - this.saveImageStore(); - - this.uploadTimestamps.push(Date.now()); - this.pruneUploadTimestamps(); - this.saveUploadRateState(); + if (!data.filename) { + status.textContent = 'error: invalid upload response'; + return; + } + const ta = document.getElementById('input'); const pos = ta.selectionStart; const txt = ta.value; - const ins = `\n@${filename}\n`; + const ins = `\n@${data.filename}\n`; ta.value = txt.substring(0, pos) + ins + txt.substring(pos); ta.selectionStart = ta.selectionEnd = pos + ins.length; ta.focus(); localStorage.setItem('sent-web-content', ta.value); - status.textContent = `stored: ${filename}`; + status.textContent = `photo inserted`; setTimeout(() => { status.textContent = ''; }, 3000); } catch (err) { - if (err && err.name === 'QuotaExceededError') { - status.textContent = 'error: browser storage quota reached'; - } else { - status.textContent = 'error: failed to store image locally'; - } + status.textContent = 'upload failed'; console.error(err); } finally { done(); @@ -969,20 +979,6 @@ questions?</textarea> // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues) downloadSent() { const text = document.getElementById('input').value; - let localRefs = 0; - for (const name of this.referencedImageNames(text)) { - if (this.imageStore[name]) { - localRefs += 1; - } - } - - if (localRefs > 0) { - const ok = window.confirm( - `this deck references ${localRefs} locally stored image(s). the .sent file will not include image binaries. continue?` - ); - if (!ok) return; - } - const blob = new Blob([text], { type: 'text/plain' }); diff --git a/src/requirements.txt b/src/requirements.txt index d5d931e..a5f169c 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,2 +1,3 @@ -flask>=3.0,<3.2 -gunicorn>=22,<24
\ No newline at end of file +flask>=3.1.1,<4.0 +gunicorn>=23.0.0,<24.0.0 +python-magic>=0.4.27,<1.0.0
\ No newline at end of file |
