diff --git a/chat.py b/chat.py index d0ac0b4..b06d1fe 100644 --- a/chat.py +++ b/chat.py @@ -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""" #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; } } @@ -152,9 +183,18 @@ CHAT_HTML = r""" +
+
+

This group is password protected

+
Wrong password
+ + +
+
@@ -164,13 +204,20 @@ CHAT_HTML = r"""
@@ -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/[/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/ — 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//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//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//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//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// — 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()