diff options
| author | kj_sh604 | 2026-05-31 00:32:02 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-05-31 00:32:02 -0400 |
| commit | cf23fda43083a7374f48fc37433c0a8117779d2d (patch) | |
| tree | 664c87fa2007fd7958d8b780d2b59f0886d0d06a /src | |
| parent | 8f2ef3227a87bb6389a40ef85c2d6ab9db405dc5 (diff) | |
refactor: hardening of the application
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.py | 240 | ||||
| -rw-r--r-- | src/index.html | 419 | ||||
| -rw-r--r-- | src/nyan.png (renamed from src/uploads/nyan_819cac51.png) | bin | 901 -> 901 bytes | |||
| -rw-r--r-- | src/requirements.txt | 3 | ||||
| -rw-r--r-- | src/uploads/.htaccess | 2 |
5 files changed, 539 insertions, 125 deletions
@@ -1,38 +1,51 @@ #!/usr/bin/env python3 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"] = 50 * 1024 * 1024 # 50 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/svg+xml": "svg", - "image/bmp": "bmp", -} +_CSP = ( + "default-src 'self'; " + "base-uri 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "img-src 'self' data: http: https:; " + "font-src 'self' data:; " + "connect-src 'self'; " + "form-action 'self'" +) + + +@app.after_request +def add_security_headers(resp: Response) -> Response: + resp.headers.setdefault("Content-Security-Policy", _CSP) + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("X-Frame-Options", "DENY") + resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + return resp # build list of allowed font directories _FONT_DIRS: list[Path] = [ Path("/usr/share/fonts"), Path("/usr/local/share/fonts"), ] -for _home in Path("/home").glob("*"): - _FONT_DIRS.append(_home / ".local" / "share" / "fonts") - _FONT_DIRS.append(_home / ".fonts") + +_FONTS_CACHE_TTL_SECONDS = 300 +_fonts_cache_lock = Lock() +_fonts_cache_until = 0.0 +_fonts_cache_payload: list[dict[str, str]] = [] +_fonts_cache_paths_by_token: dict[str, Path] = {} def _allowed_font_dirs() -> list[str]: @@ -46,49 +59,13 @@ def _allowed_font_dirs() -> list[str]: return out -@app.route("/") -def index(): - return send_from_directory(app.root_path, "index.html") - - -@app.route("/favicon.svg") -def favicon_svg(): - return send_from_directory(app.root_path, "favicon.svg") - - -@app.route("/uploads/<filename>") -def uploads(filename: str): - return send_from_directory(UPLOAD_DIR, filename) - - -@app.route("/upload", methods=["POST"]) -def upload(): - 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 +def _font_token_for_path(path: Path) -> str: + digest = hashlib.sha256(str(path).encode("utf-8")).digest() + token = base64.urlsafe_b64encode(digest[:18]).decode("ascii") + return token.rstrip("=") - ext = ALLOWED_MIME[mime] - basename = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(f.filename).stem)[:64] - 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") -def fonts(): +def _build_fonts_payload() -> tuple[list[dict[str, str]], dict[str, Path]] | None: try: result = subprocess.run( ["fc-list", "--format=%{family}|%{style}|%{file}\n"], @@ -99,13 +76,15 @@ def fonts(): check=False, ) except (FileNotFoundError, subprocess.TimeoutExpired): - return jsonify([]) + return None if result.returncode != 0: - return jsonify([]) + return None if not result.stdout.strip(): - return jsonify([]) + return [], {} + + allowed_dirs = _allowed_font_dirs() def style_score(style: str) -> int: s = style.strip().lower() @@ -117,59 +96,144 @@ def fonts(): return 2 return 3 - best: dict[str, dict] = {} + best: dict[str, dict[str, str | int]] = {} for line in result.stdout.splitlines(): parts = line.split("|", 2) if len(parts) < 3: continue + family = parts[0].split(",")[0].strip() if not family: continue - style = parts[1].split(",")[0].strip() + + style = parts[1].split(",")[0].strip() file_path = parts[2].strip() - if not Path(file_path).exists(): + + try: + real = Path(file_path).resolve(strict=True) + except (OSError, RuntimeError): + continue + + real_str = str(real) + os.sep + if not any(real_str.startswith(d) for d in allowed_dirs): continue + score = style_score(style) - if family not in best or score < best[family]["score"]: - best[family] = {"file": file_path, "score": score} + if family not in best or score < int(best[family]["score"]): + best[family] = {"file": str(real), "score": score} fmt_map = {"ttf": "truetype", "otf": "opentype", "woff": "woff", "woff2": "woff2"} - fonts_list = [ + token_map: dict[str, Path] = {} + fonts_list = [] + for family, entry in best.items(): + file_str = str(entry["file"]) + real = Path(file_str) + token = _font_token_for_path(real) + token_map[token] = real + fonts_list.append( + { + "family": family, + "file": token, + "format": fmt_map.get(real.suffix.lstrip(".").lower(), "truetype"), + } + ) + + fonts_list.sort(key=lambda x: x["family"].casefold()) + return fonts_list, token_map + + +def _get_fonts_cache() -> list[dict[str, str]]: + global _fonts_cache_until, _fonts_cache_payload, _fonts_cache_paths_by_token + + now = time.monotonic() + with _fonts_cache_lock: + if _fonts_cache_payload and now < _fonts_cache_until: + return _fonts_cache_payload + + built = _build_fonts_payload() + if built is not None: + payload, token_map = built + _fonts_cache_payload = payload + _fonts_cache_paths_by_token = token_map + _fonts_cache_until = now + _FONTS_CACHE_TTL_SECONDS + return _fonts_cache_payload + + if not _fonts_cache_payload: + _fonts_cache_payload = [] + _fonts_cache_paths_by_token = {} + + _fonts_cache_until = now + 15 + return _fonts_cache_payload + + +def _font_path_from_token(token: str) -> Path | None: + _get_fonts_cache() + with _fonts_cache_lock: + return _fonts_cache_paths_by_token.get(token) + + +def _font_path_from_legacy_param(encoded: str) -> Path | None: + try: + file_str = base64.b64decode(encoded, validate=True).decode("utf-8") + except Exception: + return None + + if "\x00" in file_str: + return None + + try: + return Path(file_str).resolve(strict=True) + except (OSError, RuntimeError): + return None + + +@app.route("/") +def index(): + return send_from_directory(app.root_path, "index.html") + + +@app.route("/favicon.svg") +def favicon_svg(): + return send_from_directory(app.root_path, "favicon.svg") + + +@app.route("/nyan.png") +def nyan_png(): + return send_from_directory(app.root_path, "nyan.png") + + +@app.route("/upload", methods=["POST"]) +def upload(): + return jsonify( { - "family": family, - "file": base64.b64encode(entry["file"].encode()).decode(), - "format": fmt_map.get(Path(entry["file"]).suffix.lstrip(".").lower(), "truetype"), + "error": "server-side uploads are disabled; images stay in browser local storage" } - for family, entry in best.items() - ] - fonts_list.sort(key=lambda x: x["family"].casefold()) + ), 410 + +@app.route("/fonts") +def fonts(): + fonts_list = _get_fonts_cache() resp = jsonify(fonts_list) - resp.headers["Cache-Control"] = "public, max-age=3600" + resp.headers["Cache-Control"] = f"public, max-age={_FONTS_CACHE_TTL_SECONDS}" return resp @app.route("/font") def font(): - encoded = request.args.get("f", "") - if not encoded: + token = request.args.get("f", "") + if not token: return Response("missing parameter", status=400) - try: - file_str = base64.b64decode(encoded).decode("utf-8") - except Exception: - return Response("invalid parameter", status=400) - - # null byte guard - if "\x00" in file_str: - return Response("invalid parameter", status=400) + path = _font_path_from_token(token) + if path is None: + path = _font_path_from_legacy_param(token) - p = Path(file_str) - if not p.exists(): + if path is None: return Response("font not found", status=404) try: - real = p.resolve(strict=True) + real = path.resolve(strict=True) except (OSError, RuntimeError): return Response("font not found", status=404) diff --git a/src/index.html b/src/index.html index a6f3ae4..cbcc7cd 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ <title>sent-web</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"> - <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: break-spaces; tab-size: 4; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> + <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: break-spaces; tab-size: 4; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> <script> /* @licstart The following is the entire license notice for the @@ -65,8 +65,8 @@ </select> </label> <div class="upload-area"> - <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button> - <input type="file" id="upload-input" accept="image/*"> + <button type="button" onclick="document.getElementById('upload-input').click()">insert image</button> + <input type="file" id="upload-input" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp"> <span id="upload-status"></span> </div> </section> @@ -82,7 +82,7 @@ for the very sucky web 🌎 \ -@nyan_819cac51.png +@nyan.png why? • PPTX sucks @@ -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</p> + <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate · images are local to this browser</p> </main> <!-- presentation overlay --> @@ -132,6 +132,42 @@ questions?</textarea> idx: 0, presenting: false, loadedFonts: new Set(), + imageStore: {}, + uploadTimestamps: [], + + storageKeys: { + images: 'sent-web-images', + uploadRate: 'sent-web-upload-rate', + }, + + uploadMimeToExt: { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + }, + + emojiFallbackFamilies: [ + 'Noto Color Emoji', + 'Segoe UI Emoji', + 'Apple Color Emoji', + ], + + uploadPolicy: { + maxImages: 1024, + maxImageBytes: 25 * 1000 * 1000, + maxTotalBytes: 25 * 1000 * 1000 * 1000, + rateLimitWindowMs: 60 * 1000, + maxUploadsPerWindow: 6, + }, + + defaultBundledImage: { + name: 'nyan.png', + aliases: ['nyan_819cac51.png'], + url: '/nyan.png', + mime: 'image/png', + }, settings: { fg: '#000000', @@ -143,7 +179,9 @@ questions?</textarea> }, init() { + this.restoreImageState(); this.restoreState(); + this.ensureBundledImageInLocalStore(); this.loadFonts(); this.bindEvents(); this.updateColors(); @@ -180,6 +218,255 @@ questions?</textarea> })); }, + canonicalImageName(name) { + if (!name) return name; + if (name === this.defaultBundledImage.name) return name; + if (this.defaultBundledImage.aliases.includes(name)) { + return this.defaultBundledImage.name; + } + return name; + }, + + isPinnedLocalImage(name) { + return this.canonicalImageName(name) === this.defaultBundledImage.name; + }, + + restoreImageState() { + this.imageStore = {}; + this.uploadTimestamps = []; + + try { + const rawImages = localStorage.getItem(this.storageKeys.images); + if (rawImages) { + const parsedImages = JSON.parse(rawImages); + if (parsedImages && typeof parsedImages === 'object') { + for (const [name, entry] of Object.entries(parsedImages)) { + if (!entry || typeof entry !== 'object') continue; + if (typeof entry.dataUrl !== 'string' || !entry.dataUrl.startsWith('data:image/')) continue; + if (typeof entry.mime !== 'string' || !(entry.mime in this.uploadMimeToExt)) continue; + if (!Number.isFinite(entry.bytes) || entry.bytes <= 0) continue; + const canonicalName = this.canonicalImageName(name); + this.imageStore[canonicalName] = { + dataUrl: entry.dataUrl, + mime: entry.mime, + bytes: entry.bytes, + createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(), + }; + } + } + } + } catch (_) {} + + try { + const rawRate = localStorage.getItem(this.storageKeys.uploadRate); + if (rawRate) { + const parsedRate = JSON.parse(rawRate); + if (Array.isArray(parsedRate)) { + this.uploadTimestamps = parsedRate + .filter(ts => Number.isFinite(ts)) + .sort((a, b) => a - b); + } + } + } catch (_) {} + + this.pruneUploadTimestamps(); + try { + this.saveUploadRateState(); + } catch (_) {} + try { + this.saveImageStore(); + } catch (_) {} + }, + + saveImageStore() { + localStorage.setItem(this.storageKeys.images, JSON.stringify(this.imageStore)); + }, + + saveUploadRateState() { + localStorage.setItem(this.storageKeys.uploadRate, JSON.stringify(this.uploadTimestamps)); + }, + + pruneUploadTimestamps(now = Date.now()) { + const cutoff = now - this.uploadPolicy.rateLimitWindowMs; + this.uploadTimestamps = this.uploadTimestamps.filter(ts => ts >= cutoff); + }, + + canStoreUpload(now = Date.now()) { + this.pruneUploadTimestamps(now); + if (this.uploadTimestamps.length < this.uploadPolicy.maxUploadsPerWindow) { + return { + ok: true + }; + } + + const oldest = this.uploadTimestamps[0]; + const retryInMs = Math.max(0, oldest + this.uploadPolicy.rateLimitWindowMs - now); + return { + ok: false, + error: `rate limit hit, retry in ${Math.max(1, Math.ceil(retryInMs / 1000))}s`, + }; + }, + + imageStoreBytes(store = this.imageStore) { + return Object.values(store).reduce((sum, entry) => { + if (!entry || typeof entry !== 'object' || !Number.isFinite(entry.bytes)) { + return sum; + } + return sum + entry.bytes; + }, 0); + }, + + formatBytes(bytes) { + if (bytes < 1000) return `${bytes} B`; + const kb = bytes / 1000; + if (kb < 1000) return `${kb.toFixed(1)} KB`; + const mb = kb / 1000; + if (mb < 1000) return `${mb.toFixed(1)} MB`; + return `${(mb / 1000).toFixed(2)} GB`; + }, + + referencedImageNames(text) { + const refs = new Set(); + for (const rawLine of this.normalizeSentNewlines(text).split('\n')) { + const line = rawLine.trim(); + if (!line.startsWith('@')) continue; + const name = line.slice(1).trim(); + if (!name || name.startsWith('http://') || name.startsWith('https://')) continue; + refs.add(this.canonicalImageName(name)); + } + return refs; + }, + + pruneUnusedLocalImages(text) { + const refs = this.referencedImageNames(text); + let changed = false; + for (const name of Object.keys(this.imageStore)) { + if (!refs.has(this.canonicalImageName(name)) && !this.isPinnedLocalImage(name)) { + delete this.imageStore[name]; + changed = true; + } + } + + if (changed) { + try { + this.saveImageStore(); + } catch (_) {} + } + }, + + makeLocalImageName(file, ext) { + const stem = (file.name || 'image') + .replace(/\.[^.]+$/, '') + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 64) || 'image'; + const rand = (crypto.randomUUID ? crypto.randomUUID().replace(/-/g, '') : String(Math.random()).slice(2)).slice(0, 8); + return `${stem}_${rand}.${ext}`; + }, + + blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('failed to encode image')); + } + }; + reader.onerror = () => reject(new Error('failed to read image')); + reader.readAsDataURL(blob); + }); + }, + + fileToDataUrl(file) { + return this.blobToDataUrl(file); + }, + + async ensureBundledImageInLocalStore() { + const key = this.defaultBundledImage.name; + const existing = this.imageStore[key]; + if (existing && typeof existing.dataUrl === 'string') { + return; + } + + try { + const res = await fetch(this.defaultBundledImage.url, { + cache: 'force-cache' + }); + if (!res.ok) { + throw new Error(`failed to fetch bundled image (${res.status})`); + } + + const blob = await res.blob(); + const mime = blob.type || this.defaultBundledImage.mime; + if (!(mime in this.uploadMimeToExt)) { + return; + } + + if (!Number.isFinite(blob.size) || blob.size <= 0) { + return; + } + + if (blob.size > this.uploadPolicy.maxImageBytes) { + return; + } + + const dataUrl = await this.blobToDataUrl(blob); + const nextStore = { + ...this.imageStore, + [key]: { + dataUrl, + mime, + bytes: blob.size, + createdAt: Date.now(), + }, + }; + + if (this.imageStoreBytes(nextStore) > this.uploadPolicy.maxTotalBytes) { + return; + } + + this.imageStore = nextStore; + this.saveImageStore(); + } catch (err) { + console.warn('could not seed bundled nyan image:', err); + } + }, + + resolveSlideImageSrc(imageRef) { + if (imageRef.startsWith('http://') || imageRef.startsWith('https://')) { + return imageRef; + } + + const canonicalName = this.canonicalImageName(imageRef); + const entry = this.imageStore[canonicalName] || this.imageStore[imageRef]; + if (entry && typeof entry.dataUrl === 'string') { + return entry.dataUrl; + } + + if (canonicalName === this.defaultBundledImage.name) { + return this.defaultBundledImage.url; + } + + return canonicalName; + }, + + buildSentFontStack(primaryFamily) { + const families = []; + const seen = new Set(); + const add = (family) => { + if (!family || seen.has(family)) return; + seen.add(family); + families.push(`'${family}'`); + }; + + add(primaryFamily); + this.emojiFallbackFamilies.forEach(add); + add('DejaVu Sans'); + + return `${families.join(', ')}, sans-serif`; + }, + async loadFonts() { try { const res = await fetch('/fonts'); @@ -188,10 +475,12 @@ questions?</textarea> sel.innerHTML = ''; // preload fallback fonts - const notoEmoji = data.find(f => f.family === 'Noto Color Emoji'); + const emojiFallback = this.emojiFallbackFamilies + .map(family => data.find(f => f.family === family)) + .find(Boolean); const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono')); - if (notoEmoji) await this.loadFont(notoEmoji); + if (emojiFallback) await this.loadFont(emojiFallback); if (dejavu) await this.loadFont(dejavu); // populate dropdown @@ -242,7 +531,7 @@ questions?</textarea> this.settings.fontFamily = fontData.family; await this.loadFont(fontData); - const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`; + const stack = this.buildSentFontStack(fontData.family); document.documentElement.style.setProperty('--sent-font', stack); this.saveSettings(); @@ -259,6 +548,9 @@ 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)); @@ -405,6 +697,7 @@ questions?</textarea> // presentation controls startPresentation() { const text = document.getElementById('input').value; + this.pruneUnusedLocalImages(text); this.slides = this.parseSent(text); if (this.slides.length === 0) { @@ -461,11 +754,7 @@ questions?</textarea> pres.style.alignItems = 'center'; pres.style.paddingLeft = '0'; const img = document.createElement('img'); - if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) { - img.src = slide.img; - } else { - img.src = 'uploads/' + slide.img; - } + img.src = this.resolveSlideImageSrc(slide.img); img.alt = slide.img; // changed to reflect the fullscreen viewport. const pw = pres.offsetWidth || window.innerWidth; @@ -586,48 +875,114 @@ questions?</textarea> if (!file) return; const status = document.getElementById('upload-status'); - status.textContent = 'uploading…'; + const done = () => { + e.target.value = ''; + }; + + const ext = this.uploadMimeToExt[file.type]; + if (!ext) { + status.textContent = `error: invalid file type: ${file.type || 'unknown'}`; + done(); + return; + } + + if (!Number.isFinite(file.size) || file.size <= 0) { + status.textContent = 'error: empty file'; + done(); + return; + } + + if (file.size > this.uploadPolicy.maxImageBytes) { + status.textContent = `error: file too large (max ${this.formatBytes(this.uploadPolicy.maxImageBytes)})`; + done(); + return; + } + + const rateCheck = this.canStoreUpload(); + if (!rateCheck.ok) { + status.textContent = `error: ${rateCheck.error}`; + done(); + return; + } - const fd = new FormData(); - fd.append('image', file); + status.textContent = 'storing locally...'; try { - const res = await fetch('/upload', { - method: 'POST', - body: fd - }); - const data = await res.json(); + const ta = document.getElementById('input'); + this.pruneUnusedLocalImages(ta.value); - if (data.error) { - status.textContent = 'error: ' + data.error; + if (Object.keys(this.imageStore).length >= this.uploadPolicy.maxImages) { + status.textContent = `error: local image limit reached (${this.uploadPolicy.maxImages})`; return; } - // insert @filename at cursor position - const ta = document.getElementById('input'); + const filename = this.makeLocalImageName(file, ext); + const dataUrl = await this.fileToDataUrl(file); + + const nextStore = { + ...this.imageStore, + [filename]: { + dataUrl, + mime: file.type, + bytes: file.size, + createdAt: Date.now(), + }, + }; + + const nextTotalBytes = this.imageStoreBytes(nextStore); + if (nextTotalBytes > this.uploadPolicy.maxTotalBytes) { + status.textContent = `error: local quota exceeded (max ${this.formatBytes(this.uploadPolicy.maxTotalBytes)} total)`; + return; + } + + this.imageStore = nextStore; + this.saveImageStore(); + + this.uploadTimestamps.push(Date.now()); + this.pruneUploadTimestamps(); + this.saveUploadRateState(); + const pos = ta.selectionStart; const txt = ta.value; - const ins = `\n@${data.filename}\n`; + const ins = `\n@${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 = `uploaded: ${data.filename}`; + status.textContent = `stored: ${filename}`; setTimeout(() => { status.textContent = ''; }, 3000); } catch (err) { - status.textContent = 'upload failed'; + if (err && err.name === 'QuotaExceededError') { + status.textContent = 'error: browser storage quota reached'; + } else { + status.textContent = 'error: failed to store image locally'; + } console.error(err); + } finally { + done(); } - - e.target.value = ''; }, // 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' }); @@ -739,9 +1094,7 @@ questions?</textarea> resolve(); }; img.onerror = resolve; // still produce a page even on failure - img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ? - slide.img : - 'uploads/' + slide.img; + img.src = this.resolveSlideImageSrc(slide.img); }); } else { // text slide — left-aligned, same sizing logic as live view diff --git a/src/uploads/nyan_819cac51.png b/src/nyan.png Binary files differindex 377b9d0..377b9d0 100644 --- a/src/uploads/nyan_819cac51.png +++ b/src/nyan.png diff --git a/src/requirements.txt b/src/requirements.txt index dfce493..d5d931e 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,3 +1,2 @@ flask>=3.0,<3.2 -gunicorn>=22,<24 -python-magic>=0.4.27,<0.5
\ No newline at end of file +gunicorn>=22,<24
\ No newline at end of file diff --git a/src/uploads/.htaccess b/src/uploads/.htaccess deleted file mode 100644 index 2cf2ddc..0000000 --- a/src/uploads/.htaccess +++ /dev/null @@ -1,2 +0,0 @@ -# prevent PHP execution in uploads directory -php_flag engine off |
