aukpad/app.py

1757 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, deque
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")
# 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.
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")
MAX_TEXT_SIZE = int(os.getenv("MAX_TEXT_SIZE", "5")) * 1024 * 1024 # 5MB default
MAX_CONNECTIONS_PER_IP = int(os.getenv("MAX_CONNECTIONS_PER_IP", "10"))
RETENTION_HOURS = int(os.getenv("RETENTION_HOURS", "48")) # Default 48 hours
MAX_ROOMS = int(os.getenv("MAX_ROOMS", "10000"))
TRUST_PROXY = os.getenv("TRUST_PROXY", "false").lower() == "true"
DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com")
ENABLE_METRICS = os.getenv("ENABLE_METRICS", "false").lower() == "true"
METRICS_KEY = os.getenv("METRICS_KEY", "") # empty = no auth on /system/metrics
START_TIME = time.time()
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.
if TRUST_PROXY:
xff = conn.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
xri = conn.headers.get("x-real-ip")
if xri:
return xri.strip()
return conn.client.host if conn.client else "unknown"
# 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, "lang": str|None}}
rooms: dict[str, dict] = {}
# Rate limiting: {ip: [timestamp, timestamp, ...]}
rate_limits: dict[str, list] = defaultdict(list)
# Failed password attempts per IP (for auth brute-force / CPU-DoS limiting)
failed_auth_attempts: dict[str, list] = defaultdict(list)
# Connection tracking: {ip: connection_count}
connections_per_ip: dict[str, int] = defaultdict(int)
# Password hashing parameters
PBKDF2_ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-SHA256
LEGACY_PBKDF2_ITERATIONS = 200_000 # backward-compat for pads hashed before the bump
MAX_AUTH_FAILURES = 10 # failed password attempts allowed per window
AUTH_FAILURE_WINDOW = 60 # seconds
def random_id(n: int = 8) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(n))
def init_valkey():
global redis_client
if USE_VALKEY:
try:
import redis
redis_client = redis.from_url(VALKEY_URL, decode_responses=True)
redis_client.ping() # Test connection
print(f"Valkey/Redis connected: {VALKEY_URL}")
except ImportError as e:
print(f"Warning: redis package import failed ({e}), falling back to memory-only storage")
redis_client = None
except Exception as e:
print(f"Warning: Failed to connect to Valkey/Redis: {e}")
redis_client = None
def get_room_data_from_cache(doc_id: str) -> Optional[dict]:
if redis_client:
try:
data = redis_client.get(f"room:{doc_id}")
if data:
cached = json.loads(data)
# Convert hex strings back to bytes
if cached.get("pw_hash"):
cached["pw_hash"] = bytes.fromhex(cached["pw_hash"])
if cached.get("pw_salt"):
cached["pw_salt"] = bytes.fromhex(cached["pw_salt"])
return cached
except Exception as e:
print(f"Cache read error for {doc_id}: {e}")
return None
def save_room_data_to_cache(doc_id: str, room: dict):
if redis_client:
try:
data = {
"text": room["text"],
"ver": room["ver"],
"last_access": room.get("last_access", time.time()),
"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:
print(f"Cache write error for {doc_id}: {e}")
def update_room_access_time(doc_id: str):
now = time.time()
if doc_id in rooms:
rooms[doc_id]["last_access"] = now
if redis_client:
try:
data = redis_client.get(f"room:{doc_id}")
if data:
room_data = json.loads(data)
room_data["last_access"] = now
redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(room_data)) # Reset TTL
except Exception as e:
print(f"Cache access update error for {doc_id}: {e}")
def cleanup_old_rooms():
while True:
try:
now = time.time()
cutoff = now - (RETENTION_HOURS * 3600) # Convert hours to seconds
# Clean in-memory rooms
to_remove = []
for doc_id, room in rooms.items():
if room.get("last_access", 0) < cutoff and len(room.get("peers", set())) == 0:
to_remove.append(doc_id)
for doc_id in to_remove:
del rooms[doc_id]
print(f"Cleaned up inactive room: {doc_id}")
# Valkey/Redis has TTL, so it cleans up automatically
except Exception as e:
print(f"Cleanup error: {e}")
time.sleep(3600) # Run every hour
def check_rate_limit(client_ip: str) -> bool:
now = time.time()
hour_ago = now - 3600
# Clean old entries
rate_limits[client_ip] = [t for t in rate_limits[client_ip] if t > hour_ago]
# Check limit (50 per hour)
if len(rate_limits[client_ip]) >= 50:
return False
# Add current request
rate_limits[client_ip].append(now)
return True
def check_auth_rate_limit(client_ip: str) -> bool:
now = time.time()
cutoff = now - AUTH_FAILURE_WINDOW
failed_auth_attempts[client_ip] = [t for t in failed_auth_attempts[client_ip] if t > cutoff]
return len(failed_auth_attempts[client_ip]) < MAX_AUTH_FAILURES
def record_auth_failure(client_ip: str):
failed_auth_attempts[client_ip].append(time.time())
HTML = """<!doctype html>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>aukpad</title>
<style>
:root {
/* Integer px, not a ratio. 14px * 1.4 = 19.6px, and a fractional line box makes the
real per-line advance 19.5938 rather than 19.6 - a 4.4px error by line 700 for any
code that computes a line's position, and a source of rounding divergence between
the textarea and the <pre> layers. 20px divides cleanly at 1x/1.25x/1.5x/2x. */
--line-h: 20px;
--bg: #fbfbfb;
--text: #111;
--border: #ddd;
--btn-bg: #fff;
--btn-hover: #f0f0f0;
--gutter-bg: #f8fafc;
--gutter-border: #eee;
--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;
--ping: #ff0000;
--ping-alpha: .5;
}
[data-theme="dark"] {
--bg: #17181E;
--text: #F0F0F0;
--border: #3a3b45;
--btn-bg: #2a2b33;
--btn-hover: #35363f;
--gutter-bg: #1e1f28;
--gutter-border: #2e2f3a;
--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;
--ping: #ff0000;
--ping-alpha: .5;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji";
max-width: 1000px; margin: 0 auto; padding: 1rem; display: flex; flex-direction: column;
height: 100vh; height: 100dvh; /* dvh excludes iOS Safari's dynamic toolbar; 100vh is the fallback */
box-sizing: border-box; }
header { display:flex; justify-content:space-between; align-items:center; gap:.5rem; margin-bottom: .5rem; flex-shrink: 0; }
#padname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
a,button { padding:.35rem 0; text-decoration:none; border:1px solid var(--border); border-radius:4px; background:var(--btn-bg); color:var(--text); cursor:pointer; font-size:.9rem; font-weight:bold; min-width:2rem; text-align:center; display:inline-block; }
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; }
/* Three groups: pad content | new pad | app UI. .25rem flex gap + .75rem = 1rem,
i.e. half of the 2rem icon box. */
#newpad, #theme-btn { margin-left:.75rem; }
.ic { width:1.05rem; height:1.05rem; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
/* Flex row so pad name, status and peer badge share one centre line. Baseline
alignment cannot do this: #peers is an inline-flex box, whose baseline comes
from its own first item rather than from the text beside it. */
#meta { display:flex; align-items:center; gap:.5rem; min-width:0; }
#status { font-size:.9rem; opacity:.7; white-space:nowrap; }
#status::before { content:""; display:inline-block; width:.55em; height:.55em; border-radius:50%; margin-right:.35rem; background:#ef4444; }
#status.connected::before { background:#22c55e; }
/* Reads as a button to match the header, but it is a status badge: no hover, no pointer. */
/* Bare status readout, no chrome. #meta's align-items:center keeps it on the
header centre line, so no vertical-align is needed. */
#peers { display:none; align-items:center; gap:.3rem; flex-shrink:0;
color:#22c55e; font-size:.9rem; font-weight:700; line-height:1.4; }
#peers .ic-sm { width:1.05rem; height:1.05rem; } /* scoped: the notice icon stays .9rem */
#wrap { display:grid; grid-template-columns: max-content 1fr; grid-template-rows: 1fr auto;
border:1px solid var(--border); border-radius:4px; overflow:hidden; flex: 1; }
#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;
cursor:pointer; }
/* 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 owns the background so that #t and #hl can both be transparent. That is what
lets the ping layer sit underneath them and read as a highlight behind the text
rather than a tint over it - and it works in both .hl states, where which of the
two text layers is the visible one flips. */
#edit { position:relative; overflow:hidden; background:var(--bg); }
#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:transparent; color:var(--text); }
#hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:transparent; color:var(--text); }
#hl code { font: inherit; padding:0; background:none; }
/* First child of #edit, so it paints beneath the text layers. */
#pings { position:absolute; inset:0; overflow:hidden; pointer-events:none; }
#pings-in { position:absolute; inset:0; }
.ping { position:absolute; left:0; right:0; background:var(--ping); opacity:var(--ping-alpha);
animation:ping-fade 5s forwards; }
@keyframes ping-fade { 0%, 70% { opacity:var(--ping-alpha); } 100% { opacity:0; } }
@media (prefers-reduced-motion: reduce) { .ping { animation-duration:5s; } }
/* Shown when a ping lands outside the viewport. The one interactive element in the
#edit stack, so unlike #pings it keeps its pointer events. Resets the global
a,button rule, which would otherwise force min-width:2rem and inline-block. */
/* --sbw is the textarea's vertical scrollbar width, kept current by syncExtent();
without it the pill sits on top of the scrollbar. */
#pingjump { display:none; position:absolute; right:calc(.75rem + var(--sbw, 0px)); top:.75rem; z-index:5;
align-items:center; gap:.5rem; padding:.5rem .85rem; min-width:0;
border:1px solid var(--border); border-radius:6px; background:var(--panel-bg);
color:var(--text); box-shadow:0 4px 12px rgba(0,0,0,.1);
font-size:1.05rem; font-weight:600; cursor:pointer; }
/* Solid, not var(--ping-alpha): the bar is translucent because it sits behind text,
but the dot needs to read as the same colour at a glance. */
#pingjump .dot { width:.65rem; height:.65rem; border-radius:50%; background:var(--ping); flex-shrink:0; }
/* Scoped: .ic-sm is shared with the notice bar and the peer count. */
#pingjump .ic-sm { width:1.15rem; height:1.15rem; }
/* A .show class, not the hidden attribute: this id rule would outrank [hidden]. */
#pingjump.show { display:inline-flex; }
#pingjump.up .ic-sm { transform:rotate(180deg); }
/* 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; }
/* Footer bar of the editor rather than loose text under it: it sits inside #wrap,
so it inherits the box's border and rounded corners. */
#notice { grid-column: 1 / -1; position:relative; display:flex; gap:.5rem;
align-items:center; justify-content:center; text-align:center;
margin:0; padding:.4rem 2rem; border-top:1px solid var(--border);
background:var(--gutter-bg); font-size:.75rem; line-height:1.5;
color:var(--text); cursor:pointer; }
.ic-sm { width:.9rem; height:.9rem; flex-shrink:0; }
/* Reset the global a,button rule: this is a bare glyph, not a control.
Absolute so it stays at the right edge without offsetting the centred text. */
#notice button { all:unset; position:absolute; right:.5rem; top:50%; transform:translateY(-50%);
cursor:pointer; padding:0 .2rem; font-size:1rem; line-height:1.1; }
#notice button:focus-visible { outline:2px solid currentColor; border-radius:3px; }
/* 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; }
#pw-panel { display:none; position:absolute; top:2.5rem; right:0; background:var(--panel-bg); border:1px solid var(--border);
border-radius:6px; padding:.75rem; box-shadow:0 4px 12px rgba(0,0,0,.1); z-index:10;
min-width:230px; max-width:calc(100vw - 2rem); }
#pw-panel.open { display:block; }
#pw-panel label { font-size:.8rem; font-weight:bold; display:block; margin-bottom:.4rem; }
#pw-panel input { width:100%; padding:.35rem .5rem; border:1px solid var(--border); border-radius:4px;
margin-bottom:.5rem; font-size:.9rem; font-family:inherit; background:var(--btn-bg); color:var(--text); }
.pw-btns { display:flex; gap:.4rem; }
.pw-btns button { flex:1; font-size:.8rem; padding:.3rem .4rem; }
#pw-msg { font-size:.8rem; margin-top:.4rem; color:#6b7280; min-height:1.2em; }
#pw-overlay { display:none; position:fixed; inset:0; background:var(--overlay-bg); z-index:100;
align-items:center; justify-content:center; }
#pw-overlay.open { display:flex; }
#pw-box { background:var(--panel-bg); border-radius:8px; padding:1.5rem; width:300px; max-width:calc(100vw - 2rem); }
#pw-box h2 { margin:0 0 .75rem; font-size:1rem; }
#auth-input { width:100%; padding:.45rem .6rem; border:1px solid var(--border); border-radius:4px;
font-size:1rem; margin-bottom:.5rem; font-family:inherit; display:block; background:var(--btn-bg); color:var(--text); }
#auth-error { color:#ef4444; font-size:.85rem; margin-bottom:.5rem; display:none; }
#auth-submit { width:100%; padding:.45rem; background:#000; color:#fff; border:none;
border-radius:4px; font-size:.95rem; cursor:pointer; }
/* --- Narrow screens: header stacks into two rows, pad name + status above,
controls below. Giving #meta a 100% flex-basis is what forces the wrap;
header's existing gap:.5rem then doubles as the row gap. Because the
controls get a full row, nothing has to be hidden or narrowed to fit. --- */
@media (max-width: 640px) {
body { padding:.5rem; }
header { flex-wrap:wrap; }
#meta { flex:1 0 100%; }
#newpad, #theme-btn { margin-left:.4rem; }
#notice { padding:.4rem 1.75rem; }
}
/* --- Touch devices: 16px fields, because iOS Safari force-zooms into anything
smaller on focus and never zooms back out. Button sizes are unchanged. --- */
@media (pointer: coarse) {
/* All three together, or the overlay desyncs. 24px keeps the line box integral at
16px the way 20px does at 14px. */
#gutter, #t, #hl { font-size:16px; line-height:24px; }
#pw-panel input { font-size:1rem; }
}
</style>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
</defs></svg>
<header>
<div id="meta">
<strong id="padname"></strong><span id="status">disconnected</span><span id="peers"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-user"/></svg><span id="peer-count"></span></span>
</div>
<div style="position:relative; display:flex; align-items:center; gap:.25rem;">
<!-- Acts on this pad's content -->
<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="lock-btn" class="ibtn" onclick="togglePwPanel()" title="No password click to set one"><svg class="ic"><use href="#i-lock"/></svg></button>
<!-- Leaves this pad -->
<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>
<!-- App-level UI -->
<button id="theme-btn" class="ibtn" onclick="toggleTheme()" title="Toggle dark/light mode" aria-label="Toggle dark/light mode"><svg class="ic"><use href="#i-moon"/></svg></button>
<a id="info" class="ibtn" href="/system/info" title="System info" aria-label="System info"><svg class="ic"><use href="#i-info"/></svg></a>
<div id="pw-panel">
<label>Password protection</label>
<input id="pw-input" type="password" placeholder="New password…" onkeydown="if(event.key==='Enter')setPassword()"/>
<div class="pw-btns">
<button onclick="setPassword()">Set</button>
<button onclick="genPassword()">Generate</button>
<button onclick="removePassword()">Remove</button>
</div>
<div id="pw-msg"></div>
</div>
</div>
</header>
<div id="wrap">
<pre id="gutter">1</pre>
<div id="edit">
<div id="pings" aria-hidden="true"><div id="pings-in"></div></div>
<pre id="hl" aria-hidden="true"><code></code></pre>
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
placeholder="Start typing…"></textarea>
<button id="pingjump" type="button"><span class="dot" aria-hidden="true"></span><span id="pingjump-txt"></span><svg class="ic ic-sm" aria-hidden="true"><use href="#i-chevron"/></svg></button>
</div>
<p id="notice" onclick="hideNotice()" title="Click to dismiss">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-info"/></svg>
<span>Content is deleted after __RETENTION_HOURS__ hours of inactivity and can be accessed
by the server and anyone with the link and optional password.</span>
<button type="button" aria-label="Dismiss notice">&times;</button>
</p>
</div>
<div id="pw-overlay">
<div id="pw-box">
<h2>This pad is password protected</h2>
<input id="auth-input" type="password" placeholder="Enter password…"
onkeydown="if(event.key==='Enter')submitAuth()"/>
<div id="auth-error"></div>
<button id="auth-submit" onclick="submitAuth()">Unlock</button>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
const setIcon = (el, name) => el.querySelector("use").setAttribute("href", "#i-" + name);
const proto = location.protocol === "https:" ? "wss" : "ws";
const rand = () => {
const arr = new Uint8Array(8);
crypto.getRandomValues(arr);
const a = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(arr, b => a[b % 36]).join("");
};
// Theme
function applyTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
setIcon($('#theme-btn'), dark ? 'sun' : 'moon');
}
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
localStorage.setItem('theme', isDark ? 'light' : 'dark');
applyTheme(!isDark);
}
(function() {
const saved = localStorage.getItem('theme');
if (saved) { applyTheme(saved === 'dark'); }
else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark);
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) applyTheme(e.matches);
});
})();
// 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);
}
const urlPw = new URLSearchParams(location.search).get("pw") || "";
// --- Line numbers ---
const ta = $("#t");
const gutter = $("#gutter");
function updateGutter() {
const lines = ta.value.split("\\n").length || 1;
// Build "1\\n2\\n3..."
let s = "";
for (let i=1; i<=lines; i++) s += i + "\\n";
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); });
// --- 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();
syncExtent();
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");
if ($("#pw-panel").classList.contains("open")) $("#pw-input").focus();
}
function setPassword() {
const pw = $("#pw-input").value.trim();
if (!pw) { $("#pw-msg").textContent = "Enter a password first."; return; }
if (ws?.readyState === 1) {
ws.send(JSON.stringify({type: "set_password", password: pw}));
$("#pw-input").value = "";
$("#pw-input").type = "password";
$("#pw-msg").textContent = "Password set.";
}
}
function genPassword() {
const arr = new Uint32Array(1);
crypto.getRandomValues(arr);
const pw = String(arr[0] % 10000).padStart(4, "0");
$("#pw-input").value = pw;
$("#pw-input").type = "text";
$("#pw-msg").textContent = "Copy this password before setting it.";
$("#pw-input").focus();
}
function removePassword() {
if (ws?.readyState === 1) {
ws.send(JSON.stringify({type: "set_password", password: ""}));
$("#pw-msg").textContent = "Password removed.";
}
}
function updateLockBtn() {
const btn = $("#lock-btn");
if (isProtected) {
btn.classList.add("locked");
btn.title = "Password protected click to manage";
} else {
btn.classList.remove("locked");
btn.title = "No password click to set one";
}
}
// Close pw-panel when clicking outside
document.addEventListener("click", (e) => {
const panel = $("#pw-panel");
const btn = $("#lock-btn");
if (panel.classList.contains("open") && !panel.contains(e.target) && e.target !== btn) {
panel.classList.remove("open");
}
});
// --- Password overlay ---
function showOverlay() {
$("#pw-overlay").classList.add("open");
setTimeout(() => $("#auth-input").focus(), 50);
}
function hideOverlay() {
$("#pw-overlay").classList.remove("open");
}
function submitAuth() {
const pw = $("#auth-input").value;
if (ws?.readyState === 1) {
ws.send(JSON.stringify({type: "auth", password: pw}));
}
}
function setPeers(n) {
const el = $("#peers");
$("#peer-count").textContent = n; // never el.textContent: it would wipe the <svg>
el.title = n + (n === 1 ? " peer" : " peers") + " connected";
el.style.display = "inline-flex";
}
// --- WS connect + sync ---
function connect(){
isAuthed = false;
$("#status").textContent = "connecting…";
$("#status").classList.remove("connected");
ws = new WebSocket(`${proto}://${location.host}/ws/${encodeURIComponent(docId)}`);
ws.onopen = () => {
reconnectDelay = 500;
$("#status").textContent = "connected";
$("#status").classList.add("connected");
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "init") {
isProtected = !!msg.protected;
updateLockBtn();
if (msg.peers !== undefined) setPeers(msg.peers);
if (isProtected && !isAuthed) {
if (urlPw) {
ws.send(JSON.stringify({type: "auth", password: urlPw}));
} else {
showOverlay();
}
} 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;
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
// would re-hash the same password and re-broadcast protected_changed.
if (urlPw && !isProtected) {
ws.send(JSON.stringify({type: "set_password", password: urlPw}));
}
}
} else if (msg.type === "auth_ok") {
isAuthed = true;
$("#auth-input").value = "";
// Real init with content follows immediately from server
} else if (msg.type === "error") {
if (!isAuthed) {
showOverlay();
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 === "peers_changed") {
setPeers(msg.count);
} else if (msg.type === "ping") {
showPing(msg.line);
showJump(msg.line);
} else if (msg.type === "lang_changed") {
applyLang(msg.lang || "");
} else if (msg.type === "protected_changed") {
isProtected = msg.protected;
updateLockBtn();
$("#pw-msg").textContent = isProtected ? "Pad is now protected." : "Pad is now unprotected.";
}
};
ws.onclose = () => {
langSel.disabled = true;
$("#status").textContent = "disconnected";
$("#status").classList.remove("connected");
$("#peers").style.display = "none";
scheduleReconnect();
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000); // back off, cap at 10s
}
// Reconnect if the socket died while we were not looking. A pad restored from the
// browser's back/forward cache resumes with its socket already closed and no close
// event delivered, which is why leaving a pad and pressing Back left it dead.
function ensureConnected() {
if (!ws || ws.readyState === 2 || ws.readyState === 3) {
clearTimeout(reconnectTimer); reconnectTimer = null;
reconnectDelay = 500;
connect();
}
}
window.addEventListener("pageshow", (e) => { if (e.persisted) ensureConnected(); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") ensureConnected();
});
window.addEventListener("online", ensureConnected);
$("#newpad").addEventListener("click", (e) => { e.preventDefault(); window.open("/" + rand() + "/", "_blank"); });
// Copy to clipboard function
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(ta.value);
} catch (err) {
// Fallback for older browsers
ta.select();
document.execCommand('copy');
}
const btn = $("#copy");
setIcon(btn, "check");
setTimeout(() => setIcon(btn, "copy"), 1500);
}
connect();
// Handle Tab key to insert 4 spaces instead of navigation
ta.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + 4;
// Trigger input event to update line numbers and send changes
ta.dispatchEvent(new Event('input'));
}
});
// 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.
ta.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(flush, 30);
});
</script>
"""
# 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")
@app.get("/health", include_in_schema=False)
def health():
checks = {"app": "ok"}
healthy = True
# Writable = can we still take new pads, or are we at room capacity?
checks["write"] = "ok" if len(rooms) < MAX_ROOMS else "at capacity"
healthy = checks["write"] == "ok"
# Cache: real round-trip (write, read back, delete) so a half-dead
# connection shows up instead of just "configured".
if not USE_VALKEY:
checks["cache"] = "disabled"
elif redis_client is None:
checks["cache"] = "unavailable"
healthy = False
else:
key = f"health:{random_id()}"
try:
redis_client.setex(key, 10, "ok")
checks["cache"] = "ok" if redis_client.get(key) == "ok" else "readback failed"
redis_client.delete(key)
except Exception as e:
# Type only — the exception text can contain the connection URL.
checks["cache"] = f"error: {type(e).__name__}"
if checks["cache"] != "ok":
healthy = False
return JSONResponse({
"status": "ok" if healthy else "degraded",
"checks": checks,
"rooms": len(rooms),
"uptime_seconds": round(time.time() - START_TIME),
}, status_code=200 if healthy else 503)
@app.get("/system/info", response_class=HTMLResponse)
def get_system_info():
max_text_size_mb = int(os.getenv("MAX_TEXT_SIZE", "5"))
html_content = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>aukpad - System Info</title>
<style>
:root {{
--bg: #fbfbfb;
--text: #111;
--heading: #333;
--section-bg: #f8fafc;
--value-bg: #e5e7eb;
}}
[data-theme="dark"] {{
--bg: #17181E;
--text: #F0F0F0;
--heading: #e0e0e0;
--section-bg: #1e1f28;
--value-bg: #2a2b33;
}}
body {{ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
max-width: 800px; margin: 2rem auto; padding: 1rem; line-height: 1.6;
background-color: var(--bg); color: var(--text); }}
h1, h2 {{ color: var(--heading); }}
.info-section {{ background: var(--section-bg); padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
.info-section ul {{ margin: 0.5rem 0 0; padding-left: 1.25rem; }}
.info-section li {{ margin: 0.35rem 0; }}
.config-item {{ margin: 0.5rem 0; }}
.value, code {{ font-family: monospace; background: var(--value-bg); padding: 0.2rem 0.4rem; border-radius: 4px; }}
code {{ padding: 0.1rem 0.35rem; font-size: 0.9em; }}
.back-link {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 1rem;
background: #000; color: #fff; text-decoration: none; border-radius: 8px; }}
.back-link:hover {{ background: #333; }}
</style>
<script>
(function() {{
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = saved ? saved === 'dark' : prefersDark;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}})();
</script>
</head>
<body>
<h1>System Information</h1>
<div class="info-section">
<h2>Instance</h2>
<p>{DESCRIPTION}</p>
<p>Simple <strong>temporary live collaboration notepad</strong> with websockets and FastAPI — pads expire automatically after a configurable retention period.</p>
</div>
<div class="info-section">
<h2>Features</h2>
<ul>
<li>real-time WebSocket collaboration with cursor preservation across remote edits</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,
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>click a line number to &quot;ping&quot; it — that line flashes for five seconds
for everyone on the pad</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>
</ul>
</div>
<div class="info-section">
<h2>Endpoints</h2>
<ul>
<li>custom pad path <code>{{pad_id}}</code> (164 chars, <code>[a-zA-Z0-9_-]</code>);
auto-generated IDs are 8-char <code>[a-z0-9]</code></li>
<li><code>POST /</code> — create a pad from the request body (curl-friendly)</li>
<li><code>GET /{{pad_id}}/</code> — editor page
(<code>?pw=…</code> sets the password on an unprotected pad, unlocks a protected one)</li>
<li><code>GET /{{pad_id}}/raw</code> — raw text (auth via <code>?pw=…</code> for protected pads)</li>
<li><code>GET /system/info</code> — this page</li>
<li><code>GET /health</code> — JSON health check (200 <code>ok</code> / 503 <code>degraded</code>)</li>
<li><code>GET /system/metrics</code> — Prometheus metrics, disabled by default</li>
<li>WebSocket <code>/ws/{{pad_id}}</code> — live collaboration</li>
</ul>
</div>
<div class="info-section">
<h2>Configuration</h2>
<div class="config-item">
<strong>Valkey/Redis:</strong> <span class="value">{'Enabled' if USE_VALKEY else 'Disabled'}</span>
</div>
<div class="config-item">
<strong>Max text size:</strong> <span class="value">{max_text_size_mb} MB</span>
</div>
<div class="config-item">
<strong>Max connections per IP:</strong> <span class="value">{MAX_CONNECTIONS_PER_IP}</span>
</div>
<div class="config-item">
<strong>Retention time:</strong> <span class="value">{RETENTION_HOURS} hours</span>
</div>
</div>
<div class="info-section">
<h2>Open Source</h2>
<div class="config-item">
<strong>Source Code:</strong> <a href="https://git.uphillsecurity.com/cf7/aukpad" target="_blank" style="color: #0066cc; text-decoration: underline;">https://git.uphillsecurity.com/cf7/aukpad</a>
</div>
<div class="config-item">
<strong>License:</strong> <span class="value">Apache License Version 2.0, January 2004</span>
</div>
<div class="config-item">
<strong>License URL:</strong> <a href="http://www.apache.org/licenses/" target="_blank" style="color: #0066cc; text-decoration: underline;">http://www.apache.org/licenses/</a>
</div>
</div>
</body>
</html>"""
return HTMLResponse(html_content)
def _metric(out: list, name: str, value, help_text: str, labels: str = ""):
out.append(f"# HELP {name} {help_text}")
out.append(f"# TYPE {name} gauge")
out.append(f"{name}{labels} {value}")
def _esc(v: str) -> str:
# Prometheus label values escape backslash, double quote and newline.
return v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
@app.get("/system/metrics", include_in_schema=False)
def metrics(request: Request):
# Off by default: these numbers are usage data, not for public instances.
if not ENABLE_METRICS:
raise HTTPException(status_code=404, detail="Not Found")
if METRICS_KEY:
scheme, _, token = request.headers.get("authorization", "").partition(" ")
# encode() both sides: compare_digest rejects non-ASCII str, and the
# header is attacker-controlled.
if scheme.lower() != "bearer" or not secrets.compare_digest(token.encode(), METRICS_KEY.encode()):
return PlainTextResponse("Unauthorized", status_code=401,
headers={"WWW-Authenticate": "Bearer"})
now = time.time()
# Snapshot: the cleanup thread mutates rooms without a lock.
snapshot = list(rooms.values())
peers = authed = active = protected = versions = 0
total_bytes = max_bytes = 0
idle_max = 0.0
for room in snapshot:
n = len(room.get("peers", ()))
peers += n
authed += len(room.get("authed_peers", ()))
active += 1 if n else 0
protected += 1 if room.get("pw_hash") else 0
versions += room.get("ver", 0)
size = len(room.get("text", "").encode("utf-8"))
total_bytes += size
max_bytes = max(max_bytes, size)
idle_max = max(idle_max, now - room.get("last_access", now))
ip_counts = [c for c in list(connections_per_ip.values()) if c > 0]
hour_ago = now - 3600
auth_cutoff = now - AUTH_FAILURE_WINDOW
# Both dicts prune lazily (only when that IP acts), so filter by age here.
creations = sum(len([t for t in ts if t > hour_ago]) for ts in list(rate_limits.values()))
auth_fails = sum(len([t for t in ts if t > auth_cutoff]) for ts in list(failed_auth_attempts.values()))
out: list[str] = []
_metric(out, "aukpad_rooms", len(snapshot), "Pads currently held in memory")
_metric(out, "aukpad_rooms_active", active, "Pads with at least one connected peer")
_metric(out, "aukpad_rooms_protected", protected, "Pads with a password set")
_metric(out, "aukpad_peers_connected", peers, "Live WebSocket peers across all pads")
_metric(out, "aukpad_peers_authenticated", authed, "Peers that passed pad authentication")
_metric(out, "aukpad_stored_bytes", total_bytes, "Total UTF-8 bytes of all pad text")
_metric(out, "aukpad_pad_bytes_mean", total_bytes // len(snapshot) if snapshot else 0,
"Mean pad size in UTF-8 bytes")
_metric(out, "aukpad_pad_bytes_max", max_bytes, "Largest single pad in UTF-8 bytes")
_metric(out, "aukpad_pad_versions", versions,
"Sum of all pad version counters; drops when pads are evicted")
_metric(out, "aukpad_room_idle_seconds_max", round(idle_max, 1),
"Seconds since last access of the least recently used pad")
_metric(out, "aukpad_client_ips", len(ip_counts), "Distinct IPs holding WebSocket connections")
_metric(out, "aukpad_ip_connections_max", max(ip_counts) if ip_counts else 0,
"Connection count of the busiest single IP")
_metric(out, "aukpad_pads_created_last_hour", creations,
"Pad creations via POST in the last hour, across all IPs")
_metric(out, "aukpad_auth_failures_recent", auth_fails,
f"Failed pad password attempts in the last {AUTH_FAILURE_WINDOW}s")
_metric(out, "aukpad_cache_enabled", 1 if USE_VALKEY else 0, "Valkey/Redis cache configured")
cache_up, cache_keys = 0, None
if redis_client:
try:
redis_client.ping()
cache_up = 1
cache_keys = redis_client.dbsize()
except Exception:
cache_up = 0
_metric(out, "aukpad_cache_up", cache_up, "Valkey/Redis answered PING")
if cache_keys is not None:
_metric(out, "aukpad_cache_keys", cache_keys,
"Keys in the Valkey/Redis database (all keys, if the DB is shared)")
_metric(out, "aukpad_max_rooms", MAX_ROOMS, "Configured pad limit")
_metric(out, "aukpad_max_text_bytes", MAX_TEXT_SIZE, "Configured per-pad size limit in bytes")
_metric(out, "aukpad_max_connections_per_ip", MAX_CONNECTIONS_PER_IP,
"Configured WebSocket connection limit per IP")
_metric(out, "aukpad_retention_seconds", RETENTION_HOURS * 3600, "Configured pad retention")
_metric(out, "aukpad_start_time_seconds", round(START_TIME, 3),
"Unix start time; uptime is time() minus this")
_metric(out, "aukpad_build_info", 1, "Instance description as a label",
labels=f'{{description="{_esc(DESCRIPTION)}"}}')
return PlainTextResponse("\n".join(out) + "\n",
media_type="text/plain; version=0.0.4; charset=utf-8")
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse(url=f"/{random_id()}/", status_code=307)
@app.post("/", include_in_schema=False)
async def create_pad_with_content(request: Request):
# Get client IP
client_ip = get_client_ip(request)
# Check rate limit
if not check_rate_limit(client_ip):
raise HTTPException(status_code=429, detail="Rate limit exceeded. Max 50 requests per hour.")
# Get and validate content
content = await request.body()
if not content:
raise HTTPException(status_code=400, detail="Empty content not allowed")
try:
text_content = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(status_code=400, detail="Content must be valid UTF-8")
# Check for null bytes
if '\x00' in text_content:
raise HTTPException(status_code=400, detail="Null bytes not allowed")
# Check text size limit
if len(text_content.encode('utf-8')) > MAX_TEXT_SIZE:
raise HTTPException(status_code=413, detail=f"Content too large. Max size: {MAX_TEXT_SIZE} bytes")
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,
"lang": None}
# Save to cache if enabled
save_room_data_to_cache(doc_id, rooms[doc_id])
# Return URL instead of redirect for CLI usage
base_url = str(request.base_url).rstrip('/')
return PlainTextResponse(f"{base_url}/{doc_id}/\n")
@app.get("/{doc_id}/", response_class=HTMLResponse)
def pad(doc_id: str):
if not is_valid_doc_id(doc_id):
raise HTTPException(status_code=400, detail="Invalid pad ID")
# Update access time when pad is accessed
update_room_access_time(doc_id)
return HTMLResponse(HTML)
@app.get("/{doc_id}/raw", response_class=PlainTextResponse)
def get_raw_pad_content(doc_id: str, request: Request, pw: str = ""):
if not is_valid_doc_id(doc_id):
raise HTTPException(status_code=400, detail="Invalid pad ID")
# Load room into memory if needed
if doc_id not in rooms:
cached_data = get_room_data_from_cache(doc_id)
if cached_data:
rooms[doc_id] = {
"text": cached_data.get("text", ""),
"ver": cached_data.get("ver", 0),
"peers": set(),
"authed_peers": set(),
"last_access": time.time(),
"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:
return PlainTextResponse("")
room = rooms[doc_id]
# Enforce password protection
if room.get("pw_hash"):
if not pw:
raise HTTPException(status_code=403, detail="This pad is password protected. Use ?pw=<password>")
client_ip = get_client_ip(request)
if not check_auth_rate_limit(client_ip):
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
iters = room.get("pw_iter") or LEGACY_PBKDF2_ITERATIONS
candidate = hashlib.pbkdf2_hmac("sha256", pw.encode(), room["pw_salt"], iters)
if candidate != room["pw_hash"]:
record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Wrong password")
update_room_access_time(doc_id)
return PlainTextResponse(room["text"])
def init_payload(room: dict, authed: bool) -> dict:
"""The full-snapshot message. Used for the first connect, after a successful unlock,
and for every resync, so a client resets its sync state through one code path."""
return {
"type": "init",
"text": room["text"] if authed else "",
"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,
}
async def _broadcast(doc_id: str, message: dict, exclude: WebSocket | None = None, authed_only: bool = False):
room = rooms.get(doc_id)
if not room: return
dead = []
payload = json.dumps(message)
targets = room["authed_peers"] if authed_only else room["peers"]
for peer in list(targets):
if peer is exclude:
continue
try:
await peer.send_text(payload)
except Exception:
dead.append(peer)
for d in dead:
room["peers"].discard(d)
room["authed_peers"].discard(d)
@app.websocket("/ws/{doc_id}")
async def ws(doc_id: str, ws: WebSocket):
# Validate doc_id format
if not is_valid_doc_id(doc_id):
await ws.close(code=1008, reason="Invalid pad ID")
return
# Get client IP for connection limiting
client_ip = get_client_ip(ws)
# Check connection limit per IP
if connections_per_ip[client_ip] >= MAX_CONNECTIONS_PER_IP:
await ws.close(code=1008, reason="Too many connections from this IP")
return
await ws.accept()
connections_per_ip[client_ip] += 1
# Try to load room from cache first
if doc_id not in rooms:
cached_data = get_room_data_from_cache(doc_id)
if cached_data:
rooms[doc_id] = {
"text": cached_data.get("text", ""),
"ver": cached_data.get("ver", 0),
"peers": set(),
"authed_peers": set(),
"last_access": time.time(),
"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
if doc_id not in rooms and len(rooms) >= MAX_ROOMS:
await ws.close(code=1008, reason="Server at capacity")
connections_per_ip[client_ip] = max(0, connections_per_ip[client_ip] - 1)
return
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, "lang": None})
room["peers"].add(ws)
# Update access time
update_room_access_time(doc_id)
# Notify all peers of updated count
await _broadcast(doc_id, {"type": "peers_changed", "count": len(room["peers"])})
# Per-connection auth state: already authed if pad has no password
last_ping = 0.0 # per-connection ping throttle; see the "ping" branch below
authed = room["pw_hash"] is None
if authed:
room["authed_peers"].add(ws)
# Send init; withhold text if protected and not yet authed
await ws.send_text(json.dumps(init_payload(room, authed)))
try:
while True:
msg = await ws.receive_text()
data = json.loads(msg)
if data.get("type") == "auth":
if room["pw_hash"] is None:
authed = True
room["authed_peers"].add(ws)
await ws.send_text(json.dumps({"type": "auth_ok"}))
await ws.send_text(json.dumps(init_payload(room, True)))
else:
if not check_auth_rate_limit(client_ip):
await ws.send_text(json.dumps({"type": "error", "message": "Too many failed attempts. Try again later."}))
continue
iters = room.get("pw_iter") or LEGACY_PBKDF2_ITERATIONS
candidate = hashlib.pbkdf2_hmac(
"sha256", str(data.get("password", "")).encode(), room["pw_salt"], iters
)
if candidate == room["pw_hash"]:
authed = True
room["authed_peers"].add(ws)
await ws.send_text(json.dumps({"type": "auth_ok"}))
await ws.send_text(json.dumps(init_payload(room, True)))
else:
record_auth_failure(client_ip)
await ws.send_text(json.dumps({"type": "error", "message": "Wrong password"}))
continue
if not authed:
await ws.send_text(json.dumps({"type": "error", "message": "Authentication required"}))
continue
if data.get("type") == "edit":
op = parse_op(data.get("op"))
base = data.get("base")
if op is None or isinstance(base, bool) or not isinstance(base, int) or base < 0:
await ws.send_text(json.dumps({"type": "error", "message": "Malformed edit"}))
continue
# Derived state, initialized on the first edit rather than in the three
# places a room gets built. Both are then O(1) to read and to maintain,
# which is what keeps a keystroke off the length of the document.
log = room.setdefault("ops", deque(maxlen=MAX_OP_LOG))
astral = room.setdefault("astral", count_astral(room["text"]))
nbytes = room.setdefault("bytes", len(room["text"].encode("utf-8")))
# Rebase onto everything that landed since the client's base version. If
# the log no longer reaches that far back the client cannot be rebased and
# has to start from a snapshot - the same path a room restored from cache
# takes, since its op history did not survive.
if base < room["ver"] - len(log):
await ws.send_text(json.dumps(init_payload(room, True)))
continue
for v, prev in log:
if v > base:
# The logged op was ordered first, so the incoming one follows it.
op = transform(op, prev, False)
if op[0] + op[1] > len(room["text"]) + astral:
await ws.send_text(json.dumps(init_payload(room, True)))
continue
try:
new_text, removed = splice(room["text"], op, astral > 0)
delta = len(op[2].encode("utf-8")) - len(removed.encode("utf-8"))
except UnicodeEncodeError:
# A clamped op split a surrogate pair, so the result is not storable.
# Rare, and cheaper to resync than to repair.
await ws.send_text(json.dumps(init_payload(room, True)))
continue
if nbytes + delta > MAX_TEXT_SIZE:
# The client rolls its own text back to ours, so the two cannot
# diverge silently the way they used to when this was ignored.
await ws.send_text(json.dumps({"type": "error", "message": f"Text too large. Max size: {MAX_TEXT_SIZE} bytes"}))
continue
room["text"] = new_text
room["bytes"] = nbytes + delta
room["astral"] = astral - count_astral(removed) + count_astral(op[2])
room["ver"] += 1
room["last_access"] = time.time()
log.append((room["ver"], op))
while len(log) > 1 and sum(len(o[2]) for _, o in log) > MAX_OP_LOG_BYTES:
log.popleft()
# Save to cache
save_room_data_to_cache(doc_id, room)
# Goes to everyone including the author: the author recognises its own
# clientId and treats the echo as the ack, which carries the op as it was
# actually applied - rebased, if it arrived stale. `len` is the divergence
# tripwire: a client whose own length disagrees asks for a snapshot.
await _broadcast(doc_id, {
"type": "update",
"op": {"pos": op[0], "del": op[1], "ins": op[2]},
"ver": room["ver"],
"len": len(room["text"]) + room["astral"],
"clientId": data.get("clientId"),
}, authed_only=True)
elif data.get("type") == "resync":
# A client whose shadow no longer matches us asks for a fresh snapshot.
# Unthrottled on purpose: unlike a ping this fans out to nobody, so the
# cost lands on the asker's own connection, same as hitting /raw.
await ws.send_text(json.dumps(init_payload(room, True)))
elif data.get("type") == "set_password":
pw = str(data.get("password", ""))
if pw:
salt = os.urandom(16)
room["pw_hash"] = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, PBKDF2_ITERATIONS)
room["pw_salt"] = salt
room["pw_iter"] = PBKDF2_ITERATIONS
else:
room["pw_hash"] = None
room["pw_salt"] = None
room["pw_iter"] = None
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)
elif data.get("type") == "ping":
# Flash a line for everyone. Stateless: nothing is stored, so there is
# nothing to drift, expire or clean up on disconnect.
now = time.monotonic()
if now - last_ping < PING_MIN_INTERVAL:
continue # one inbound message fans out to every peer - throttle it
last_ping = now
line = data.get("line")
# bool subclasses int, so True would otherwise pass as line 1
if isinstance(line, bool) or not isinstance(line, int):
continue
if not 1 <= line <= room["text"].count("\n") + 1:
continue
await _broadcast(doc_id, {"type": "ping", "line": line}, authed_only=True)
except WebSocketDisconnect:
pass
finally:
room["peers"].discard(ws)
room["authed_peers"].discard(ws)
await _broadcast(doc_id, {"type": "peers_changed", "count": len(room["peers"])})
# Decrement connection count for this IP
connections_per_ip[client_ip] = max(0, connections_per_ip[client_ip] - 1)
# Initialize Valkey/Redis and cleanup thread on startup
@app.on_event("startup")
async def startup_event():
init_valkey()
# Start cleanup thread
cleanup_thread = threading.Thread(target=cleanup_old_rooms, daemon=True)
cleanup_thread.start()
print("Aukpad started with cleanup routine")
# Run locally: uvicorn aukpad:app --reload