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
1- +
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():
?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