feat: initialize transient.chat application with static UI, blueprints, and state management
This commit is contained in:
parent
1a5f55eb93
commit
3accce44db
20 changed files with 1726 additions and 1572 deletions
109
README.md
109
README.md
|
|
@ -1,52 +1,45 @@
|
||||||
# Transient Chat
|
# 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.
|
There are no profiles. No feeds. No tracking. No persistent databases.
|
||||||
Just temporary rooms, simple handles, message expiration, and live synced watchrooms.
|
Just on-demand private encrypted rooms, zero plaintext in server memory, message expiration, and integrated toggleable watch parties.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
- All messages automatically disappear after **1 hour**
|
- **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.
|
||||||
- Empty rooms are purged — nothing is saved
|
- **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.
|
||||||
- Users are identified by transient handles, released when inactive
|
- **On-Demand Private Rooms:** Rooms are created on demand and protected by passwords.
|
||||||
- No databases, no disk writes, no persistence of any kind
|
- **Active Server Browser:** The server browser exclusively lists active rooms currently occupied by users.
|
||||||
- **Watchrooms** allow embedded synced YouTube viewing and conversation
|
- **Integrated Watch Party:** Any room can toggle YouTube synchronized playback on and off in real time.
|
||||||
- Works without WebSockets using traditional HTTP polling
|
- **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.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Regional and topical chatroom lists
|
- Zero-knowledge end-to-end encryption without complicated keyring management
|
||||||
- Synchronized YouTube playback in "watchparty" mode
|
- Ephemeral in-memory ciphertext storage (zero plaintext)
|
||||||
- Ephemeral message and session design
|
- On-demand room creation and password verification
|
||||||
- Audio indicators and user join/leave sounds
|
- Active-only server browser
|
||||||
- Owner moderation (kick users from rooms)
|
- Integrated YouTube Watch Party toggle in all rooms
|
||||||
- Admin panel for IP/username bans
|
- Ephemeral audio notes (WebM / Opus) with optional autoplay
|
||||||
- Admin routes protected by environment-based Basic Auth
|
- Room owner moderation (kick users)
|
||||||
- No database dependency (runs entirely in volatile memory)
|
- Admin dashboard for IP/username bans and system notifications
|
||||||
|
- Pure Vanilla CSS and lightweight modern architecture
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)**
|
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.
|
||||||
as a Python Flask application.
|
|
||||||
|
|
||||||
You should:
|
Set environment variables for admin login:
|
||||||
|
|
||||||
- Point DigitalOcean to this repo
|
|
||||||
- Specify Python as the runtime
|
|
||||||
- Set environment variables for your admin login:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ADMIN_USERNAME=your_admin_name
|
ADMIN_USERNAME=your_admin_name
|
||||||
|
|
@ -62,50 +55,40 @@ transient.chat/
|
||||||
├── app.py # Flask app factory and launcher
|
├── app.py # Flask app factory and launcher
|
||||||
├── config.py # Loads environment variables
|
├── config.py # Loads environment variables
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── state.py # In-memory shared state
|
│ ├── state.py # In-memory shared state & password hashing
|
||||||
│ └── cleanup.py # Background cleanup thread
|
│ └── cleanup.py # Background cleanup thread for empty rooms & expired msgs
|
||||||
├── blueprints/ # Modular route definitions
|
├── blueprints/ # Modular route definitions
|
||||||
│ ├── auth.py
|
│ ├── auth.py # Handle registration and token validation
|
||||||
│ ├── chat.py
|
│ ├── chat.py # Encrypted message relay and polling
|
||||||
│ ├── rooms.py
|
│ ├── rooms.py # Active rooms browser and password join/create
|
||||||
│ ├── admin.py
|
│ ├── admin.py # Admin dashboard endpoints
|
||||||
│ ├── watchparty.py
|
│ ├── watchparty.py # YouTube synchronized playback control
|
||||||
│ └── static_routes.py
|
│ └── static_routes.py
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── index.html
|
│ ├── index.html # Landing page & handle registration
|
||||||
│ ├── chat.html
|
│ ├── chat.html # Unified encrypted chat & watch party interface
|
||||||
│ ├── watchparty_chat.html
|
│ ├── server_list.html # Active rooms server browser & room creation
|
||||||
│ ├── server_list.html
|
│ ├── faq.html # Frequently asked questions & security details
|
||||||
│ ├── faq.html
|
│ ├── admin.html # Admin dashboard interface
|
||||||
│ ├── admin.html
|
|
||||||
│ └── assets/ # Frontend resources
|
│ └── assets/ # Frontend resources
|
||||||
│ ├── css/ # Stylesheets
|
│ ├── css/ # Vanilla CSS stylesheets
|
||||||
│ │ └── styles.css
|
│ │ ├── styles.css
|
||||||
│ ├── js/ # JavaScript files
|
│ │ ├── chat.css
|
||||||
│ │ ├── admin.js
|
│ │ ├── watchparty.css
|
||||||
│ │ ├── chat.js
|
│ │ └── admin.css
|
||||||
│ │ ├── faq.js
|
│ ├── 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
|
│ │ ├── index.js
|
||||||
│ │ ├── server_list.js
|
│ │ └── admin.js
|
||||||
│ │ └── watchparty_chat.js
|
│ ├── sounds/ # Notification audio clips (.wav)
|
||||||
│ ├── sounds/ # .wav audio clips
|
│ └── images/ # Logos and branding
|
||||||
│ │ ├── user-in.wav
|
|
||||||
│ │ ├── user-out.wav
|
|
||||||
│ │ └── message.wav
|
|
||||||
│ └── favicon.ico
|
|
||||||
└── requirements.txt # Python dependencies
|
└── 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 archived.
|
||||||
Nothing is retained.
|
Nothing is retained.
|
||||||
**Everything is ephemeral.**
|
**Everything is ephemeral.**
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from flask import Blueprint, request, jsonify, Response, send_from_directory
|
from flask import Blueprint, request, jsonify, Response, send_from_directory
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List, Tuple, Callable
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
@ -8,6 +9,8 @@ from models.state import (
|
||||||
user_sessions,
|
user_sessions,
|
||||||
user_ips,
|
user_ips,
|
||||||
room_users,
|
room_users,
|
||||||
|
room_passwords,
|
||||||
|
authorized_room_users,
|
||||||
messages,
|
messages,
|
||||||
banned_ips,
|
banned_ips,
|
||||||
banned_usernames,
|
banned_usernames,
|
||||||
|
|
@ -15,7 +18,6 @@ from models.state import (
|
||||||
video_state,
|
video_state,
|
||||||
room_owners,
|
room_owners,
|
||||||
nuked_rooms,
|
nuked_rooms,
|
||||||
public_chat_rooms,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__)
|
admin_bp = Blueprint("admin", __name__)
|
||||||
|
|
@ -23,21 +25,21 @@ admin_bp = Blueprint("admin", __name__)
|
||||||
SERVER_START_TIME = time.time()
|
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(
|
return username == os.getenv("ADMIN_USERNAME") and password == os.getenv(
|
||||||
"ADMIN_PASSWORD"
|
"ADMIN_PASSWORD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def authenticate():
|
def authenticate() -> Response:
|
||||||
return Response(
|
return Response(
|
||||||
"Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}
|
"Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def requires_auth(f):
|
def requires_auth(f: Callable) -> Callable:
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args: Any, **kwargs: Any) -> Any:
|
||||||
auth = request.authorization
|
auth = request.authorization
|
||||||
if not auth or not check_auth(auth.username, auth.password):
|
if not auth or not check_auth(auth.username, auth.password):
|
||||||
return authenticate()
|
return authenticate()
|
||||||
|
|
@ -48,19 +50,20 @@ def requires_auth(f):
|
||||||
|
|
||||||
@admin_bp.route("/admin")
|
@admin_bp.route("/admin")
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_panel():
|
def admin_panel() -> Response:
|
||||||
"""Serves the static admin HTML file."""
|
"""Serves the static admin HTML file."""
|
||||||
return send_from_directory("static", "admin.html")
|
return send_from_directory("static", "admin.html")
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/stats", methods=["GET"])
|
@admin_bp.route("/admin/stats", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_stats():
|
def admin_stats() -> Response:
|
||||||
total_messages = sum(len(msgs) for msgs in messages.values())
|
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({
|
return jsonify({
|
||||||
"uptime": int(time.time() - SERVER_START_TIME),
|
"uptime": int(time.time() - SERVER_START_TIME),
|
||||||
"active_users": len(user_sessions),
|
"active_users": len(user_sessions),
|
||||||
"active_rooms": len(room_users),
|
"active_rooms": len(active_room_names),
|
||||||
"total_messages": total_messages,
|
"total_messages": total_messages,
|
||||||
"banned_count": len(banned_ips) + len(banned_usernames)
|
"banned_count": len(banned_ips) + len(banned_usernames)
|
||||||
})
|
})
|
||||||
|
|
@ -68,7 +71,7 @@ def admin_stats():
|
||||||
|
|
||||||
@admin_bp.route("/admin/users", methods=["GET"])
|
@admin_bp.route("/admin/users", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_users():
|
def admin_users() -> Response:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -83,26 +86,27 @@ def admin_users():
|
||||||
|
|
||||||
@admin_bp.route("/admin/rooms", methods=["GET"])
|
@admin_bp.route("/admin/rooms", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_rooms():
|
def admin_rooms() -> Response:
|
||||||
|
all_rooms = set(room_users.keys()) | set(room_passwords.keys())
|
||||||
return jsonify(
|
return jsonify(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": r,
|
"name": r,
|
||||||
"users": list(room_users.get(r, {}).keys()),
|
"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"),
|
"owner": room_owners.get(r, "System"),
|
||||||
"msg_count": len(messages.get(r, [])),
|
"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"])
|
@admin_bp.route("/admin/banip", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_ban_ip():
|
def admin_ban_ip() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username = data.get("username")
|
||||||
ip_to_ban = user_ips.get(username)
|
ip_to_ban = user_ips.get(username)
|
||||||
|
|
||||||
|
|
@ -110,51 +114,52 @@ def admin_ban_ip():
|
||||||
return jsonify({"error": "no ip"}), 400
|
return jsonify({"error": "no ip"}), 400
|
||||||
|
|
||||||
banned_ips.add(ip_to_ban)
|
banned_ips.add(ip_to_ban)
|
||||||
|
|
||||||
user_sessions.pop(username, None)
|
user_sessions.pop(username, None)
|
||||||
|
|
||||||
for room in room_users:
|
for room in room_users:
|
||||||
room_users[room].pop(username, None)
|
room_users[room].pop(username, None)
|
||||||
|
for room in authorized_room_users:
|
||||||
|
authorized_room_users[room].discard(username)
|
||||||
|
|
||||||
return jsonify({"banned": ip_to_ban})
|
return jsonify({"banned": ip_to_ban})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/banusername", methods=["POST"])
|
@admin_bp.route("/admin/banusername", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_ban_username():
|
def admin_ban_username() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username = data.get("username")
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
return jsonify({"error": "no username"}), 400
|
return jsonify({"error": "no username"}), 400
|
||||||
|
|
||||||
banned_usernames.add(username)
|
banned_usernames.add(username)
|
||||||
|
|
||||||
user_sessions.pop(username, None)
|
user_sessions.pop(username, None)
|
||||||
|
|
||||||
for room in room_users:
|
for room in room_users:
|
||||||
room_users[room].pop(username, None)
|
room_users[room].pop(username, None)
|
||||||
|
for room in authorized_room_users:
|
||||||
|
authorized_room_users[room].discard(username)
|
||||||
|
|
||||||
return jsonify({"banned": username})
|
return jsonify({"banned": username})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/banrawip", methods=["POST"])
|
@admin_bp.route("/admin/banrawip", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_ban_raw_ip():
|
def admin_ban_raw_ip() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
ip = data.get("ip")
|
ip = data.get("ip")
|
||||||
|
|
||||||
if not ip:
|
if not ip:
|
||||||
return jsonify({"error": "no ip"}), 400
|
return jsonify({"error": "no ip"}), 400
|
||||||
|
|
||||||
banned_ips.add(ip)
|
banned_ips.add(ip)
|
||||||
|
|
||||||
return jsonify({"banned": ip})
|
return jsonify({"banned": ip})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/banned", methods=["GET"])
|
@admin_bp.route("/admin/banned", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_banned_list():
|
def admin_banned_list() -> Response:
|
||||||
banned = [{"type": "ip", "value": ip} for ip in banned_ips]
|
banned = [{"type": "ip", "value": ip} for ip in banned_ips]
|
||||||
banned += [{"type": "username", "value": uname} for uname in banned_usernames]
|
banned += [{"type": "username", "value": uname} for uname in banned_usernames]
|
||||||
return jsonify(banned)
|
return jsonify(banned)
|
||||||
|
|
@ -162,8 +167,8 @@ def admin_banned_list():
|
||||||
|
|
||||||
@admin_bp.route("/admin/releaseuser", methods=["POST"])
|
@admin_bp.route("/admin/releaseuser", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_release_user():
|
def admin_release_user() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username = data.get("username")
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
|
|
@ -174,26 +179,34 @@ def admin_release_user():
|
||||||
|
|
||||||
for room in room_users:
|
for room in room_users:
|
||||||
room_users[room].pop(username, None)
|
room_users[room].pop(username, None)
|
||||||
|
for room in authorized_room_users:
|
||||||
|
authorized_room_users[room].discard(username)
|
||||||
|
|
||||||
return jsonify({"released": username})
|
return jsonify({"released": username})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/broadcast", methods=["POST"])
|
@admin_bp.route("/admin/broadcast", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_broadcast():
|
def admin_broadcast() -> Tuple[Response, int] | Response:
|
||||||
"""Sends a system message to all active rooms."""
|
"""Sends a system notice to all active rooms."""
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
text = data.get("text")
|
text = data.get("text")
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
return jsonify({"error": "no text"}), 400
|
return jsonify({"error": "no text"}), 400
|
||||||
|
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
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
|
count = 0
|
||||||
for room in messages:
|
for room in list(room_users.keys()):
|
||||||
messages[room].append(message)
|
messages.setdefault(room, []).append(message)
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return jsonify({"status": "sent", "rooms_affected": count})
|
return jsonify({"status": "sent", "rooms_affected": count})
|
||||||
|
|
@ -201,30 +214,27 @@ def admin_broadcast():
|
||||||
|
|
||||||
@admin_bp.route("/admin/nukeroom", methods=["POST"])
|
@admin_bp.route("/admin/nukeroom", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_nuke_room():
|
def admin_nuke_room() -> Response:
|
||||||
"""Instantly deletes a room and clears its state."""
|
"""Instantly deletes a room and clears its state."""
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
room = data.get("room")
|
room = data.get("room")
|
||||||
|
|
||||||
# Lock the room for 10 seconds to force all clients to disconnect
|
|
||||||
nuked_rooms[room] = time.time() + 10
|
nuked_rooms[room] = time.time() + 10
|
||||||
|
|
||||||
messages.pop(room, None)
|
messages.pop(room, None)
|
||||||
room_users.pop(room, None)
|
room_users.pop(room, None)
|
||||||
|
room_passwords.pop(room, None)
|
||||||
|
authorized_room_users.pop(room, None)
|
||||||
room_owners.pop(room, None)
|
room_owners.pop(room, None)
|
||||||
|
|
||||||
video_state.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})
|
return jsonify({"status": "nuked", "room": room})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/unban", methods=["POST"])
|
@admin_bp.route("/admin/unban", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_unban():
|
def admin_unban() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
ban_type = data.get("type")
|
ban_type = data.get("type")
|
||||||
value = data.get("value")
|
value = data.get("value")
|
||||||
|
|
||||||
|
|
@ -243,10 +253,10 @@ def admin_unban():
|
||||||
|
|
||||||
@admin_bp.route("/admin/room/setowner", methods=["POST"])
|
@admin_bp.route("/admin/room/setowner", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_set_room_owner():
|
def admin_set_room_owner() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
room = data.get("room")
|
room = data.get("room")
|
||||||
owner = data.get("owner") # If None, clears the owner
|
owner = data.get("owner")
|
||||||
|
|
||||||
if not room:
|
if not room:
|
||||||
return jsonify({"error": "Missing room name"}), 400
|
return jsonify({"error": "Missing room name"}), 400
|
||||||
|
|
@ -261,8 +271,8 @@ def admin_set_room_owner():
|
||||||
|
|
||||||
@admin_bp.route("/admin/kick", methods=["POST"])
|
@admin_bp.route("/admin/kick", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_kick_user():
|
def admin_kick_user() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
room = data.get("room")
|
room = data.get("room")
|
||||||
target = data.get("target")
|
target = data.get("target")
|
||||||
|
|
||||||
|
|
@ -270,14 +280,18 @@ def admin_kick_user():
|
||||||
return jsonify({"error": "Missing room or target"}), 400
|
return jsonify({"error": "Missing room or target"}), 400
|
||||||
|
|
||||||
kicked_users.setdefault(room, set()).add(target)
|
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})
|
return jsonify({"kicked": target, "room": room})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/room/clearmessages", methods=["POST"])
|
@admin_bp.route("/admin/room/clearmessages", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_clear_room_messages():
|
def admin_clear_room_messages() -> Tuple[Response, int] | Response:
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
room = data.get("room")
|
room = data.get("room")
|
||||||
|
|
||||||
if not room:
|
if not room:
|
||||||
|
|
@ -289,9 +303,8 @@ def admin_clear_room_messages():
|
||||||
|
|
||||||
@admin_bp.route("/admin/room/messages", methods=["GET"])
|
@admin_bp.route("/admin/room/messages", methods=["GET"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def admin_room_messages():
|
def admin_room_messages() -> Tuple[Response, int] | Response:
|
||||||
room = request.args.get("room")
|
room = request.args.get("room")
|
||||||
if not room:
|
if not room:
|
||||||
return jsonify({"error": "Missing room parameter"}), 400
|
return jsonify({"error": "Missing room parameter"}), 400
|
||||||
return jsonify(messages.get(room, []))
|
return jsonify(messages.get(room, []))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,46 @@
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify, Response
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, Tuple
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from blueprints.auth import verify_user_key
|
||||||
from models.state import (
|
from models.state import (
|
||||||
messages,
|
messages,
|
||||||
room_users,
|
room_users,
|
||||||
|
authorized_room_users,
|
||||||
kicked_users,
|
kicked_users,
|
||||||
room_owners,
|
room_owners,
|
||||||
banned_ips,
|
banned_ips,
|
||||||
banned_usernames,
|
banned_usernames,
|
||||||
user_ips,
|
user_ips,
|
||||||
|
user_sessions,
|
||||||
nuked_rooms,
|
nuked_rooms,
|
||||||
)
|
)
|
||||||
|
|
||||||
chat_bp = Blueprint("chat", __name__)
|
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/<room>", methods=["GET", "POST"])
|
@chat_bp.route("/chat/<room>", methods=["GET", "POST"])
|
||||||
def chat_room(room):
|
def chat_room(room: str) -> Tuple[Response, int] | Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.json
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username: str = str(data.get("username", "")).strip()
|
||||||
text = data.get("text")
|
key: str = str(data.get("key", "")).strip()
|
||||||
audio = data.get("audio")
|
ciphertext: str = str(data.get("ciphertext", "")).strip()
|
||||||
|
iv: str = str(data.get("iv", "")).strip()
|
||||||
user_ip = request.remote_addr
|
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 (
|
if (
|
||||||
user_ip in banned_ips
|
user_ip in banned_ips
|
||||||
or username in banned_usernames
|
or username in banned_usernames
|
||||||
|
|
@ -47,23 +48,28 @@ def chat_room(room):
|
||||||
):
|
):
|
||||||
return jsonify({"error": "banned"}), 403
|
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 = {
|
message = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"text": text,
|
"ciphertext": ciphertext,
|
||||||
"audio": audio,
|
"iv": iv,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"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)
|
messages.setdefault(room, []).append(message)
|
||||||
if len(messages[room]) > 100:
|
if len(messages[room]) > 100:
|
||||||
messages[room] = messages[room][-100:]
|
messages[room] = messages[room][-100:]
|
||||||
|
|
||||||
|
return jsonify({"status": "sent"})
|
||||||
|
|
||||||
|
# GET endpoint (optional polling fallback)
|
||||||
since = request.args.get("since")
|
since = request.args.get("since")
|
||||||
room_messages = messages.setdefault(room, [])
|
room_messages = messages.setdefault(room, [])
|
||||||
|
|
||||||
|
|
@ -77,16 +83,15 @@ def chat_room(room):
|
||||||
]
|
]
|
||||||
return jsonify(new_msgs)
|
return jsonify(new_msgs)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Ignore bad timestamp
|
pass
|
||||||
|
|
||||||
return jsonify(room_messages)
|
return jsonify(room_messages)
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route("/chat/<room>/users", methods=["GET", "POST"])
|
@chat_bp.route("/chat/<room>/users", methods=["GET", "POST"])
|
||||||
def room_user_list(room):
|
def room_user_list(room: str) -> Tuple[Response, int] | Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
|
|
||||||
# Check if room is temporarily locked (nuked)
|
|
||||||
if room in nuked_rooms:
|
if room in nuked_rooms:
|
||||||
if time.time() < nuked_rooms[room]:
|
if time.time() < nuked_rooms[room]:
|
||||||
return jsonify({"error": "Room is unavailable"}), 403
|
return jsonify({"error": "Room is unavailable"}), 403
|
||||||
|
|
@ -94,56 +99,69 @@ def room_user_list(room):
|
||||||
nuked_rooms.pop(room, None)
|
nuked_rooms.pop(room, None)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.json
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username: str = str(data.get("username", "")).strip()
|
||||||
|
key: str = str(data.get("key", "")).strip()
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
return jsonify({"error": "No username"}), 400
|
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()):
|
if username in kicked_users.get(room, set()):
|
||||||
return jsonify({"error": "Kicked from room"}), 403
|
return jsonify({"error": "Kicked from room"}), 403
|
||||||
|
|
||||||
room_users.setdefault(room, {})
|
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()
|
room_users[room][username] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
users = room_users.get(room, {})
|
users = room_users.get(room, {})
|
||||||
response = {"users": list(users.keys())}
|
response: Dict[str, Any] = {"users": list(users.keys())}
|
||||||
if room in room_owners:
|
if room in room_owners:
|
||||||
response["owner"] = room_owners[room]
|
response["owner"] = room_owners[room]
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route("/chat/<room>/kick", methods=["POST"])
|
@chat_bp.route("/chat/<room>/kick", methods=["POST"])
|
||||||
def kick_user(room):
|
def kick_user(room: str) -> Tuple[Response, int] | Response:
|
||||||
data = request.json
|
room = room.strip()
|
||||||
owner = data.get("owner")
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
target = data.get("target")
|
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:
|
if not owner or not target:
|
||||||
return jsonify({"error": "Missing owner or target"}), 400
|
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:
|
if room_owners.get(room) != owner:
|
||||||
return jsonify({"error": "Not authorized"}), 403
|
return jsonify({"error": "Not authorized"}), 403
|
||||||
|
|
||||||
kicked_users.setdefault(room, set()).add(target)
|
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})
|
return jsonify({"kicked": target})
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route("/chat/<room>/poll", methods=["POST"])
|
@chat_bp.route("/chat/<room>/poll", methods=["POST"])
|
||||||
def poll_room(room):
|
def poll_room(room: str) -> Tuple[Response, int] | Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
data = request.json or {}
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
username = data.get("username")
|
username: str = str(data.get("username", "")).strip()
|
||||||
key = data.get("key")
|
key: str = str(data.get("key", "")).strip()
|
||||||
since = data.get("since")
|
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):
|
if not username or not key or not verify_user_key(username, key):
|
||||||
return jsonify({"error": "Invalid credentials"}), 403
|
return jsonify({"error": "Invalid credentials"}), 403
|
||||||
|
|
||||||
# Check if room is temporarily locked (nuked)
|
|
||||||
if room in nuked_rooms:
|
if room in nuked_rooms:
|
||||||
if time.time() < nuked_rooms[room]:
|
if time.time() < nuked_rooms[room]:
|
||||||
return jsonify({"error": "Room is unavailable"}), 403
|
return jsonify({"error": "Room is unavailable"}), 403
|
||||||
|
|
@ -153,21 +171,20 @@ def poll_room(room):
|
||||||
if username in kicked_users.get(room, set()):
|
if username in kicked_users.get(room, set()):
|
||||||
return jsonify({"error": "Kicked from room"}), 403
|
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
|
# Update user presence
|
||||||
room_users.setdefault(room, {})
|
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()
|
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] = {
|
user_sessions[username] = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"last_active": datetime.now(timezone.utc).isoformat(),
|
"last_active": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fetch messages
|
# Fetch encrypted messages from memory
|
||||||
room_messages = messages.setdefault(room, [])
|
room_messages = messages.setdefault(room, [])
|
||||||
filtered_messages = room_messages
|
filtered_messages = room_messages
|
||||||
if since:
|
if since:
|
||||||
|
|
@ -182,12 +199,11 @@ def poll_room(room):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
users_list = room_users.get(room, {})
|
users_list = room_users.get(room, {})
|
||||||
response = {
|
response: Dict[str, Any] = {
|
||||||
"messages": filtered_messages,
|
"messages": filtered_messages,
|
||||||
"users": list(users_list.keys())
|
"users": list(users_list.keys()),
|
||||||
}
|
}
|
||||||
if room in room_owners:
|
if room in room_owners:
|
||||||
response["owner"] = room_owners[room]
|
response["owner"] = room_owners[room]
|
||||||
|
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
from models.state import (
|
||||||
room_users,
|
room_users,
|
||||||
messages,
|
room_passwords,
|
||||||
public_chat_rooms,
|
authorized_room_users,
|
||||||
regional_chat_rooms,
|
room_owners,
|
||||||
topical_chat_rooms,
|
kicked_users,
|
||||||
|
nuked_rooms,
|
||||||
|
banned_ips,
|
||||||
|
banned_usernames,
|
||||||
|
user_ips,
|
||||||
video_state,
|
video_state,
|
||||||
|
hash_room_password,
|
||||||
|
verify_room_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
rooms_bp = Blueprint("rooms", __name__)
|
rooms_bp = Blueprint("rooms", __name__)
|
||||||
|
|
||||||
|
|
||||||
@rooms_bp.route("/rooms", methods=["GET", "POST"])
|
@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":
|
if request.method == "POST":
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
room_name = data.get("name", "").strip()
|
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 handle_room_join_or_create(room_name, password, username, key)
|
||||||
return jsonify({"error": "Room name required"}), 400
|
|
||||||
|
|
||||||
all_rooms = regional_chat_rooms + topical_chat_rooms + public_chat_rooms
|
# GET request: Return ONLY active rooms
|
||||||
if room_name in all_rooms:
|
active_rooms = []
|
||||||
return jsonify({"error": "Room already exists"}), 400
|
all_users = set()
|
||||||
|
|
||||||
public_chat_rooms.append(room_name)
|
for room, users in list(room_users.items()):
|
||||||
return jsonify({"name": room_name})
|
user_count = len(users)
|
||||||
|
if user_count > 0:
|
||||||
active_only = request.args.get("activeOnly") == "true"
|
all_users.update(users.keys())
|
||||||
|
has_wp = bool(
|
||||||
def build_room_data(room_list, active=False):
|
room in video_state
|
||||||
result = []
|
and (video_state[room].get("is_active") or video_state[room].get("url"))
|
||||||
for room in room_list:
|
)
|
||||||
users = room_users.get(room, {})
|
active_rooms.append(
|
||||||
if active and not users:
|
|
||||||
continue
|
|
||||||
result.append(
|
|
||||||
{
|
{
|
||||||
"name": room,
|
"name": room,
|
||||||
"users": len(users),
|
"users": user_count,
|
||||||
"type": "watchparty" if room in video_state else "chat",
|
"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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"regional": build_room_data(regional_chat_rooms, active_only),
|
"active_rooms": active_rooms,
|
||||||
"topical": build_room_data(topical_chat_rooms, active_only),
|
|
||||||
"public": build_room_data(public_chat_rooms, active_only),
|
|
||||||
"unique_users": len(all_users),
|
"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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,20 @@
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify, Response
|
||||||
from datetime import datetime, timezone
|
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 = Blueprint("watchparty", __name__)
|
||||||
|
|
||||||
|
|
||||||
@watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"])
|
@watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"])
|
||||||
def watchparty_video_control(room):
|
def watchparty_video_control(room: str) -> Tuple[Response, int] | Response:
|
||||||
room = room.strip()
|
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, {})
|
video_state.setdefault(room, {})
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.get_json()
|
data: Dict[str, Any] = request.get_json() or {}
|
||||||
url = data.get("url")
|
url: str = data.get("url", "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return jsonify({"error": "No video URL"}), 400
|
return jsonify({"error": "No video URL"}), 400
|
||||||
|
|
||||||
|
|
@ -27,14 +22,15 @@ def watchparty_video_control(room):
|
||||||
"url": url,
|
"url": url,
|
||||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"is_paused": False,
|
"is_paused": False,
|
||||||
|
"is_active": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonify({"status": "started"})
|
return jsonify({"status": "started", "url": url})
|
||||||
|
|
||||||
# Handle GET: return URL + elapsed time
|
# GET: return URL, elapsed time, is_paused, is_active
|
||||||
video_data = video_state.get(room)
|
video_data = video_state.get(room, {})
|
||||||
if not video_data or "url" not in video_data:
|
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:
|
try:
|
||||||
is_paused = video_data.get("is_paused", False)
|
is_paused = video_data.get("is_paused", False)
|
||||||
|
|
@ -43,46 +39,49 @@ def watchparty_video_control(room):
|
||||||
else:
|
else:
|
||||||
start_time = datetime.fromisoformat(video_data["started_at"])
|
start_time = datetime.fromisoformat(video_data["started_at"])
|
||||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||||
|
|
||||||
return jsonify(
|
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:
|
except Exception:
|
||||||
return jsonify({"error": "Invalid timestamp"}), 500
|
return jsonify({"is_active": False, "url": None, "elapsed": 0, "is_paused": False})
|
||||||
|
|
||||||
|
|
||||||
@watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"])
|
@watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"])
|
||||||
def clear_watchparty_video(room):
|
def clear_watchparty_video(room: str) -> Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
video_state.pop(room, None)
|
video_state.pop(room, None)
|
||||||
return jsonify({"status": "cleared"})
|
return jsonify({"status": "cleared"})
|
||||||
|
|
||||||
|
|
||||||
@watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"])
|
@watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"])
|
||||||
def watchparty_pause_video(room):
|
def watchparty_pause_video(room: str) -> Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
state = video_state.get(room)
|
state = video_state.get(room)
|
||||||
if not state or state.get("is_paused"):
|
if not state or state.get("is_paused"):
|
||||||
return jsonify({"status": "ignored"})
|
return jsonify({"status": "ignored"})
|
||||||
|
|
||||||
# Calculate elapsed time and freeze it
|
|
||||||
start_time = datetime.fromisoformat(state["started_at"])
|
start_time = datetime.fromisoformat(state["started_at"])
|
||||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||||
|
|
||||||
state["is_paused"] = True
|
state["is_paused"] = True
|
||||||
state["elapsed_at_pause"] = elapsed
|
state["elapsed_at_pause"] = max(0, int(elapsed))
|
||||||
|
|
||||||
return jsonify({"status": "paused"})
|
return jsonify({"status": "paused"})
|
||||||
|
|
||||||
|
|
||||||
@watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"])
|
@watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"])
|
||||||
def watchparty_resume_video(room):
|
def watchparty_resume_video(room: str) -> Response:
|
||||||
room = room.strip()
|
room = room.strip()
|
||||||
state = video_state.get(room)
|
state = video_state.get(room)
|
||||||
if not state or not state.get("is_paused"):
|
if not state or not state.get("is_paused"):
|
||||||
return jsonify({"status": "ignored"})
|
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)
|
elapsed = state.get("elapsed_at_pause", 0)
|
||||||
new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed
|
new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed
|
||||||
|
|
||||||
|
|
@ -91,3 +90,17 @@ def watchparty_resume_video(room):
|
||||||
state.pop("elapsed_at_pause", None)
|
state.pop("elapsed_at_pause", None)
|
||||||
|
|
||||||
return jsonify({"status": "resumed"})
|
return jsonify({"status": "resumed"})
|
||||||
|
|
||||||
|
|
||||||
|
@watchparty_bp.route("/watchparty/<room>/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"]})
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,23 @@ from datetime import datetime, timedelta, timezone
|
||||||
from models.state import (
|
from models.state import (
|
||||||
messages,
|
messages,
|
||||||
room_users,
|
room_users,
|
||||||
public_chat_rooms,
|
room_passwords,
|
||||||
|
authorized_room_users,
|
||||||
|
room_owners,
|
||||||
kicked_users,
|
kicked_users,
|
||||||
user_sessions,
|
user_sessions,
|
||||||
|
user_ips,
|
||||||
video_state,
|
video_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_loop():
|
def cleanup_loop() -> None:
|
||||||
|
"""Background loop that periodically cleans expired messages, inactive users, and empty rooms."""
|
||||||
while True:
|
while True:
|
||||||
# Remove chat messages older than 1 hour
|
now = datetime.now(timezone.utc)
|
||||||
message_cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
|
|
||||||
|
# 1. Remove chat messages older than 1 hour
|
||||||
|
message_cutoff = now - timedelta(hours=1)
|
||||||
for room, room_messages in list(messages.items()):
|
for room, room_messages in list(messages.items()):
|
||||||
messages[room] = [
|
messages[room] = [
|
||||||
msg
|
msg
|
||||||
|
|
@ -23,8 +29,8 @@ def cleanup_loop():
|
||||||
if datetime.fromisoformat(msg["timestamp"]) > message_cutoff
|
if datetime.fromisoformat(msg["timestamp"]) > message_cutoff
|
||||||
]
|
]
|
||||||
|
|
||||||
# Remove inactive users from rooms (after 1 min)
|
# 2. Remove inactive users from rooms (after 1 min of silence)
|
||||||
inactive_cutoff = datetime.now(timezone.utc) - timedelta(minutes=1)
|
inactive_cutoff = now - timedelta(minutes=1)
|
||||||
for room in list(room_users.keys()):
|
for room in list(room_users.keys()):
|
||||||
room_users[room] = {
|
room_users[room] = {
|
||||||
user: ts
|
user: ts
|
||||||
|
|
@ -32,31 +38,44 @@ def cleanup_loop():
|
||||||
if datetime.fromisoformat(ts) > inactive_cutoff
|
if datetime.fromisoformat(ts) > inactive_cutoff
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove inactive public rooms
|
# 3. Clean up on-demand rooms that have become empty
|
||||||
for room in list(public_chat_rooms):
|
# Rooms exist strictly while active; once empty for over 1 minute, everything vanishes.
|
||||||
last_active = room_users.get(room, {}).values()
|
for room in list(room_users.keys()):
|
||||||
if not last_active or all(
|
if not room_users[room]:
|
||||||
datetime.fromisoformat(ts) < inactive_cutoff for ts in last_active
|
|
||||||
):
|
|
||||||
if room in public_chat_rooms:
|
|
||||||
public_chat_rooms.remove(room)
|
|
||||||
messages.pop(room, None)
|
messages.pop(room, None)
|
||||||
room_users.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)
|
kicked_users.pop(room, None)
|
||||||
video_state.pop(room, None)
|
video_state.pop(room, None)
|
||||||
|
|
||||||
# Remove stale user sessions
|
# Also purge any dangling rooms in room_passwords that have no users
|
||||||
user_cutoff = datetime.now(timezone.utc) - timedelta(days=3)
|
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()):
|
for user, session in list(user_sessions.items()):
|
||||||
if datetime.fromisoformat(session["last_active"]) < user_cutoff:
|
if datetime.fromisoformat(session["last_active"]) < user_cutoff:
|
||||||
user_sessions.pop(user, None)
|
user_sessions.pop(user, None)
|
||||||
|
user_ips.pop(user, None)
|
||||||
|
|
||||||
time.sleep(60) # Run every 60 seconds
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
_cleanup_thread_started = False
|
_cleanup_thread_started = False
|
||||||
|
|
||||||
def start_cleanup_thread():
|
|
||||||
|
def start_cleanup_thread() -> None:
|
||||||
|
"""Starts daemon thread for ephemeral resource cleanup."""
|
||||||
global _cleanup_thread_started
|
global _cleanup_thread_started
|
||||||
if _cleanup_thread_started:
|
if _cleanup_thread_started:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
139
models/state.py
139
models/state.py
|
|
@ -1,118 +1,45 @@
|
||||||
from typing import Dict, List, Set
|
from typing import Dict, List, Set
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
# In-Memory Data Stores
|
# 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}}
|
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_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_ips: Set[str] = set()
|
||||||
banned_usernames: Set[str] = set()
|
banned_usernames: Set[str] = set()
|
||||||
user_ips: Dict[str, str] = {} # {username: ip_address}
|
user_ips: Dict[str, str] = {} # {username: ip_address}
|
||||||
kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}}
|
video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str, 'is_paused': bool, 'is_active': bool}}
|
||||||
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}}
|
|
||||||
nuked_rooms: Dict[str, float] = {} # {room_name: expiry_timestamp_epoch}
|
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 = [
|
def hash_room_password(password: str) -> str:
|
||||||
"Tech",
|
"""Hashes a room password using PBKDF2-HMAC-SHA256 with a cryptographically secure salt."""
|
||||||
"Science",
|
salt = secrets.token_hex(16)
|
||||||
"Gaming",
|
key_hash = hashlib.pbkdf2_hmac(
|
||||||
"Movies",
|
"sha256",
|
||||||
"Music",
|
password.encode("utf-8"),
|
||||||
"Books",
|
salt.encode("utf-8"),
|
||||||
"Current Events",
|
100000,
|
||||||
"Sports",
|
)
|
||||||
"Travel",
|
return f"{salt}:{key_hash.hex()}"
|
||||||
"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 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
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,71 @@
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: calc(100vh - 100px);
|
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 {
|
#chat-center {
|
||||||
flex: 1 1 700px;
|
flex: 1 1 700px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -24,6 +83,10 @@
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.watchparty-active #chat-center {
|
||||||
|
flex: 1 1 500px;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Messages === */
|
/* === Messages === */
|
||||||
#messages {
|
#messages {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
|
|
@ -42,17 +105,16 @@
|
||||||
|
|
||||||
#messages .message {
|
#messages .message {
|
||||||
line-height: 1.5em;
|
line-height: 1.5em;
|
||||||
padding: 4px 10px;
|
padding: 6px 12px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
background-color: #399fff8a;
|
background-color: #399fff8a;
|
||||||
color: #f0f0f0;
|
color: #f0f0f0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
text-align: left
|
text-align: left;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages .my-message {
|
#messages .my-message {
|
||||||
|
|
@ -62,6 +124,16 @@
|
||||||
color: #fff;
|
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 Input Form === */
|
||||||
#chat-form {
|
#chat-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -73,7 +145,6 @@
|
||||||
#message-input.inputfield {
|
#message-input.inputfield {
|
||||||
height: 42px;
|
height: 42px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
/* horizontal padding only */
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +161,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-list-container h3 {
|
#user-list-container h3 {
|
||||||
margin-top: 0;
|
margin-top: 10px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,23 +171,30 @@
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-list-container > button {
|
#user-list-container > button,
|
||||||
display: block; /* Forces buttons to stack vertically */
|
#user-list-container > a > button {
|
||||||
width: fit-content; /* Only as wide as the text, not "full screen" */
|
display: block;
|
||||||
margin-bottom: 4px;
|
width: 100%;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-list-container #user-list-toggle {
|
#user-list-container #user-list-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 750px) {
|
@media (max-width: 950px) {
|
||||||
#page-container {
|
#page-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.watchparty-active #video-section,
|
||||||
|
body.watchparty-active #chat-center {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#user-list-container {
|
#user-list-container {
|
||||||
display: block;
|
display: block;
|
||||||
order: -1;
|
order: -1;
|
||||||
|
|
@ -137,7 +215,7 @@
|
||||||
|
|
||||||
#user-list-container #user-list-toggle {
|
#user-list-container #user-list-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%; /* The toggle bar should be full width on mobile */
|
width: 100%;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
border-radius: 7px 7px 0 0;
|
border-radius: 7px 7px 0 0;
|
||||||
}
|
}
|
||||||
|
|
@ -183,4 +261,58 @@
|
||||||
|
|
||||||
.kick-button {
|
.kick-button {
|
||||||
margin-left: 0.5em;
|
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;
|
||||||
}
|
}
|
||||||
182
static/assets/css/server_list.css
Normal file
182
static/assets/css/server_list.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -76,16 +76,14 @@ async function loadRooms() {
|
||||||
|
|
||||||
const titleGroup = document.createElement('div');
|
const titleGroup = document.createElement('div');
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = r.type === 'watchparty'
|
link.href = `/chat.html?room=${encodeURIComponent(r.name)}${r.type === 'watchparty' ? '&watchparty=1' : ''}`;
|
||||||
? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}`
|
|
||||||
: `/chat.html?room=${encodeURIComponent(r.name)}`;
|
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
link.textContent = r.name;
|
link.textContent = r.name;
|
||||||
link.style.fontWeight = 'bold';
|
link.style.fontWeight = 'bold';
|
||||||
link.style.fontSize = '1.1em';
|
link.style.fontSize = '1.1em';
|
||||||
|
|
||||||
const typeSpan = document.createElement('span');
|
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.color = '#888';
|
||||||
typeSpan.style.fontSize = '0.95em';
|
typeSpan.style.fontSize = '0.95em';
|
||||||
typeSpan.style.marginLeft = '5px';
|
typeSpan.style.marginLeft = '5px';
|
||||||
|
|
@ -414,7 +412,11 @@ async function viewTranscript(room) {
|
||||||
} else {
|
} else {
|
||||||
contentDiv.textContent = msgs.map(m => {
|
contentDiv.textContent = msgs.map(m => {
|
||||||
const timeStr = new Date(m.timestamp).toLocaleTimeString();
|
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');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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() {
|
(function() {
|
||||||
let keyring = {}; // { keyId: { passphrase, key } }
|
// Helper: string to ArrayBuffer (UTF-8)
|
||||||
let activeKeyId = null;
|
|
||||||
|
|
||||||
// Helper: string to array buffer
|
|
||||||
function strToBuf(str) {
|
function strToBuf(str) {
|
||||||
return new TextEncoder().encode(str);
|
return new TextEncoder().encode(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: array buffer to string
|
// Helper: ArrayBuffer to string (UTF-8)
|
||||||
function bufToStr(buf) {
|
function bufToStr(buf) {
|
||||||
return new TextDecoder().decode(buf);
|
return new TextDecoder().decode(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: array buffer to hex string
|
// Helper: ArrayBuffer to hex string
|
||||||
function bufToHex(buf) {
|
function bufToHex(buf) {
|
||||||
return Array.from(new Uint8Array(buf))
|
return Array.from(new Uint8Array(buf))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: hex string to array buffer
|
// Helper: hex string to ArrayBuffer
|
||||||
function hexToBuf(hex) {
|
function hexToBuf(hex) {
|
||||||
if (!hex) return new Uint8Array(0).buffer;
|
if (!hex) return new Uint8Array(0).buffer;
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
|
@ -29,18 +32,13 @@
|
||||||
return bytes.buffer;
|
return bytes.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short hash of passphrase to identify the key (non-reversible identifier)
|
/**
|
||||||
async function computeKeyId(passphrase) {
|
* Derives a 256-bit AES-GCM key from the room password and room name.
|
||||||
const msgUint8 = strToBuf(passphrase);
|
* Uses PBKDF2 with SHA-256 and 100,000 iterations with a room-bound salt.
|
||||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8);
|
*/
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
window.deriveRoomKey = async function(password, roomName) {
|
||||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 8);
|
const passwordBuffer = strToBuf(password);
|
||||||
}
|
const saltBuffer = strToBuf("transient.chat.room." + roomName);
|
||||||
|
|
||||||
// Derive PBKDF2 key from passphrase
|
|
||||||
async function deriveKey(passphrase, roomName) {
|
|
||||||
const passwordBuffer = strToBuf(passphrase);
|
|
||||||
const saltBuffer = strToBuf("transient.chat.salt." + roomName);
|
|
||||||
|
|
||||||
const baseKey = await window.crypto.subtle.importKey(
|
const baseKey = await window.crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
|
|
@ -62,90 +60,38 @@
|
||||||
false,
|
false,
|
||||||
["encrypt", "decrypt"]
|
["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];
|
* Encrypts a message payload object (text + optional audio) using the derived room key.
|
||||||
if (activeKeyId === keyId) {
|
* Returns hex-encoded ciphertext and 96-bit (12-byte) initialization vector (IV).
|
||||||
const remaining = Object.keys(keyring);
|
*/
|
||||||
activeKeyId = remaining.length > 0 ? remaining[0] : null;
|
window.encryptRoomPayload = async function(cryptoKey, payloadObj) {
|
||||||
}
|
const jsonStr = JSON.stringify(payloadObj);
|
||||||
};
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
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 },
|
{ name: "AES-GCM", iv: iv },
|
||||||
keyring[keyId].key,
|
cryptoKey,
|
||||||
strToBuf(plaintext)
|
strToBuf(jsonStr)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ciphertext: bufToHex(encrypted),
|
ciphertext: bufToHex(encryptedBuffer),
|
||||||
iv: bufToHex(iv),
|
iv: bufToHex(iv)
|
||||||
keyId: keyId
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
window.decryptData = async function(ciphertextHex, ivHex, keyId) {
|
/**
|
||||||
if (!keyId || !keyring[keyId]) {
|
* Decrypts ciphertext and IV hex strings back to the original payload object.
|
||||||
throw new Error("Key not found in keyring");
|
*/
|
||||||
}
|
window.decryptRoomPayload = async function(cryptoKey, ciphertextHex, ivHex) {
|
||||||
const decrypted = await window.crypto.subtle.decrypt(
|
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: hexToBuf(ivHex) },
|
{ name: "AES-GCM", iv: hexToBuf(ivHex) },
|
||||||
keyring[keyId].key,
|
cryptoKey,
|
||||||
hexToBuf(ciphertextHex)
|
hexToBuf(ciphertextHex)
|
||||||
);
|
);
|
||||||
return bufToStr(decrypted);
|
|
||||||
|
return JSON.parse(bufToStr(decryptedBuffer));
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const room = params.get('room');
|
const room = params.get('room');
|
||||||
const isWatchParty = params.has('watchparty'); // Use explicit watchparty URL flag
|
const isWatchParty = params.has('watchparty');
|
||||||
|
|
||||||
function redirectToRoom(roomName) {
|
function redirectToRoom(roomName) {
|
||||||
if (!roomName) {
|
if (!roomName) {
|
||||||
window.location.href = '/server_list.html';
|
window.location.href = '/server_list.html';
|
||||||
} else {
|
} else {
|
||||||
window.location.href = isWatchParty
|
const wpParam = isWatchParty ? '&watchparty=1' : '';
|
||||||
? `/watchparty_chat.html?room=${encodeURIComponent(roomName)}`
|
window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`;
|
||||||
: `/chat.html?room=${encodeURIComponent(roomName)}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +28,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
redirectToRoom(room);
|
redirectToRoom(room);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Session expired or invalid; clear storage but pre-fill username
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
const input = document.getElementById('username-input');
|
const input = document.getElementById('username-input');
|
||||||
if (input) input.value = storedUsername;
|
if (input) input.value = storedUsername;
|
||||||
|
|
@ -56,7 +54,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const username = usernameInput.value.trim();
|
const username = usernameInput.value.trim();
|
||||||
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
alert('Please enter a username.');
|
alert('Please enter a handle/username.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +78,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
redirectToRoom(room);
|
redirectToRoom(room);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration error:', error);
|
console.error('Registration error:', error);
|
||||||
alert('An error occurred.');
|
alert('An error occurred during registration.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,23 +35,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User header bar
|
||||||
const userInfoEl = document.getElementById('user-info');
|
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');
|
const logoutBtn = document.createElement('button');
|
||||||
logoutBtn.textContent = 'Logout';
|
logoutBtn.textContent = 'Logout';
|
||||||
logoutBtn.style.backgroundColor = '#d9534f';
|
logoutBtn.className = 'logout-btn';
|
||||||
logoutBtn.style.color = 'white';
|
|
||||||
logoutBtn.style.border = 'none';
|
|
||||||
logoutBtn.style.padding = '6px 12px';
|
|
||||||
logoutBtn.style.borderRadius = '4px';
|
|
||||||
logoutBtn.style.cursor = 'pointer';
|
|
||||||
logoutBtn.onclick = async () => {
|
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 {
|
try {
|
||||||
await fetch('/logout', {
|
await fetch('/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -62,6 +53,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
console.error("Logout request failed:", e);
|
console.error("Logout request failed:", e);
|
||||||
}
|
}
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -71,163 +63,197 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
|
||||||
userInfoEl.append(logoutBtn, userSpan);
|
userInfoEl.append(logoutBtn, userSpan);
|
||||||
|
|
||||||
document.querySelectorAll('.collapsible').forEach(heading => {
|
const activeRoomsContainer = document.getElementById('active-rooms-container');
|
||||||
heading.addEventListener('click', () => {
|
const statsBadge = document.getElementById('stats-badge');
|
||||||
const content = heading.nextElementSibling;
|
|
||||||
const isOpen = content.style.display === 'block';
|
|
||||||
content.style.display = isOpen ? 'none' : 'block';
|
|
||||||
heading.textContent = (isOpen ? '▶' : '▼') + heading.textContent.slice(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch("/rooms")
|
// Modal elements
|
||||||
.then(response => response.json())
|
const joinModal = document.getElementById('join-modal');
|
||||||
.then(data => {
|
const joinModalForm = document.getElementById('join-modal-form');
|
||||||
const { regional, topical, public: publicRooms, unique_users } = data;
|
const modalRoomTitle = document.getElementById('modal-room-title');
|
||||||
|
const modalPasswordInput = document.getElementById('modal-room-password');
|
||||||
|
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
||||||
|
|
||||||
const regionalList = document.getElementById('regional-rooms');
|
let pendingJoinRoom = null;
|
||||||
const topicalList = document.getElementById('topical-rooms');
|
let pendingJoinHasWP = false;
|
||||||
const publicList = document.getElementById('public-rooms');
|
|
||||||
const watchpartyList = document.getElementById('watchparty-rooms');
|
|
||||||
|
|
||||||
// Helper to create a room <li>
|
function openJoinModal(roomName, hasWP) {
|
||||||
function createRoomItem({ name, users, type }) {
|
pendingJoinRoom = roomName;
|
||||||
const li = document.createElement('li');
|
pendingJoinHasWP = hasWP;
|
||||||
const a = document.createElement('a');
|
modalRoomTitle.textContent = `🔒 Join "${roomName}"`;
|
||||||
const isWatchParty = type && type.startsWith('watchparty');
|
modalPasswordInput.value = '';
|
||||||
a.href = isWatchParty
|
joinModal.classList.remove('hidden');
|
||||||
? `/watchparty_chat.html?room=${encodeURIComponent(name)}`
|
modalPasswordInput.focus();
|
||||||
: `/chat.html?room=${encodeURIComponent(name)}`;
|
|
||||||
a.textContent = `${name} ( ${users} )`;
|
|
||||||
li.appendChild(a);
|
|
||||||
return { li, isWatchParty };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add regional rooms
|
function closeJoinModal() {
|
||||||
regional.forEach(room => {
|
joinModal.classList.add('hidden');
|
||||||
regionalList.appendChild(createRoomItem(room).li);
|
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
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add topical rooms
|
if (joinBtn) joinBtn.disabled = false;
|
||||||
topical.forEach(room => {
|
|
||||||
topicalList.appendChild(createRoomItem(room).li);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add public/watchparty rooms
|
if (!res.ok) {
|
||||||
publicRooms.forEach(room => {
|
const err = await res.json().catch(() => ({}));
|
||||||
const { li, isWatchParty } = createRoomItem(room);
|
alert(err.error || 'Incorrect room password.');
|
||||||
if (isWatchParty) {
|
modalPasswordInput.value = '';
|
||||||
watchpartyList.appendChild(li);
|
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.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveRooms() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/rooms');
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch rooms');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const rooms = data.active_rooms || [];
|
||||||
|
const uniqueUsers = data.unique_users || 0;
|
||||||
|
|
||||||
|
statsBadge.innerHTML = `🟢 Active Rooms: <strong>${rooms.length}</strong> • 👥 Online Users: <strong>${uniqueUsers}</strong>`;
|
||||||
|
|
||||||
|
activeRoomsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (rooms.length === 0) {
|
||||||
|
activeRoomsContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
No active rooms right now.<br>Create an on-demand room below to start chatting!
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.forEach(room => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'room-card';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.className = 'room-info-left';
|
||||||
|
|
||||||
|
const titleLink = document.createElement('span');
|
||||||
|
titleLink.className = 'room-title-link';
|
||||||
|
titleLink.textContent = room.name;
|
||||||
|
|
||||||
|
const badges = document.createElement('div');
|
||||||
|
badges.className = 'room-badges';
|
||||||
|
badges.innerHTML = `
|
||||||
|
<span>👥 ${room.users} user${room.users === 1 ? '' : 's'}</span>
|
||||||
|
<span class="badge-encrypted">🔒 Encrypted</span>
|
||||||
|
${room.has_watchparty ? '<span class="badge-watchparty">🎬 Watch Party</span>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
left.append(titleLink, badges);
|
||||||
|
|
||||||
|
const joinBtn = document.createElement('button');
|
||||||
|
joinBtn.textContent = 'Join Room';
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
publicList.appendChild(li);
|
openJoinModal(room.name, room.has_watchparty);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
titleLink.addEventListener('click', handleJoin);
|
||||||
|
joinBtn.addEventListener('click', handleJoin);
|
||||||
|
|
||||||
|
card.append(left, joinBtn);
|
||||||
|
activeRoomsContainer.appendChild(card);
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
// Show total unique user count
|
console.error('Failed to load active rooms:', err);
|
||||||
document.getElementById('user-count').textContent = `Users Chatting ( ${unique_users} )`;
|
statsBadge.textContent = 'Failed to load rooms';
|
||||||
|
}
|
||||||
// 🧠 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;
|
|
||||||
|
|
||||||
function createActiveRoomItem({ name, users, type }) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
const a = document.createElement("a");
|
|
||||||
|
|
||||||
a.href = type === "watchparty"
|
|
||||||
? `/watchparty_chat.html?room=${encodeURIComponent(name)}`
|
|
||||||
: `/chat.html?room=${encodeURIComponent(name)}`;
|
|
||||||
a.textContent = `${name} ( ${users} )`;
|
|
||||||
li.appendChild(a);
|
|
||||||
return li;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[...regional, ...topical, ...publicRooms].forEach(room => {
|
await loadActiveRooms();
|
||||||
activeList.appendChild(createActiveRoomItem(room));
|
setInterval(loadActiveRooms, 3000); // Poll active rooms every 3 seconds
|
||||||
count++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Active Rooms heading with count
|
// On-Demand Room Creation Form
|
||||||
const activeHeadingEl = document.getElementById("active-rooms-header");
|
const createRoomForm = document.getElementById('create-room-form');
|
||||||
if (activeHeadingEl) {
|
if (createRoomForm) {
|
||||||
const baseText = activeHeadingEl.textContent.replace(/▶|▼|\(\d+\)/g, "").trim();
|
createRoomForm.addEventListener('submit', async (e) => {
|
||||||
activeHeadingEl.textContent = `▶ ${baseText} ( ${count} )`;
|
e.preventDefault();
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Failed to load active rooms:", err);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Failed to load rooms:", err);
|
|
||||||
alert("Failed to load rooms.");
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('create-room-btn').addEventListener('click', () => {
|
|
||||||
const roomName = document.getElementById('new-room-name').value.trim();
|
const roomName = document.getElementById('new-room-name').value.trim();
|
||||||
if (!roomName) return alert('Room name cannot be empty.');
|
const password = document.getElementById('new-room-password').value.trim();
|
||||||
|
const isWatchParty = document.getElementById('new-room-watchparty').checked;
|
||||||
|
|
||||||
fetch('/rooms', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: roomName })
|
body: JSON.stringify({
|
||||||
|
room: roomName,
|
||||||
|
password: password,
|
||||||
|
username: username,
|
||||||
|
key: userKey
|
||||||
})
|
})
|
||||||
.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', () => {
|
createBtn.disabled = false;
|
||||||
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
|
if (!res.ok) {
|
||||||
document.getElementById('create-watchparty-btn').addEventListener('click', () => {
|
const err = await res.json().catch(() => ({}));
|
||||||
const roomName = document.getElementById('new-room-name').value.trim();
|
alert(err.error || 'Failed to create room.');
|
||||||
if (!roomName) return alert('Room name cannot be empty.');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/rooms', {
|
// Store password in session
|
||||||
method: 'POST',
|
sessionStorage.setItem(`room_password_${roomName}`, password);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const wpParam = isWatchParty ? '&watchparty=1' : '';
|
||||||
body: JSON.stringify({ name: roomName })
|
window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`;
|
||||||
})
|
} catch (err) {
|
||||||
.then(response => {
|
createBtn.disabled = false;
|
||||||
if (!response.ok) throw new Error('Watch party creation failed.');
|
console.error('Room creation error:', err);
|
||||||
return response.json();
|
alert('Network error while creating room.');
|
||||||
})
|
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
|
|
@ -3,14 +3,47 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Transient.chat - Chat Room</title>
|
<title>Transient.chat - Private Encrypted Chat</title>
|
||||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||||
<link rel="stylesheet" href="/assets/css/chat.css" />
|
<link rel="stylesheet" href="/assets/css/chat.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/watchparty.css" />
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="room-title">Chat Room</h1>
|
<h1 id="room-title">Chat Room</h1>
|
||||||
|
|
||||||
<div id="page-container">
|
<div id="page-container">
|
||||||
|
<!-- Collapsible Watch Party Video Section -->
|
||||||
|
<div id="video-section" class="hidden">
|
||||||
|
<div id="video-controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="video-url-input"
|
||||||
|
placeholder="Paste YouTube video URL..."
|
||||||
|
/>
|
||||||
|
<button id="start-video-button">▶️ Play</button>
|
||||||
|
<button id="pause-video-button" class="hidden">⏸ Pause</button>
|
||||||
|
<button id="resume-video-button" class="hidden">▶ Resume</button>
|
||||||
|
<button id="clear-video-button" class="hidden">🛑 Clear Video</button>
|
||||||
|
</div>
|
||||||
|
<div class="video-wrapper">
|
||||||
|
<iframe
|
||||||
|
id="video-iframe"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
class="hidden"
|
||||||
|
></iframe>
|
||||||
|
<img
|
||||||
|
id="paused-overlay"
|
||||||
|
src="/assets/images/paused.png"
|
||||||
|
class="hidden"
|
||||||
|
alt="Video Paused"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Chat Center -->
|
||||||
<div id="chat-center">
|
<div id="chat-center">
|
||||||
<div id="messages"></div>
|
<div id="messages"></div>
|
||||||
<form id="chat-form">
|
<form id="chat-form">
|
||||||
|
|
@ -18,13 +51,15 @@
|
||||||
type="text"
|
type="text"
|
||||||
class="inputfield"
|
class="inputfield"
|
||||||
id="message-input"
|
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"
|
autocomplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Controls & Users -->
|
||||||
<div id="user-list-container">
|
<div id="user-list-container">
|
||||||
<a
|
<a
|
||||||
href="server_list.html"
|
href="server_list.html"
|
||||||
|
|
@ -61,15 +96,44 @@
|
||||||
🔒 External Content: Off
|
🔒 External Content: Off
|
||||||
</button>
|
</button>
|
||||||
<br />
|
<br />
|
||||||
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;">
|
<button
|
||||||
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong>
|
id="watchparty-toggle"
|
||||||
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div>
|
style="
|
||||||
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button>
|
margin-bottom: 10px;
|
||||||
</div>
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
🎬 Watch Party: Off
|
||||||
|
</button>
|
||||||
|
|
||||||
<h3>Users in room</h3>
|
<h3>Users in room</h3>
|
||||||
<div id="user-list"></div>
|
<div id="user-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Modal for Room Access & Key Derivation -->
|
||||||
|
<div id="password-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>🔒 Room Password Required</h3>
|
||||||
|
<p>This room is private and encrypted. Enter the room password to join and decrypt messages:</p>
|
||||||
|
<form id="password-form">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="modal-password-input"
|
||||||
|
placeholder="Room Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="submit" id="modal-submit-btn">Enter Room</button>
|
||||||
|
<a href="/server_list.html"><button type="button" class="cancel-btn">Back to Server List</button></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/assets/js/crypto.js"></script>
|
<script src="/assets/js/crypto.js"></script>
|
||||||
<script src="/assets/js/chat.js"></script>
|
<script src="/assets/js/chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<title>FAQ - Transient.chat</title>
|
<title>FAQ - Transient.chat</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Frequently asked questions about using Transient.chat"
|
content="Frequently asked questions about Transient.chat's encrypted, on-demand ephemeral messaging."
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||||
|
|
@ -24,56 +24,62 @@
|
||||||
<h1>Frequently Asked Questions</h1>
|
<h1>Frequently Asked Questions</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>How long do messages last?</h2>
|
<h2>How does encryption work?</h2>
|
||||||
<p>All chat messages automatically disappear after 1 hour.</p>
|
|
||||||
|
|
||||||
<h2>How long do rooms last?</h2>
|
|
||||||
<p>
|
<p>
|
||||||
All chat rooms automatically disappear after all users leave the room.
|
Transient.chat is built with an <strong>encryption-first</strong> 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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Do I need an account to use this?</h2>
|
<h2>Are all messages encrypted in server memory?</h2>
|
||||||
<p>
|
<p>
|
||||||
No. Just pick a handle and start chatting. If you don't come back in 3
|
Yes. The server only holds ephemeral encrypted ciphertext and initialization vectors (IVs) in volatile RAM.
|
||||||
days your handle is released.
|
The server never has access to the plaintext messages or audio notes.
|
||||||
|
Nothing is ever saved to a database or written to disk.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Is anything stored permanently?</h2>
|
<h2>Do all rooms require passwords?</h2>
|
||||||
<p>
|
<p>
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Is my chat secure / private?</h2>
|
<h2>How does the Server Browser work?</h2>
|
||||||
<p>
|
<p>
|
||||||
Yes! We support <strong>Zero-Knowledge End-to-End Encryption (E2EE)</strong>.
|
The server browser displays only <strong>active rooms</strong> currently occupied by chatting users.
|
||||||
You can enable encryption for any room by clicking <strong>+ Add Passphrase</strong> in the sidebar and entering a passphrase.
|
Empty rooms do not clutter the list. You can join any active room (with its password) or spin up a new room on demand.
|
||||||
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 <em>never</em> 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.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>What is a Watchroom?</h2>
|
<h2>Can any room be a Watch Party?</h2>
|
||||||
<p>
|
<p>
|
||||||
A Watchroom lets you sync YouTube playback with others while chatting.
|
Yes! Every room has an integrated Watch Party feature that can be toggled on or off at any time.
|
||||||
Everyone sees the same video at the same time.
|
When active, you can synchronize YouTube video playback with everyone in the room in real time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How long do messages and rooms last?</h2>
|
||||||
|
<p>
|
||||||
|
All messages automatically expire and vanish after <strong>1 hour</strong>.
|
||||||
|
When all users leave a room, the room itself, its password, video state, and all remaining messages are immediately purged from server memory.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Can I send voice messages?</h2>
|
<h2>Can I send voice messages?</h2>
|
||||||
<p>
|
<p>
|
||||||
Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button.
|
Yes. Hold down the microphone button to record encrypted audio messages (up to 15 seconds).
|
||||||
Enable <strong>Autoplay</strong> in the sidebar to hear messages automatically as they arrive.
|
You can enable <strong>Autoplay</strong> in the sidebar to hear new voice notes automatically as they arrive.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>What is External Content?</h2>
|
<h2>Do I need to create an account?</h2>
|
||||||
<p>
|
<p>
|
||||||
External images and videos linked by other users. You can toggle <strong>External Content</strong> 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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Can users be removed?</h2>
|
<h2>Where can I inspect the code?</h2>
|
||||||
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p>
|
<p>
|
||||||
|
Transient.chat is 100% open source. Check out the source code at:<br />
|
||||||
<h2>Where can I find the source code?</h2>
|
<a href="https://forgejo.benjimanmiller.com/ben/transient.chat">https://forgejo.benjimanmiller.com/ben/transient.chat</a>
|
||||||
<p><a href="https://forgejo.benjimanmiller.com/ben/transient.chat">https://forgejo.benjimanmiller.com/ben/transient.chat</a></p>
|
</p>
|
||||||
|
|
||||||
<br /><br />
|
<br /><br />
|
||||||
<a href="index.html">← Back to Home</a>
|
<a href="index.html">← Back to Home</a>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Transient.chat</title>
|
<title>Transient.chat - Private, Encrypted, Ephemeral Chat</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Live chats that vanish. No data. No history. Pure ephemerality."
|
content="Live encrypted chats on demand. Zero logs, zero disk storage, 100% ephemeral."
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||||
|
|
@ -25,26 +25,26 @@
|
||||||
<h1>Chat without a trace!</h1>
|
<h1>Chat without a trace!</h1>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Real-time conversations that disappear after
|
Real-time encrypted conversations that disappear after
|
||||||
<strong>1 hour</strong>.<br />
|
<strong>1 hour</strong>.<br />
|
||||||
No accounts. No data trails. Nothing is saved... ever.<br />
|
No accounts. No logs. No trackers. Nothing is saved... ever.<br />
|
||||||
Everything is held in server memory and is never written to any disk or
|
All messages are encrypted in memory on the server and never written to disk or database.<br /><br />
|
||||||
database. <br /><br />
|
|
||||||
Join a room and talk freely. When the room empties, everything
|
<strong>🔐 Encryption First & On-Demand Rooms:</strong> All rooms are created on demand and protected by passwords.
|
||||||
vanishes.<br />
|
Your room password automatically encrypts all messages and audio in your browser using AES-256-GCM. No confusing keys interface needed.<br /><br />
|
||||||
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
|
|
||||||
watching YouTube together.<br /><br />
|
<strong>🎬 Integrated Watch Party:</strong> Toggle YouTube synchronized playback on and off in any room at any time.<br /><br />
|
||||||
<strong>Voice Messages:</strong> Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.<br /><br />
|
|
||||||
<strong>End-to-End Encryption:</strong> Encrypt your chat rooms locally with passphrases. Keys remain strictly in your browser and are never sent to the server.<br /><br />
|
<strong>🎤 Ephemeral Voice Notes:</strong> Hold the microphone button to record encrypted audio clips. Turn on Autoplay to hear them live.<br /><br />
|
||||||
<strong>No tracking • No storage • 100% ephemeral</strong><br /><br />
|
|
||||||
If you're tired of feeds, filters, or forever — you're in the right
|
<strong>🌪️ 100% Ephemeral Lifecycle:</strong> When everyone leaves a room, the room, its password, and its messages vanish completely from memory.<br /><br />
|
||||||
place.
|
|
||||||
<a href="faq.html">Learn More →</a>
|
<a href="faq.html">Read the FAQ & Learn More →</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="login-container">
|
<div id="login-container">
|
||||||
<input type="text" id="username-input" placeholder="Enter Your Handle" />
|
<input type="text" id="username-input" placeholder="Choose a Handle" autocomplete="username" />
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<button id="start-chat-button">Start Chatting!</button>
|
<button id="start-chat-button">Start Chatting!</button>
|
||||||
|
|
@ -52,8 +52,7 @@
|
||||||
|
|
||||||
<div id="info-text">
|
<div id="info-text">
|
||||||
<p>
|
<p>
|
||||||
Don't trust me? I don't blame you. Review the source and host your own
|
Don't trust me? I don't blame you. Review the open source code and host your own server!<br />
|
||||||
server!<br />
|
|
||||||
<a href="https://forgejo.benjimanmiller.com/ben/transient.chat">
|
<a href="https://forgejo.benjimanmiller.com/ben/transient.chat">
|
||||||
https://forgejo.benjimanmiller.com/ben/transient.chat
|
https://forgejo.benjimanmiller.com/ben/transient.chat
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Transient.chat - Server List</title>
|
<title>Transient.chat - Active Rooms</title>
|
||||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/server_list.css" />
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -12,61 +13,68 @@
|
||||||
<a href="index.html">
|
<a href="index.html">
|
||||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
|
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
|
||||||
</a>
|
</a>
|
||||||
<h1>Server List</h1>
|
<h1>Server Browser</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="user-info"></div>
|
<div id="user-info"></div>
|
||||||
|
|
||||||
<div id="user-count" style="margin-top: 8px; font-weight: bold"></div>
|
<div id="server-browser-container">
|
||||||
|
<div id="stats-badge" class="stats-badge">
|
||||||
|
Loading active rooms...
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="chat-rooms">
|
<h2>Active Rooms</h2>
|
||||||
<h2 class="collapsible">▶ US Regional Rooms</h2>
|
<div id="active-rooms-container"></div>
|
||||||
<ul
|
|
||||||
id="regional-rooms"
|
|
||||||
class="collapsible-content"
|
|
||||||
style="display: none"
|
|
||||||
></ul>
|
|
||||||
|
|
||||||
<h2 class="collapsible">▶ Topical Rooms</h2>
|
<div class="form-card">
|
||||||
<ul
|
<h2>Create or Join On-Demand Room</h2>
|
||||||
id="topical-rooms"
|
<p class="form-subtitle">
|
||||||
class="collapsible-content"
|
All rooms are private, password-protected, and end-to-end encrypted.
|
||||||
style="display: none"
|
</p>
|
||||||
></ul>
|
<form id="create-room-form">
|
||||||
|
|
||||||
<h2 class="collapsible">▶ User Created Public Rooms</h2>
|
|
||||||
<ul
|
|
||||||
id="public-rooms"
|
|
||||||
class="collapsible-content"
|
|
||||||
style="display: none"
|
|
||||||
></ul>
|
|
||||||
|
|
||||||
<h2 class="collapsible">▶ Watch Party Rooms</h2>
|
|
||||||
<ul
|
|
||||||
id="watchparty-rooms"
|
|
||||||
class="collapsible-content"
|
|
||||||
style="display: none"
|
|
||||||
></ul>
|
|
||||||
|
|
||||||
<h2 class="collapsible" id="active-rooms-header">▶ Active Rooms</h2>
|
|
||||||
<ul
|
|
||||||
id="active-rooms"
|
|
||||||
class="collapsible-content"
|
|
||||||
style="display: none"
|
|
||||||
></ul>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<div id="create-room-form">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="new-room-name"
|
id="new-room-name"
|
||||||
placeholder="Enter New Room Name"
|
placeholder="Room Name"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<br />
|
<input
|
||||||
<div class="button-row">
|
type="password"
|
||||||
<button id="create-room-btn">Create Public Room</button>
|
id="new-room-password"
|
||||||
<button id="create-unlisted-room-btn">Create Unlisted Room</button>
|
placeholder="Room Password (required for encryption)"
|
||||||
<button id="create-watchparty-btn">Create Watch Party</button>
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input type="checkbox" id="new-room-watchparty" />
|
||||||
|
<span>Enable Watch Party mode immediately</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" id="create-room-btn">
|
||||||
|
Enter Room
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Prompt Modal for Joining an Active Room -->
|
||||||
|
<div id="join-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="modal-room-title">🔒 Enter Room Password</h3>
|
||||||
|
<p>This room is private and encrypted. Enter the password to join:</p>
|
||||||
|
<form id="join-modal-form">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="modal-room-password"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="submit" id="modal-join-btn">Join</button>
|
||||||
|
<button type="button" class="cancel-btn" id="modal-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Transient.chat - Watch Party</title>
|
|
||||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
|
||||||
<link rel="stylesheet" href="/assets/css/chat.css" />
|
|
||||||
<link rel="stylesheet" href="/assets/css/watchparty.css" />
|
|
||||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
|
||||||
</head>
|
|
||||||
<body class="watchparty">
|
|
||||||
<h1 id="room-title">Watch Party Room</h1>
|
|
||||||
|
|
||||||
<div id="video-chat-container">
|
|
||||||
<!-- Column 1: Video Section -->
|
|
||||||
<div id="video-section">
|
|
||||||
<div id="video-controls">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="video-url-input"
|
|
||||||
placeholder="Paste YouTube video URL..."
|
|
||||||
/>
|
|
||||||
<button id="start-video-button">▶️ Play</button>
|
|
||||||
<button id="pause-video-button" class="hidden">⏸ Pause</button>
|
|
||||||
<button id="resume-video-button" class="hidden">▶ Resume</button>
|
|
||||||
<button id="clear-video-button" class="hidden">
|
|
||||||
🛑 Clear Video
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="video-wrapper">
|
|
||||||
<iframe
|
|
||||||
id="video-iframe"
|
|
||||||
frameborder="0"
|
|
||||||
allowfullscreen
|
|
||||||
allow="autoplay; encrypted-media"
|
|
||||||
class="hidden"
|
|
||||||
></iframe>
|
|
||||||
<img
|
|
||||||
id="paused-overlay"
|
|
||||||
src="/assets/images/paused.png"
|
|
||||||
class="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column 2: Chat Section -->
|
|
||||||
<div id="chat-center">
|
|
||||||
<div id="messages"></div>
|
|
||||||
<form id="chat-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="inputfield"
|
|
||||||
id="message-input"
|
|
||||||
placeholder="Type a message, paste in a link, or url to an image or YouTube video."
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button type="submit">Send</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column 3: User List + Controls -->
|
|
||||||
<div id="user-list-container">
|
|
||||||
<a
|
|
||||||
href="server_list.html"
|
|
||||||
style="
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button>Server List</button>
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
id="sound-toggle"
|
|
||||||
style="
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: left;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
🔊 Sound: On
|
|
||||||
</button>
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
id="external-toggle"
|
|
||||||
style="
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
text-align: left;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
🔒 External Content: Off
|
|
||||||
</button>
|
|
||||||
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;">
|
|
||||||
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong>
|
|
||||||
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div>
|
|
||||||
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button>
|
|
||||||
</div>
|
|
||||||
<h3>Users in room</h3>
|
|
||||||
<div id="user-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/assets/js/crypto.js"></script>
|
|
||||||
<script src="/assets/js/chat.js"></script>
|
|
||||||
<script src="/assets/js/watchparty_chat.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Reference in a new issue