1361 lines
59 KiB
Python
1361 lines
59 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
|
||
from typing import Optional
|
||
|
||
app = FastAPI()
|
||
application = app # alias if you prefer "application"
|
||
|
||
# Vendored highlight.js ES modules, fetched lazily by the editor and only for pads
|
||
# that actually set a language. Mounted before the catch-all /{doc_id}/ route so
|
||
# pad-id routing cannot swallow it; the cost is that "static" is not a usable pad id.
|
||
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "vendor")),
|
||
name="vendor")
|
||
|
||
# Languages offered in the editor dropdown. This is a security boundary as much as a
|
||
# feature list: the value is interpolated into a module URL client-side, so it must
|
||
# never be free-form. Keep in sync with vendor/fetch.sh.
|
||
LANGS = ("accesslog", "apache", "bash", "cpp", "css", "diff", "dockerfile", "go",
|
||
"graphql", "http", "ini", "java", "javascript", "json", "markdown", "nginx",
|
||
"php", "powershell", "python", "rust", "sql", "typescript", "xml", "yaml")
|
||
|
||
# Environment variables
|
||
USE_VALKEY = os.getenv("USE_VALKEY", "false").lower() == "true"
|
||
VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0")
|
||
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))
|
||
|
||
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 {
|
||
--line-h: 1.4;
|
||
--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;
|
||
}
|
||
[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;
|
||
}
|
||
* { 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; }
|
||
/* Highlight overlay. #t and #hl must render glyph-for-glyph identically: any
|
||
difference in padding, white-space or tab-size shows up as drifting colors. */
|
||
#edit { position:relative; overflow:hidden; }
|
||
#t, #hl { padding:.5rem .75rem; border:0; margin:0; white-space:pre; tab-size:4; }
|
||
#t { position:absolute; inset:0; width:100%; height:100%; resize:none; outline:0;
|
||
overflow:auto; background:var(--bg); color:var(--text); }
|
||
#hl { position:absolute; inset:0; overflow:hidden; pointer-events:none; background:var(--bg); color:var(--text); }
|
||
#hl code { font: inherit; padding:0; background:none; }
|
||
/* Only while highlighting is live; without .hl the editor is byte-identical to
|
||
the plain version, which is the fallback for every failure path. */
|
||
#wrap.hl #t { color:transparent; background:transparent; caret-color:var(--text); }
|
||
#wrap.hl #t::placeholder { color:var(--gutter-color); }
|
||
#wrap.hl #t::selection { background:rgba(59,130,246,.35); }
|
||
pre { margin: 0; }
|
||
/* 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: keep the header on one row by shedding text, not controls. --- */
|
||
@media (max-width: 640px) {
|
||
body { padding:.5rem; }
|
||
/* Hide the word but keep the dot. It is a ::before on #status itself, sized in em,
|
||
so it must be re-declared in rem to survive font-size:0. Doing it this way avoids
|
||
wrapping the label in a child element, which would break the four places where
|
||
JS assigns to $("#status").textContent. */
|
||
#status { font-size:0; }
|
||
#status::before { width:.55rem; height:.55rem; margin-right:0; }
|
||
#lang { max-width:5.5rem; }
|
||
#newpad, #theme-btn { margin-left:.4rem; }
|
||
#notice { padding:.4rem 1.75rem; }
|
||
}
|
||
|
||
/* Below ~450px the controls (5 buttons + dropdown = 281px) leave the pad name too
|
||
little room to be legible - it degrades to "/..". Hide it rather than show a stub:
|
||
it is not interactive and the phone's URL bar already displays it. */
|
||
@media (max-width: 450px) {
|
||
#padname { display:none; }
|
||
}
|
||
|
||
/* --- 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) {
|
||
#gutter, #t, #hl { font-size:16px; } /* all three, or the overlay desyncs */
|
||
#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-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">
|
||
<pre id="hl" aria-hidden="true"><code></code></pre>
|
||
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||
placeholder="Start typing…"></textarea>
|
||
</div>
|
||
<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+"/";
|
||
|
||
let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce;
|
||
let isProtected = false, isAuthed = false;
|
||
// dirty: local edits the server has not acknowledged. Guards the reconnect path
|
||
// from overwriting text typed while the socket was down.
|
||
let dirty = false, reconnectTimer = null, reconnectDelay = 500;
|
||
const urlPw = new URLSearchParams(location.search).get("pw") || "";
|
||
|
||
// --- Line numbers ---
|
||
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);
|
||
function syncScroll() {
|
||
gutter.scrollTop = ta.scrollTop;
|
||
// The overlay needs horizontal sync too: white-space:pre means long lines
|
||
// scroll sideways, and the gutter never does.
|
||
hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft;
|
||
}
|
||
ta.addEventListener("scroll", syncScroll);
|
||
// Also sync on keydown for immediate response
|
||
ta.addEventListener("keydown", () => { setTimeout(syncScroll, 0); });
|
||
|
||
// --- Syntax highlighting ---
|
||
// Vendored highlight.js, fetched only when a pad actually uses a language. A plain
|
||
// pad downloads nothing. Every failure path falls back to the plain textarea.
|
||
const LANGS = ["accesslog","apache","bash","cpp","css","diff","dockerfile","go","graphql",
|
||
"http","ini","java","javascript","json","markdown","nginx","php","powershell",
|
||
"python","rust","sql","typescript","xml","yaml"];
|
||
const LANG_LABELS = {accesslog:"Access log", apache:"Apache", bash:"Bash", cpp:"C / C++",
|
||
css:"CSS", diff:"Diff", dockerfile:"Dockerfile", go:"Go", graphql:"GraphQL", http:"HTTP",
|
||
ini:"INI / TOML", java:"Java", javascript:"JavaScript", json:"JSON", markdown:"Markdown",
|
||
nginx:"nginx", php:"PHP", powershell:"PowerShell", python:"Python", rust:"Rust", sql:"SQL",
|
||
typescript:"TypeScript", xml:"HTML / XML", yaml:"YAML"};
|
||
const HL_MAX = 100000; // above this, highlighting costs more than it is worth
|
||
|
||
const hl = $("#hl"), hlCode = hl.firstElementChild, wrap = $("#wrap"), langSel = $("#lang");
|
||
let curLang = null, hljs = null, hlPending = false;
|
||
const langLoaded = new Set();
|
||
|
||
langSel.append(new Option("Plain", ""));
|
||
for (const l of [...LANGS].sort((a, b) => LANG_LABELS[a].localeCompare(LANG_LABELS[b])))
|
||
langSel.append(new Option(LANG_LABELS[l], l));
|
||
|
||
async function ensureLang(lang) {
|
||
// Allowlist check before the value reaches a URL, not merely for correctness.
|
||
if (!LANGS.includes(lang)) return false;
|
||
if (!hljs) hljs = (await import("/static/hljs/core.min.js")).default;
|
||
if (!langLoaded.has(lang)) {
|
||
const m = await import(`/static/hljs/languages/${lang}.min.js`);
|
||
hljs.registerLanguage(lang, m.default);
|
||
langLoaded.add(lang);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function renderHl() {
|
||
if (!curLang || !hljs || ta.value.length > HL_MAX) { wrap.classList.remove("hl"); return; }
|
||
try {
|
||
// Trailing newline: a <pre> renders one fewer line than the textarea without it.
|
||
hlCode.innerHTML = hljs.highlight(ta.value + "\\n", {language: curLang, ignoreIllegals: true}).value;
|
||
wrap.classList.add("hl");
|
||
syncScroll();
|
||
} catch (e) {
|
||
wrap.classList.remove("hl");
|
||
}
|
||
}
|
||
|
||
async function applyLang(lang) {
|
||
langSel.value = lang || "";
|
||
if (!lang || !(await ensureLang(lang).catch(() => false))) {
|
||
curLang = null; wrap.classList.remove("hl"); hlCode.textContent = ""; return;
|
||
}
|
||
curLang = lang;
|
||
renderHl();
|
||
}
|
||
|
||
langSel.addEventListener("change", () => {
|
||
const lang = langSel.value;
|
||
if (ws?.readyState === 1 && isAuthed) ws.send(JSON.stringify({type: "set_lang", lang}));
|
||
applyLang(lang);
|
||
});
|
||
|
||
// Coalesce highlighting to one render per animation frame. This must NOT be debounced
|
||
// on a timer: while .hl is on the textarea text is transparent, so any delay before the
|
||
// overlay catches up is a delay before the typed character is visible at all.
|
||
function scheduleHl() {
|
||
if (hlPending) return;
|
||
hlPending = true;
|
||
requestAnimationFrame(() => { hlPending = false; renderHl(); });
|
||
}
|
||
|
||
// Re-render gutter and highlighting together.
|
||
function refresh() {
|
||
updateGutter();
|
||
if (ta.value.length > HL_MAX) {
|
||
wrap.classList.remove("hl");
|
||
langSel.disabled = true;
|
||
langSel.title = "Highlighting is off above 100 KB";
|
||
return;
|
||
}
|
||
if (isAuthed) { langSel.disabled = false; langSel.title = "Syntax highlighting for this pad"; }
|
||
scheduleHl();
|
||
}
|
||
|
||
// --- Password panel ---
|
||
function togglePwPanel() {
|
||
$("#pw-panel").classList.toggle("open");
|
||
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();
|
||
ver = msg.ver;
|
||
if (dirty && ta.value !== msg.text) {
|
||
// Reconnected with unsent edits: push them rather than lose them.
|
||
ws.send(JSON.stringify({type: "edit", ver, text: ta.value, clientId}));
|
||
} else {
|
||
ta.value = msg.text;
|
||
}
|
||
refresh();
|
||
applyLang(msg.lang || "");
|
||
// ?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 if (msg.type === "update" && isAuthed && msg.ver > ver && msg.clientId !== clientId) {
|
||
const {selectionStart:s, selectionEnd:e} = ta;
|
||
const oldText = ta.value;
|
||
ta.value = msg.text; ver = msg.ver; refresh();
|
||
ta.selectionStart = adjustCursor(oldText, msg.text, s);
|
||
ta.selectionEnd = adjustCursor(oldText, msg.text, e);
|
||
} else if (msg.type === "peers_changed") {
|
||
setPeers(msg.count);
|
||
} else if (msg.type === "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);
|
||
}
|
||
|
||
// Adjust cursor position after a remote text update.
|
||
// Finds the single changed region (common prefix + suffix),
|
||
// then shifts the cursor accordingly:
|
||
// - change is after cursor → no movement
|
||
// - change is before cursor → shift by length delta
|
||
// - cursor was inside the changed region → place at end of new content
|
||
function adjustCursor(oldText, newText, pos) {
|
||
let start = 0;
|
||
const minLen = Math.min(oldText.length, newText.length);
|
||
while (start < minLen && oldText[start] === newText[start]) start++;
|
||
if (pos <= start) return pos; // change is entirely after cursor
|
||
let oldEnd = oldText.length, newEnd = newText.length;
|
||
while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) {
|
||
oldEnd--; newEnd--;
|
||
}
|
||
if (pos >= oldEnd) return pos + (newEnd - oldEnd); // change is before cursor
|
||
return newEnd; // cursor was inside changed region
|
||
}
|
||
|
||
connect();
|
||
|
||
// Handle Tab key to insert 4 spaces instead of navigation
|
||
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 (debounced)
|
||
ta.addEventListener("input", () => {
|
||
dirty = true;
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(() => {
|
||
if (ws?.readyState === 1 && isAuthed) {
|
||
ws.send(JSON.stringify({type:"edit", ver, text: ta.value, clientId}));
|
||
dirty = false;
|
||
}
|
||
}, 120);
|
||
});
|
||
</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>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"])
|
||
|
||
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
|
||
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({
|
||
"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,
|
||
}))
|
||
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({
|
||
"type": "init", "text": room["text"], "ver": room["ver"], "protected": False,
|
||
"lang": room.get("lang"),
|
||
}))
|
||
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({
|
||
"type": "init", "text": room["text"], "ver": room["ver"], "protected": True,
|
||
"lang": room.get("lang"),
|
||
}))
|
||
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":
|
||
new_text = str(data.get("text", ""))
|
||
|
||
# Check text size limit
|
||
if len(new_text.encode('utf-8')) > MAX_TEXT_SIZE:
|
||
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["ver"] += 1
|
||
room["last_access"] = time.time()
|
||
|
||
# Save to cache
|
||
save_room_data_to_cache(doc_id, room)
|
||
|
||
await _broadcast(doc_id, {
|
||
"type": "update",
|
||
"text": room["text"],
|
||
"ver": room["ver"],
|
||
"clientId": data.get("clientId"),
|
||
}, authed_only=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)
|
||
|
||
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
|
||
|