ADD password protection via websocket auth #9
This commit is contained in:
parent
31520c90cc
commit
a37e261abe
2 changed files with 267 additions and 56 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
268
app.py
268
app.py
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
@ -128,7 +140,7 @@ HTML = """<!doctype html>
|
||||||
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; }
|
||||||
|
|
@ -142,16 +154,48 @@ HTML = """<!doctype html>
|
||||||
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");
|
||||||
|
|
@ -190,8 +244,82 @@ 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)}`);
|
||||||
|
|
@ -202,12 +330,34 @@ function connect(){
|
||||||
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") {
|
||||||
|
isProtected = !!msg.protected;
|
||||||
|
updateLockBtn();
|
||||||
|
if (isProtected && !isAuthed) {
|
||||||
|
showOverlay();
|
||||||
|
} else {
|
||||||
|
isAuthed = true;
|
||||||
|
hideOverlay();
|
||||||
ver = msg.ver; ta.value = msg.text; updateGutter();
|
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 = () => {
|
||||||
|
|
@ -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,28 +529,36 @@ 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)
|
|
||||||
return PlainTextResponse(rooms[doc_id]["text"])
|
|
||||||
|
|
||||||
# Check cache if not in memory
|
|
||||||
cached_data = get_room_data_from_cache(doc_id)
|
cached_data = get_room_data_from_cache(doc_id)
|
||||||
if cached_data:
|
if cached_data:
|
||||||
# Load into memory for future access
|
|
||||||
rooms[doc_id] = {
|
rooms[doc_id] = {
|
||||||
"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"),
|
||||||
}
|
}
|
||||||
update_room_access_time(doc_id)
|
|
||||||
return PlainTextResponse(cached_data.get("text", ""))
|
|
||||||
|
|
||||||
# Return empty content if pad doesn't exist
|
if doc_id not in rooms:
|
||||||
return PlainTextResponse("")
|
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>")
|
||||||
|
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)
|
||||||
if not room: return
|
if not room: return
|
||||||
|
|
@ -436,20 +595,58 @@ 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", ""))
|
||||||
|
|
||||||
|
|
@ -463,14 +660,27 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue