feat: ADD syntax highlighting - lazyload only when selected - avoided autodetec bc of bloat #6
This commit is contained in:
parent
a8c9f72296
commit
ba36703a71
3 changed files with 185 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
193
app.py
193
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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype 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 = """<!doctype html>
|
|||
<strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -278,8 +332,11 @@ HTML = """<!doctype html>
|
|||
</header>
|
||||
<div id="wrap">
|
||||
<pre id="gutter">1</pre>
|
||||
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||
placeholder="Start typing…"></textarea>
|
||||
<div id="edit">
|
||||
<pre id="hl" aria-hidden="true"><code></code></pre>
|
||||
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||
placeholder="Start typing…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pw-overlay">
|
||||
<div id="pw-box">
|
||||
|
|
@ -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 <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 ---
|
||||
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():
|
|||
<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,
|
||||
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>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>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue