feat: ADD syntax highlighting - lazyload only when selected - avoided autodetec bc of bloat #6

This commit is contained in:
Caffeine Fueled 2026-09-02 18:31:20 +02:00
parent a8c9f72296
commit ba36703a71
Signed by: cf7
GPG key ID: CA295D643074C68C
3 changed files with 185 additions and 14 deletions

View file

@ -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) # Application source (its own layer so source-only changes don't bust dep cache)
COPY --chown=nonroot:nonroot app.py favicon.ico /app/ 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 USER nonroot
EXPOSE 8000 EXPOSE 8000

View file

@ -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 - 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 — - `?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 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 - line numbers; Tab inserts 4 spaces
- dark / light mode (auto-detects system preference, manual toggle) - dark / light mode (auto-detects system preference, manual toggle)
- copy-to-clipboard and "new pad" buttons, live peer count in the header - 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 - configurable text-size, connection, room, and retention limits
- per-IP rate limiting (pad creation + failed password attempts), reverse-proxy aware - per-IP rate limiting (pad creation + failed password attempts), reverse-proxy aware
- distroless, non-root, read-only-rootfs container image - 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:** **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) [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)

189
app.py
View file

@ -1,6 +1,7 @@
# aukpad.py # aukpad.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import json, re, secrets, string, time, os, threading, asyncio, hashlib import json, re, secrets, string, time, os, threading, asyncio, hashlib
from collections import defaultdict from collections import defaultdict
from typing import Optional from typing import Optional
@ -8,6 +9,19 @@ from typing import Optional
app = FastAPI() app = FastAPI()
application = app # alias if you prefer "application" 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 # Environment variables
USE_VALKEY = os.getenv("USE_VALKEY", "false").lower() == "true" USE_VALKEY = os.getenv("USE_VALKEY", "false").lower() == "true"
VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0") 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) # Valkey/Redis client (initialized later if enabled)
redis_client = None 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] = {} rooms: dict[str, dict] = {}
# Rate limiting: {ip: [timestamp, timestamp, ...]} # 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_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_salt": room["pw_salt"].hex() if room.get("pw_salt") else None,
"pw_iter": room.get("pw_iter"), "pw_iter": room.get("pw_iter"),
"lang": room.get("lang"),
} }
redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data)) redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data))
except Exception as e: except Exception as e:
@ -187,6 +202,13 @@ HTML = """<!doctype html>
--gutter-color: #9ca3af; --gutter-color: #9ca3af;
--panel-bg: #fff; --panel-bg: #fff;
--overlay-bg: rgba(0,0,0,.55); --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"] { [data-theme="dark"] {
--bg: #17181E; --bg: #17181E;
@ -199,6 +221,13 @@ HTML = """<!doctype html>
--gutter-color: #6b7280; --gutter-color: #6b7280;
--panel-bg: #2a2b33; --panel-bg: #2a2b33;
--overlay-bg: rgba(0,0,0,.75); --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; } * { box-sizing: border-box; }
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); } html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); }
@ -209,6 +238,9 @@ HTML = """<!doctype html>
a:hover,button:hover { background:var(--btn-hover); } a:hover,button:hover { background:var(--btn-hover); }
/* Icon buttons: inline SVG so glyphs render the same on every platform */ /* 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; } .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; } .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 { 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; } #status::before { content:""; display:inline-block; width:.55em; height:.55em; border-radius:50%; margin-right:.35rem; background:#ef4444; }
@ -216,12 +248,33 @@ HTML = """<!doctype html>
#peers { font-size:.95rem; font-weight:bold; margin-left:.5rem; margin-right:.3rem; color:#22c55e; display:none; } #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; #wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid var(--border); border-radius:4px; overflow:hidden;
flex: 1; } 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); #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; } 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; /* Highlight overlay. #t and #hl must render glyph-for-glyph identically: any
overflow:auto; white-space: pre; background:var(--bg); color:var(--text); } 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; } 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 */ /* Password protection */
#lock-btn.locked { background:#dbeafe; border-color:#3b82f6; } #lock-btn.locked { background:#dbeafe; border-color:#3b82f6; }
[data-theme="dark"] #lock-btn.locked { background:#1e3a5f; border-color:#3b82f6; } [data-theme="dark"] #lock-btn.locked { background:#1e3a5f; border-color:#3b82f6; }
@ -259,6 +312,7 @@ HTML = """<!doctype html>
<strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span> <strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span>
</div> </div>
<div style="position:relative; display:flex; align-items:center; gap:.25rem;"> <div style="position:relative; display:flex; align-items:center; gap:.25rem;">
<select id="lang" title="Syntax highlighting for this pad" aria-label="Syntax highlighting" disabled></select>
<button id="copy" class="ibtn" onclick="copyToClipboard()" title="Copy to clipboard" aria-label="Copy to clipboard"><svg class="ic"><use href="#i-copy"/></svg></button> <button id="copy" class="ibtn" onclick="copyToClipboard()" title="Copy to clipboard" aria-label="Copy to clipboard"><svg class="ic"><use href="#i-copy"/></svg></button>
<a id="newpad" class="ibtn" href="/" target="_blank" title="Opens a new pad in a new tab" aria-label="New pad"><svg class="ic"><use href="#i-plus"/></svg></a> <a id="newpad" class="ibtn" href="/" target="_blank" title="Opens a new pad in a new tab" aria-label="New pad"><svg class="ic"><use href="#i-plus"/></svg></a>
<button id="lock-btn" class="ibtn" onclick="togglePwPanel()" title="No password click to set one"><svg class="ic"><use href="#i-lock"/></svg></button> <button id="lock-btn" class="ibtn" onclick="togglePwPanel()" title="No password click to set one"><svg class="ic"><use href="#i-lock"/></svg></button>
@ -278,9 +332,12 @@ HTML = """<!doctype html>
</header> </header>
<div id="wrap"> <div id="wrap">
<pre id="gutter">1</pre> <pre id="gutter">1</pre>
<div id="edit">
<pre id="hl" aria-hidden="true"><code></code></pre>
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" <textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
placeholder="Start typing…"></textarea> placeholder="Start typing…"></textarea>
</div> </div>
</div>
<div id="pw-overlay"> <div id="pw-overlay">
<div id="pw-box"> <div id="pw-box">
<h2>This pad is password protected</h2> <h2>This pad is password protected</h2>
@ -344,13 +401,99 @@ function updateGutter() {
for (let i=1; i<=lines; i++) s += i + "\\n"; for (let i=1; i<=lines; i++) s += i + "\\n";
gutter.textContent = s; gutter.textContent = s;
} }
ta.addEventListener("input", updateGutter); ta.addEventListener("input", refresh);
ta.addEventListener("scroll", () => { gutter.scrollTop = ta.scrollTop; }); 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 // Also sync on keydown for immediate response
ta.addEventListener("keydown", () => { ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); });
setTimeout(() => { gutter.scrollTop = ta.scrollTop; }, 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 <pre> 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 --- // --- Password panel ---
function togglePwPanel() { function togglePwPanel() {
$("#pw-panel").classList.toggle("open"); $("#pw-panel").classList.toggle("open");
@ -447,7 +590,8 @@ function connect(){
} else { } else {
isAuthed = true; isAuthed = true;
hideOverlay(); 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. // ?pw= on an unprotected pad sets the password instead of unlocking.
// Guard on !isProtected: the server re-sends init after a successful // Guard on !isProtected: the server re-sends init after a successful
// auth, and that init lands here too without the guard every unlock // 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) { } else if (msg.type === "update" && isAuthed && msg.ver > ver && msg.clientId !== clientId) {
const {selectionStart:s, selectionEnd:e} = ta; const {selectionStart:s, selectionEnd:e} = ta;
const oldText = ta.value; 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.selectionStart = adjustCursor(oldText, msg.text, s);
ta.selectionEnd = adjustCursor(oldText, msg.text, e); ta.selectionEnd = adjustCursor(oldText, msg.text, e);
} else if (msg.type === "peers_changed") { } else if (msg.type === "peers_changed") {
const el = $("#peers"); const el = $("#peers");
el.textContent = msg.count; el.textContent = msg.count;
el.style.display = "inline"; el.style.display = "inline";
} else if (msg.type === "lang_changed") {
applyLang(msg.lang || "");
} else if (msg.type === "protected_changed") { } else if (msg.type === "protected_changed") {
isProtected = msg.protected; isProtected = msg.protected;
updateLockBtn(); updateLockBtn();
@ -484,6 +630,7 @@ function connect(){
} }
}; };
ws.onclose = () => { ws.onclose = () => {
langSel.disabled = true;
$("#status").textContent = "disconnected"; $("#status").textContent = "disconnected";
$("#status").classList.remove("connected"); $("#status").classList.remove("connected");
$("#peers").style.display = "none"; $("#peers").style.display = "none";
@ -653,6 +800,8 @@ def get_system_info():
<li>per-pad password protection (PBKDF2-SHA256), with a built-in password generator in the UI</li> <li>per-pad password protection (PBKDF2-SHA256), with a built-in password generator in the UI</li>
<li><code>?pw=</code> on a pad URL locks it with that password if it is not protected yet, <li><code>?pw=</code> on a pad URL locks it with that password if it is not protected yet,
and unlocks it if it is so <code>/{{pad_id}}/?pw=s3cret</code> is a one-step create-and-lock link</li> and unlocks it if it is so <code>/{{pad_id}}/?pw=s3cret</code> is a one-step create-and-lock link</li>
<li>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</li>
<li>line numbers; Tab inserts 4 spaces</li> <li>line numbers; Tab inserts 4 spaces</li>
<li>dark / light mode (auto-detects system preference, manual toggle)</li> <li>dark / light mode (auto-detects system preference, manual toggle)</li>
<li>copy-to-clipboard and "new pad" buttons, live peer count in the header</li> <li>copy-to-clipboard and "new pad" buttons, live peer count in the header</li>
@ -840,7 +989,8 @@ async def create_pad_with_content(request: Request):
doc_id = random_id() doc_id = random_id()
rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "authed_peers": set(), 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 to cache if enabled
save_room_data_to_cache(doc_id, rooms[doc_id]) 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_hash": cached_data.get("pw_hash"),
"pw_salt": cached_data.get("pw_salt"), "pw_salt": cached_data.get("pw_salt"),
"pw_iter": cached_data.get("pw_iter"), "pw_iter": cached_data.get("pw_iter"),
"lang": cached_data.get("lang"),
} }
if doc_id not in rooms: 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_hash": cached_data.get("pw_hash"),
"pw_salt": cached_data.get("pw_salt"), "pw_salt": cached_data.get("pw_salt"),
"pw_iter": cached_data.get("pw_iter"), "pw_iter": cached_data.get("pw_iter"),
"lang": cached_data.get("lang"),
} }
# Refuse to create new rooms when at capacity # 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(), room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "authed_peers": set(),
"last_access": time.time(), "pw_hash": None, "pw_salt": None, "last_access": time.time(), "pw_hash": None, "pw_salt": None,
"pw_iter": None}) "pw_iter": None, "lang": None})
room["peers"].add(ws) room["peers"].add(ws)
# Update access time # Update access time
@ -976,6 +1128,9 @@ async def ws(doc_id: str, ws: WebSocket):
"ver": room["ver"], "ver": room["ver"],
"protected": room["pw_hash"] is not None, "protected": room["pw_hash"] is not None,
"peers": len(room["peers"]), "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: try:
while True: 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": "auth_ok"}))
await ws.send_text(json.dumps({ await ws.send_text(json.dumps({
"type": "init", "text": room["text"], "ver": room["ver"], "protected": False, "type": "init", "text": room["text"], "ver": room["ver"], "protected": False,
"lang": room.get("lang"),
})) }))
else: else:
if not check_auth_rate_limit(client_ip): 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": "auth_ok"}))
await ws.send_text(json.dumps({ await ws.send_text(json.dumps({
"type": "init", "text": room["text"], "ver": room["ver"], "protected": True, "type": "init", "text": room["text"], "ver": room["ver"], "protected": True,
"lang": room.get("lang"),
})) }))
else: else:
record_auth_failure(client_ip) 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) save_room_data_to_cache(doc_id, room)
await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None}) 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: except WebSocketDisconnect:
pass pass
finally: finally: