aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorkj_sh6042026-05-31 00:32:02 -0400
committerkj_sh6042026-05-31 00:32:02 -0400
commitcf23fda43083a7374f48fc37433c0a8117779d2d (patch)
tree664c87fa2007fd7958d8b780d2b59f0886d0d06a /src
parent8f2ef3227a87bb6389a40ef85c2d6ab9db405dc5 (diff)
refactor: hardening of the application
Diffstat (limited to 'src')
-rw-r--r--src/app.py240
-rw-r--r--src/index.html419
-rw-r--r--src/nyan.png (renamed from src/uploads/nyan_819cac51.png)bin901 -> 901 bytes
-rw-r--r--src/requirements.txt3
-rw-r--r--src/uploads/.htaccess2
5 files changed, 539 insertions, 125 deletions
diff --git a/src/app.py b/src/app.py
index d74d11d..da8f5df 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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
index 377b9d0..377b9d0 100644
--- a/src/uploads/nyan_819cac51.png
+++ b/src/nyan.png
Binary files differ
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