diff --git a/app.py b/app.py index 5d3f599..9bf09f5 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPExcept 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, deque +from collections import defaultdict from typing import Optional app = FastAPI() @@ -15,11 +15,6 @@ application = app # alias if you prefer "application" app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")), name="vendor") -# Minimum gap between accepted pings, per connection. A ping is one inbound message -# fanned out to every peer, so this bounds that amplification. Low enough that rapid -# human clicking gets through. -PING_MIN_INTERVAL = 0.15 - # 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. @@ -45,111 +40,6 @@ DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") def is_valid_doc_id(doc_id: str) -> bool: return bool(DOC_ID_RE.match(doc_id)) -# --- Operational transform core ------------------------------------------------ -# An op is a single splice: at `pos`, remove `dele` code units, insert `ins`. Every -# textarea interaction - typing, deleting, pasting, Tab, autocorrect - reduces to one -# splice, so this is the whole edit vocabulary. Ops travel over the wire instead of -# document snapshots because two snapshots taken concurrently cannot be combined: one -# has to win, which is what used to drop a peer's text and yank carets between lines. -# -# Offsets are UTF-16 code units, because that is what a textarea reports and what JS -# string indexing uses. Python indexes by code point; the two diverge only on pads -# holding astral characters (emoji), which is what splice()'s second path is for. -# -# diffOp/applyOp/transform in the client mirror this. The two copies MUST agree: -# test_ot.py fuzzes convergence, and every update carries the server's length so a -# disagreement resyncs instead of silently drifting. - -MAX_OP_LOG = 256 # ops kept per room, for rebasing edits from stale clients -MAX_OP_LOG_BYTES = 262144 # ...and a size ceiling, so one big paste cannot pin memory - - -def count_astral(s: str) -> int: - """Chars needing two UTF-16 code units. isascii() is a flag on the string object, - so the common case is O(1) and never walks the document.""" - if s.isascii(): - return 0 - return sum(1 for ch in s if ord(ch) > 0xFFFF) - - -def u16len(s: str) -> int: - return len(s) + count_astral(s) - - -def splice(text: str, op, u16: bool): - """Apply an op; returns (new_text, removed_text). - - `u16` says the text holds astral characters, so code-unit offsets no longer line up - with Python's code-point indexing and the splice has to happen in UTF-16 space. That - path costs an encode/decode of the document, which is why it is gated rather than - unconditional.""" - pos, dele, ins = op - if not u16: - return text[:pos] + ins + text[pos + dele:], text[pos:pos + dele] - b = text.encode("utf-16-le") - lo, hi = pos * 2, (pos + dele) * 2 - return ((b[:lo] + ins.encode("utf-16-le") + b[hi:]).decode("utf-16-le"), - b[lo:hi].decode("utf-16-le")) - - -def transform(a, b, a_first: bool): - """Rewrite op `a` so it applies to a text that already has op `b` applied. - - `a_first` says whether `a` precedes `b` in the server's total order - which is not the - same question as which one is already applied. The server rebases an incoming op onto - ops it has already ordered (a_first=False); a client rebases an op the server has - already ordered onto its own uncommitted edits (a_first=True). Both sides must agree - on the resulting order or their documents drift apart, so the flag is required rather - than defaulted.""" - apos, adel, ains = a - bpos, bdel, bins = b - aend, bend = apos + adel, bpos + bdel - # Two pure inserts at one position do not overlap - only the order of the two - # insertions is ambiguous - so settle it on the server's ordering. Without this each - # side puts the other's text first and two people typing at the same spot diverge - # immediately. - if adel == 0 and bdel == 0 and apos == bpos: - return a if a_first else (apos + u16len(bins), 0, ains) - if bend <= apos: # b entirely before a - return (apos + u16len(bins) - bdel, adel, ains) - if aend <= bpos: # b entirely after a - return a - # The two edits touch the same characters. A single splice cannot express the honest - # result - a's surviving fragments are no longer contiguous - so clamp, identically on - # both sides so they still converge. Only reachable when two people edit the same - # characters inside one round trip, where any answer is arbitrary. - if apos < bpos: - return (apos, bpos - apos, ains) - return (bpos + u16len(bins), max(0, aend - bend), ains) - - -def parse_op(data): - """Validate a wire op into (pos, dele, ins), or None. Structural checks only: the - range is checked after rebasing, since these offsets describe the client's older - version of the text, and the size limit is checked against the resulting document so - that an oversize paste gets told it was too large rather than malformed.""" - if not isinstance(data, dict): - return None - pos, dele, ins = data.get("pos"), data.get("del"), data.get("ins") - # bool subclasses int, so True would otherwise pass as position 1 - if isinstance(pos, bool) or isinstance(dele, bool): - return None - if not isinstance(pos, int) or not isinstance(dele, int) or not isinstance(ins, str): - return None - if pos < 0 or dele < 0: - return None - if "\x00" in ins: # matches the POST / check - return None - try: - ins.encode("utf-8") # rejects lone surrogates, which cannot be stored - except UnicodeEncodeError: - return None - return (pos, dele, ins) - -# --- end operational transform core -------------------------------------------- -# test_ot.py slices the block between these two markers and execs it, so that the -# fuzzer can run on a bare python with no fastapi installed. Keep them in place. - def get_client_ip(conn) -> str: # Only honor proxy headers when explicitly opted in — otherwise attackers # can spoof them to bypass per-IP limits. @@ -298,15 +188,10 @@ def record_auth_failure(client_ip: str): HTML = """ - aukpad
-
- disconnected +
+ disconnected
- - - - +
@@ -537,20 +333,11 @@ HTML = """
1
- -
-

- - Content is deleted after __RETENTION_HOURS__ hours of inactivity and can be accessed - by the server and anyone with the link and optional password. - -

-

This pad is password protected

@@ -594,116 +381,17 @@ function toggleTheme() { }); })(); -// Bottom notice: click anywhere on it to dismiss, remembered across pads and reloads. -function hideNotice() { - $("#notice").style.display = "none"; - try { localStorage.setItem("notice", "off"); } catch (e) {} -} -(function() { - try { if (localStorage.getItem("notice") === "off") $("#notice").style.display = "none"; } - catch (e) {} // private mode / storage disabled: just leave the notice up -})(); - // Derive docId from path; redirect root to random let docId = decodeURIComponent(location.pathname.replace(/(^\\/|\\/$)/g, "")); if (!docId) { location.replace("/" + rand() + "/"); } $("#padname").textContent = "/"+docId+"/"; -// Set client-side rather than server-side: the pad page is one shared HTML constant, -// so interpolating per request would mean rebuilding it on every hit. -document.title = docId + " - aukpad"; let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce; let isProtected = false, isAuthed = false; -let reconnectTimer = null, reconnectDelay = 500; - -// --- Sync state --- -// Invariant: shadow -> sent -> ta.value, each step being one splice. -// shadow the server text this client knows about, at version `ver` -// sent shadow with the op currently in flight applied, i.e. what the server will -// hold once it acks. Equal to shadow when nothing is in flight. -// Ops are never stored, only derived by diffing these three strings. That is what keeps -// the state machine small enough to reason about: there is no operation queue. -let shadow = "", sent = "", inflight = false, resyncing = false; - -// --- Operational transform core (mirror of the Python side; the two MUST agree) --- -// An op is one splice {pos, del, ins} in UTF-16 code units, which is what textarea -// offsets and JS string indices already use, so nothing is converted on this side. -const isHigh = c => c >= 0xD800 && c <= 0xDBFF; -const isLow = c => c >= 0xDC00 && c <= 0xDFFF; - -// The single changed region between two texts: common prefix, common suffix, and -// whatever is left in the middle. Every textarea interaction produces exactly one. -function diffOp(a, b) { - if (a === b) return null; - let s = 0; - const min = Math.min(a.length, b.length); - while (s < min && a[s] === b[s]) s++; - let ae = a.length, be = b.length; - while (ae > s && be > s && a[ae-1] === b[be-1]) { ae--; be--; } - // Never split a surrogate pair: a lone surrogate has no UTF-8 encoding, so the server - // would reject the op instead of syncing it. Nudge the boundaries outward instead. - if (s > 0 && isLow(a.charCodeAt(s)) && isHigh(a.charCodeAt(s-1))) s--; - if (ae < a.length && isLow(a.charCodeAt(ae)) && isHigh(a.charCodeAt(ae-1))) { ae++; be++; } - return {pos: s, del: ae - s, ins: b.slice(s, be)}; -} - -function applyOp(text, op) { - return text.slice(0, op.pos) + op.ins + text.slice(op.pos + op.del); -} - -// Rewrite op `a` so it applies to a text that already has op `b` applied. `aFirst` says -// whether `a` precedes `b` in the server's total order, which is a different question -// from which of the two is already applied: here it is always the op the server has -// ordered that comes first, and the server applies the identical rule. -function transform(a, b, aFirst) { - const aEnd = a.pos + a.del, bEnd = b.pos + b.del; - // Two pure inserts at one position do not overlap - only the order of the two - // insertions is ambiguous - so settle it on the server's ordering. Without this each - // side puts the other's text first and two people typing at the same spot diverge - // immediately. - if (!a.del && !b.del && a.pos === b.pos) - return aFirst ? a : {pos: a.pos + b.ins.length, del: 0, ins: a.ins}; - if (bEnd <= a.pos) return {pos: a.pos + b.ins.length - b.del, del: a.del, ins: a.ins}; - if (aEnd <= b.pos) return a; - // Same-characters overlap, which one splice cannot express faithfully. Clamp exactly - // as the server does; the length tripwire below catches anything this gets wrong. - if (a.pos < b.pos) return {pos: a.pos, del: b.pos - a.pos, ins: a.ins}; - return {pos: b.pos + b.ins.length, del: Math.max(0, aEnd - bEnd), ins: a.ins}; -} - -// Our picture of the server is wrong, so stop guessing and ask for a snapshot. Cheap to -// trigger and self-healing, which is what lets transform() clamp overlaps crudely rather -// than carry the machinery needed to always be exactly right. Blocks sending meanwhile: -// an op diffed against a bad shadow would be nonsense. -function resync() { - if (resyncing || ws?.readyState !== 1) return; - resyncing = true; - ws.send(JSON.stringify({type: "resync"})); -} - -// Send edits: one op in flight at a time, so the server always has a base version to -// rebase against. Anything typed while it is in flight is picked up by the next diff. -function flush() { - if (inflight || resyncing || ws?.readyState !== 1 || !isAuthed) return; - const op = diffOp(shadow, ta.value); - if (!op) return; - ws.send(JSON.stringify({type: "edit", base: ver, op, clientId})); - sent = ta.value; - inflight = true; -} - -// Transient message in the status slot. There is no error surface in the editor, which -// is how a refused edit used to pass unnoticed. -let notifyTimer = null; -function notify(text) { - const el = $("#status"); - el.textContent = text; - clearTimeout(notifyTimer); - notifyTimer = setTimeout(() => { - el.textContent = ws?.readyState === 1 ? "connected" : "disconnected"; - }, 4000); -} +// dirty: local edits the server has not acknowledged. Guards the reconnect path +// from overwriting text typed while the socket was down. +let dirty = false, reconnectTimer = null, reconnectDelay = 500; const urlPw = new URLSearchParams(location.search).get("pw") || ""; // --- Line numbers --- @@ -717,85 +405,12 @@ function updateGutter() { gutter.textContent = s; } ta.addEventListener("input", refresh); -// The textarea's horizontal scrollbar eats into its client height, so it can scroll -// further than the mirrors, which have overflow:hidden and no scrollbar. At the bottom -// they clamp and lag behind by the scrollbar's height. Give them matching bottom padding -// so their scrollable extents line up. Recomputed on input and resize, the only times a -// horizontal scrollbar can appear or vanish. -function syncExtent() { - const sb = ta.offsetHeight - ta.clientHeight; // 0 when there is no h-scrollbar - const pad = parseFloat(getComputedStyle(ta).paddingBottom) + sb; - gutter.style.paddingBottom = pad + "px"; - hl.style.paddingBottom = pad + "px"; - // Same idea horizontally, for anything anchored to the editor's right edge. - document.documentElement.style.setProperty("--sbw", (ta.offsetWidth - ta.clientWidth) + "px"); -} -window.addEventListener("resize", () => { syncExtent(); syncScroll(); }); - 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; - // Pings sit at document coordinates; move the wrapper rather than every bar. - pingsIn.style.transform = "translateY(" + (-ta.scrollTop) + "px)"; - // Only drop the notice if the document shrank past the line it points at. - if (jumpLine && jumpLine > lineCount()) hideJump(); } - -// --- Line ping: click a line number to flash that line for every peer --- -// Read from computed style, never hardcoded: the touch media query switches the -// editor font from 14px to 16px, so a literal line height would be wrong on mobile. -const pingsIn = $("#pings-in"); -const lineH = () => parseFloat(getComputedStyle(ta).lineHeight); -const padTop = () => parseFloat(getComputedStyle(ta).paddingTop); -const lineCount = () => ta.value.split("\\n").length; - -gutter.addEventListener("click", (e) => { - if (!isAuthed || ws?.readyState !== 1) return; - const y = e.clientY - gutter.getBoundingClientRect().top + ta.scrollTop - padTop(); - const line = Math.floor(y / lineH()) + 1; - if (line < 1 || line > lineCount()) return; // e.g. clicked below the last line - ws.send(JSON.stringify({type: "ping", line})); -}); - -function showPing(line) { - // Re-pinging a visible line restarts it rather than stacking a second bar. - const prev = pingsIn.querySelector('[data-line="' + line + '"]'); - if (prev) prev.remove(); - const el = document.createElement("div"); - el.className = "ping"; - el.dataset.line = line; - el.style.top = (padTop() + (line - 1) * lineH()) + "px"; - el.style.height = lineH() + "px"; - el.addEventListener("animationend", () => el.remove()); - pingsIn.appendChild(el); -} - -// --- Ping notice --- -// Raised for every ping, for every peer, whether or not the line is on screen: on a long -// pad the bar alone is easy to miss. Click it to jump to the line and re-flash it. -const jump = $("#pingjump"); -let jumpLine = 0, jumpTimer = null; - -function showJump(line) { - jumpLine = line; - $("#pingjump-txt").textContent = "Ping on line " + line; - jump.classList.toggle("up", padTop() + (line - 1) * lineH() < ta.scrollTop); - jump.classList.add("show"); - clearTimeout(jumpTimer); - jumpTimer = setTimeout(hideJump, 10000); -} - -function hideJump() { jump.classList.remove("show"); jumpLine = 0; } - -jump.addEventListener("click", () => { - const line = jumpLine; - hideJump(); - ta.scrollTop = Math.max(0, padTop() + (line - 1) * lineH() - (ta.clientHeight - lineH()) / 2); - syncScroll(); - showPing(line); // re-flash: the original bar may have almost faded by now -}); ta.addEventListener("scroll", syncScroll); // Also sync on keydown for immediate response ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); }); @@ -872,7 +487,6 @@ function scheduleHl() { // Re-render gutter and highlighting together. function refresh() { updateGutter(); - syncExtent(); if (ta.value.length > HL_MAX) { wrap.classList.remove("hl"); langSel.disabled = true; @@ -954,13 +568,6 @@ function submitAuth() { } } -function setPeers(n) { - const el = $("#peers"); - $("#peer-count").textContent = n; // never el.textContent: it would wipe the - el.title = n + (n === 1 ? " peer" : " peers") + " connected"; - el.style.display = "inline-flex"; -} - // --- WS connect + sync --- function connect(){ isAuthed = false; @@ -977,7 +584,7 @@ function connect(){ if (msg.type === "init") { isProtected = !!msg.protected; updateLockBtn(); - if (msg.peers !== undefined) setPeers(msg.peers); + if (msg.peers !== undefined) { const el = $("#peers"); el.textContent = msg.peers; el.style.display = "inline"; } if (isProtected && !isAuthed) { if (urlPw) { ws.send(JSON.stringify({type: "auth", password: urlPw})); @@ -987,17 +594,15 @@ function connect(){ } else { isAuthed = true; hideOverlay(); - // One path for the first snapshot, the post-auth snapshot and every resync. - // Edits the server has never seen survive: keep the textarea as it is and let the - // next diff carry them onto the fresh snapshot rather than pushing a stale copy. - const local = diffOp(shadow, ta.value); - shadow = sent = msg.text; ver = msg.ver; - inflight = resyncing = false; - if (!local) ta.value = msg.text; + if (dirty && ta.value !== msg.text) { + // Reconnected with unsent edits: push them rather than lose them. + ws.send(JSON.stringify({type: "edit", ver, text: ta.value, clientId})); + } else { + ta.value = msg.text; + } refresh(); applyLang(msg.lang || ""); - flush(); // ?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 @@ -1016,55 +621,17 @@ function connect(){ const errEl = $("#auth-error"); errEl.textContent = msg.message; errEl.style.display = "block"; - } else { - // An edit was refused - it would have pushed the pad over the size limit. Roll - // back to the server's text and release the send slot: leaving the two diverged - // is what used to make the limit look like random text loss. - inflight = false; - ta.value = sent = shadow; - refresh(); - notify(msg.message); } - } else if (msg.type === "update" && isAuthed) { - if (msg.ver <= ver) return; // stale: a snapshot already covered it - if (msg.ver > ver + 1) { resync(); return; } // we missed one - if (msg.clientId === clientId) { - // Our own edit, echoed back: the ack. It carries the op as the server actually - // applied it, which is not necessarily the op we sent - it may have been rebased - // onto edits we had not seen when we sent it. - shadow = applyOp(shadow, msg.op); - ver = msg.ver; - inflight = false; - if (shadow !== sent) resync(); // our model of the server was wrong - else flush(); // push whatever was typed since - } else { - // Rebase the remote op past our own uncommitted edits - the one in flight and - // anything typed since - so it lands where the server put it, relative to text - // the server has not seen yet. Nothing else in the document is touched, which is - // the whole reason the caret stays where it is. - const mine = diffOp(shadow, sent), buffered = diffOp(sent, ta.value); - shadow = applyOp(shadow, msg.op); - ver = msg.ver; - // The remote op was ordered before anything of ours that is still local, so it - // comes first in both rebases. - let r = mine ? transform(msg.op, mine, true) : msg.op; - sent = applyOp(sent, r); - if (buffered) r = transform(r, buffered, true); - // setRangeText is the native splice: the spec shifts the selection by the length - // delta for us, and the browser's undo stack survives - both of which assigning - // to .value destroys. It fires no input event, so there is no echo back to the - // server, but that also means the gutter and overlay need refreshing by hand. - ta.setRangeText(r.ins, r.pos, r.pos + r.del, "preserve"); - refresh(); - } - // Divergence tripwire. shadow is exactly what we believe the server holds, so this - // is valid whether or not we have local edits outstanding. - if (shadow.length !== msg.len) resync(); + } 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; refresh(); + ta.selectionStart = adjustCursor(oldText, msg.text, s); + ta.selectionEnd = adjustCursor(oldText, msg.text, e); } else if (msg.type === "peers_changed") { - setPeers(msg.count); - } else if (msg.type === "ping") { - showPing(msg.line); - showJump(msg.line); + 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") { @@ -1119,6 +686,25 @@ async function copyToClipboard() { setTimeout(() => setIcon(btn, "copy"), 1500); } +// Adjust cursor position after a remote text update. +// Finds the single changed region (common prefix + suffix), +// then shifts the cursor accordingly: +// - change is after cursor → no movement +// - change is before cursor → shift by length delta +// - cursor was inside the changed region → place at end of new content +function adjustCursor(oldText, newText, pos) { + let start = 0; + const minLen = Math.min(oldText.length, newText.length); + while (start < minLen && oldText[start] === newText[start]) start++; + if (pos <= start) return pos; // change is entirely after cursor + let oldEnd = oldText.length, newEnd = newText.length; + while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) { + oldEnd--; newEnd--; + } + if (pos >= oldEnd) return pos + (newEnd - oldEnd); // change is before cursor + return newEnd; // cursor was inside changed region +} + connect(); // Handle Tab key to insert 4 spaces instead of navigation @@ -1134,19 +720,20 @@ ta.addEventListener("keydown", (e) => { } }); -// Send edits (coalesced). Short window: the payload is a splice now rather than the -// whole document, and the less local text is sitting unsent, the smaller the window in -// which two people's edits can overlap at all. +// Send edits (debounced) ta.addEventListener("input", () => { + dirty = true; clearTimeout(debounce); - debounce = setTimeout(flush, 30); + debounce = setTimeout(() => { + if (ws?.readyState === 1 && isAuthed) { + ws.send(JSON.stringify({type:"edit", ver, text: ta.value, clientId})); + dirty = false; + } + }, 120); }); """ -# RETENTION_HOURS is fixed at startup, so bake it in once rather than per request. -HTML = HTML.replace("__RETENTION_HOURS__", str(RETENTION_HOURS)) - @app.get("/favicon.ico", include_in_schema=False) def favicon(): return FileResponse("favicon.ico") @@ -1194,7 +781,6 @@ def get_system_info(): - aukpad - System Info