diff --git a/README.md b/README.md index b4c9b9c..5c64ea4 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,45 @@ # Transient Chat -> Everything is ephemeral. +> Everything is ephemeral. Encryption first. -**Transient.chat** is a lightweight, in-memory chat platform designed for real-time, impermanent conversation. +**Transient.chat** is a lightweight, encryption-first in-memory chat platform designed for real-time, impermanent conversation. -There are no profiles. No feeds. No likes. -Just temporary rooms, simple handles, message expiration, and live synced watchrooms. +There are no profiles. No feeds. No tracking. No persistent databases. +Just on-demand private encrypted rooms, zero plaintext in server memory, message expiration, and integrated toggleable watch parties. --- ## About -- All messages automatically disappear after **1 hour** -- Empty rooms are purged — nothing is saved -- Users are identified by transient handles, released when inactive -- No databases, no disk writes, no persistence of any kind -- **Watchrooms** allow embedded synced YouTube viewing and conversation -- Works without WebSockets using traditional HTTP polling - -This project is a throwback to early internet chatrooms — lightweight, anonymous, and inherently temporary. - -Perfect for those who miss casual chat with no expectations or archives. +- **Encryption-First:** Client-side zero-knowledge E2EE using AES-256-GCM and PBKDF2 (100,000 iterations). All text and voice messages are encrypted in browser before sending. +- **Encrypted In-Memory Storage:** The server only ever holds ephemeral ciphertext and IVs in memory. No plaintext or raw audio is ever stored in memory or written to disk. +- **On-Demand Private Rooms:** Rooms are created on demand and protected by passwords. +- **Active Server Browser:** The server browser exclusively lists active rooms currently occupied by users. +- **Integrated Watch Party:** Any room can toggle YouTube synchronized playback on and off in real time. +- **Ephemeral Voice Notes:** Record and send encrypted voice messages with optional autoplay. +- **1-Hour Expiration & Room Purge:** Messages expire after 1 hour. When all users leave a room, the room, its password, video state, and messages vanish completely. --- ## Features -- Regional and topical chatroom lists -- Synchronized YouTube playback in "watchparty" mode -- Ephemeral message and session design -- Audio indicators and user join/leave sounds -- Owner moderation (kick users from rooms) -- Admin panel for IP/username bans -- Admin routes protected by environment-based Basic Auth -- No database dependency (runs entirely in volatile memory) +- Zero-knowledge end-to-end encryption without complicated keyring management +- Ephemeral in-memory ciphertext storage (zero plaintext) +- On-demand room creation and password verification +- Active-only server browser +- Integrated YouTube Watch Party toggle in all rooms +- Ephemeral audio notes (WebM / Opus) with optional autoplay +- Room owner moderation (kick users) +- Admin dashboard for IP/username bans and system notifications +- Pure Vanilla CSS and lightweight modern architecture --- ## Deployment -This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)** -as a Python Flask application. +This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)** or any container/PaaS as a Python Flask application. -You should: - -- Point DigitalOcean to this repo -- Specify Python as the runtime -- Set environment variables for your admin login: +Set environment variables for admin login: ```env ADMIN_USERNAME=your_admin_name @@ -62,50 +55,40 @@ transient.chat/ ├── app.py # Flask app factory and launcher ├── config.py # Loads environment variables ├── models/ -│ ├── state.py # In-memory shared state -│ └── cleanup.py # Background cleanup thread +│ ├── state.py # In-memory shared state & password hashing +│ └── cleanup.py # Background cleanup thread for empty rooms & expired msgs ├── blueprints/ # Modular route definitions -│ ├── auth.py -│ ├── chat.py -│ ├── rooms.py -│ ├── admin.py -│ ├── watchparty.py +│ ├── auth.py # Handle registration and token validation +│ ├── chat.py # Encrypted message relay and polling +│ ├── rooms.py # Active rooms browser and password join/create +│ ├── admin.py # Admin dashboard endpoints +│ ├── watchparty.py # YouTube synchronized playback control │ └── static_routes.py ├── static/ -│ ├── index.html -│ ├── chat.html -│ ├── watchparty_chat.html -│ ├── server_list.html -│ ├── faq.html -│ ├── admin.html +│ ├── index.html # Landing page & handle registration +│ ├── chat.html # Unified encrypted chat & watch party interface +│ ├── server_list.html # Active rooms server browser & room creation +│ ├── faq.html # Frequently asked questions & security details +│ ├── admin.html # Admin dashboard interface │ └── assets/ # Frontend resources -│ ├── css/ # Stylesheets -│ │ └── styles.css -│ ├── js/ # JavaScript files -│ │ ├── admin.js -│ │ ├── chat.js -│ │ ├── faq.js +│ ├── css/ # Vanilla CSS stylesheets +│ │ ├── styles.css +│ │ ├── chat.css +│ │ ├── watchparty.css +│ │ └── admin.css +│ ├── js/ # Frontend JavaScript +│ │ ├── crypto.js # Web Crypto API (PBKDF2 + AES-GCM) +│ │ ├── chat.js # Unified encrypted chat & watch party +│ │ ├── server_list.js # Active rooms & password join modal │ │ ├── index.js -│ │ ├── server_list.js -│ │ └── watchparty_chat.js -│ ├── sounds/ # .wav audio clips -│ │ ├── user-in.wav -│ │ ├── user-out.wav -│ │ └── message.wav -│ └── favicon.ico +│ │ └── admin.js +│ ├── sounds/ # Notification audio clips (.wav) +│ └── images/ # Logos and branding └── requirements.txt # Python dependencies ``` --- -## Contributing - -Pull requests and issues are welcome. - -If you'd like to add features, improve styling, or help build out exportable modules, feel free to fork and contribute to the project. - ---- - Nothing is archived. Nothing is retained. **Everything is ephemeral.** \ No newline at end of file diff --git a/blueprints/admin.py b/blueprints/admin.py index 58c7714..f5427eb 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify, Response, send_from_directory from functools import wraps from datetime import datetime, timezone +from typing import Dict, Any, List, Tuple, Callable import os import time @@ -8,6 +9,8 @@ from models.state import ( user_sessions, user_ips, room_users, + room_passwords, + authorized_room_users, messages, banned_ips, banned_usernames, @@ -15,7 +18,6 @@ from models.state import ( video_state, room_owners, nuked_rooms, - public_chat_rooms, ) admin_bp = Blueprint("admin", __name__) @@ -23,21 +25,21 @@ admin_bp = Blueprint("admin", __name__) SERVER_START_TIME = time.time() -def check_auth(username, password): +def check_auth(username: str | None, password: str | None) -> bool: return username == os.getenv("ADMIN_USERNAME") and password == os.getenv( "ADMIN_PASSWORD" ) -def authenticate(): +def authenticate() -> Response: return Response( "Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'} ) -def requires_auth(f): +def requires_auth(f: Callable) -> Callable: @wraps(f) - def decorated(*args, **kwargs): + def decorated(*args: Any, **kwargs: Any) -> Any: auth = request.authorization if not auth or not check_auth(auth.username, auth.password): return authenticate() @@ -48,19 +50,20 @@ def requires_auth(f): @admin_bp.route("/admin") @requires_auth -def admin_panel(): +def admin_panel() -> Response: """Serves the static admin HTML file.""" return send_from_directory("static", "admin.html") @admin_bp.route("/admin/stats", methods=["GET"]) @requires_auth -def admin_stats(): +def admin_stats() -> Response: total_messages = sum(len(msgs) for msgs in messages.values()) + active_room_names = {r for r, u in room_users.items() if len(u) > 0} return jsonify({ "uptime": int(time.time() - SERVER_START_TIME), "active_users": len(user_sessions), - "active_rooms": len(room_users), + "active_rooms": len(active_room_names), "total_messages": total_messages, "banned_count": len(banned_ips) + len(banned_usernames) }) @@ -68,7 +71,7 @@ def admin_stats(): @admin_bp.route("/admin/users", methods=["GET"]) @requires_auth -def admin_users(): +def admin_users() -> Response: return jsonify( [ { @@ -83,26 +86,27 @@ def admin_users(): @admin_bp.route("/admin/rooms", methods=["GET"]) @requires_auth -def admin_rooms(): +def admin_rooms() -> Response: + all_rooms = set(room_users.keys()) | set(room_passwords.keys()) return jsonify( [ { "name": r, "users": list(room_users.get(r, {}).keys()), - "type": "watchparty" if r in video_state else "chat", + "type": "watchparty" if r in video_state and video_state[r].get("url") else "chat", "owner": room_owners.get(r, "System"), "msg_count": len(messages.get(r, [])), - "is_public": r in public_chat_rooms, + "is_public": False, } - for r in list(messages.keys()) + for r in all_rooms ] ) @admin_bp.route("/admin/banip", methods=["POST"]) @requires_auth -def admin_ban_ip(): - data = request.get_json() +def admin_ban_ip() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} username = data.get("username") ip_to_ban = user_ips.get(username) @@ -110,51 +114,52 @@ def admin_ban_ip(): return jsonify({"error": "no ip"}), 400 banned_ips.add(ip_to_ban) - user_sessions.pop(username, None) for room in room_users: room_users[room].pop(username, None) + for room in authorized_room_users: + authorized_room_users[room].discard(username) return jsonify({"banned": ip_to_ban}) @admin_bp.route("/admin/banusername", methods=["POST"]) @requires_auth -def admin_ban_username(): - data = request.get_json() +def admin_ban_username() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} username = data.get("username") if not username: return jsonify({"error": "no username"}), 400 banned_usernames.add(username) - user_sessions.pop(username, None) for room in room_users: room_users[room].pop(username, None) + for room in authorized_room_users: + authorized_room_users[room].discard(username) return jsonify({"banned": username}) @admin_bp.route("/admin/banrawip", methods=["POST"]) @requires_auth -def admin_ban_raw_ip(): - data = request.get_json() +def admin_ban_raw_ip() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} ip = data.get("ip") if not ip: return jsonify({"error": "no ip"}), 400 banned_ips.add(ip) - return jsonify({"banned": ip}) @admin_bp.route("/admin/banned", methods=["GET"]) @requires_auth -def admin_banned_list(): +def admin_banned_list() -> Response: banned = [{"type": "ip", "value": ip} for ip in banned_ips] banned += [{"type": "username", "value": uname} for uname in banned_usernames] return jsonify(banned) @@ -162,8 +167,8 @@ def admin_banned_list(): @admin_bp.route("/admin/releaseuser", methods=["POST"]) @requires_auth -def admin_release_user(): - data = request.get_json() +def admin_release_user() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} username = data.get("username") if not username: @@ -174,26 +179,34 @@ def admin_release_user(): for room in room_users: room_users[room].pop(username, None) + for room in authorized_room_users: + authorized_room_users[room].discard(username) return jsonify({"released": username}) @admin_bp.route("/admin/broadcast", methods=["POST"]) @requires_auth -def admin_broadcast(): - """Sends a system message to all active rooms.""" - data = request.get_json() +def admin_broadcast() -> Tuple[Response, int] | Response: + """Sends a system notice to all active rooms.""" + data: Dict[str, Any] = request.get_json() or {} text = data.get("text") if not text: return jsonify({"error": "no text"}), 400 timestamp = datetime.now(timezone.utc).isoformat() - message = {"username": "SYSTEM", "text": text, "timestamp": timestamp} + message = { + "username": "SYSTEM", + "ciphertext": "", + "iv": "", + "system_notice": text, + "timestamp": timestamp, + } count = 0 - for room in messages: - messages[room].append(message) + for room in list(room_users.keys()): + messages.setdefault(room, []).append(message) count += 1 return jsonify({"status": "sent", "rooms_affected": count}) @@ -201,30 +214,27 @@ def admin_broadcast(): @admin_bp.route("/admin/nukeroom", methods=["POST"]) @requires_auth -def admin_nuke_room(): +def admin_nuke_room() -> Response: """Instantly deletes a room and clears its state.""" - data = request.get_json() + data: Dict[str, Any] = request.get_json() or {} room = data.get("room") - # Lock the room for 10 seconds to force all clients to disconnect nuked_rooms[room] = time.time() + 10 messages.pop(room, None) room_users.pop(room, None) + room_passwords.pop(room, None) + authorized_room_users.pop(room, None) room_owners.pop(room, None) - video_state.pop(room, None) - - if room in public_chat_rooms: - public_chat_rooms.remove(room) return jsonify({"status": "nuked", "room": room}) @admin_bp.route("/admin/unban", methods=["POST"]) @requires_auth -def admin_unban(): - data = request.get_json() +def admin_unban() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} ban_type = data.get("type") value = data.get("value") @@ -243,10 +253,10 @@ def admin_unban(): @admin_bp.route("/admin/room/setowner", methods=["POST"]) @requires_auth -def admin_set_room_owner(): - data = request.get_json() +def admin_set_room_owner() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} room = data.get("room") - owner = data.get("owner") # If None, clears the owner + owner = data.get("owner") if not room: return jsonify({"error": "Missing room name"}), 400 @@ -261,8 +271,8 @@ def admin_set_room_owner(): @admin_bp.route("/admin/kick", methods=["POST"]) @requires_auth -def admin_kick_user(): - data = request.get_json() +def admin_kick_user() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} room = data.get("room") target = data.get("target") @@ -270,14 +280,18 @@ def admin_kick_user(): return jsonify({"error": "Missing room or target"}), 400 kicked_users.setdefault(room, set()).add(target) - room_users.get(room, {}).pop(target, None) + if room in authorized_room_users: + authorized_room_users[room].discard(target) + if room in room_users: + room_users[room].pop(target, None) + return jsonify({"kicked": target, "room": room}) @admin_bp.route("/admin/room/clearmessages", methods=["POST"]) @requires_auth -def admin_clear_room_messages(): - data = request.get_json() +def admin_clear_room_messages() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} room = data.get("room") if not room: @@ -289,9 +303,8 @@ def admin_clear_room_messages(): @admin_bp.route("/admin/room/messages", methods=["GET"]) @requires_auth -def admin_room_messages(): +def admin_room_messages() -> Tuple[Response, int] | Response: room = request.args.get("room") if not room: return jsonify({"error": "Missing room parameter"}), 400 return jsonify(messages.get(room, [])) - diff --git a/blueprints/chat.py b/blueprints/chat.py index 784d71b..788eec5 100644 --- a/blueprints/chat.py +++ b/blueprints/chat.py @@ -1,45 +1,46 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, Response from datetime import datetime, timezone +from typing import Dict, Any, Tuple import time +from blueprints.auth import verify_user_key from models.state import ( messages, room_users, + authorized_room_users, kicked_users, room_owners, banned_ips, banned_usernames, user_ips, + user_sessions, nuked_rooms, ) chat_bp = Blueprint("chat", __name__) -@chat_bp.route("/chat", methods=["GET", "POST"]) -def general_chat(): - if request.method == "POST": - new_message = request.json.get("message") - if new_message: - messages.setdefault("global", []).append( - {"text": new_message, "timestamp": datetime.now(timezone.utc).isoformat()} - ) - if len(messages["global"]) > 100: - messages["global"] = messages["global"][-100:] - return jsonify(messages.get("global", [])) - - @chat_bp.route("/chat/", methods=["GET", "POST"]) -def chat_room(room): +def chat_room(room: str) -> Tuple[Response, int] | Response: room = room.strip() if request.method == "POST": - data = request.json - username = data.get("username") - text = data.get("text") - audio = data.get("audio") + data: Dict[str, Any] = request.get_json() or {} + username: str = str(data.get("username", "")).strip() + key: str = str(data.get("key", "")).strip() + ciphertext: str = str(data.get("ciphertext", "")).strip() + iv: str = str(data.get("iv", "")).strip() user_ip = request.remote_addr + # Verify authentication + if not username or not key or not verify_user_key(username, key): + return jsonify({"error": "Invalid user credentials"}), 403 + + # Verify room authorization + if username not in authorized_room_users.get(room, set()): + return jsonify({"error": "Room password authorization required", "code": "AUTH_REQUIRED"}), 401 + + # Check bans and kicks if ( user_ip in banned_ips or username in banned_usernames @@ -47,23 +48,28 @@ def chat_room(room): ): return jsonify({"error": "banned"}), 403 + if username in kicked_users.get(room, set()): + return jsonify({"error": "kicked"}), 403 + + # Encryption-first enforcement: all messages stored in memory MUST be encrypted + if not ciphertext or not iv: + return jsonify({"error": "Encryption required: ciphertext and IV missing"}), 400 + + # Ephemeral encrypted message in server memory message = { "username": username, - "text": text, - "audio": audio, + "ciphertext": ciphertext, + "iv": iv, "timestamp": datetime.now(timezone.utc).isoformat(), } - if "encrypted" in data: - message["encrypted"] = data["encrypted"] - if "iv" in data: - message["iv"] = data["iv"] - if "keyId" in data: - message["keyId"] = data["keyId"] messages.setdefault(room, []).append(message) if len(messages[room]) > 100: messages[room] = messages[room][-100:] + return jsonify({"status": "sent"}) + + # GET endpoint (optional polling fallback) since = request.args.get("since") room_messages = messages.setdefault(room, []) @@ -77,16 +83,15 @@ def chat_room(room): ] return jsonify(new_msgs) except ValueError: - pass # Ignore bad timestamp + pass return jsonify(room_messages) @chat_bp.route("/chat//users", methods=["GET", "POST"]) -def room_user_list(room): +def room_user_list(room: str) -> Tuple[Response, int] | Response: room = room.strip() - # Check if room is temporarily locked (nuked) if room in nuked_rooms: if time.time() < nuked_rooms[room]: return jsonify({"error": "Room is unavailable"}), 403 @@ -94,56 +99,69 @@ def room_user_list(room): nuked_rooms.pop(room, None) if request.method == "POST": - data = request.json - username = data.get("username") + data: Dict[str, Any] = request.get_json() or {} + username: str = str(data.get("username", "")).strip() + key: str = str(data.get("key", "")).strip() + if not username: return jsonify({"error": "No username"}), 400 + if key and not verify_user_key(username, key): + return jsonify({"error": "Invalid user credentials"}), 403 + + if username not in authorized_room_users.get(room, set()): + return jsonify({"error": "Room authorization required", "code": "AUTH_REQUIRED"}), 401 + if username in kicked_users.get(room, set()): return jsonify({"error": "Kicked from room"}), 403 room_users.setdefault(room, {}) - if room not in room_owners and username not in room_users[room]: - room_owners[room] = username # First user becomes the owner - room_users[room][username] = datetime.now(timezone.utc).isoformat() users = room_users.get(room, {}) - response = {"users": list(users.keys())} + response: Dict[str, Any] = {"users": list(users.keys())} if room in room_owners: response["owner"] = room_owners[room] return jsonify(response) @chat_bp.route("/chat//kick", methods=["POST"]) -def kick_user(room): - data = request.json - owner = data.get("owner") - target = data.get("target") +def kick_user(room: str) -> Tuple[Response, int] | Response: + room = room.strip() + data: Dict[str, Any] = request.get_json() or {} + owner: str = str(data.get("owner", "")).strip() + key: str = str(data.get("key", "")).strip() + target: str = str(data.get("target", "")).strip() if not owner or not target: return jsonify({"error": "Missing owner or target"}), 400 + + if key and not verify_user_key(owner, key): + return jsonify({"error": "Invalid credentials"}), 403 + if room_owners.get(room) != owner: return jsonify({"error": "Not authorized"}), 403 kicked_users.setdefault(room, set()).add(target) - room_users.get(room, {}).pop(target, None) + if room in authorized_room_users: + authorized_room_users[room].discard(target) + if room in room_users: + room_users[room].pop(target, None) + return jsonify({"kicked": target}) @chat_bp.route("/chat//poll", methods=["POST"]) -def poll_room(room): +def poll_room(room: str) -> Tuple[Response, int] | Response: room = room.strip() - data = request.json or {} - username = data.get("username") - key = data.get("key") - since = data.get("since") + data: Dict[str, Any] = request.get_json() or {} + username: str = str(data.get("username", "")).strip() + key: str = str(data.get("key", "")).strip() + since: str = data.get("since") - from blueprints.auth import verify_user_key if not username or not key or not verify_user_key(username, key): return jsonify({"error": "Invalid credentials"}), 403 - # Check if room is temporarily locked (nuked) if room in nuked_rooms: if time.time() < nuked_rooms[room]: return jsonify({"error": "Room is unavailable"}), 403 @@ -153,21 +171,20 @@ def poll_room(room): if username in kicked_users.get(room, set()): return jsonify({"error": "Kicked from room"}), 403 + # Check room authorization + if username not in authorized_room_users.get(room, set()): + return jsonify({"error": "Room authorization required", "code": "AUTH_REQUIRED"}), 401 + # Update user presence room_users.setdefault(room, {}) - if room not in room_owners and username not in room_users[room]: - room_owners[room] = username - room_users[room][username] = datetime.now(timezone.utc).isoformat() - # Restore active user session in-memory if lost on restart - from models.state import user_sessions user_sessions[username] = { "key": key, "last_active": datetime.now(timezone.utc).isoformat(), } - # Fetch messages + # Fetch encrypted messages from memory room_messages = messages.setdefault(room, []) filtered_messages = room_messages if since: @@ -182,12 +199,11 @@ def poll_room(room): pass users_list = room_users.get(room, {}) - response = { + response: Dict[str, Any] = { "messages": filtered_messages, - "users": list(users_list.keys()) + "users": list(users_list.keys()), } if room in room_owners: response["owner"] = room_owners[room] return jsonify(response) - diff --git a/blueprints/rooms.py b/blueprints/rooms.py index a7e5fb3..c780833 100644 --- a/blueprints/rooms.py +++ b/blueprints/rooms.py @@ -1,58 +1,136 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, Response +from datetime import datetime, timezone +from typing import Dict, Any, Tuple +import time + +from blueprints.auth import verify_user_key from models.state import ( room_users, - messages, - public_chat_rooms, - regional_chat_rooms, - topical_chat_rooms, + room_passwords, + authorized_room_users, + room_owners, + kicked_users, + nuked_rooms, + banned_ips, + banned_usernames, + user_ips, video_state, + hash_room_password, + verify_room_password, ) rooms_bp = Blueprint("rooms", __name__) @rooms_bp.route("/rooms", methods=["GET", "POST"]) -def get_or_create_rooms(): +def get_or_create_rooms() -> Tuple[Response, int] | Response: if request.method == "POST": - data = request.get_json() - room_name = data.get("name", "").strip() + data: Dict[str, Any] = request.get_json() or {} + room_name: str = (data.get("name") or data.get("room", "")).strip() + password: str = str(data.get("password", "")).strip() + username: str = str(data.get("username", "")).strip() + key: str = str(data.get("key", "")).strip() - if not room_name: - return jsonify({"error": "Room name required"}), 400 + return handle_room_join_or_create(room_name, password, username, key) - all_rooms = regional_chat_rooms + topical_chat_rooms + public_chat_rooms - if room_name in all_rooms: - return jsonify({"error": "Room already exists"}), 400 + # GET request: Return ONLY active rooms + active_rooms = [] + all_users = set() - public_chat_rooms.append(room_name) - return jsonify({"name": room_name}) - - active_only = request.args.get("activeOnly") == "true" - - def build_room_data(room_list, active=False): - result = [] - for room in room_list: - users = room_users.get(room, {}) - if active and not users: - continue - result.append( + for room, users in list(room_users.items()): + user_count = len(users) + if user_count > 0: + all_users.update(users.keys()) + has_wp = bool( + room in video_state + and (video_state[room].get("is_active") or video_state[room].get("url")) + ) + active_rooms.append( { "name": room, - "users": len(users), - "type": "watchparty" if room in video_state else "chat", + "users": user_count, + "has_watchparty": has_wp, + "type": "watchparty" if has_wp else "chat", } ) - return result - - all_users = set() - for users in room_users.values(): - all_users.update(users.keys()) return jsonify( { - "regional": build_room_data(regional_chat_rooms, active_only), - "topical": build_room_data(topical_chat_rooms, active_only), - "public": build_room_data(public_chat_rooms, active_only), + "active_rooms": active_rooms, "unique_users": len(all_users), + # Maintain backward compatibility aliases + "public": active_rooms, + "regional": [], + "topical": [], + } + ) + + +@rooms_bp.route("/rooms/join", methods=["POST"]) +def join_room() -> Tuple[Response, int] | Response: + data: Dict[str, Any] = request.get_json() or {} + room_name: str = (data.get("room") or data.get("name", "")).strip() + password: str = str(data.get("password", "")).strip() + username: str = str(data.get("username", "")).strip() + key: str = str(data.get("key", "")).strip() + + return handle_room_join_or_create(room_name, password, username, key) + + +def handle_room_join_or_create( + room_name: str, password: str, username: str, key: str +) -> Tuple[Response, int] | Response: + if not room_name: + return jsonify({"error": "Room name is required"}), 400 + + if not password: + return jsonify({"error": "Room password is required for private encryption"}), 400 + + if not username or not key: + return jsonify({"error": "User credentials are required"}), 401 + + if not verify_user_key(username, key): + return jsonify({"error": "Invalid user credentials"}), 403 + + user_ip = request.remote_addr + if ( + user_ip in banned_ips + or username in banned_usernames + or (username in user_ips and user_ips[username] in banned_ips) + ): + return jsonify({"error": "User is banned"}), 403 + + if room_name in nuked_rooms: + if time.time() < nuked_rooms[room_name]: + return jsonify({"error": "Room is currently unavailable"}), 403 + else: + nuked_rooms.pop(room_name, None) + + if username in kicked_users.get(room_name, set()): + return jsonify({"error": "You were kicked from this room"}), 403 + + # On-demand room creation vs existing room verification + if room_name not in room_passwords: + # Create new on-demand encrypted room + room_passwords[room_name] = hash_room_password(password) + room_owners[room_name] = username + else: + # Verify password for existing room + if not verify_room_password(password, room_passwords[room_name]): + return jsonify({"error": "Incorrect room password"}), 401 + + # Authorize user session for this room + authorized_room_users.setdefault(room_name, set()).add(username) + + # Register user presence + room_users.setdefault(room_name, {})[username] = datetime.now(timezone.utc).isoformat() + + return jsonify( + { + "status": "ok", + "name": room_name, + "room": room_name, + "owner": room_owners.get(room_name), + "is_owner": room_owners.get(room_name) == username, } ) diff --git a/blueprints/watchparty.py b/blueprints/watchparty.py index b44bb51..77d00fc 100644 --- a/blueprints/watchparty.py +++ b/blueprints/watchparty.py @@ -1,25 +1,20 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, Response from datetime import datetime, timezone +from typing import Dict, Any, Tuple -from models.state import video_state, public_chat_rooms +from models.state import video_state watchparty_bp = Blueprint("watchparty", __name__) @watchparty_bp.route("/watchparty//video", methods=["GET", "POST"]) -def watchparty_video_control(room): +def watchparty_video_control(room: str) -> Tuple[Response, int] | Response: room = room.strip() - - # Ensure it's tracked as a public room - if room not in public_chat_rooms: - public_chat_rooms.append(room) - - # Ensure video_state storage is initialized video_state.setdefault(room, {}) if request.method == "POST": - data = request.get_json() - url = data.get("url") + data: Dict[str, Any] = request.get_json() or {} + url: str = data.get("url", "").strip() if not url: return jsonify({"error": "No video URL"}), 400 @@ -27,14 +22,15 @@ def watchparty_video_control(room): "url": url, "started_at": datetime.now(timezone.utc).isoformat(), "is_paused": False, + "is_active": True, } - return jsonify({"status": "started"}) + return jsonify({"status": "started", "url": url}) - # Handle GET: return URL + elapsed time - video_data = video_state.get(room) + # GET: return URL, elapsed time, is_paused, is_active + video_data = video_state.get(room, {}) if not video_data or "url" not in video_data: - return jsonify({"error": "No video"}), 404 + return jsonify({"is_active": False, "url": None, "elapsed": 0, "is_paused": False}) try: is_paused = video_data.get("is_paused", False) @@ -43,46 +39,49 @@ def watchparty_video_control(room): else: start_time = datetime.fromisoformat(video_data["started_at"]) elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() + return jsonify( - {"url": video_data["url"], "elapsed": int(elapsed), "is_paused": is_paused} + { + "url": video_data["url"], + "elapsed": max(0, int(elapsed)), + "is_paused": is_paused, + "is_active": video_data.get("is_active", True), + } ) - except ValueError: - return jsonify({"error": "Invalid timestamp"}), 500 + except Exception: + return jsonify({"is_active": False, "url": None, "elapsed": 0, "is_paused": False}) @watchparty_bp.route("/watchparty//video/clear", methods=["POST"]) -def clear_watchparty_video(room): +def clear_watchparty_video(room: str) -> Response: room = room.strip() video_state.pop(room, None) return jsonify({"status": "cleared"}) @watchparty_bp.route("/watchparty//video/pause", methods=["POST"]) -def watchparty_pause_video(room): +def watchparty_pause_video(room: str) -> Response: room = room.strip() state = video_state.get(room) if not state or state.get("is_paused"): return jsonify({"status": "ignored"}) - # Calculate elapsed time and freeze it start_time = datetime.fromisoformat(state["started_at"]) elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() state["is_paused"] = True - state["elapsed_at_pause"] = elapsed + state["elapsed_at_pause"] = max(0, int(elapsed)) return jsonify({"status": "paused"}) @watchparty_bp.route("/watchparty//video/resume", methods=["POST"]) -def watchparty_resume_video(room): +def watchparty_resume_video(room: str) -> Response: room = room.strip() state = video_state.get(room) if not state or not state.get("is_paused"): return jsonify({"status": "ignored"}) - # Adjust started_at so that (now - started_at) == elapsed_at_pause - # This effectively shifts the start time forward by the duration of the pause elapsed = state.get("elapsed_at_pause", 0) new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed @@ -91,3 +90,17 @@ def watchparty_resume_video(room): state.pop("elapsed_at_pause", None) return jsonify({"status": "resumed"}) + + +@watchparty_bp.route("/watchparty//video/toggle", methods=["POST"]) +def watchparty_toggle_video(room: str) -> Response: + room = room.strip() + data: Dict[str, Any] = request.get_json() or {} + state = video_state.setdefault(room, {}) + + if "is_active" in data: + state["is_active"] = bool(data["is_active"]) + else: + state["is_active"] = not state.get("is_active", False) + + return jsonify({"status": "toggled", "is_active": state["is_active"]}) diff --git a/models/cleanup.py b/models/cleanup.py index 8307448..535f19a 100644 --- a/models/cleanup.py +++ b/models/cleanup.py @@ -5,17 +5,23 @@ from datetime import datetime, timedelta, timezone from models.state import ( messages, room_users, - public_chat_rooms, + room_passwords, + authorized_room_users, + room_owners, kicked_users, user_sessions, + user_ips, video_state, ) -def cleanup_loop(): +def cleanup_loop() -> None: + """Background loop that periodically cleans expired messages, inactive users, and empty rooms.""" while True: - # Remove chat messages older than 1 hour - message_cutoff = datetime.now(timezone.utc) - timedelta(hours=1) + now = datetime.now(timezone.utc) + + # 1. Remove chat messages older than 1 hour + message_cutoff = now - timedelta(hours=1) for room, room_messages in list(messages.items()): messages[room] = [ msg @@ -23,8 +29,8 @@ def cleanup_loop(): if datetime.fromisoformat(msg["timestamp"]) > message_cutoff ] - # Remove inactive users from rooms (after 1 min) - inactive_cutoff = datetime.now(timezone.utc) - timedelta(minutes=1) + # 2. Remove inactive users from rooms (after 1 min of silence) + inactive_cutoff = now - timedelta(minutes=1) for room in list(room_users.keys()): room_users[room] = { user: ts @@ -32,31 +38,44 @@ def cleanup_loop(): if datetime.fromisoformat(ts) > inactive_cutoff } - # Remove inactive public rooms - for room in list(public_chat_rooms): - last_active = room_users.get(room, {}).values() - if not last_active or all( - datetime.fromisoformat(ts) < inactive_cutoff for ts in last_active - ): - if room in public_chat_rooms: - public_chat_rooms.remove(room) + # 3. Clean up on-demand rooms that have become empty + # Rooms exist strictly while active; once empty for over 1 minute, everything vanishes. + for room in list(room_users.keys()): + if not room_users[room]: messages.pop(room, None) room_users.pop(room, None) + room_passwords.pop(room, None) + authorized_room_users.pop(room, None) + room_owners.pop(room, None) kicked_users.pop(room, None) video_state.pop(room, None) - # Remove stale user sessions - user_cutoff = datetime.now(timezone.utc) - timedelta(days=3) + # Also purge any dangling rooms in room_passwords that have no users + for room in list(room_passwords.keys()): + if room not in room_users or not room_users[room]: + messages.pop(room, None) + room_users.pop(room, None) + room_passwords.pop(room, None) + authorized_room_users.pop(room, None) + room_owners.pop(room, None) + kicked_users.pop(room, None) + video_state.pop(room, None) + + # 4. Remove stale user sessions (after 3 days) + user_cutoff = now - timedelta(days=3) for user, session in list(user_sessions.items()): if datetime.fromisoformat(session["last_active"]) < user_cutoff: user_sessions.pop(user, None) + user_ips.pop(user, None) - time.sleep(60) # Run every 60 seconds + time.sleep(60) _cleanup_thread_started = False -def start_cleanup_thread(): + +def start_cleanup_thread() -> None: + """Starts daemon thread for ephemeral resource cleanup.""" global _cleanup_thread_started if _cleanup_thread_started: return diff --git a/models/state.py b/models/state.py index 179f759..df98c3f 100644 --- a/models/state.py +++ b/models/state.py @@ -1,118 +1,45 @@ from typing import Dict, List, Set +import hashlib +import secrets # In-Memory Data Stores -# Since this app is ephemeral, we use global dictionaries instead of a database. +# Ephemeral in-memory state: zero persistent database, zero disk logging. user_sessions: Dict[str, dict] = {} # {username: {'key': str, 'last_active': str}} -messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'text': str, 'timestamp': str}]} +messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'ciphertext': str, 'iv': str, 'timestamp': str}]} room_users: Dict[str, Dict[str, str]] = {} # {room_name: {username: last_active_timestamp}} +room_passwords: Dict[str, str] = {} # {room_name: "salt:hash"} - secure room password hashes +authorized_room_users: Dict[str, Set[str]] = {} # {room_name: {username, ...}} - validated sessions for room +room_owners: Dict[str, str] = {} # {room_name: owner_username} +kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}} banned_ips: Set[str] = set() banned_usernames: Set[str] = set() user_ips: Dict[str, str] = {} # {username: ip_address} -kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}} -room_owners: Dict[str, str] = {} # {room_name: owner_username} -public_chat_rooms: List[str] = [] # List of user-created public room names -video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str}} +video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str, 'is_paused': bool, 'is_active': bool}} nuked_rooms: Dict[str, float] = {} # {room_name: expiry_timestamp_epoch} -regional_chat_rooms = [ - "Alabama", - "Alaska", - "Arizona", - "Arkansas", - "California", - "Colorado", - "Connecticut", - "Delaware", - "Florida", - "Georgia", - "Hawaii", - "Idaho", - "Illinois", - "Indiana", - "Iowa", - "Kansas", - "Kentucky", - "Louisiana", - "Maine", - "Maryland", - "Massachusetts", - "Michigan", - "Minnesota", - "Mississippi", - "Missouri", - "Montana", - "Nebraska", - "Nevada", - "New Hampshire", - "New Jersey", - "New Mexico", - "New York", - "North Carolina", - "North Dakota", - "Ohio", - "Oklahoma", - "Oregon", - "Pennsylvania", - "Rhode Island", - "South Carolina", - "South Dakota", - "Tennessee", - "Texas", - "Utah", - "Vermont", - "Virginia", - "Washington", - "West Virginia", - "Wisconsin", - "Wyoming", -] -topical_chat_rooms = [ - "Tech", - "Science", - "Gaming", - "Movies", - "Music", - "Books", - "Current Events", - "Sports", - "Travel", - "Food & Cooking", - "Health & Fitness", - "History", - "Programming", - "Investing", - "Cryptocurrency", - "Parenting", - "DIY & Home Improvement", - "Photography", - "Fashion", - "Automotive", - "Theater & Performing Arts", - "Pets & Animals", - "Space Exploration", - "Climate Change", - "Entrepreneurship", - "Relationships", - "Comics & Manga", - "Anime", - "Board Games", - "Card Games", - "Tabletop RPGs", - "Language Learning", - "Economics", - "Politics", - "Fan Theories", - "Science Fiction", - "Fantasy", - "Artificial Intelligence", - "Web Development", - "Mobile Apps", - "Cybersecurity", - "3D Printing", - "Virtual Reality", - "Augmented Reality", - "Home Automation", - "Sustainable Living", -] +def hash_room_password(password: str) -> str: + """Hashes a room password using PBKDF2-HMAC-SHA256 with a cryptographically secure salt.""" + salt = secrets.token_hex(16) + key_hash = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + 100000, + ) + return f"{salt}:{key_hash.hex()}" + +def verify_room_password(password: str, stored_hash: str) -> bool: + """Verifies a room password against the stored salt:hash in constant time.""" + try: + salt, expected_hash = stored_hash.split(":", 1) + test_hash = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + 100000, + ) + return secrets.compare_digest(test_hash.hex(), expected_hash) + except Exception: + return False diff --git a/static/assets/css/chat.css b/static/assets/css/chat.css index bcd63fc..5acdbcd 100644 --- a/static/assets/css/chat.css +++ b/static/assets/css/chat.css @@ -8,12 +8,71 @@ padding: 0 8px; margin-top: 5px; flex-wrap: nowrap; - gap: 10px; + gap: 12px; height: 100%; flex: 1; height: calc(100vh - 100px); + transition: max-width 0.3s ease; } +body.watchparty-active #page-container { + max-width: 1600px; +} + +/* === Watch Party Video Section === */ +#video-section { + flex: 1.5; + display: flex; + flex-direction: column; + max-width: 800px; + width: 100%; +} + +#video-section.hidden { + display: none !important; +} + +#video-controls { + display: flex; + gap: 8px; + margin-bottom: 10px; + width: 100%; + flex-wrap: wrap; +} + +#video-controls input { + flex: 1; + min-width: 200px; + margin-bottom: 0; +} + +.video-wrapper { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #000; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +#video-iframe { + width: 100%; + height: 100%; + border: none; +} + +#paused-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +/* === Chat Center === */ #chat-center { flex: 1 1 700px; display: flex; @@ -24,6 +83,10 @@ max-height: 100%; } +body.watchparty-active #chat-center { + flex: 1 1 500px; +} + /* === Messages === */ #messages { background-color: #2e2e2e; @@ -42,17 +105,16 @@ #messages .message { line-height: 1.5em; - padding: 4px 10px; + padding: 6px 12px; word-wrap: break-word; background-color: #399fff8a; color: #f0f0f0; border-radius: 10px; - margin-bottom: 4px; + margin-bottom: 6px; max-width: 90%; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); align-self: flex-start; - text-align: left - + text-align: left; } #messages .my-message { @@ -62,6 +124,16 @@ color: #fff; } +#messages .system-message { + align-self: center; + background-color: #4a3810; + border: 1px solid #d4a017; + color: #ffeb99; + text-align: center; + font-size: 0.9em; + max-width: 95%; +} + /* === Chat Input Form === */ #chat-form { display: flex; @@ -73,7 +145,6 @@ #message-input.inputfield { height: 42px; padding: 0 12px; - /* horizontal padding only */ line-height: 1; } @@ -90,7 +161,7 @@ } #user-list-container h3 { - margin-top: 0; + margin-top: 10px; font-size: 16px; } @@ -100,23 +171,30 @@ border-bottom: 1px solid #333; } -#user-list-container > button { - display: block; /* Forces buttons to stack vertically */ - width: fit-content; /* Only as wide as the text, not "full screen" */ - margin-bottom: 4px; +#user-list-container > button, +#user-list-container > a > button { + display: block; + width: 100%; + margin-bottom: 6px; + text-align: left; } #user-list-container #user-list-toggle { display: none; } -@media (max-width: 750px) { +@media (max-width: 950px) { #page-container { flex-direction: column; height: auto; align-items: stretch; } + body.watchparty-active #video-section, + body.watchparty-active #chat-center { + max-width: 100%; + } + #user-list-container { display: block; order: -1; @@ -137,7 +215,7 @@ #user-list-container #user-list-toggle { display: block; - width: 100%; /* The toggle bar should be full width on mobile */ + width: 100%; margin-bottom: 0; border-radius: 7px 7px 0 0; } @@ -183,4 +261,58 @@ .kick-button { margin-left: 0.5em; + padding: 2px 6px; + font-size: 11px; + background-color: #d9534f; +} + +/* === Password Modal Overlay === */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-box { + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 24px 30px; + max-width: 440px; + width: 90%; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); + text-align: center; +} + +.modal-box h3 { + margin-top: 0; + color: #fff; +} + +.modal-box p { + color: #ccc; + font-size: 14px; + margin-bottom: 16px; +} + +.modal-buttons { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 15px; +} + +.cancel-btn { + background-color: #555 !important; +} + +.cancel-btn:hover { + background-color: #666 !important; } \ No newline at end of file diff --git a/static/assets/css/server_list.css b/static/assets/css/server_list.css new file mode 100644 index 0000000..7cbd1c6 --- /dev/null +++ b/static/assets/css/server_list.css @@ -0,0 +1,182 @@ +/* === Server List / Server Browser Styles === */ + +#user-info { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + margin-bottom: 15px; +} + +#user-info .logout-btn { + background-color: #d9534f; + color: white; + border: none; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 14px; + transition: background-color 0.2s ease; +} + +#user-info .logout-btn:hover { + background-color: #c9302c; +} + +#server-browser-container { + width: 100%; + max-width: 650px; + margin-top: 15px; +} + +.stats-badge { + background: #2a2a2a; + border: 1px solid #444; + padding: 10px 16px; + border-radius: 6px; + text-align: center; + margin-bottom: 20px; + font-weight: bold; + color: #79c4ff; +} + +.room-card { + background: #252525; + border: 1px solid #3d3d3d; + border-radius: 6px; + padding: 14px 18px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + transition: border-color 0.2s, background 0.2s; +} + +.room-card:hover { + border-color: #555; + background: #2a2a2a; +} + +.room-info-left { + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; +} + +.room-title-link { + font-size: 1.15em; + font-weight: bold; + color: #fff; + text-decoration: none; + cursor: pointer; + transition: color 0.2s; +} + +.room-title-link:hover { + color: #79c4ff; +} + +.room-badges { + display: flex; + gap: 8px; + font-size: 0.85em; + align-items: center; +} + +.badge-encrypted { + color: #81c784; +} + +.badge-watchparty { + color: #ffb74d; +} + +.empty-state { + text-align: center; + color: #888; + padding: 30px 15px; + font-style: italic; + line-height: 1.6; +} + +.form-card { + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 20px; + margin-top: 25px; +} + +.form-card h2 { + margin-top: 0; + font-size: 1.25em; +} + +.form-subtitle { + font-size: 0.9em; + color: #aaa; + margin-top: -5px; + margin-bottom: 15px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 15px; + font-size: 0.95em; + color: #ccc; + cursor: pointer; +} + +.checkbox-row input { + cursor: pointer; + width: auto; + margin-bottom: 0; +} + +#create-room-btn { + width: 100%; +} + +/* === Password Join Modal === */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-box { + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + padding: 24px 30px; + max-width: 440px; + width: 90%; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); + text-align: center; +} + +.modal-buttons { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 15px; +} + +.cancel-btn { + background-color: #555 !important; +} + +.cancel-btn:hover { + background-color: #666 !important; +} diff --git a/static/assets/js/admin.js b/static/assets/js/admin.js index 95ec274..0065375 100644 --- a/static/assets/js/admin.js +++ b/static/assets/js/admin.js @@ -76,16 +76,14 @@ async function loadRooms() { const titleGroup = document.createElement('div'); const link = document.createElement('a'); - link.href = r.type === 'watchparty' - ? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}` - : `/chat.html?room=${encodeURIComponent(r.name)}`; + link.href = `/chat.html?room=${encodeURIComponent(r.name)}${r.type === 'watchparty' ? '&watchparty=1' : ''}`; link.target = "_blank"; link.textContent = r.name; link.style.fontWeight = 'bold'; link.style.fontSize = '1.1em'; const typeSpan = document.createElement('span'); - typeSpan.textContent = ` (${r.type} | ${r.is_public ? 'Public' : 'Unlisted'})`; + typeSpan.textContent = ` (${r.type} | Encrypted On-Demand)`; typeSpan.style.color = '#888'; typeSpan.style.fontSize = '0.95em'; typeSpan.style.marginLeft = '5px'; @@ -414,7 +412,11 @@ async function viewTranscript(room) { } else { contentDiv.textContent = msgs.map(m => { const timeStr = new Date(m.timestamp).toLocaleTimeString(); - return `[${timeStr}] ${m.username}: ${m.text}`; + if (m.system_notice) { + return `[${timeStr}] SYSTEM: ${m.system_notice}`; + } + const snippet = m.ciphertext ? `${m.ciphertext.substring(0, 32)}...` : 'empty'; + return `[${timeStr}] ${m.username}: [🔒 Encrypted Payload: ${snippet}]`; }).join('\n'); } } diff --git a/static/assets/js/chat.js b/static/assets/js/chat.js index 48404af..f9815cd 100644 --- a/static/assets/js/chat.js +++ b/static/assets/js/chat.js @@ -1,20 +1,25 @@ document.addEventListener("DOMContentLoaded", async () => { const params = new URLSearchParams(window.location.search); - const room = params.get("room") || "Unknown Room"; + const room = params.get("room") || ""; const username = localStorage.getItem("username"); const userKey = localStorage.getItem("userKey"); + const initialWatchPartyRequested = params.has("watchparty"); + let firstLoad = true; let isRefreshing = false; + let currentRoomKey = null; // AES-GCM 256-bit CryptoKey derived from room password + let isRoomOwner = false; + let watchPartyActive = false; if (!room || !username || !userKey) { alert("Missing room or user credentials."); - const url = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; - window.location.href = url; + window.location.href = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; return; } - document.title = room; + document.title = `${room} - Transient.chat`; + // 1. Validate user session try { const res = await fetch('/validate', { method: 'POST', @@ -30,46 +35,12 @@ document.addEventListener("DOMContentLoaded", async () => { } catch (err) { console.error('Validation error:', err); localStorage.clear(); - alert("Validation failed."); - const redirectUrl = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`; - window.location.href = redirectUrl; + alert("Validation error."); + window.location.href = '/'; return; } - // Initialize E2EE Keyring from sessionStorage - const keyringStorageKey = `keyring_${room}`; - let storedKeys = []; - try { - const raw = sessionStorage.getItem(keyringStorageKey); - if (raw) { - storedKeys = JSON.parse(raw); - } - } catch (e) {} - - // Initialize E2EE with stored keys - for (const phrase of storedKeys) { - await window.addKeyToRing(phrase, room); - } - - // Check URL hash for new key - const hash = window.location.hash; - if (hash && hash.startsWith('#key=')) { - const passphrase = decodeURIComponent(hash.substring(5)); - if (passphrase) { - if (!storedKeys.includes(passphrase)) { - storedKeys.push(passphrase); - sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); - } - const keyId = await window.addKeyToRing(passphrase, room); - if (keyId) { - window.setActiveKey(keyId); - } - // Clear URL hash securely - history.replaceState(null, null, window.location.pathname + window.location.search); - } - } - - // 🔒 Security: Escape HTML to prevent XSS + // 🔒 Security: Escape HTML function escapeHtml(text) { if (!text) return text; return text @@ -81,62 +52,82 @@ document.addEventListener("DOMContentLoaded", async () => { } function updateRoomTitle() { - const e2ee = window.isCryptoEnabled && window.isCryptoEnabled(); - document.getElementById("room-title").innerHTML = ` -
- - Transient.chat - - Room: ${escapeHtml(room)} - ${e2ee ? '🔒 E2EE Active' : ''} -
- `; + const roomTitleEl = document.getElementById("room-title"); + if (roomTitleEl) { + roomTitleEl.innerHTML = ` +
+ + Transient.chat + + Room: ${escapeHtml(room)} + 🔒 Private & Encrypted +
+ `; + } } updateRoomTitle(); + // DOM Elements const messagesDiv = document.getElementById("messages"); const form = document.getElementById("chat-form"); const input = document.getElementById("message-input"); const usersDiv = document.getElementById("user-list"); const soundToggleBtn = document.getElementById("sound-toggle"); const externalToggleBtn = document.getElementById("external-toggle"); + const watchpartyToggleBtn = document.getElementById("watchparty-toggle"); + const videoSection = document.getElementById("video-section"); - // 🎤 Voice Message Setup - const micBtn = document.createElement("button"); - micBtn.textContent = "🎤"; - micBtn.type = "button"; - micBtn.className = "mic-button"; - micBtn.title = "Hold to record (max 15s)"; - - // Insert mic button after input (between input and send button) - if (input && input.parentNode) { - input.parentNode.insertBefore(micBtn, input.nextSibling); - } + // Modal elements + const passwordModal = document.getElementById("password-modal"); + const passwordForm = document.getElementById("password-form"); + const passwordInput = document.getElementById("modal-password-input"); - // 🗣️ Autoplay Toggle - const autoplayToggleBtn = document.createElement("button"); - autoplayToggleBtn.id = "autoplay-toggle"; - // Insert after external toggle - if (externalToggleBtn && externalToggleBtn.parentNode) { - externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling); - } - - // Audio setup + // Audio & Sounds setup const sounds = { message: new Audio('/assets/sounds/message.wav'), in: new Audio('/assets/sounds/user-in.wav'), out: new Audio('/assets/sounds/user-out.wav'), }; - // Load saved preferences from localStorage - soundEnabled = localStorage.getItem("soundEnabled") !== "false"; // default true - externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; // default true - let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; // default true + let soundEnabled = localStorage.getItem("soundEnabled") !== "false"; + let externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; + let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off"; soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); + const updateExternalToggleBtn = () => { + externalToggleBtn.textContent = externalContentEnabled + ? "🖥️ External Content: On" + : "🔒 External Content: Off"; + externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled); + }; + updateExternalToggleBtn(); + + soundToggleBtn.addEventListener("click", () => { + soundEnabled = !soundEnabled; + localStorage.setItem("soundEnabled", soundEnabled); + soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off"; + soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); + }); + + externalToggleBtn.addEventListener("click", () => { + externalContentEnabled = !externalContentEnabled; + localStorage.setItem("externalContentEnabled", externalContentEnabled); + updateExternalToggleBtn(); + }); + + // 🗣️ Autoplay Toggle + const autoplayToggleBtn = document.createElement("button"); + autoplayToggleBtn.id = "autoplay-toggle"; + autoplayToggleBtn.style.marginBottom = "10px"; + autoplayToggleBtn.style.textAlign = "left"; + autoplayToggleBtn.style.display = "inline-block"; + if (externalToggleBtn && externalToggleBtn.parentNode) { + externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling); + } + const updateAutoplayToggleBtn = () => { autoplayToggleBtn.textContent = autoplayEnabled ? "🗣️ Autoplay: On" @@ -145,14 +136,21 @@ document.addEventListener("DOMContentLoaded", async () => { }; updateAutoplayToggleBtn(); - const updateExternalToggleBtn = () => { - externalToggleBtn.textContent = externalContentEnabled - ? "🖥️ External Content: On" - : "🔒 External Content: Off"; - externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled); - }; + autoplayToggleBtn.addEventListener("click", () => { + autoplayEnabled = !autoplayEnabled; + localStorage.setItem("autoplayEnabled", autoplayEnabled); + updateAutoplayToggleBtn(); + }); - updateExternalToggleBtn(); // Reset style on load + // 🎤 Voice Message Button Setup + const micBtn = document.createElement("button"); + micBtn.textContent = "🎤"; + micBtn.type = "button"; + micBtn.className = "mic-button"; + micBtn.title = "Hold to record voice message (max 15s)"; + if (input && input.parentNode) { + input.parentNode.insertBefore(micBtn, input.nextSibling); + } // Mobile Toggle for User List & Settings const userListContainer = document.getElementById("user-list-container"); @@ -168,136 +166,298 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - soundToggleBtn.addEventListener("click", () => { - soundEnabled = !soundEnabled; - localStorage.setItem("soundEnabled", soundEnabled); // ✅ Persist - soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off"; - soundToggleBtn.classList.toggle("toggle-off", !soundEnabled); - }); + // 🔐 Room Password & Encryption Key Derivation Logic + const sessionPasswordKey = `room_password_${room}`; - externalToggleBtn.addEventListener("click", () => { - externalContentEnabled = !externalContentEnabled; - localStorage.setItem("externalContentEnabled", externalContentEnabled); // ✅ Persist - updateExternalToggleBtn(); - }); - - // Manage sidebar keyring list UI - const keyringListDiv = document.getElementById("keyring-list"); - const addKeyBtn = document.getElementById("add-key-btn"); - - function renderKeyring() { - if (!keyringListDiv) return; - keyringListDiv.innerHTML = ""; - - const list = window.getKeyring(); - if (list.length === 0) { - keyringListDiv.innerHTML = `No active keys. Messages you send will be plaintext.`; - return; - } - - const activeId = window.getActiveKeyId(); - - list.forEach(item => { - const row = document.createElement("div"); - row.style.display = "flex"; - row.style.alignItems = "center"; - row.style.justifyContent = "space-between"; - row.style.marginBottom = "5px"; - row.style.padding = "4px"; - row.style.border = "1px solid #444"; - row.style.borderRadius = "3px"; - row.style.background = activeId === item.keyId ? "rgba(76, 175, 80, 0.15)" : "transparent"; - - const label = document.createElement("label"); - label.style.display = "flex"; - label.style.alignItems = "center"; - label.style.cursor = "pointer"; - label.style.flex = "1"; - label.style.overflow = "hidden"; - label.style.textOverflow = "ellipsis"; - label.style.whiteSpace = "nowrap"; - - const radio = document.createElement("input"); - radio.type = "radio"; - radio.name = "active-key"; - radio.checked = activeId === item.keyId; - radio.style.marginRight = "5px"; - radio.addEventListener("change", () => { - window.setActiveKey(item.keyId); - renderKeyring(); - updateRoomTitle(); + async function authenticateAndDeriveKey(password) { + try { + const res = await fetch("/rooms/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + room: room, + password: password, + username: username, + key: userKey + }) }); - label.appendChild(radio); - - const textSpan = document.createElement("span"); - textSpan.textContent = `🔑 ${item.passphrase.substring(0, 3)}*** (${item.keyId})`; - textSpan.title = `Passphrase: ${item.passphrase} (Key ID: ${item.keyId})`; - label.appendChild(textSpan); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + return { success: false, error: errData.error || "Room authentication failed" }; + } - row.appendChild(label); + const data = await res.json(); + isRoomOwner = !!data.is_owner; - const delBtn = document.createElement("button"); - delBtn.textContent = "❌"; - delBtn.style.background = "none"; - delBtn.style.border = "none"; - delBtn.style.color = "#ff5722"; - delBtn.style.cursor = "pointer"; - delBtn.style.padding = "0 5px"; - delBtn.title = "Remove key"; - delBtn.onclick = () => { - window.removeKeyFromRing(item.keyId); - storedKeys = storedKeys.filter(k => k !== item.passphrase); - sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); - window.location.reload(); - }; + // Store password in sessionStorage for this browser tab session + sessionStorage.setItem(sessionPasswordKey, password); - row.appendChild(delBtn); - keyringListDiv.appendChild(row); + // Derive 256-bit AES-GCM encryption key using PBKDF2 + currentRoomKey = await window.deriveRoomKey(password, room); + return { success: true }; + } catch (err) { + console.error("Auth error:", err); + return { success: false, error: "Network or server error" }; + } + } + + // Check if password exists in sessionStorage + const storedPassword = sessionStorage.getItem(sessionPasswordKey); + if (storedPassword) { + const result = await authenticateAndDeriveKey(storedPassword); + if (!result.success) { + sessionStorage.removeItem(sessionPasswordKey); + showPasswordModal(); + } else { + startChatSession(); + } + } else { + showPasswordModal(); + } + + function showPasswordModal() { + if (passwordModal) { + passwordModal.classList.remove("hidden"); + if (passwordInput) passwordInput.focus(); + } + } + + function hidePasswordModal() { + if (passwordModal) { + passwordModal.classList.add("hidden"); + } + } + + if (passwordForm) { + passwordForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const enteredPass = passwordInput.value.trim(); + if (!enteredPass) return; + + const submitBtn = document.getElementById("modal-submit-btn"); + if (submitBtn) submitBtn.disabled = true; + + const result = await authenticateAndDeriveKey(enteredPass); + if (submitBtn) submitBtn.disabled = false; + + if (result.success) { + hidePasswordModal(); + startChatSession(); + } else { + alert(result.error || "Incorrect room password."); + passwordInput.value = ""; + passwordInput.focus(); + } }); } - if (addKeyBtn) { - addKeyBtn.onclick = async () => { - const phrase = prompt("Enter a passphrase to add to your E2EE keyring for this room:"); - if (phrase) { - const trimmed = phrase.trim(); - if (trimmed) { - if (!storedKeys.includes(trimmed)) { - storedKeys.push(trimmed); - sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); - } - const keyId = await window.addKeyToRing(trimmed, room); - if (keyId) { - window.setActiveKey(keyId); - } - window.location.reload(); - } - } - }; + // 🎬 Watch Party State & Video Synchronization + const videoControls = document.getElementById("video-controls"); + const videoInput = document.getElementById("video-url-input"); + const videoButton = document.getElementById("start-video-button"); + const pauseButton = document.getElementById("pause-video-button"); + const resumeButton = document.getElementById("resume-video-button"); + const clearButton = document.getElementById("clear-video-button"); + const iframe = document.getElementById("video-iframe"); + const pausedOverlay = document.getElementById("paused-overlay"); + + let lastVideoId = null; + + function extractYouTubeId(url) { + if (!url) return null; + const match = url.match(/(?:youtube\.com\/.*v=|youtu\.be\/)([\w-]{11})/); + return match ? match[1] : null; } - renderKeyring(); + function loadYouTubeVideo(videoId, startSeconds = 0) { + if (lastVideoId === videoId && !iframe.classList.contains("hidden")) return; + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&start=${startSeconds}`; + iframe.classList.remove("hidden"); + lastVideoId = videoId; + } - autoplayToggleBtn.addEventListener("click", () => { - autoplayEnabled = !autoplayEnabled; - localStorage.setItem("autoplayEnabled", autoplayEnabled); - updateAutoplayToggleBtn(); - }); + function clearVideoUI() { + if (iframe) { + iframe.src = ""; + iframe.classList.add("hidden"); + } + lastVideoId = null; + if (pausedOverlay) pausedOverlay.classList.add("hidden"); + updateVideoControlsVisibility(false, false); + } - // Recording Logic + function updateVideoControlsVisibility(hasVideo, isPaused) { + if (!videoControls) return; + if (isRoomOwner) { + videoControls.classList.remove("hidden"); + if (hasVideo) { + if (videoInput) videoInput.classList.add("hidden"); + if (videoButton) videoButton.classList.add("hidden"); + if (clearButton) clearButton.classList.remove("hidden"); + + if (isPaused) { + if (pauseButton) pauseButton.classList.add("hidden"); + if (resumeButton) resumeButton.classList.remove("hidden"); + } else { + if (pauseButton) pauseButton.classList.remove("hidden"); + if (resumeButton) resumeButton.classList.add("hidden"); + } + } else { + if (videoInput) videoInput.classList.remove("hidden"); + if (videoButton) videoButton.classList.remove("hidden"); + if (clearButton) clearButton.classList.add("hidden"); + if (pauseButton) pauseButton.classList.add("hidden"); + if (resumeButton) resumeButton.classList.add("hidden"); + } + } else { + videoControls.classList.add("hidden"); + } + } + + function setWatchPartyActive(active) { + watchPartyActive = active; + if (active) { + document.body.classList.add("watchparty-active"); + if (videoSection) videoSection.classList.remove("hidden"); + if (watchpartyToggleBtn) { + watchpartyToggleBtn.textContent = "🎬 Watch Party: On"; + watchpartyToggleBtn.classList.remove("toggle-off"); + } + } else { + document.body.classList.remove("watchparty-active"); + if (videoSection) videoSection.classList.add("hidden"); + if (watchpartyToggleBtn) { + watchpartyToggleBtn.textContent = "🎬 Watch Party: Off"; + watchpartyToggleBtn.classList.add("toggle-off"); + } + } + } + + if (watchpartyToggleBtn) { + watchpartyToggleBtn.addEventListener("click", () => { + setWatchPartyActive(!watchPartyActive); + }); + } + + if (videoButton && videoInput) { + videoButton.addEventListener("click", async () => { + const url = videoInput.value.trim(); + const videoId = extractYouTubeId(url); + if (!videoId) return alert("Please enter a valid YouTube URL."); + + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + loadYouTubeVideo(videoId, 0); + updateVideoControlsVisibility(true, false); + } catch (err) { + console.error("Failed to start video:", err); + } + }); + } + + if (pauseButton) { + pauseButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/pause`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + } catch (err) { + console.error("Failed to pause video:", err); + } + }); + } + + if (resumeButton) { + resumeButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/resume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + } catch (err) { + console.error("Failed to resume video:", err); + } + }); + } + + if (clearButton) { + clearButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + clearVideoUI(); + } catch (err) { + console.error("Failed to clear video:", err); + } + }); + } + + async function syncWatchPartyFromServer() { + try { + const res = await fetch(`/watchparty/${encodeURIComponent(room)}/video`); + if (!res.ok) return; + + const data = await res.json(); + const { url, elapsed, is_paused, is_active } = data; + + if (url) { + const videoId = extractYouTubeId(url); + if (videoId) { + // Auto-open watch party panel if video is playing + if (!watchPartyActive) { + setWatchPartyActive(true); + } + + if (is_paused) { + if (iframe && !iframe.classList.contains("hidden")) { + iframe.classList.add("hidden"); + iframe.src = ""; + lastVideoId = null; + } + if (pausedOverlay) pausedOverlay.classList.remove("hidden"); + updateVideoControlsVisibility(true, true); + } else { + if (pausedOverlay) pausedOverlay.classList.add("hidden"); + loadYouTubeVideo(videoId, elapsed || 0); + updateVideoControlsVisibility(true, false); + } + return; + } + } + + // No active video URL + if (lastVideoId !== null) { + clearVideoUI(); + } else { + updateVideoControlsVisibility(false, false); + } + } catch (err) { + // Silently ignore sync failures + } + } + + // 🎤 Audio & Voice Messages Recording let mediaRecorder; let audioChunks = []; let isRecording = false; let recordingTimeout; async function startRecording() { - if (isRecording) return; + if (isRecording || !currentRoomKey) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - // Low bitrate preference (16kbps) const options = { mimeType: 'audio/webm;codecs=opus', bitsPerSecond: 16000 }; - // Fallback if codec not supported if (!MediaRecorder.isTypeSupported(options.mimeType)) delete options.mimeType; mediaRecorder = new MediaRecorder(stream, options); @@ -312,38 +472,31 @@ document.addEventListener("DOMContentLoaded", async () => { micBtn.textContent = "🎤"; isRecording = false; clearTimeout(recordingTimeout); - + const blob = new Blob(audioChunks, { type: 'audio/webm' }); const reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = async () => { const base64Audio = reader.result; - let payload = { username, text: "🎤 Voice Message", audio: base64Audio }; + const clearPayload = { text: "🎤 Voice Message", audio: base64Audio }; - if (window.isCryptoEnabled && window.isCryptoEnabled()) { - try { - const clearPayload = { text: "🎤 Voice Message", audio: base64Audio }; - const encrypted = await window.encryptData(JSON.stringify(clearPayload)); - payload = { + try { + const encrypted = await window.encryptRoomPayload(currentRoomKey, clearPayload); + await fetch(`/chat/${encodeURIComponent(room)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, - text: encrypted.ciphertext, - iv: encrypted.iv, - keyId: encrypted.keyId, - encrypted: true - }; - } catch (err) { - console.error("Encryption error:", err); - alert("Failed to encrypt audio."); - return; - } + key: userKey, + ciphertext: encrypted.ciphertext, + iv: encrypted.iv + }) + }); + refresh(); + } catch (err) { + console.error("Audio encryption error:", err); + alert("Failed to send encrypted voice note."); } - - // Send immediately - fetch(`/chat/${encodeURIComponent(room)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }).then(() => refresh()); }; stream.getTracks().forEach(t => t.stop()); }; @@ -351,8 +504,7 @@ document.addEventListener("DOMContentLoaded", async () => { mediaRecorder.start(); recordingTimeout = setTimeout(() => { if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop(); - }, 15000); // 15s limit - + }, 15000); } catch (err) { console.error("Mic error:", err); alert("Could not access microphone."); @@ -371,30 +523,12 @@ document.addEventListener("DOMContentLoaded", async () => { micBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); }); micBtn.addEventListener("touchend", (e) => { e.preventDefault(); stopRecording(); }); - async function refreshUserPresence() { - const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }) - }); - - if (!res.ok) { - alert("You were removed from the room."); - window.location.href = "/server_list.html"; // ✅ go to server list - return; - } - } - - let lastUserList = []; - let lastTimestamp = null; - const seenMessageTimestamps = new Set(); - + // Audio Playback Queue const audioQueue = []; let isPlayingAudio = false; function processAudioQueue() { if (isPlayingAudio || audioQueue.length === 0) return; - const audio = audioQueue.shift(); isPlayingAudio = true; @@ -404,173 +538,137 @@ document.addEventListener("DOMContentLoaded", async () => { }; audio.addEventListener('ended', onComplete, { once: true }); - audio.addEventListener('error', () => { - console.error("Audio playback error"); - onComplete(); - }, { once: true }); + audio.addEventListener('error', onComplete, { once: true }); + audio.play().catch(onComplete); + } - audio.play().catch(e => { - console.log("Autoplay blocked or failed:", e); - onComplete(); + // 🔗 Linkify URLs & Media Embeds + function linkify(text) { + const safeText = escapeHtml(text); + return safeText.replace(/(https?:\/\/[^\s]+)/g, (url) => { + const imagePattern = /\.(png|jpe?g|gif)$/i; + const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{11})/; + const id = `preview-${Math.random().toString(36).substr(2, 9)}`; + + // YouTube + const ytMatch = url.match(youtubeRegex); + if (ytMatch) { + const videoId = ytMatch[1]; + const embedUrl = `https://www.youtube.com/embed/${videoId}`; + if (externalContentEnabled) { + return ` + ${url} +
+ +
+ `; + } else { + return ` + ${url} +
+ +
+
+ `; + } + } + + // Image + if (imagePattern.test(url)) { + if (externalContentEnabled) { + return ` + ${url} +
+ +
+ `; + } else { + return ` + ${url} +
+ +
+
+ `; + } + } + + return `${url}`; }); } - let windowFocused = true; + // Message Rendering & State + let lastUserList = []; + const seenMessageTimestamps = new Set(); let unreadCount = 0; + let windowFocused = true; window.addEventListener('focus', () => { windowFocused = true; unreadCount = 0; - document.title = `${room}`; + document.title = `${room} - Transient.chat`; }); window.addEventListener('blur', () => { windowFocused = false; }); - function linkify(text) { - // 1. Escape the raw text first to prevent XSS - const safeText = escapeHtml(text); - - // 2. Replace URLs in the escaped text with HTML - return safeText.replace(/(https?:\/\/[^\s]+)/g, (url) => { - const imagePattern = /\.(png|jpe?g|gif)$/i; - const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{11})/; - - const id = `preview-${Math.random().toString(36).substr(2, 9)}`; - - // ✅ YouTube Video - const ytMatch = url.match(youtubeRegex); - if (ytMatch) { - const videoId = ytMatch[1]; - const embedUrl = `https://www.youtube.com/embed/${videoId}`; - - if (externalContentEnabled) { - return ` - ${url} -
- -
- `; - } else { - return ` - ${url} -
- -
-
- `; - } - } - - // ✅ Image - if (imagePattern.test(url)) { - if (externalContentEnabled) { - return ` - ${url} -
- -
- `; - } else { - return ` - ${url} -
- -
-
- `; - } - } - - // 🔗 Default fallback: plain link - return `${url}`; - }); - } - - function cleanExpiredMessages() { - const now = Date.now(); - const oneHourAgo = now - 60 * 60 * 1000; - const messageDivs = document.querySelectorAll("#messages .message"); - - messageDivs.forEach(msgDiv => { - const ts = msgDiv.getAttribute("data-timestamp"); - if (!ts) return; - - const cleanedTs = ts.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}).+$/, '$1Z'); - const parsedTimestamp = Date.parse(cleanedTs); - if (isNaN(parsedTimestamp)) return; - - if (parsedTimestamp < oneHourAgo) { - msgDiv.remove(); - seenMessageTimestamps.delete(ts); - } - }); - } - - function renderUsers(data) { - const currentList = data.users || []; - const isOwner = data.owner === username; + function renderUsers(users, owner) { + const currentList = users || []; + isRoomOwner = owner === username; usersDiv.innerHTML = ""; const joined = currentList.filter(user => !lastUserList.includes(user)); const left = lastUserList.filter(user => !currentList.includes(user)); if (!firstLoad && soundEnabled) { - if (joined.length > 0) sounds.in.play(); - if (left.length > 0) sounds.out.play(); + if (joined.length > 0) sounds.in.play().catch(() => {}); + if (left.length > 0) sounds.out.play().catch(() => {}); } lastUserList = currentList; currentList.forEach(user => { const div = document.createElement("div"); - div.textContent = user; + div.textContent = user + (user === owner ? " 👑" : ""); - if (isOwner && user !== username) { + if (isRoomOwner && user !== username) { const btn = document.createElement("button"); btn.textContent = "Kick"; btn.className = "kick-button"; btn.onclick = async () => { - if (confirm(`Kick ${user}?`)) { + if (confirm(`Kick ${user} from this room?`)) { await fetch(`/chat/${encodeURIComponent(room)}/kick`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ owner: username, target: user }), + body: JSON.stringify({ owner: username, key: userKey, target: user }), }); refresh(); } @@ -582,202 +680,184 @@ document.addEventListener("DOMContentLoaded", async () => { }); } - async function renderMessages(data) { - if (!Array.isArray(data)) return; + async function renderMessages(messagesList) { + if (!Array.isArray(messagesList)) return; const now = Date.now(); const oneHourAgo = now - 60 * 60 * 1000; + let hasNewIncoming = false; - const newMessages = data.filter(msg => { - const msgTime = new Date(msg.timestamp).getTime(); - if (isNaN(msgTime)) return false; - if (firstLoad) return true; - return msgTime >= oneHourAgo; - }); - - for (const msg of newMessages) { - // Skip if already rendered + for (const msg of messagesList) { if (seenMessageTimestamps.has(msg.timestamp)) continue; + const msgTime = new Date(msg.timestamp).getTime(); + if (!isNaN(msgTime) && !firstLoad && msgTime < oneHourAgo) { + continue; + } + + seenMessageTimestamps.add(msg.timestamp); + const div = document.createElement("div"); div.classList.add("message"); div.setAttribute("data-timestamp", msg.timestamp); - seenMessageTimestamps.add(msg.timestamp); if (msg.username === username) { div.classList.add("my-message"); + } else if (!firstLoad) { + hasNewIncoming = true; } - let isDecryptionFailed = false; - let textToRender = msg.text; - let audioToRender = msg.audio; + // System notices + if (msg.username === "SYSTEM" || msg.system_notice) { + div.classList.add("system-message"); + div.innerHTML = `📢 SYSTEM: ${escapeHtml(msg.system_notice || msg.text || "")}`; + messagesDiv.appendChild(div); + continue; + } - if (msg.encrypted) { - if (window.isCryptoEnabled && window.isCryptoEnabled() && window.getKeyringIds().includes(msg.keyId)) { - try { - const decryptedJson = await window.decryptData(msg.text, msg.iv, msg.keyId); - const parsed = JSON.parse(decryptedJson); - textToRender = parsed.text; - audioToRender = parsed.audio; - } catch (err) { - textToRender = "⚠️ Failed to decrypt message (bad key)"; - audioToRender = null; - } - } else { - textToRender = `🔒 Message encrypted (Key ID: ${msg.keyId}).`; - audioToRender = null; - isDecryptionFailed = true; + // Encrypted chat messages + let textToRender = ""; + let audioToRender = null; + + if (msg.ciphertext && msg.iv && currentRoomKey) { + try { + const decrypted = await window.decryptRoomPayload(currentRoomKey, msg.ciphertext, msg.iv); + textToRender = decrypted.text || ""; + audioToRender = decrypted.audio || null; + } catch (err) { + textToRender = "⚠️ Decryption error (corrupted payload)"; } + } else { + textToRender = "🔒 Encrypted Message"; } div.innerHTML = `${escapeHtml(msg.username)}: ${linkify(textToRender)}`; - if (isDecryptionFailed) { - const addKeyLink = document.createElement("a"); - addKeyLink.href = "#"; - addKeyLink.textContent = " Enter passphrase to decrypt"; - addKeyLink.style.color = "#2196f3"; - addKeyLink.style.textDecoration = "underline"; - addKeyLink.style.marginLeft = "5px"; - addKeyLink.onclick = async (e) => { - e.preventDefault(); - const phrase = prompt(`Enter the passphrase for Key ID ${msg.keyId}:`); - if (phrase) { - const trimmed = phrase.trim(); - if (trimmed) { - if (!storedKeys.includes(trimmed)) { - storedKeys.push(trimmed); - sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys)); - } - const derivedId = await window.addKeyToRing(trimmed, room); - if (derivedId) { - if (derivedId === msg.keyId) { - window.setActiveKey(derivedId); - window.location.reload(); - } else { - alert(`Incorrect passphrase. The entered passphrase generated Key ID: ${derivedId}, but this message requires Key ID: ${msg.keyId}.`); - } - } - } - } - }; - div.appendChild(addKeyLink); - } - if (audioToRender) { const audioContainer = document.createElement("div"); audioContainer.className = "audio-container"; - const audio = document.createElement("audio"); - audio.controls = true; - audio.className = "voice-message-audio"; - - audio.src = audioToRender; + const audioElement = document.createElement("audio"); + audioElement.controls = true; + audioElement.src = audioToRender; + audioElement.className = "voice-message-audio"; + audioContainer.appendChild(audioElement); + div.appendChild(audioContainer); - // Autoplay if enabled, not first load, and not my own message if (autoplayEnabled && !firstLoad && msg.username !== username) { - audioQueue.push(audio); + audioQueue.push(audioElement); processAudioQueue(); } - - audioContainer.appendChild(audio); - div.appendChild(audioContainer); } messagesDiv.appendChild(div); } - if (newMessages.length > 0) { - messagesDiv.scrollTop = messagesDiv.scrollHeight; - lastTimestamp = newMessages[newMessages.length - 1].timestamp; + if (hasNewIncoming && soundEnabled) { + sounds.message.play().catch(() => {}); } - if (!firstLoad && soundEnabled && newMessages.some(msg => msg.username !== username)) { - sounds.message.play(); + if (hasNewIncoming && !windowFocused) { + unreadCount++; + document.title = `(${unreadCount}) ${room} - Transient.chat`; } - if (!windowFocused && newMessages.length > 0) { - unreadCount += newMessages.length; - document.title = `${room} (${unreadCount})`; - } + messagesDiv.scrollTop = messagesDiv.scrollHeight; } - form.addEventListener("submit", async e => { - e.preventDefault(); - const text = input.value.trim(); - if (!text) return; - - let payload = { username, text }; - - if (window.isCryptoEnabled && window.isCryptoEnabled()) { - try { - const clearPayload = { text: text, audio: null }; - const encrypted = await window.encryptData(JSON.stringify(clearPayload)); - payload = { - username: username, - text: encrypted.ciphertext, - iv: encrypted.iv, - keyId: encrypted.keyId, - encrypted: true - }; - } catch (err) { - console.error("Encryption error:", err); - alert("Failed to encrypt message."); - return; + function cleanExpiredMessages() { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const msgDivs = document.querySelectorAll("#messages .message"); + msgDivs.forEach(div => { + const ts = div.getAttribute("data-timestamp"); + if (ts) { + const t = new Date(ts).getTime(); + if (!isNaN(t) && t < oneHourAgo) { + div.remove(); + seenMessageTimestamps.delete(ts); + } } - } - - fetch(`/chat/${encodeURIComponent(room)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }).then(() => { - input.value = ""; - refresh(); // force fetch after post to ensure sync }); - }); + } async function refresh() { - if (isRefreshing) return; + if (isRefreshing || !currentRoomKey) return; isRefreshing = true; try { const res = await fetch(`/chat/${encodeURIComponent(room)}/poll`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username, - key: userKey, - since: lastTimestamp + key: userKey }) }); - if (!res.ok) { - localStorage.clear(); - alert("Session expired or you were removed."); - window.location.href = '/'; + if (res.status === 401) { + // Room authorization required or expired + sessionStorage.removeItem(sessionPasswordKey); + showPasswordModal(); + isRefreshing = false; return; } - const data = await res.json(); - renderUsers(data); - await renderMessages(data.messages); - cleanExpiredMessages(); - } catch (e) { - console.error("Refresh error:", e); + if (res.status === 403) { + alert("You are not authorized in this room or have been kicked."); + window.location.href = "/server_list.html"; + return; + } + + if (res.ok) { + const data = await res.json(); + renderUsers(data.users, data.owner); + await renderMessages(data.messages); + cleanExpiredMessages(); + firstLoad = false; + } + } catch (err) { + console.error("Poll error:", err); } finally { - firstLoad = false; isRefreshing = false; } } - // Start smart poll loop instead of setInterval - let pollTimeoutId = null; - async function pollLoop() { - await refresh(); - const interval = windowFocused ? 1000 : 4000; - pollTimeoutId = setTimeout(pollLoop, interval); + // Message form submit + if (form) { + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const text = input.value.trim(); + if (!text || !currentRoomKey) return; + + input.value = ""; + try { + const payload = { text: text, audio: null }; + const encrypted = await window.encryptRoomPayload(currentRoomKey, payload); + + await fetch(`/chat/${encodeURIComponent(room)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: username, + key: userKey, + ciphertext: encrypted.ciphertext, + iv: encrypted.iv + }) + }); + refresh(); + } catch (err) { + console.error("Send error:", err); + alert("Failed to send encrypted message."); + } + }); } - // Initialize E2EE and begin polling - await pollLoop(); + function startChatSession() { + if (initialWatchPartyRequested) { + setWatchPartyActive(true); + } + + refresh(); + setInterval(refresh, 1000); + setInterval(syncWatchPartyFromServer, 1000); + } }); \ No newline at end of file diff --git a/static/assets/js/crypto.js b/static/assets/js/crypto.js index e2a79a6..20d001b 100644 --- a/static/assets/js/crypto.js +++ b/static/assets/js/crypto.js @@ -1,25 +1,28 @@ +/** + * Transient.chat - Encryption First Engine + * Zero-Knowledge End-to-End Encryption (E2EE) + * All chat payloads (text & voice audio) are encrypted client-side using AES-256-GCM. + * Server memory stores strictly ephemeral encrypted ciphertext and IVs. + */ (function() { - let keyring = {}; // { keyId: { passphrase, key } } - let activeKeyId = null; - - // Helper: string to array buffer + // Helper: string to ArrayBuffer (UTF-8) function strToBuf(str) { return new TextEncoder().encode(str); } - // Helper: array buffer to string + // Helper: ArrayBuffer to string (UTF-8) function bufToStr(buf) { return new TextDecoder().decode(buf); } - // Helper: array buffer to hex string + // Helper: ArrayBuffer to hex string function bufToHex(buf) { return Array.from(new Uint8Array(buf)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } - // Helper: hex string to array buffer + // Helper: hex string to ArrayBuffer function hexToBuf(hex) { if (!hex) return new Uint8Array(0).buffer; const bytes = new Uint8Array(hex.length / 2); @@ -29,18 +32,13 @@ return bytes.buffer; } - // Short hash of passphrase to identify the key (non-reversible identifier) - async function computeKeyId(passphrase) { - const msgUint8 = strToBuf(passphrase); - const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 8); - } - - // Derive PBKDF2 key from passphrase - async function deriveKey(passphrase, roomName) { - const passwordBuffer = strToBuf(passphrase); - const saltBuffer = strToBuf("transient.chat.salt." + roomName); + /** + * Derives a 256-bit AES-GCM key from the room password and room name. + * Uses PBKDF2 with SHA-256 and 100,000 iterations with a room-bound salt. + */ + window.deriveRoomKey = async function(password, roomName) { + const passwordBuffer = strToBuf(password); + const saltBuffer = strToBuf("transient.chat.room." + roomName); const baseKey = await window.crypto.subtle.importKey( "raw", @@ -62,90 +60,38 @@ false, ["encrypt", "decrypt"] ); - } - - // Add a passphrase to the keyring - window.addKeyToRing = async function(passphrase, roomName) { - try { - const keyId = await computeKeyId(passphrase); - const cryptoKey = await deriveKey(passphrase, roomName); - keyring[keyId] = { - passphrase: passphrase, - key: cryptoKey - }; - if (!activeKeyId) { - activeKeyId = keyId; - } - return keyId; - } catch (e) { - console.error("Failed to add key to ring:", e); - return null; - } }; - window.removeKeyFromRing = function(keyId) { - delete keyring[keyId]; - if (activeKeyId === keyId) { - const remaining = Object.keys(keyring); - activeKeyId = remaining.length > 0 ? remaining[0] : null; - } - }; - - window.setActiveKey = function(keyId) { - if (keyring[keyId]) { - activeKeyId = keyId; - return true; - } - return false; - }; - - window.getActiveKeyId = function() { - return activeKeyId; - }; - - window.getKeyringIds = function() { - return Object.keys(keyring); - }; - - window.getKeyring = function() { - // Return passive list of { keyId, passphrase } - const list = []; - for (const [id, data] of Object.entries(keyring)) { - list.push({ keyId: id, passphrase: data.passphrase }); - } - return list; - }; - - window.isCryptoEnabled = function() { - return activeKeyId !== null; - }; - - window.encryptData = async function(plaintext, keyId = activeKeyId) { - if (!keyId || !keyring[keyId]) { - throw new Error("Key not found in keyring"); - } + /** + * Encrypts a message payload object (text + optional audio) using the derived room key. + * Returns hex-encoded ciphertext and 96-bit (12-byte) initialization vector (IV). + */ + window.encryptRoomPayload = async function(cryptoKey, payloadObj) { + const jsonStr = JSON.stringify(payloadObj); const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const encrypted = await window.crypto.subtle.encrypt( + + const encryptedBuffer = await window.crypto.subtle.encrypt( { name: "AES-GCM", iv: iv }, - keyring[keyId].key, - strToBuf(plaintext) + cryptoKey, + strToBuf(jsonStr) ); + return { - ciphertext: bufToHex(encrypted), - iv: bufToHex(iv), - keyId: keyId + ciphertext: bufToHex(encryptedBuffer), + iv: bufToHex(iv) }; }; - window.decryptData = async function(ciphertextHex, ivHex, keyId) { - if (!keyId || !keyring[keyId]) { - throw new Error("Key not found in keyring"); - } - const decrypted = await window.crypto.subtle.decrypt( + /** + * Decrypts ciphertext and IV hex strings back to the original payload object. + */ + window.decryptRoomPayload = async function(cryptoKey, ciphertextHex, ivHex) { + const decryptedBuffer = await window.crypto.subtle.decrypt( { name: "AES-GCM", iv: hexToBuf(ivHex) }, - keyring[keyId].key, + cryptoKey, hexToBuf(ciphertextHex) ); - return bufToStr(decrypted); + + return JSON.parse(bufToStr(decryptedBuffer)); }; })(); diff --git a/static/assets/js/index.js b/static/assets/js/index.js index 58db67b..e21a38e 100644 --- a/static/assets/js/index.js +++ b/static/assets/js/index.js @@ -4,15 +4,14 @@ document.addEventListener('DOMContentLoaded', async () => { const params = new URLSearchParams(window.location.search); const room = params.get('room'); - const isWatchParty = params.has('watchparty'); // Use explicit watchparty URL flag + const isWatchParty = params.has('watchparty'); function redirectToRoom(roomName) { if (!roomName) { window.location.href = '/server_list.html'; } else { - window.location.href = isWatchParty - ? `/watchparty_chat.html?room=${encodeURIComponent(roomName)}` - : `/chat.html?room=${encodeURIComponent(roomName)}`; + const wpParam = isWatchParty ? '&watchparty=1' : ''; + window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`; } } @@ -29,7 +28,6 @@ document.addEventListener('DOMContentLoaded', async () => { redirectToRoom(room); return; } else { - // Session expired or invalid; clear storage but pre-fill username localStorage.clear(); const input = document.getElementById('username-input'); if (input) input.value = storedUsername; @@ -56,7 +54,7 @@ document.addEventListener('DOMContentLoaded', async () => { const username = usernameInput.value.trim(); if (!username) { - alert('Please enter a username.'); + alert('Please enter a handle/username.'); return; } @@ -80,7 +78,7 @@ document.addEventListener('DOMContentLoaded', async () => { redirectToRoom(room); } catch (error) { console.error('Registration error:', error); - alert('An error occurred.'); + alert('An error occurred during registration.'); } }); } diff --git a/static/assets/js/server_list.js b/static/assets/js/server_list.js index a962b97..564946d 100644 --- a/static/assets/js/server_list.js +++ b/static/assets/js/server_list.js @@ -35,23 +35,14 @@ document.addEventListener("DOMContentLoaded", async () => { .replace(/'/g, "'"); } + // User header bar const userInfoEl = document.getElementById('user-info'); - userInfoEl.style.display = 'flex'; - userInfoEl.style.alignItems = 'center'; - userInfoEl.style.justifyContent = 'flex-start'; - userInfoEl.style.gap = '15px'; - userInfoEl.style.marginBottom = '20px'; const logoutBtn = document.createElement('button'); logoutBtn.textContent = 'Logout'; - logoutBtn.style.backgroundColor = '#d9534f'; - logoutBtn.style.color = 'white'; - logoutBtn.style.border = 'none'; - logoutBtn.style.padding = '6px 12px'; - logoutBtn.style.borderRadius = '4px'; - logoutBtn.style.cursor = 'pointer'; + logoutBtn.className = 'logout-btn'; logoutBtn.onclick = async () => { - if (confirm("Are you sure you want to logout? This will release your username immediately.")) { + if (confirm("Are you sure you want to logout? This releases your username.")) { try { await fetch('/logout', { method: 'POST', @@ -62,6 +53,7 @@ document.addEventListener("DOMContentLoaded", async () => { console.error("Logout request failed:", e); } localStorage.clear(); + sessionStorage.clear(); window.location.href = '/'; } }; @@ -71,163 +63,197 @@ document.addEventListener("DOMContentLoaded", async () => { userInfoEl.append(logoutBtn, userSpan); - document.querySelectorAll('.collapsible').forEach(heading => { - heading.addEventListener('click', () => { - const content = heading.nextElementSibling; - const isOpen = content.style.display === 'block'; - content.style.display = isOpen ? 'none' : 'block'; - heading.textContent = (isOpen ? '▶' : '▼') + heading.textContent.slice(1); + const activeRoomsContainer = document.getElementById('active-rooms-container'); + const statsBadge = document.getElementById('stats-badge'); + + // Modal elements + const joinModal = document.getElementById('join-modal'); + const joinModalForm = document.getElementById('join-modal-form'); + const modalRoomTitle = document.getElementById('modal-room-title'); + const modalPasswordInput = document.getElementById('modal-room-password'); + const modalCancelBtn = document.getElementById('modal-cancel-btn'); + + let pendingJoinRoom = null; + let pendingJoinHasWP = false; + + function openJoinModal(roomName, hasWP) { + pendingJoinRoom = roomName; + pendingJoinHasWP = hasWP; + modalRoomTitle.textContent = `🔒 Join "${roomName}"`; + modalPasswordInput.value = ''; + joinModal.classList.remove('hidden'); + modalPasswordInput.focus(); + } + + function closeJoinModal() { + joinModal.classList.add('hidden'); + pendingJoinRoom = null; + } + + if (modalCancelBtn) { + modalCancelBtn.addEventListener('click', closeJoinModal); + } + + if (joinModalForm) { + joinModalForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const password = modalPasswordInput.value.trim(); + if (!password || !pendingJoinRoom) return; + + const joinBtn = document.getElementById('modal-join-btn'); + if (joinBtn) joinBtn.disabled = true; + + try { + const res = await fetch('/rooms/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + room: pendingJoinRoom, + password: password, + username: username, + key: userKey + }) + }); + + if (joinBtn) joinBtn.disabled = false; + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Incorrect room password.'); + modalPasswordInput.value = ''; + modalPasswordInput.focus(); + return; + } + + // Success + sessionStorage.setItem(`room_password_${pendingJoinRoom}`, password); + const wpParam = pendingJoinHasWP ? '&watchparty=1' : ''; + window.location.href = `/chat.html?room=${encodeURIComponent(pendingJoinRoom)}${wpParam}`; + } catch (err) { + if (joinBtn) joinBtn.disabled = false; + console.error('Join error:', err); + alert('Network error while joining room.'); + } }); - }); + } - fetch("/rooms") - .then(response => response.json()) - .then(data => { - const { regional, topical, public: publicRooms, unique_users } = data; + async function loadActiveRooms() { + try { + const res = await fetch('/rooms'); + if (!res.ok) throw new Error('Failed to fetch rooms'); - const regionalList = document.getElementById('regional-rooms'); - const topicalList = document.getElementById('topical-rooms'); - const publicList = document.getElementById('public-rooms'); - const watchpartyList = document.getElementById('watchparty-rooms'); + const data = await res.json(); + const rooms = data.active_rooms || []; + const uniqueUsers = data.unique_users || 0; - // Helper to create a room
  • - function createRoomItem({ name, users, type }) { - const li = document.createElement('li'); - const a = document.createElement('a'); - const isWatchParty = type && type.startsWith('watchparty'); - a.href = isWatchParty - ? `/watchparty_chat.html?room=${encodeURIComponent(name)}` - : `/chat.html?room=${encodeURIComponent(name)}`; - a.textContent = `${name} ( ${users} )`; - li.appendChild(a); - return { li, isWatchParty }; + statsBadge.innerHTML = `🟢 Active Rooms: ${rooms.length}  •  👥 Online Users: ${uniqueUsers}`; + + activeRoomsContainer.innerHTML = ''; + + if (rooms.length === 0) { + activeRoomsContainer.innerHTML = ` +
    + No active rooms right now.
    Create an on-demand room below to start chatting! +
    + `; + return; } - // Add regional rooms - regional.forEach(room => { - regionalList.appendChild(createRoomItem(room).li); - }); + rooms.forEach(room => { + const card = document.createElement('div'); + card.className = 'room-card'; - // Add topical rooms - topical.forEach(room => { - topicalList.appendChild(createRoomItem(room).li); - }); + const left = document.createElement('div'); + left.className = 'room-info-left'; - // Add public/watchparty rooms - publicRooms.forEach(room => { - const { li, isWatchParty } = createRoomItem(room); - if (isWatchParty) { - watchpartyList.appendChild(li); - } else { - publicList.appendChild(li); - } - }); + const titleLink = document.createElement('span'); + titleLink.className = 'room-title-link'; + titleLink.textContent = room.name; - // Show total unique user count - document.getElementById('user-count').textContent = `Users Chatting ( ${unique_users} )`; + const badges = document.createElement('div'); + badges.className = 'room-badges'; + badges.innerHTML = ` + 👥 ${room.users} user${room.users === 1 ? '' : 's'} + 🔒 Encrypted + ${room.has_watchparty ? '🎬 Watch Party' : ''} + `; - // 🧠 Load and render Active Rooms (regional, topical, public w/ users) - fetch("/rooms?activeOnly=true") - .then(response => response.json()) - .then(({ regional, topical, public: publicRooms }) => { - const activeList = document.getElementById("active-rooms"); - let count = 0; + left.append(titleLink, badges); - function createActiveRoomItem({ name, users, type }) { - const li = document.createElement("li"); - const a = document.createElement("a"); + const joinBtn = document.createElement('button'); + joinBtn.textContent = 'Join Room'; - a.href = type === "watchparty" - ? `/watchparty_chat.html?room=${encodeURIComponent(name)}` - : `/chat.html?room=${encodeURIComponent(name)}`; - a.textContent = `${name} ( ${users} )`; - li.appendChild(a); - return li; + const handleJoin = () => { + const storedPass = sessionStorage.getItem(`room_password_${room.name}`); + if (storedPass) { + const wpParam = room.has_watchparty ? '&watchparty=1' : ''; + window.location.href = `/chat.html?room=${encodeURIComponent(room.name)}${wpParam}`; + } else { + openJoinModal(room.name, room.has_watchparty); } + }; - [...regional, ...topical, ...publicRooms].forEach(room => { - activeList.appendChild(createActiveRoomItem(room)); - count++; - }); + titleLink.addEventListener('click', handleJoin); + joinBtn.addEventListener('click', handleJoin); - // Update Active Rooms heading with count - const activeHeadingEl = document.getElementById("active-rooms-header"); - if (activeHeadingEl) { - const baseText = activeHeadingEl.textContent.replace(/▶|▼|\(\d+\)/g, "").trim(); - activeHeadingEl.textContent = `▶ ${baseText} ( ${count} )`; - } - }) - .catch(err => { - console.error("Failed to load active rooms:", err); + card.append(left, joinBtn); + activeRoomsContainer.appendChild(card); + }); + } catch (err) { + console.error('Failed to load active rooms:', err); + statsBadge.textContent = 'Failed to load rooms'; + } + } + + await loadActiveRooms(); + setInterval(loadActiveRooms, 3000); // Poll active rooms every 3 seconds + + // On-Demand Room Creation Form + const createRoomForm = document.getElementById('create-room-form'); + if (createRoomForm) { + createRoomForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const roomName = document.getElementById('new-room-name').value.trim(); + const password = document.getElementById('new-room-password').value.trim(); + const isWatchParty = document.getElementById('new-room-watchparty').checked; + + if (!roomName || !password) { + alert('Please enter both a room name and password.'); + return; + } + + const createBtn = document.getElementById('create-room-btn'); + createBtn.disabled = true; + + try { + const res = await fetch('/rooms/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + room: roomName, + password: password, + username: username, + key: userKey + }) }); - }) - .catch(err => { - console.error("Failed to load rooms:", err); - alert("Failed to load rooms."); + + createBtn.disabled = false; + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Failed to create room.'); + return; + } + + // Store password in session + sessionStorage.setItem(`room_password_${roomName}`, password); + const wpParam = isWatchParty ? '&watchparty=1' : ''; + window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`; + } catch (err) { + createBtn.disabled = false; + console.error('Room creation error:', err); + alert('Network error while creating room.'); + } }); - - document.getElementById('create-room-btn').addEventListener('click', () => { - const roomName = document.getElementById('new-room-name').value.trim(); - if (!roomName) return alert('Room name cannot be empty.'); - - fetch('/rooms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: roomName }) - }) - .then(response => { - if (!response.ok) throw new Error('Room creation failed.'); - return response.json(); - }) - .then(data => { - window.location.href = `/chat.html?room=${encodeURIComponent(data.name)}`; - }) - .catch(err => alert(err.message)); - }); - - document.getElementById('create-unlisted-room-btn').addEventListener('click', () => { - const roomName = document.getElementById('new-room-name').value.trim(); - if (!roomName) return alert('Room name cannot be empty.'); - window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}`; - }); - - // 🔥 Watch Party creation - document.getElementById('create-watchparty-btn').addEventListener('click', () => { - const roomName = document.getElementById('new-room-name').value.trim(); - if (!roomName) return alert('Room name cannot be empty.'); - - fetch('/rooms', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: roomName }) - }) - .then(response => { - if (!response.ok) throw new Error('Watch party creation failed.'); - return response.json(); - }) - .then(data => { - window.location.href = `/watchparty_chat.html?room=${encodeURIComponent(data.name)}`; - }) - .catch(err => alert(err.message)); - }); -}); - -// Enable Enter key for room creation -document.getElementById('new-room-name').addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - document.getElementById('create-room-btn').click(); - } -}); - -// Enable Enter key for watch party creation -document.getElementById('new-room-name').addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - document.getElementById('create-watchparty-btn').click(); - } -}); - -window.addEventListener('pageshow', (event) => { - if (event.persisted) { - window.location.reload(); } }); \ No newline at end of file diff --git a/static/assets/js/watchparty_chat.js b/static/assets/js/watchparty_chat.js deleted file mode 100644 index 01a0007..0000000 --- a/static/assets/js/watchparty_chat.js +++ /dev/null @@ -1,224 +0,0 @@ -document.addEventListener("DOMContentLoaded", async () => { - const params = new URLSearchParams(window.location.search); - const room = params.get("room"); - const username = localStorage.getItem("username"); - const userKey = localStorage.getItem("userKey"); - - if (!room || !username || !userKey) { - alert("Missing room or user credentials."); - window.location.href = `/index.html${room ? `?room=${encodeURIComponent(room)}&watchparty=1` : ""}`; - return; - } - - // Note: We rely on chat.js (loaded in the HTML) to handle the server-side - // /validate call and the rendering of the #room-title header. - // This script focuses solely on the video synchronization logic. - - const videoControls = document.getElementById("video-controls"); - const videoInput = document.getElementById("video-url-input"); - const videoButton = document.getElementById("start-video-button"); - const pauseButton = document.getElementById("pause-video-button"); - const resumeButton = document.getElementById("resume-video-button"); - const clearButton = document.getElementById("clear-video-button"); - const iframe = document.getElementById("video-iframe"); - const pausedOverlay = document.getElementById("paused-overlay"); - - let videoExpireAt = null; - let videoStarted = false; - let lastVideoId = null; - let isRoomOwner = false; - - function extractYouTubeId(url) { - const match = url.match(/(?:youtube\.com\/.*v=|youtu\.be\/)([\w-]{11})/); - return match ? match[1] : null; - } - - async function fetchYouTubeDuration(videoId) { - return 24 * 60 * 60; // 24 hours in seconds - } - - function loadYouTubeVideo(videoId, startSeconds = 0) { - if (lastVideoId === videoId) return; - iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&start=${startSeconds}`; - iframe.classList.remove("hidden"); - lastVideoId = videoId; - } - - function clearVideoUI() { - iframe.src = ""; - iframe.classList.add("hidden"); - videoExpireAt = null; - videoStarted = false; - lastVideoId = null; - pausedOverlay.classList.add("hidden"); - updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner }); - } - - function updateControlVisibility({ hasVideo, isPaused, isOwner }) { - if (isOwner) { - if (hasVideo) { - videoInput.classList.add("hidden"); - videoButton.classList.add("hidden"); - clearButton.classList.remove("hidden"); - - if (isPaused) { - pauseButton.classList.add("hidden"); - resumeButton.classList.remove("hidden"); - } else { - pauseButton.classList.remove("hidden"); - resumeButton.classList.add("hidden"); - } - } else { - videoInput.classList.remove("hidden"); - videoButton.classList.remove("hidden"); - clearButton.classList.add("hidden"); - pauseButton.classList.add("hidden"); - resumeButton.classList.add("hidden"); - } - videoControls.classList.remove("hidden"); - } else { - videoControls.classList.add("hidden"); - } - } - - async function checkRoomOwnership() { - try { - const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }); - const data = await res.json(); - isRoomOwner = data.owner && data.owner === username; - } catch (err) { - console.error("Failed to determine ownership", err); - isRoomOwner = false; - } - } - - videoButton.addEventListener("click", async () => { - const url = videoInput.value.trim(); - const videoId = extractYouTubeId(url); - if (!videoId) return alert("Invalid YouTube URL."); - - await fetch(`/watchparty/${encodeURIComponent(room)}/video`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }), - }); - - const duration = await fetchYouTubeDuration(videoId); - videoExpireAt = Date.now() + duration * 1000; - videoStarted = true; - - loadYouTubeVideo(videoId); - updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner }); - }); - - pauseButton.addEventListener("click", async () => { - try { - await fetch(`/watchparty/${encodeURIComponent(room)}/video/pause`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }); - } catch (err) { - console.error("Failed to pause video", err); - } - }); - - resumeButton.addEventListener("click", async () => { - try { - await fetch(`/watchparty/${encodeURIComponent(room)}/video/resume`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }); - } catch (err) { - console.error("Failed to resume video", err); - } - }); - - clearButton.addEventListener("click", async () => { - try { - await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }); - - clearVideoUI(); - } catch (err) { - console.error("Failed to clear video", err); - } - }); - - async function syncVideoFromServer() { - try { - const res = await fetch(`/watchparty/${encodeURIComponent(room)}/video`); - if (!res.ok) throw new Error("video fetch failed"); - - const { url, elapsed, is_paused } = await res.json(); - const videoId = extractYouTubeId(url); - if (!videoId) { - clearVideoUI(); - return; - } - - if (is_paused) { - if (!iframe.classList.contains("hidden")) { - iframe.classList.add("hidden"); - iframe.src = ""; // Stop audio - pausedOverlay.classList.remove("hidden"); - lastVideoId = null; // Force reload on resume - } - videoStarted = true; - updateControlVisibility({ hasVideo: true, isPaused: true, isOwner: isRoomOwner }); - return; - } - - pausedOverlay.classList.add("hidden"); - const duration = await fetchYouTubeDuration(videoId); - videoExpireAt = Date.now() + ((duration - elapsed) * 1000); - videoStarted = true; - - loadYouTubeVideo(videoId, elapsed); - updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner }); - } catch (err) { - // Fallback: video was likely cleared - if (videoStarted || !iframe.classList.contains("hidden")) { - clearVideoUI(); - } - } - } - - setInterval(async () => { - if (videoExpireAt && Date.now() > videoExpireAt) { - clearVideoUI(); - - if (isRoomOwner) { - try { - await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }); - } catch (err) { - console.error("Failed to auto-clear video", err); - } - } - } else { - await syncVideoFromServer(); - } - }, 1000); - - async function initialize() { - await checkRoomOwnership(); - await syncVideoFromServer(); - if (!videoStarted) { - updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner }); - } - } - - await initialize(); -}); \ No newline at end of file diff --git a/static/chat.html b/static/chat.html index ba3be9c..4524cff 100644 --- a/static/chat.html +++ b/static/chat.html @@ -3,14 +3,47 @@ - Transient.chat - Chat Room + Transient.chat - Private Encrypted Chat +

    Chat Room

    +
    + + + +
    @@ -18,13 +51,15 @@ type="text" class="inputfield" id="message-input" - placeholder="Type a message, paste in a link, or url to an image or youtube video." + placeholder="Type a message, paste a link, or send an image/video URL." autocomplete="off" required />
    + +
    + + + + diff --git a/static/faq.html b/static/faq.html index 08279a4..211da3b 100644 --- a/static/faq.html +++ b/static/faq.html @@ -6,7 +6,7 @@ FAQ - Transient.chat @@ -24,56 +24,62 @@

    Frequently Asked Questions

    -

    How long do messages last?

    -

    All chat messages automatically disappear after 1 hour.

    - -

    How long do rooms last?

    +

    How does encryption work?

    - All chat rooms automatically disappear after all users leave the room. + Transient.chat is built with an encryption-first approach. + When you enter or create a room, your browser derives a 256-bit AES-GCM encryption key from your room password using PBKDF2 (100,000 rounds of SHA-256 and a room-unique salt). + All text messages and voice notes are encrypted right inside your browser before transmission. + There is no complicated keyring interface—your room password does everything automatically.

    -

    Do I need an account to use this?

    +

    Are all messages encrypted in server memory?

    - No. Just pick a handle and start chatting. If you don't come back in 3 - days your handle is released. + Yes. The server only holds ephemeral encrypted ciphertext and initialization vectors (IVs) in volatile RAM. + The server never has access to the plaintext messages or audio notes. + Nothing is ever saved to a database or written to disk.

    -

    Is anything stored permanently?

    +

    Do all rooms require passwords?

    - No databases or disk files are used for chat messages or media—everything exists temporarily in memory and vanishes within an hour. We persist only basic session configuration, ban records, and room owner mappings using a lightweight local database so they survive server updates. + Yes. We use a private, on-demand room model. Every room requires a password to create or join. + Only people with the room password can enter the room and decrypt its messages.

    -

    Is my chat secure / private?

    +

    How does the Server Browser work?

    - Yes! We support Zero-Knowledge End-to-End Encryption (E2EE). - You can enable encryption for any room by clicking + Add Passphrase in the sidebar and entering a passphrase. - Encryption keys are derived locally in your browser using PBKDF2 and AES-GCM. - The passphrases reside strictly in your browser's session memory and are never sent to the server. - Anyone without the correct passphrase will only see a placeholder indicating the message is encrypted, with a quick link to add the passphrase to their keyring if they know it. + The server browser displays only active rooms currently occupied by chatting users. + Empty rooms do not clutter the list. You can join any active room (with its password) or spin up a new room on demand.

    -

    What is a Watchroom?

    +

    Can any room be a Watch Party?

    - A Watchroom lets you sync YouTube playback with others while chatting. - Everyone sees the same video at the same time. + Yes! Every room has an integrated Watch Party feature that can be toggled on or off at any time. + When active, you can synchronize YouTube video playback with everyone in the room in real time. +

    + +

    How long do messages and rooms last?

    +

    + All messages automatically expire and vanish after 1 hour. + When all users leave a room, the room itself, its password, video state, and all remaining messages are immediately purged from server memory.

    Can I send voice messages?

    - Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button. - Enable Autoplay in the sidebar to hear messages automatically as they arrive. + Yes. Hold down the microphone button to record encrypted audio messages (up to 15 seconds). + You can enable Autoplay in the sidebar to hear new voice notes automatically as they arrive.

    -

    What is External Content?

    +

    Do I need to create an account?

    - External images and videos linked by other users. You can toggle External Content on to automatically show embedded media. + No accounts, no email addresses, no phone numbers. Simply pick a handle and start chatting. + If you are inactive for 3 days, your handle is released.

    -

    Can users be removed?

    -

    Yes. Room owners can kick users. Admins can ban users by IP.

    - -

    Where can I find the source code?

    -

    https://forgejo.benjimanmiller.com/ben/transient.chat

    +

    Where can I inspect the code?

    +

    + Transient.chat is 100% open source. Check out the source code at:
    + https://forgejo.benjimanmiller.com/ben/transient.chat +



    ← Back to Home diff --git a/static/index.html b/static/index.html index 2b65887..125f67d 100644 --- a/static/index.html +++ b/static/index.html @@ -3,10 +3,10 @@ - Transient.chat + Transient.chat - Private, Encrypted, Ephemeral Chat @@ -25,26 +25,26 @@

    Chat without a trace!

    - Real-time conversations that disappear after + Real-time encrypted conversations that disappear after 1 hour.
    - No accounts. No data trails. Nothing is saved... ever.
    - Everything is held in server memory and is never written to any disk or - database.

    - Join a room and talk freely. When the room empties, everything - vanishes.
    - Want to sync up a video? Try a Watchroom and chat while - watching YouTube together.

    - Voice Messages: Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.

    - End-to-End Encryption: Encrypt your chat rooms locally with passphrases. Keys remain strictly in your browser and are never sent to the server.

    - No tracking • No storage • 100% ephemeral

    - If you're tired of feeds, filters, or forever — you're in the right - place. - Learn More → + No accounts. No logs. No trackers. Nothing is saved... ever.
    + All messages are encrypted in memory on the server and never written to disk or database.

    + + 🔐 Encryption First & On-Demand Rooms: All rooms are created on demand and protected by passwords. + Your room password automatically encrypts all messages and audio in your browser using AES-256-GCM. No confusing keys interface needed.

    + + 🎬 Integrated Watch Party: Toggle YouTube synchronized playback on and off in any room at any time.

    + + 🎤 Ephemeral Voice Notes: Hold the microphone button to record encrypted audio clips. Turn on Autoplay to hear them live.

    + + 🌪️ 100% Ephemeral Lifecycle: When everyone leaves a room, the room, its password, and its messages vanish completely from memory.

    + + Read the FAQ & Learn More →

    - +

    @@ -52,8 +52,7 @@

    - Don't trust me? I don't blame you. Review the source and host your own - server!
    + Don't trust me? I don't blame you. Review the open source code and host your own server!
    https://forgejo.benjimanmiller.com/ben/transient.chat diff --git a/static/server_list.html b/static/server_list.html index de3a0c9..b7205ee 100644 --- a/static/server_list.html +++ b/static/server_list.html @@ -3,8 +3,9 @@ - Transient.chat - Server List + Transient.chat - Active Rooms + @@ -12,62 +13,69 @@ Transient.chat -

    Server List

    +

    Server Browser

    +
    -
    +
    +
    + Loading active rooms... +
    -
    -

    ▶ US Regional Rooms

    - +

    Active Rooms

    +
    -

    ▶ Topical Rooms

    - +
    +

    Create or Join On-Demand Room

    +

    + All rooms are private, password-protected, and end-to-end encrypted. +

    +
    + + + + +
    +
    +
    -

    ▶ User Created Public Rooms

    - - -

    ▶ Watch Party Rooms

    - - -

    ▶ Active Rooms

    - - -
    - -
    - -
    -
    - - - -
    + + diff --git a/static/watchparty_chat.html b/static/watchparty_chat.html deleted file mode 100644 index ad10f39..0000000 --- a/static/watchparty_chat.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - Transient.chat - Watch Party - - - - - - -

    Watch Party Room

    - -
    - -
    -
    - - - - - -
    - -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    - - - -
    - -
    - -
    - 🔒 Encryption Keyring -
    - -
    -

    Users in room

    -
    -
    -
    - - - - - -