From ba36703a71d21cbb407351f0b88a291e37b07671 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Wed, 2 Sep 2026 18:31:20 +0200 Subject: [PATCH] feat: ADD syntax highlighting - lazyload only when selected - avoided autodetec bc of bloat #6 --- Dockerfile | 3 + README.md | 3 + app.py | 193 +++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 185 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1f510dd..9848bee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,9 @@ COPY --from=builder --chown=nonroot:nonroot /opt/venv /opt/venv # Application source (its own layer so source-only changes don't bust dep cache) COPY --chown=nonroot:nonroot app.py favicon.ico /app/ +# Vendored highlight.js ES modules (see vendor/fetch.sh). Baked in at build time: +# the runtime is distroless with a read-only rootfs and no network dependency. +COPY --chown=nonroot:nonroot vendor /app/vendor USER nonroot EXPOSE 8000 diff --git a/README.md b/README.md index bd1d604..e9118cb 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg - per-pad password protection (PBKDF2-SHA256), with a built-in password generator in the UI - `?pw=…` on a pad URL locks it with that password if it is not protected yet, and unlocks it if it is — so `https://aukpad.com/{pad_id}/?pw=s3cret` is a one-step create-and-lock link +- optional syntax highlighting (24 languages) from a per-pad dropdown, shared with everyone on the pad - line numbers; Tab inserts 4 spaces - dark / light mode (auto-detects system preference, manual toggle) - copy-to-clipboard and "new pad" buttons, live peer count in the header @@ -44,6 +45,8 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg - configurable text-size, connection, room, and retention limits - per-IP rate limiting (pad creation + failed password attempts), reverse-proxy aware - distroless, non-root, read-only-rootfs container image +- highlight.js is vendored in `vendor/` and loaded lazily — no CDN, no network calls from the browser; + a plain pad fetches nothing, a highlighted one fetches only the engine plus its one language **Ideas:** [Check out the open feature requests](https://git.uphillsecurity.com/cf7/aukpad/issues?q=&type=all&sort=&state=open&labels=12&milestone=0&project=0&assignee=0&poster=0&archived=false) diff --git a/app.py b/app.py index be83b0c..0ed3319 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ # aukpad.py from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles import json, re, secrets, string, time, os, threading, asyncio, hashlib from collections import defaultdict from typing import Optional @@ -8,6 +9,19 @@ from typing import Optional app = FastAPI() application = app # alias if you prefer "application" +# Vendored highlight.js ES modules, fetched lazily by the editor and only for pads +# that actually set a language. Mounted before the catch-all /{doc_id}/ route so +# pad-id routing cannot swallow it; the cost is that "static" is not a usable pad id. +app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")), + name="vendor") + +# Languages offered in the editor dropdown. This is a security boundary as much as a +# feature list: the value is interpolated into a module URL client-side, so it must +# never be free-form. Keep in sync with vendor/fetch.sh. +LANGS = ("accesslog", "apache", "bash", "cpp", "css", "diff", "dockerfile", "go", + "graphql", "http", "ini", "java", "javascript", "json", "markdown", "nginx", + "php", "powershell", "python", "rust", "sql", "typescript", "xml", "yaml") + # Environment variables USE_VALKEY = os.getenv("USE_VALKEY", "false").lower() == "true" VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0") @@ -41,7 +55,7 @@ def get_client_ip(conn) -> str: # Valkey/Redis client (initialized later if enabled) redis_client = None -# In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "authed_peers": set[WebSocket], "last_access": float, "pw_hash": bytes|None, "pw_salt": bytes|None, "pw_iter": int|None}} +# In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "authed_peers": set[WebSocket], "last_access": float, "pw_hash": bytes|None, "pw_salt": bytes|None, "pw_iter": int|None, "lang": str|None}} rooms: dict[str, dict] = {} # Rate limiting: {ip: [timestamp, timestamp, ...]} @@ -104,6 +118,7 @@ def save_room_data_to_cache(doc_id: str, room: dict): "pw_hash": room["pw_hash"].hex() if room.get("pw_hash") else None, "pw_salt": room["pw_salt"].hex() if room.get("pw_salt") else None, "pw_iter": room.get("pw_iter"), + "lang": room.get("lang"), } redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data)) except Exception as e: @@ -187,6 +202,13 @@ HTML = """ --gutter-color: #9ca3af; --panel-bg: #fff; --overlay-bg: rgba(0,0,0,.55); + --hl-comment: #6a737d; + --hl-keyword: #d73a49; + --hl-string: #032f62; + --hl-number: #005cc5; + --hl-title: #6f42c1; + --hl-attr: #e36209; + --hl-meta: #6a737d; } [data-theme="dark"] { --bg: #17181E; @@ -199,6 +221,13 @@ HTML = """ --gutter-color: #6b7280; --panel-bg: #2a2b33; --overlay-bg: rgba(0,0,0,.75); + --hl-comment: #8b949e; + --hl-keyword: #ff7b72; + --hl-string: #a5d6ff; + --hl-number: #79c0ff; + --hl-title: #d2a8ff; + --hl-attr: #ffa657; + --hl-meta: #8b949e; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); } @@ -209,6 +238,9 @@ HTML = """ a:hover,button:hover { background:var(--btn-hover); } /* Icon buttons: inline SVG so glyphs render the same on every platform */ .ibtn { display:inline-flex; align-items:center; justify-content:center; width:2rem; height:2rem; padding:0; } + #lang { height:2rem; max-width:7.5rem; padding:0 .35rem; border:1px solid var(--border); border-radius:4px; + background:var(--btn-bg); color:var(--text); font-size:.8rem; font-family:inherit; cursor:pointer; } + #lang:disabled { opacity:.5; cursor:default; } .ic { width:1.05rem; height:1.05rem; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; } #status { font-size:.9rem; opacity:.7; margin-left:.5rem; } #status::before { content:""; display:inline-block; width:.55em; height:.55em; border-radius:50%; margin-right:.35rem; background:#ef4444; } @@ -216,12 +248,33 @@ HTML = """ #peers { font-size:.95rem; font-weight:bold; margin-left:.5rem; margin-right:.3rem; color:#22c55e; display:none; } #wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid var(--border); border-radius:4px; overflow:hidden; flex: 1; } - #gutter, #t { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } + #gutter, #t, #hl { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } #gutter { padding:.5rem .75rem; text-align:right; color:var(--gutter-color); background:var(--gutter-bg); border-right:1px solid var(--gutter-border); user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; } - #t { padding:.5rem .75rem; width:100%; height: 100%; resize: none; border:0; outline:0; - overflow:auto; white-space: pre; background:var(--bg); color:var(--text); } + /* Highlight overlay. #t and #hl must render glyph-for-glyph identically: any + difference in padding, white-space or tab-size shows up as drifting colors. */ + #edit { position:relative; overflow:hidden; } + #t, #hl { padding:.5rem .75rem; border:0; margin:0; white-space:pre; tab-size:4; } + #t { position:absolute; inset:0; width:100%; height:100%; resize:none; outline:0; + overflow:auto; background:var(--bg); color:var(--text); } + #hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:var(--bg); color:var(--text); } + #hl code { font: inherit; padding:0; background:none; } + /* Only while highlighting is live; without .hl the editor is byte-identical to + the plain version, which is the fallback for every failure path. */ + #wrap.hl #t { color:transparent; background:transparent; caret-color:var(--text); } + #wrap.hl #t::placeholder { color:var(--gutter-color); } + #wrap.hl #t::selection { background:rgba(59,130,246,.35); } pre { margin: 0; } + /* highlight.js tokens, mapped onto the theme variables above so dark/light just works */ + .hljs-comment, .hljs-quote { color:var(--hl-comment); font-style:italic; } + .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-type, .hljs-doctag { color:var(--hl-keyword); } + .hljs-string, .hljs-regexp, .hljs-addition { color:var(--hl-string); } + .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-variable, .hljs-template-variable { color:var(--hl-number); } + .hljs-title, .hljs-name, .hljs-section, .hljs-built_in, .hljs-class .hljs-title { color:var(--hl-title); } + .hljs-attr, .hljs-attribute, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id { color:var(--hl-attr); } + .hljs-meta, .hljs-comment .hljs-doctag, .hljs-deletion { color:var(--hl-meta); } + .hljs-emphasis { font-style:italic; } + .hljs-strong { font-weight:bold; } /* Password protection */ #lock-btn.locked { background:#dbeafe; border-color:#3b82f6; } [data-theme="dark"] #lock-btn.locked { background:#1e3a5f; border-color:#3b82f6; } @@ -259,6 +312,7 @@ HTML = """ disconnected
+ @@ -278,8 +332,11 @@ HTML = """
1
- +
+ + +
@@ -344,13 +401,99 @@ function updateGutter() { for (let i=1; i<=lines; i++) s += i + "\\n"; gutter.textContent = s; } -ta.addEventListener("input", updateGutter); -ta.addEventListener("scroll", () => { gutter.scrollTop = ta.scrollTop; }); +ta.addEventListener("input", refresh); +function syncScroll() { + gutter.scrollTop = ta.scrollTop; + // The overlay needs horizontal sync too: white-space:pre means long lines + // scroll sideways, and the gutter never does. + hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; +} +ta.addEventListener("scroll", syncScroll); // Also sync on keydown for immediate response -ta.addEventListener("keydown", () => { - setTimeout(() => { gutter.scrollTop = ta.scrollTop; }, 0); +ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); }); + +// --- Syntax highlighting --- +// Vendored highlight.js, fetched only when a pad actually uses a language. A plain +// pad downloads nothing. Every failure path falls back to the plain textarea. +const LANGS = ["accesslog","apache","bash","cpp","css","diff","dockerfile","go","graphql", + "http","ini","java","javascript","json","markdown","nginx","php","powershell", + "python","rust","sql","typescript","xml","yaml"]; +const LANG_LABELS = {accesslog:"Access log", apache:"Apache", bash:"Bash", cpp:"C / C++", + css:"CSS", diff:"Diff", dockerfile:"Dockerfile", go:"Go", graphql:"GraphQL", http:"HTTP", + ini:"INI / TOML", java:"Java", javascript:"JavaScript", json:"JSON", markdown:"Markdown", + nginx:"nginx", php:"PHP", powershell:"PowerShell", python:"Python", rust:"Rust", sql:"SQL", + typescript:"TypeScript", xml:"HTML / XML", yaml:"YAML"}; +const HL_MAX = 100000; // above this, highlighting costs more than it is worth + +const hl = $("#hl"), hlCode = hl.firstElementChild, wrap = $("#wrap"), langSel = $("#lang"); +let curLang = null, hljs = null, hlPending = false; +const langLoaded = new Set(); + +langSel.append(new Option("Plain", "")); +for (const l of [...LANGS].sort((a, b) => LANG_LABELS[a].localeCompare(LANG_LABELS[b]))) + langSel.append(new Option(LANG_LABELS[l], l)); + +async function ensureLang(lang) { + // Allowlist check before the value reaches a URL, not merely for correctness. + if (!LANGS.includes(lang)) return false; + if (!hljs) hljs = (await import("/static/hljs/core.min.js")).default; + if (!langLoaded.has(lang)) { + const m = await import(`/static/hljs/languages/${lang}.min.js`); + hljs.registerLanguage(lang, m.default); + langLoaded.add(lang); + } + return true; +} + +function renderHl() { + if (!curLang || !hljs || ta.value.length > HL_MAX) { wrap.classList.remove("hl"); return; } + try { + // Trailing newline: a
 renders one fewer line than the textarea without it.
+    hlCode.innerHTML = hljs.highlight(ta.value + "\\n", {language: curLang, ignoreIllegals: true}).value;
+    wrap.classList.add("hl");
+    syncScroll();
+  } catch (e) {
+    wrap.classList.remove("hl");
+  }
+}
+
+async function applyLang(lang) {
+  langSel.value = lang || "";
+  if (!lang || !(await ensureLang(lang).catch(() => false))) {
+    curLang = null; wrap.classList.remove("hl"); hlCode.textContent = ""; return;
+  }
+  curLang = lang;
+  renderHl();
+}
+
+langSel.addEventListener("change", () => {
+  const lang = langSel.value;
+  if (ws?.readyState === 1 && isAuthed) ws.send(JSON.stringify({type: "set_lang", lang}));
+  applyLang(lang);
 });
 
+// Coalesce highlighting to one render per animation frame. This must NOT be debounced
+// on a timer: while .hl is on the textarea text is transparent, so any delay before the
+// overlay catches up is a delay before the typed character is visible at all.
+function scheduleHl() {
+  if (hlPending) return;
+  hlPending = true;
+  requestAnimationFrame(() => { hlPending = false; renderHl(); });
+}
+
+// Re-render gutter and highlighting together.
+function refresh() {
+  updateGutter();
+  if (ta.value.length > HL_MAX) {
+    wrap.classList.remove("hl");
+    langSel.disabled = true;
+    langSel.title = "Highlighting is off above 100 KB";
+    return;
+  }
+  if (isAuthed) { langSel.disabled = false; langSel.title = "Syntax highlighting for this pad"; }
+  scheduleHl();
+}
+
 // --- Password panel ---
 function togglePwPanel() {
   $("#pw-panel").classList.toggle("open");
@@ -447,7 +590,8 @@ function connect(){
       } else {
         isAuthed = true;
         hideOverlay();
-        ver = msg.ver; ta.value = msg.text; updateGutter();
+        ver = msg.ver; ta.value = msg.text; refresh();
+        applyLang(msg.lang || "");
         // ?pw= on an unprotected pad sets the password instead of unlocking.
         // Guard on !isProtected: the server re-sends init after a successful
         // auth, and that init lands here too — without the guard every unlock
@@ -470,13 +614,15 @@ function connect(){
     } else if (msg.type === "update" && isAuthed && msg.ver > ver && msg.clientId !== clientId) {
       const {selectionStart:s, selectionEnd:e} = ta;
       const oldText = ta.value;
-      ta.value = msg.text; ver = msg.ver; updateGutter();
+      ta.value = msg.text; ver = msg.ver; refresh();
       ta.selectionStart = adjustCursor(oldText, msg.text, s);
       ta.selectionEnd   = adjustCursor(oldText, msg.text, e);
     } else if (msg.type === "peers_changed") {
       const el = $("#peers");
       el.textContent = msg.count;
       el.style.display = "inline";
+    } else if (msg.type === "lang_changed") {
+      applyLang(msg.lang || "");
     } else if (msg.type === "protected_changed") {
       isProtected = msg.protected;
       updateLockBtn();
@@ -484,6 +630,7 @@ function connect(){
     }
   };
   ws.onclose = () => {
+    langSel.disabled = true;
     $("#status").textContent = "disconnected";
     $("#status").classList.remove("connected");
     $("#peers").style.display = "none";
@@ -653,6 +800,8 @@ def get_system_info():
       
  • per-pad password protection (PBKDF2-SHA256), with a built-in password generator in the UI
  • ?pw=… on a pad URL locks it with that password if it is not protected yet, and unlocks it if it is — so /{{pad_id}}/?pw=s3cret is a one-step create-and-lock link
  • +
  • optional syntax highlighting (24 languages) from a per-pad dropdown, shared with everyone on the pad; + highlight.js is vendored locally and loaded lazily, so a plain pad fetches nothing
  • line numbers; Tab inserts 4 spaces
  • dark / light mode (auto-detects system preference, manual toggle)
  • copy-to-clipboard and "new pad" buttons, live peer count in the header
  • @@ -840,7 +989,8 @@ async def create_pad_with_content(request: Request): doc_id = random_id() rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "authed_peers": set(), - "last_access": time.time(), "pw_hash": None, "pw_salt": None, "pw_iter": None} + "last_access": time.time(), "pw_hash": None, "pw_salt": None, "pw_iter": None, + "lang": None} # Save to cache if enabled save_room_data_to_cache(doc_id, rooms[doc_id]) @@ -874,6 +1024,7 @@ def get_raw_pad_content(doc_id: str, request: Request, pw: str = ""): "pw_hash": cached_data.get("pw_hash"), "pw_salt": cached_data.get("pw_salt"), "pw_iter": cached_data.get("pw_iter"), + "lang": cached_data.get("lang"), } if doc_id not in rooms: @@ -945,6 +1096,7 @@ async def ws(doc_id: str, ws: WebSocket): "pw_hash": cached_data.get("pw_hash"), "pw_salt": cached_data.get("pw_salt"), "pw_iter": cached_data.get("pw_iter"), + "lang": cached_data.get("lang"), } # Refuse to create new rooms when at capacity @@ -955,7 +1107,7 @@ async def ws(doc_id: str, ws: WebSocket): room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "authed_peers": set(), "last_access": time.time(), "pw_hash": None, "pw_salt": None, - "pw_iter": None}) + "pw_iter": None, "lang": None}) room["peers"].add(ws) # Update access time @@ -976,6 +1128,9 @@ async def ws(doc_id: str, ws: WebSocket): "ver": room["ver"], "protected": room["pw_hash"] is not None, "peers": len(room["peers"]), + # Withheld until authed: the language is content metadata and would tell + # someone sitting at the unlock overlay what kind of file this is. + "lang": room.get("lang") if authed else None, })) try: while True: @@ -989,6 +1144,7 @@ async def ws(doc_id: str, ws: WebSocket): await ws.send_text(json.dumps({"type": "auth_ok"})) await ws.send_text(json.dumps({ "type": "init", "text": room["text"], "ver": room["ver"], "protected": False, + "lang": room.get("lang"), })) else: if not check_auth_rate_limit(client_ip): @@ -1004,6 +1160,7 @@ async def ws(doc_id: str, ws: WebSocket): await ws.send_text(json.dumps({"type": "auth_ok"})) await ws.send_text(json.dumps({ "type": "init", "text": room["text"], "ver": room["ver"], "protected": True, + "lang": room.get("lang"), })) else: record_auth_failure(client_ip) @@ -1050,6 +1207,14 @@ async def ws(doc_id: str, ws: WebSocket): save_room_data_to_cache(doc_id, room) await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None}) + elif data.get("type") == "set_lang": + lang = str(data.get("lang", "")) + if lang and lang not in LANGS: + continue # unknown language: ignore rather than echo it back + room["lang"] = lang or None + save_room_data_to_cache(doc_id, room) + await _broadcast(doc_id, {"type": "lang_changed", "lang": room["lang"]}, authed_only=True) + except WebSocketDisconnect: pass finally: