v12: group chat with optional passwords

- /group/<name> URL creates/joins a group (auto-created on first visit)
- / and /chat redirect to /group/lobby (default group)
- Each group has isolated history, clients, and SSE streams
- /setpass <password> sets a password for the current group
- /clearpass removes the password
- Password prompt modal in web UI, stored in sessionStorage
- SSE sends auth-fail event if wrong password, triggers re-prompt
- Group name shown as tag in header
- TCP clients use lobby group by default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 15:33:28 +04:00
parent 087334ffe9
commit c97a3834d1

405
chat.py
View File

@@ -25,7 +25,7 @@ import html
import urllib.parse
PORT = 9999
VERSION = "11"
VERSION = "12"
TUNNEL_TARGETS = {
"parspack": ("185.208.174.152", 22),
"mequ": ("188.213.68.133", 2022),
@@ -36,9 +36,6 @@ MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
# ── Server ──────────────────────────────────────────────────────────────
clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
sse_queues: list[asyncio.Queue] = [] # web clients
history: list[dict] = []
uploaded_files: dict[str, bytes] = {} # file_id -> raw bytes (insertion order)
total_file_bytes = 0
@@ -53,31 +50,52 @@ def store_file(file_id: str, data: bytes):
total_file_bytes -= len(uploaded_files.pop(oldest_id))
class Group:
"""A chat group with its own history, clients, and optional password."""
def __init__(self, name: str):
self.name = name
self.password: str | None = None
self.history: list[dict] = []
self.clients: dict[asyncio.StreamWriter, str] = {} # TCP clients
self.sse_queues: list[asyncio.Queue] = []
async def broadcast(msg: dict):
history.append(msg)
line = json.dumps(msg) + "\n"
async def broadcast(self, msg: dict):
self.history.append(msg)
line = json.dumps(msg) + "\n"
# TCP clients
dead = []
for w in clients:
try:
w.write(line.encode())
await w.drain()
except Exception:
dead.append(w)
for w in dead:
clients.pop(w, None)
dead = []
for w in self.clients:
try:
w.write(line.encode())
await w.drain()
except Exception:
dead.append(w)
for w in dead:
self.clients.pop(w, None)
# SSE web clients
dead_q = []
for q in sse_queues:
try:
q.put_nowait(msg)
except Exception:
dead_q.append(q)
for q in dead_q:
sse_queues.remove(q)
dead_q = []
for q in self.sse_queues:
try:
q.put_nowait(msg)
except Exception:
dead_q.append(q)
for q in dead_q:
self.sse_queues.remove(q)
# All groups keyed by name. Auto-created on first access.
groups: dict[str, Group] = {}
DEFAULT_GROUP = "lobby"
def get_group(name: str) -> Group:
"""Get or create a group by name."""
name = name.lower().strip()
if not name:
name = DEFAULT_GROUP
if name not in groups:
groups[name] = Group(name)
return groups[name]
# ── HTML / JS chat page ────────────────────────────────────────────────
@@ -139,10 +157,23 @@ CHAT_HTML = r"""<!DOCTYPE html>
#install-bar button { background: #e94560; border: none; color: #fff; padding: 6px 16px;
border-radius: 4px; cursor: pointer; margin: 0 4px; }
#install-bar .dismiss { background: transparent; color: #666; }
#pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8);
z-index:100; align-items:center; justify-content:center; }
#pw-overlay.show { display:flex; }
#pw-box { background:#16213e; border:1px solid #444; border-radius:8px; padding:24px;
text-align:center; max-width:300px; width:90%; }
#pw-box h3 { margin-bottom:12px; color:#e0e0e0; }
#pw-box input { width:100%; padding:10px; background:#0f3460; border:1px solid #444;
color:#e0e0e0; border-radius:4px; margin-bottom:10px; font-size:16px; }
#pw-box button { padding:8px 20px; background:#e94560; border:none; color:#fff;
border-radius:4px; cursor:pointer; }
#pw-box .pw-err { color:#e94560; font-size:0.85em; margin-bottom:8px; display:none; }
#group-tag { background:#e94560; color:#fff; padding:2px 8px; border-radius:10px;
font-size:0.75em; margin-left:6px; }
@media (max-width: 500px) {
.msg { font-size: 13px; }
.ts { font-size: 11px; }
#input { font-size: 16px; /* prevents iOS zoom */ }
#input { font-size: 16px; }
}
</style>
</head>
@@ -152,9 +183,18 @@ CHAT_HTML = r"""<!DOCTYPE html>
<button id="install-btn">Install</button>
<button class="dismiss" id="install-dismiss">Later</button>
</div>
<div id="pw-overlay">
<div id="pw-box">
<h3>This group is password protected</h3>
<div class="pw-err" id="pw-err">Wrong password</div>
<input type="password" id="pw-input" placeholder="Enter password…" autocomplete="off">
<button id="pw-btn">Join</button>
</div>
</div>
<div id="header">
<input id="name" placeholder="Name" value="" autocomplete="off">
<span id="header-info">Shift+Enter newline</span>
<span id="group-tag"></span>
<span id="header-info">Shift+Enter newline · /setpass &lt;pw&gt; · /clearpass</span>
</div>
<div id="messages"></div>
<div id="bottom">
@@ -164,13 +204,20 @@ CHAT_HTML = r"""<!DOCTYPE html>
<button id="send">&#9654;</button>
</div>
<script>
const GROUP = '%%GROUP%%';
const HAS_PASSWORD = %%HAS_PASSWORD%%;
const BASE = '/group/' + GROUP;
const $msg = document.getElementById('messages');
const $input = document.getElementById('input');
const $name = document.getElementById('name');
const $send = document.getElementById('send');
const $file = document.getElementById('file-input');
$name.value = 'user' + Math.floor(Math.random() * 1000);
document.getElementById('group-tag').textContent = GROUP;
document.title = GROUP + ' - Chat';
$name.value = localStorage.getItem('chat-name') || ('user' + Math.floor(Math.random() * 1000));
const USER_COLORS = [
'#e6a23c', '#f56c9d', '#67c7eb', '#b39ddb',
@@ -255,6 +302,7 @@ function addMsg(data) {
function send() {
const text = $input.value.trimEnd();
const name = $name.value.trim() || 'anon';
localStorage.setItem('chat-name', name);
if (!text) return;
// Local commands
if (text === '/colors' || text === '/color') {
@@ -263,7 +311,28 @@ function send() {
$input.style.height = 'auto';
return;
}
fetch('/chat/send', {
if (text.startsWith('/setpass ')) {
const pw = text.substring(9).trim();
fetch(BASE + '/setpass', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password=' + encodeURIComponent(pw)
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password set for this group.'}));
$input.value = '';
$input.style.height = 'auto';
return;
}
if (text === '/clearpass') {
fetch(BASE + '/setpass', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'password='
}).then(() => addMsg({ts:Date.now()/1000, user:'***', text:'Password cleared.'}));
$input.value = '';
$input.style.height = 'auto';
return;
}
fetch(BASE + '/send', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(name) + '&text=' + encodeURIComponent(text)
@@ -294,7 +363,7 @@ $file.onchange = function() {
const fd = new FormData();
fd.append('name', name);
fd.append('file', f);
fetch('/chat/upload', { method: 'POST', body: fd });
fetch(BASE + '/upload', { method: 'POST', body: fd });
this.value = '';
};
@@ -333,14 +402,49 @@ window.addEventListener('focus', function() {
document.title = baseTitle;
});
// SSE
const es = new EventSource('/chat/events');
es.onmessage = function(e) {
const data = JSON.parse(e.data);
addMsg(data);
notify(data);
// Password gate
let sessionPass = sessionStorage.getItem('pw-' + GROUP) || '';
function startSSE() {
const url = BASE + '/events' + (sessionPass ? '?password=' + encodeURIComponent(sessionPass) : '');
const es = new EventSource(url);
es.onmessage = function(e) {
// Check for auth error
const data = JSON.parse(e.data);
if (data._auth === 'fail') {
es.close();
showPasswordPrompt();
return;
}
addMsg(data);
notify(data);
};
es.onerror = function() {
addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'});
};
}
function showPasswordPrompt() {
document.getElementById('pw-overlay').classList.add('show');
document.getElementById('pw-input').focus();
}
document.getElementById('pw-btn').onclick = function() {
sessionPass = document.getElementById('pw-input').value;
sessionStorage.setItem('pw-' + GROUP, sessionPass);
document.getElementById('pw-overlay').classList.remove('show');
startSSE();
};
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
document.getElementById('pw-input').onkeydown = function(e) {
if (e.key === 'Enter') document.getElementById('pw-btn').click();
};
// Check password requirement and start
if (HAS_PASSWORD && !sessionPass) {
showPasswordPrompt();
} else {
startSSE();
}
// PWA install prompt
let deferredPrompt = null;
@@ -365,18 +469,10 @@ if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(){});
}
// Mobile keyboard handling: scroll to bottom when keyboard opens
if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
$msg.scrollTop = $msg.scrollHeight;
});
// Save name to localStorage
const savedName = localStorage.getItem('chat-name');
if (savedName) $name.value = savedName;
$name.addEventListener('change', function() {
localStorage.setItem('chat-name', this.value);
});
}
// Mobile
window.visualViewport && window.visualViewport.addEventListener('resize', function() {
$msg.scrollTop = $msg.scrollHeight;
});
</script>
</body>
</html>
@@ -626,86 +722,128 @@ async def handle_http(reader, writer, first_line):
writer.close()
return
# GET / or /chat — serve the web UI
# GET / or /chat → redirect to /group/lobby
if method == "GET" and path in ("/", "/chat", "/chat/"):
resp = CHAT_HTML.encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
writer.write(b"HTTP/1.1 302 Found\r\nLocation: /group/lobby\r\nContent-Length: 0\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /chat/events — SSE stream
if method == "GET" and path == "/chat/events":
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/event-stream\r\n")
writer.write(b"Cache-Control: no-cache\r\n")
writer.write(b"Connection: keep-alive\r\n")
writer.write(b"X-Accel-Buffering: no\r\n")
writer.write(b"Access-Control-Allow-Origin: *\r\n")
writer.write(b"\r\n")
await writer.drain()
# ── Group routes: /group/<name>[/action] ──
if path.startswith("/group/"):
parts = path[7:].strip("/").split("/", 1) # strip "/group/"
group_name = urllib.parse.unquote(parts[0]) if parts[0] else DEFAULT_GROUP
action = parts[1] if len(parts) > 1 else ""
grp = get_group(group_name)
for msg in history:
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
await writer.drain()
q: asyncio.Queue = asyncio.Queue()
sse_queues.append(q)
try:
while True:
msg = await q.get()
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
await writer.drain()
except Exception:
pass
finally:
if q in sse_queues:
sse_queues.remove(q)
# GET /group/<name> — serve the web UI
if method == "GET" and action == "":
has_pw = "true" if grp.password else "false"
resp = CHAT_HTML.replace("%%GROUP%%", group_name).replace("%%HAS_PASSWORD%%", has_pw).encode()
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/html; charset=utf-8\r\n")
writer.write(f"Content-Length: {len(resp)}\r\n".encode())
writer.write(b"\r\n")
writer.write(resp)
await writer.drain()
writer.close()
return
return
# POST /chat/send — send a message (supports multiline via JSON text field)
if method == "POST" and path == "/chat/send":
params = urllib.parse.parse_qs(body.decode())
name = params.get("name", ["anon"])[0]
text = params.get("text", [""])[0].strip()
if text:
await broadcast({"ts": time.time(), "user": name, "text": text})
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /group/<name>/events — SSE stream
if method == "GET" and action.startswith("events"):
# Check password
query = ""
if "?" in action:
query = action.split("?", 1)[1]
qs = urllib.parse.parse_qs(query)
pw = qs.get("password", [""])[0]
if grp.password and pw != grp.password:
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/event-stream\r\n")
writer.write(b"Cache-Control: no-cache\r\n")
writer.write(b"X-Accel-Buffering: no\r\n")
writer.write(b"\r\n")
writer.write(f"data: {json.dumps({'_auth': 'fail'})}\n\n".encode())
await writer.drain()
writer.close()
return
# POST /chat/upload — file upload (multipart/form-data)
if method == "POST" and path == "/chat/upload":
ct = headers.get("content-type", "")
if "multipart/form-data" in ct and "boundary=" in ct:
boundary = ct.split("boundary=")[1].strip()
fields = parse_multipart(body, boundary)
name = fields.get("name", (None, b"anon"))[1]
if isinstance(name, bytes):
name = name.decode()
file_entry = fields.get("file")
if file_entry and file_entry[0]:
filename = file_entry[0]
file_data = file_entry[1]
if len(file_data) <= MAX_FILE_SIZE:
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
store_file(file_id, file_data)
await broadcast({
"ts": time.time(), "user": name,
"text": f"[file: {filename}]",
"file_id": file_id, "filename": filename,
"file_size": len(file_data)
})
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
writer.write(b"HTTP/1.1 200 OK\r\n")
writer.write(b"Content-Type: text/event-stream\r\n")
writer.write(b"Cache-Control: no-cache\r\n")
writer.write(b"Connection: keep-alive\r\n")
writer.write(b"X-Accel-Buffering: no\r\n")
writer.write(b"Access-Control-Allow-Origin: *\r\n")
writer.write(b"\r\n")
await writer.drain()
for msg in grp.history:
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
await writer.drain()
q: asyncio.Queue = asyncio.Queue()
grp.sse_queues.append(q)
try:
while True:
msg = await q.get()
writer.write(f"data: {json.dumps(msg)}\n\n".encode())
await writer.drain()
except Exception:
pass
finally:
if q in grp.sse_queues:
grp.sse_queues.remove(q)
writer.close()
return
# POST /group/<name>/send
if method == "POST" and action == "send":
params = urllib.parse.parse_qs(body.decode())
name = params.get("name", ["anon"])[0]
text = params.get("text", [""])[0].strip()
if text:
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# POST /group/<name>/upload
if method == "POST" and action == "upload":
ct = headers.get("content-type", "")
if "multipart/form-data" in ct and "boundary=" in ct:
boundary = ct.split("boundary=")[1].strip()
fields = parse_multipart(body, boundary)
name = fields.get("name", (None, b"anon"))[1]
if isinstance(name, bytes):
name = name.decode()
file_entry = fields.get("file")
if file_entry and file_entry[0]:
filename = file_entry[0]
file_data = file_entry[1]
if len(file_data) <= MAX_FILE_SIZE:
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
store_file(file_id, file_data)
await grp.broadcast({
"ts": time.time(), "user": name,
"text": f"[file: {filename}]",
"file_id": file_id, "filename": filename,
"file_size": len(file_data)
})
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# POST /group/<name>/setpass
if method == "POST" and action == "setpass":
params = urllib.parse.parse_qs(body.decode(), keep_blank_values=True)
pw = params.get("password", [""])[0].strip()
grp.password = pw if pw else None
writer.write(b"HTTP/1.1 204 No Content\r\n\r\n")
await writer.drain()
writer.close()
return
# GET /files/<id>/<filename> — download uploaded file
if method == "GET" and path.startswith("/files/"):
@@ -779,16 +917,17 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
await handle_http(reader, writer, first_line)
return
# Raw TCP chat client — first line is the name
# Raw TCP chat client — first line is the name (uses lobby group)
name = first_line
clients[writer] = name
grp = get_group(DEFAULT_GROUP)
grp.clients[writer] = name
for msg in history:
for msg in grp.history:
writer.write((json.dumps(msg) + "\n").encode())
await writer.drain()
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
print(f"+ {name} connected ({len(clients)} online)")
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} joined"})
print(f"+ {name} connected ({len(grp.clients)} online in {grp.name})")
try:
while True:
@@ -798,7 +937,6 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
line = data.decode().rstrip("\n")
if not line:
continue
# Try JSON (new protocol: supports multiline + files)
try:
pkt = json.loads(line)
if pkt.get("type") == "file":
@@ -807,7 +945,7 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
continue
file_id = hashlib.sha256(file_data + str(time.time()).encode()).hexdigest()[:16]
store_file(file_id, file_data)
await broadcast({
await grp.broadcast({
"ts": time.time(), "user": name,
"text": f"[file: {pkt['filename']}]",
"file_id": file_id, "filename": pkt["filename"],
@@ -816,17 +954,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
else:
text = pkt.get("text", "").strip()
if text:
await broadcast({"ts": time.time(), "user": name, "text": text})
await grp.broadcast({"ts": time.time(), "user": name, "text": text})
except (json.JSONDecodeError, KeyError):
# Legacy plain text (single line, backwards compat)
if line.strip():
await broadcast({"ts": time.time(), "user": name, "text": line.strip()})
await grp.broadcast({"ts": time.time(), "user": name, "text": line.strip()})
except Exception:
pass
finally:
clients.pop(writer, None)
await broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
print(f"- {name} disconnected ({len(clients)} online)")
grp.clients.pop(writer, None)
await grp.broadcast({"ts": time.time(), "user": "***", "text": f"{name} left"})
print(f"- {name} disconnected ({len(grp.clients)} online in {grp.name})")
writer.close()