ADD password protection via websocket auth #9

This commit is contained in:
Caffeine Fueled 2026-02-24 19:37:39 +01:00
parent 31520c90cc
commit a0c8af82b9
Signed by: cf7
GPG key ID: CA295D643074C68C
2 changed files with 267 additions and 56 deletions

View file

@ -27,6 +27,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg
- optional caching with valkey/redis - optional caching with valkey/redis
- pad creation with HTTP post requests with curl (see *Usage*) - pad creation with HTTP post requests with curl (see *Usage*)
- `{pad_id}/raw` HTTP endpoint - `{pad_id}/raw` HTTP endpoint
- optional password protection (HTTP endpoint accessible with GET /{pad_id}/raw?pw=mysecurepasswordhunter2)
**Ideas**: **Ideas**:
[Check out the open feature requests](https://git.uphillsecurity.com/cf7/aukpad/issues?q=&type=all&sort=&state=open&labels=12&milestone=0&project=0&assignee=0&poster=0&archived=false) [Check out the open feature requests](https://git.uphillsecurity.com/cf7/aukpad/issues?q=&type=all&sort=&state=open&labels=12&milestone=0&project=0&assignee=0&poster=0&archived=false)

322
app.py
View file

@ -1,7 +1,7 @@
# aukpad.py # aukpad.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse
import json, secrets, string, time, os, threading, asyncio import json, secrets, string, time, os, threading, asyncio, hashlib
from collections import defaultdict from collections import defaultdict
from typing import Optional from typing import Optional
@ -19,7 +19,7 @@ DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com")
# Valkey/Redis client (initialized later if enabled) # Valkey/Redis client (initialized later if enabled)
redis_client = None redis_client = None
# In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "last_access": float}} # In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "last_access": float, "pw_hash": bytes|None, "pw_salt": bytes|None}}
rooms: dict[str, dict] = {} rooms: dict[str, dict] = {}
# Rate limiting: {ip: [timestamp, timestamp, ...]} # Rate limiting: {ip: [timestamp, timestamp, ...]}
@ -52,16 +52,28 @@ def get_room_data_from_cache(doc_id: str) -> Optional[dict]:
try: try:
data = redis_client.get(f"room:{doc_id}") data = redis_client.get(f"room:{doc_id}")
if data: if data:
return json.loads(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: except Exception as e:
print(f"Cache read error for {doc_id}: {e}") print(f"Cache read error for {doc_id}: {e}")
return None return None
def save_room_data_to_cache(doc_id: str, text: str, ver: int): def save_room_data_to_cache(doc_id: str, room: dict):
if redis_client: if redis_client:
try: try:
data = {"text": text, "ver": ver, "last_access": time.time()} data = {
redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data)) # TTL in seconds "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,
}
redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data))
except Exception as e: except Exception as e:
print(f"Cache write error for {doc_id}: {e}") print(f"Cache write error for {doc_id}: {e}")
@ -125,33 +137,65 @@ HTML = """<!doctype html>
:root { --line-h: 1.4; } :root { --line-h: 1.4; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; margin: 0; padding: 0; background-color: #fbfbfb; } html, body { height: 100%; margin: 0; padding: 0; background-color: #fbfbfb; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji"; 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; box-sizing: border-box; } max-width: 1000px; margin: 0 auto; padding: 1rem; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
header { display:flex; justify-content:space-between; align-items:center; margin-bottom: .5rem; flex-shrink: 0; } header { display:flex; justify-content:space-between; align-items:center; margin-bottom: .5rem; flex-shrink: 0; }
a,button { padding:.35rem .6rem; text-decoration:none; border:1px solid #ddd; border-radius:4px; background:#fff; } a,button { padding:.35rem .6rem; text-decoration:none; border:1px solid #ddd; border-radius:4px; background:#fff; cursor:pointer; }
#newpad { background:#000; color:#fff; border:1px solid #000; font-weight:bold; } #newpad { background:#000; color:#fff; border:1px solid #000; font-weight:bold; }
#status { font-size:.9rem; opacity:.7; margin-left:.5rem; } #status { font-size:.9rem; opacity:.7; margin-left:.5rem; }
#status::before { content: "●"; margin-right: .3rem; color: #ef4444; } #status::before { content: "●"; margin-right: .3rem; color: #ef4444; }
#status.connected::before { color: #22c55e; } #status.connected::before { color: #22c55e; }
#wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid #ddd; border-radius:4px; overflow:hidden; #wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid #ddd; border-radius:4px; overflow:hidden;
flex: 1; } flex: 1; }
#gutter, #t { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } #gutter, #t { 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:#9ca3af; background:#f8fafc; border-right:1px solid #eee; #gutter { padding:.5rem .75rem; text-align:right; color:#9ca3af; background:#f8fafc; border-right:1px solid #eee;
user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; } user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; }
#t { padding:.5rem .75rem; width:100%; height: 100%; resize: none; border:0; outline:0; #t { padding:.5rem .75rem; width:100%; height: 100%; resize: none; border:0; outline:0;
overflow:auto; white-space: pre; } overflow:auto; white-space: pre; }
#newpad { margin-left:.5rem; } #newpad { margin-left:.5rem; }
#info { margin-left:.5rem; color: black; font-size: 0.8rem; } #info { margin-left:.5rem; color: black; font-size: 0.8rem; }
pre {margin: 0; } pre { margin: 0; }
/* Password protection */
#lock-btn.locked { background:#fef3c7; border-color:#f59e0b; }
#pw-panel { display:none; position:absolute; top:2.5rem; right:0; background:#fff; border:1px solid #ddd;
border-radius:6px; padding:.75rem; box-shadow:0 4px 12px rgba(0,0,0,.1); z-index:10; min-width:230px; }
#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 #ddd; border-radius:4px;
margin-bottom:.5rem; font-size:.9rem; font-family:inherit; }
.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:rgba(0,0,0,.55); z-index:100;
align-items:center; justify-content:center; }
#pw-overlay.open { display:flex; }
#pw-box { background:#fff; border-radius:8px; padding:1.5rem; width:300px; }
#pw-box h2 { margin:0 0 .75rem; font-size:1rem; }
#auth-input { width:100%; padding:.45rem .6rem; border:1px solid #ddd; border-radius:4px;
font-size:1rem; margin-bottom:.5rem; font-family:inherit; display:block; }
#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; }
</style> </style>
<header> <header>
<div> <div>
<strong id="padname"></strong><span id="status">disconnected</span> <strong id="padname"></strong><span id="status">disconnected</span>
</div> </div>
<div> <div style="position:relative; display:flex; align-items:center; gap:.25rem;">
<button id="copy" onclick="copyToClipboard()">Copy</button> <button id="copy" onclick="copyToClipboard()">Copy</button>
<button id="lock-btn" onclick="togglePwPanel()" title="No password click to set one">Lock</button>
<a id="newpad" href="/">New pad</a> <a id="newpad" href="/">New pad</a>
<a id="info" href="/system/info">Info</a> <a id="info" href="/system/info">Info</a>
<div id="pw-panel">
<label>Password protection</label>
<input id="pw-input" type="password" placeholder="New password…"/>
<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> </div>
</header> </header>
<div id="wrap"> <div id="wrap">
@ -159,6 +203,15 @@ HTML = """<!doctype html>
<textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" <textarea id="t" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
placeholder="Start typing…"></textarea> placeholder="Start typing…"></textarea>
</div> </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> <script>
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
@ -172,6 +225,7 @@ if (!docId) { location.replace("/" + rand() + "/"); }
$("#padname").textContent = "/"+docId+"/"; $("#padname").textContent = "/"+docId+"/";
let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce; let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce;
let isProtected = false, isAuthed = false;
// --- Line numbers --- // --- Line numbers ---
const ta = $("#t"); const ta = $("#t");
@ -186,31 +240,127 @@ function updateGutter() {
ta.addEventListener("input", updateGutter); ta.addEventListener("input", updateGutter);
ta.addEventListener("scroll", () => { gutter.scrollTop = ta.scrollTop; }); ta.addEventListener("scroll", () => { gutter.scrollTop = ta.scrollTop; });
// Also sync on keydown for immediate response // Also sync on keydown for immediate response
ta.addEventListener("keydown", () => { ta.addEventListener("keydown", () => {
setTimeout(() => { gutter.scrollTop = ta.scrollTop; }, 0); setTimeout(() => { gutter.scrollTop = ta.scrollTop; }, 0);
}); });
// --- 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 chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
const arr = new Uint32Array(10);
crypto.getRandomValues(arr);
const pw = Array.from(arr, n => chars[n % chars.length]).join("");
$("#pw-input").value = pw;
$("#pw-input").type = "text";
$("#pw-msg").textContent = "Copy this password before setting it.";
}
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.textContent = "Locked";
btn.classList.add("locked");
btn.title = "Password protected click to manage";
} else {
btn.textContent = "Lock";
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}));
}
}
// --- WS connect + sync --- // --- WS connect + sync ---
function connect(){ function connect(){
isAuthed = false;
$("#status").textContent = "connecting…"; $("#status").textContent = "connecting…";
$("#status").classList.remove("connected"); $("#status").classList.remove("connected");
ws = new WebSocket(`${proto}://${location.host}/ws/${encodeURIComponent(docId)}`); ws = new WebSocket(`${proto}://${location.host}/ws/${encodeURIComponent(docId)}`);
ws.onopen = () => { ws.onopen = () => {
$("#status").textContent = "connected"; $("#status").textContent = "connected";
$("#status").classList.add("connected"); $("#status").classList.add("connected");
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === "init") { if (msg.type === "init") {
ver = msg.ver; ta.value = msg.text; updateGutter(); isProtected = !!msg.protected;
updateLockBtn();
if (isProtected && !isAuthed) {
showOverlay();
} else {
isAuthed = true;
hideOverlay();
ver = msg.ver; ta.value = msg.text; updateGutter();
}
} 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) {
const errEl = $("#auth-error");
errEl.textContent = msg.message;
errEl.style.display = "block";
}
} else if (msg.type === "update" && msg.ver > ver && msg.clientId !== clientId) { } else if (msg.type === "update" && msg.ver > ver && msg.clientId !== clientId) {
const {selectionStart:s, selectionEnd:e} = ta; const {selectionStart:s, selectionEnd:e} = ta;
ta.value = msg.text; ver = msg.ver; updateGutter(); ta.value = msg.text; ver = msg.ver; updateGutter();
ta.selectionStart = Math.min(s, ta.value.length); ta.selectionStart = Math.min(s, ta.value.length);
ta.selectionEnd = Math.min(e, ta.value.length); ta.selectionEnd = Math.min(e, ta.value.length);
} else if (msg.type === "protected_changed") {
isProtected = msg.protected;
updateLockBtn();
$("#pw-msg").textContent = isProtected ? "Pad is now protected." : "Pad is now unprotected.";
} }
}; };
ws.onclose = () => { ws.onclose = () => {
$("#status").textContent = "disconnected"; $("#status").textContent = "disconnected";
$("#status").classList.remove("connected"); $("#status").classList.remove("connected");
}; };
@ -255,7 +405,7 @@ ta.addEventListener("keydown", (e) => {
ta.addEventListener("input", () => { ta.addEventListener("input", () => {
clearTimeout(debounce); clearTimeout(debounce);
debounce = setTimeout(() => { debounce = setTimeout(() => {
if (ws?.readyState === 1) { if (ws?.readyState === 1 && isAuthed) {
ws.send(JSON.stringify({type:"edit", ver, text: ta.value, clientId})); ws.send(JSON.stringify({type:"edit", ver, text: ta.value, clientId}));
} }
}, 120); }, 120);
@ -362,10 +512,11 @@ async def create_pad_with_content(request: Request):
raise HTTPException(status_code=413, detail=f"Content too large. Max size: {MAX_TEXT_SIZE} bytes") raise HTTPException(status_code=413, detail=f"Content too large. Max size: {MAX_TEXT_SIZE} bytes")
doc_id = random_id() doc_id = random_id()
rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "last_access": time.time()} rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "last_access": time.time(),
"pw_hash": None, "pw_salt": None}
# Save to cache if enabled # Save to cache if enabled
save_room_data_to_cache(doc_id, text_content, 1) save_room_data_to_cache(doc_id, rooms[doc_id])
# Return URL instead of redirect for CLI usage # Return URL instead of redirect for CLI usage
base_url = str(request.base_url).rstrip('/') base_url = str(request.base_url).rstrip('/')
@ -378,27 +529,35 @@ def pad(doc_id: str):
return HTMLResponse(HTML) return HTMLResponse(HTML)
@app.get("/{doc_id}/raw", response_class=PlainTextResponse) @app.get("/{doc_id}/raw", response_class=PlainTextResponse)
def get_raw_pad_content(doc_id: str): def get_raw_pad_content(doc_id: str, pw: str = ""):
# Check in-memory rooms first # Load room into memory if needed
if doc_id in rooms: if doc_id not in rooms:
update_room_access_time(doc_id) cached_data = get_room_data_from_cache(doc_id)
return PlainTextResponse(rooms[doc_id]["text"]) if cached_data:
rooms[doc_id] = {
# Check cache if not in memory "text": cached_data.get("text", ""),
cached_data = get_room_data_from_cache(doc_id) "ver": cached_data.get("ver", 0),
if cached_data: "peers": set(),
# Load into memory for future access "last_access": time.time(),
rooms[doc_id] = { "pw_hash": cached_data.get("pw_hash"),
"text": cached_data.get("text", ""), "pw_salt": cached_data.get("pw_salt"),
"ver": cached_data.get("ver", 0), }
"peers": set(),
"last_access": time.time() if doc_id not in rooms:
} return PlainTextResponse("")
update_room_access_time(doc_id)
return PlainTextResponse(cached_data.get("text", "")) room = rooms[doc_id]
# Return empty content if pad doesn't exist # Enforce password protection
return PlainTextResponse("") if room.get("pw_hash"):
if not pw:
raise HTTPException(status_code=403, detail="This pad is password protected. Use ?pw=<password>")
candidate = hashlib.pbkdf2_hmac("sha256", pw.encode(), room["pw_salt"], 200_000)
if candidate != room["pw_hash"]:
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): async def _broadcast(doc_id: str, message: dict, exclude: WebSocket | None = None):
room = rooms.get(doc_id) room = rooms.get(doc_id)
@ -427,7 +586,7 @@ async def ws(doc_id: str, ws: WebSocket):
await ws.accept() await ws.accept()
connections_per_ip[client_ip] += 1 connections_per_ip[client_ip] += 1
# Try to load room from cache first # Try to load room from cache first
if doc_id not in rooms: if doc_id not in rooms:
cached_data = get_room_data_from_cache(doc_id) cached_data = get_room_data_from_cache(doc_id)
@ -436,41 +595,92 @@ async def ws(doc_id: str, ws: WebSocket):
"text": cached_data.get("text", ""), "text": cached_data.get("text", ""),
"ver": cached_data.get("ver", 0), "ver": cached_data.get("ver", 0),
"peers": set(), "peers": set(),
"last_access": time.time() "last_access": time.time(),
"pw_hash": cached_data.get("pw_hash"),
"pw_salt": cached_data.get("pw_salt"),
} }
room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "last_access": time.time()}) room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "last_access": time.time(),
"pw_hash": None, "pw_salt": None})
room["peers"].add(ws) room["peers"].add(ws)
# Update access time # Update access time
update_room_access_time(doc_id) update_room_access_time(doc_id)
await ws.send_text(json.dumps({"type": "init", "text": room["text"], "ver": room["ver"]})) # Per-connection auth state: already authed if pad has no password
authed = room["pw_hash"] is None
# 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,
}))
try: try:
while True: while True:
msg = await ws.receive_text() msg = await ws.receive_text()
data = json.loads(msg) data = json.loads(msg)
if data.get("type") == "auth":
if room["pw_hash"] is None:
authed = True
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,
}))
else:
candidate = hashlib.pbkdf2_hmac(
"sha256", str(data.get("password", "")).encode(), room["pw_salt"], 200_000
)
if candidate == room["pw_hash"]:
authed = True
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,
}))
else:
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": if data.get("type") == "edit":
new_text = str(data.get("text", "")) new_text = str(data.get("text", ""))
# Check text size limit # Check text size limit
if len(new_text.encode('utf-8')) > MAX_TEXT_SIZE: 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"})) await ws.send_text(json.dumps({"type": "error", "message": f"Text too large. Max size: {MAX_TEXT_SIZE} bytes"}))
continue continue
room["text"] = new_text room["text"] = new_text
room["ver"] += 1 room["ver"] += 1
room["last_access"] = time.time() room["last_access"] = time.time()
# Save to cache # Save to cache
save_room_data_to_cache(doc_id, room["text"], room["ver"]) save_room_data_to_cache(doc_id, room)
await _broadcast(doc_id, { await _broadcast(doc_id, {
"type": "update", "type": "update",
"text": room["text"], "text": room["text"],
"ver": room["ver"], "ver": room["ver"],
"clientId": data.get("clientId") "clientId": data.get("clientId"),
}) })
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, 200_000)
room["pw_salt"] = salt
else:
room["pw_hash"] = None
room["pw_salt"] = None
save_room_data_to_cache(doc_id, room)
await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally: