1757 lines
80 KiB
Python
1757 lines
80 KiB
Python
# 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">×</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 "ping" 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> (1–64 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
|
||
|