aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorkj_sh6042026-06-01 13:13:13 -0400
committerkj_sh6042026-06-01 13:13:13 -0400
commit6aea2bf6305e6d266f7ec7d54bd1966b050e7f79 (patch)
treebbe495406d5b949fcda9ccbb509812e2526b8040 /src
parenta9abb6d4e0c173e44e42cb6267133ef34c6d023c (diff)
refactor: revert back to image upload
just discovered that localStorage has limits
Diffstat (limited to 'src')
-rw-r--r--src/app.py49
-rw-r--r--src/index.html126
-rw-r--r--src/requirements.txt5
3 files changed, 108 insertions, 72 deletions
diff --git a/src/app.py b/src/app.py
index e97b8e6..edd0f59 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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