commit ad510c57e15119ce5c583be7dbec8e41e5c34a26 Author: ComputerTech Date: Sun Apr 12 17:55:40 2026 +0100 Initial commit: SexyChat (Aphrodite) v1.0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc4e5df --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# SexyChat – Environment Variables +# Copy to .env and fill in your values. Never commit .env to source control. + +# ── Flask ────────────────────────────────────────────────────────────────── +SECRET_KEY=change-me-flask-secret-key +HOST=0.0.0.0 +PORT=5000 +DEBUG=false + +# ── Database ─────────────────────────────────────────────────────────────── +# PostgreSQL (Recommended for production) +DATABASE_URL=postgresql://sexchat:sexchat_dev@localhost/sexchat +# SQLite fallback (used if DATABASE_URL is not set) +# DATABASE_URL=sqlite:///sexchat.db + +# ── Redis (Socket.IO adapter for multi-worker scale) ─────────────────────── +REDIS_URL=redis://localhost:6379 + +# ── Authentication ───────────────────────────────────────────────────────── +JWT_SECRET=change-me-jwt-secret +# JWT tokens expire after 7 days + +# ── Moderator ────────────────────────────────────────────────────────────── +ADMIN_PASSWORD=admin1234 + +# ── AI + Payment ─────────────────────────────────────────────────────────── +# Secret used to validate /api/payment/success webhook calls +PAYMENT_SECRET=change-me-payment-webhook-secret + +# Optional: real AI provider keys (leave blank to use mock responses) +# OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f994e0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.venv/ +env/ +venv/ +ENV/ + +# Database +instance/ +*.db +*.sqlite3 + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +*.log +access.log +error.log + +# PID files +*.pid + +# Artifacts +artifacts/ + +# OS +.DS_Store +Thumbs.db diff --git a/app.py b/app.py new file mode 100644 index 0000000..4953c67 --- /dev/null +++ b/app.py @@ -0,0 +1,785 @@ +""" +app.py – Flask-SocketIO backend for SexyChat (Phase 2). + +Architecture +------------ + Single 'lobby' room – ephemeral, nothing persisted. + PM rooms – AES-GCM encrypted; persisted for registered users. + AI ('violet') room – transit-decrypted for Ollama, re-encrypted for DB. + Inference queue – one Ollama request at a time; broadcasts violet_typing. + +Socket events (client → server) +-------------------------------- + join { mode, username, password?, email?, mod_password? } + message { text } + pm_open { target } + pm_accept { room } + pm_message { room, text? } | { room, ciphertext, nonce } + ai_message { ciphertext, nonce, transit_key } + mod_kick { target } + mod_ban { target } + mod_mute { target } + +Socket events (server → client) +-------------------------------- + joined { username, is_admin, is_registered, has_ai_access, + ai_messages_used, token? } + nicklist { users } + message { username, text, is_admin, is_registered, ts } + system { msg, ts } + error { msg } + kicked { msg } + pm_invite { from, room } + pm_ready { with, room } + pm_message { from, text?, ciphertext?, nonce?, room, ts } + violet_typing { busy: bool } + ai_response { ciphertext, nonce, ai_messages_used, has_ai_access } + or { error: 'ai_limit_reached' } + ai_unlock { msg } +""" + +import os +import time +import uuid +import base64 +import functools +from collections import defaultdict +from datetime import datetime, timedelta + +import bcrypt +import jwt as pyjwt +import eventlet # noqa – monkey-patched in start.py before any other import +from eventlet.queue import Queue as EvQueue + +from flask import Flask, request, send_from_directory +from flask_socketio import SocketIO, emit, join_room, disconnect + +from database import db, init_db +from models import User, Message, UserIgnore + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SECRET_KEY = os.environ.get("SECRET_KEY", uuid.uuid4().hex) +JWT_SECRET = os.environ.get("JWT_SECRET", uuid.uuid4().hex) +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin1234") +MAX_MSG_LEN = 500 +LOBBY = "lobby" +AI_FREE_LIMIT = 3 +AI_BOT_NAME = "Violet" + +# Ollama +OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") +VIOLET_MODEL = os.environ.get("VIOLET_MODEL", "dolphin-llama3.2") +VIOLET_SYSTEM = ( + "You are Violet, a flirtatious and sophisticated nightclub hostess at " + "an exclusive, dimly-lit members-only club. You are charming, witty, " + "and seductive — never crude or offensive. You speak with elegance, " + "mystery, and a hint of playful danger. Keep every reply to 1–3 " + "sentences maximum. You are in a private conversation with a special " + "guest who has caught your eye." +) + +# --------------------------------------------------------------------------- +# In-process state +# --------------------------------------------------------------------------- + +# sid → { username, ip, is_admin, joined_at, user_id, is_registered, +# has_ai_access, ai_messages_used } +connected_users: dict = {} +username_to_sid: dict = {} # lowercase_name → sid +muted_users: set = set() +banned_usernames: set = set() +banned_ips: set = set() +message_timestamps: dict = defaultdict(list) + +RATE_LIMIT = 6 +RATE_WINDOW = 5 + +# AI inference queue (one Ollama call at a time) +ai_queue: EvQueue = EvQueue() +_app_ref = None # set in create_app() for greenlet app-context access + +# --------------------------------------------------------------------------- +# AES-GCM helpers (server-side, transit only) +# --------------------------------------------------------------------------- + +def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + key = base64.b64decode(key_b64) + nonce = os.urandom(12) + ct = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None) + return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode() + + +def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + key = base64.b64decode(key_b64) + ct = base64.b64decode(ciphertext_b64) + nonce = base64.b64decode(nonce_b64) + return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8") + + +# --------------------------------------------------------------------------- +# Ollama integration +# --------------------------------------------------------------------------- + +def call_ollama(user_message: str) -> str: + """Call the local Ollama API. Returns plaintext AI response.""" + import requests as req + try: + resp = req.post( + f"{OLLAMA_URL}/api/chat", + json={ + "model": VIOLET_MODEL, + "messages": [ + {"role": "system", "content": VIOLET_SYSTEM}, + {"role": "user", "content": user_message}, + ], + "stream": False, + "options": {"temperature": 0.88, "num_predict": 120}, + }, + timeout=90, + ) + resp.raise_for_status() + return resp.json()["message"]["content"].strip() + except Exception as exc: + print(f"[Violet/Ollama] error: {exc}") + return "Give me just a moment, darling... 💜" + + +# --------------------------------------------------------------------------- +# AI inference queue worker (single greenlet, serialises Ollama calls) +# --------------------------------------------------------------------------- + +def _ai_worker() -> None: + """Eventlet greenlet – drains ai_queue one task at a time.""" + global _app_ref + + while True: + task = ai_queue.get() # blocks cooperatively until item available + + # ── Announce Violet is busy ─────────────────────────────────────────── + room = _pm_room(db.session.get(User, task["user_id"]).username, AI_BOT_NAME) if _app_ref else None + if room: + socketio.emit("violet_typing", {"busy": True, "room": room}, to=room) + else: + socketio.emit("violet_typing", {"busy": True}) + + # ── Decrypt user message (transit; key never stored) ────────────────── + try: + plaintext = _aesgcm_decrypt( + task["transit_key"], task["ciphertext"], task["nonce_val"] + ) + ai_text = call_ollama(plaintext) + except Exception as exc: + print(f"[Violet] processing error: {exc}") + ai_text = "Mmm, something went wrong, darling 💜" + + # ── Re-encrypt AI response ──────────────────────────────────────────── + resp_ct, resp_nonce = _aesgcm_encrypt(task["transit_key"], ai_text) + + ai_messages_used = task.get("ai_messages_used", 0) + has_ai_access = task.get("has_ai_access", False) + + # ── DB operations (need explicit app context in greenlet) ───────────── + with _app_ref.app_context(): + bot = User.query.filter_by(username=AI_BOT_NAME).first() + if bot and task.get("user_id"): + _save_pm(task["user_id"], bot.id, + task["ciphertext"], task["nonce_val"]) # user → Violet + _save_pm(bot.id, task["user_id"], + resp_ct, resp_nonce) # Violet → user + + if task.get("user_id") and not has_ai_access: + db_user = db.session.get(User, task["user_id"]) + if db_user and not db_user.has_ai_access: + db_user.ai_messages_used = min( + db_user.ai_messages_used + 1, AI_FREE_LIMIT + ) + db.session.commit() + ai_messages_used = db_user.ai_messages_used + has_ai_access = db_user.has_ai_access + + # Update in-process cache + sid = task["sid"] + if sid in connected_users: + connected_users[sid]["ai_messages_used"] = ai_messages_used + connected_users[sid]["has_ai_access"] = has_ai_access + + # ── Emit response to originating client ─────────────────────────────── + with _app_ref.app_context(): + db_user = db.session.get(User, task["user_id"]) + room = _pm_room(db_user.username, AI_BOT_NAME) + + socketio.emit("pm_message", { + "from": AI_BOT_NAME, + "ciphertext": resp_ct, + "nonce": resp_nonce, + "room": room, + "ts": _ts() + }, to=room) + + socketio.emit("violet_typing", {"busy": False, "room": room}, to=room) + ai_queue.task_done() + + # Clear typing indicator when queue drains + # Done in per-room emit above + + +# --------------------------------------------------------------------------- +# General helpers +# --------------------------------------------------------------------------- + +def _pm_room(a: str, b: str) -> str: + return "pm:" + ":".join(sorted([a.lower(), b.lower()])) + + +def _get_nicklist() -> list: + users = [] + for info in connected_users.values(): + if not info.get("username"): + continue + users.append({ + "username": info["username"], + "is_admin": info["is_admin"], + "is_registered": info.get("is_registered", False), + "is_verified": info.get("is_verified", False), + }) + # Static "Violet" AI user + users.append({ + "username": AI_BOT_NAME, + "is_admin": False, + "is_registered": True, + "is_verified": True, + "is_ai": True + }) + return sorted(users, key=lambda u: u["username"].lower()) + + +def _require_admin(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + user = connected_users.get(request.sid) + if not user or not user.get("is_admin"): + emit("error", {"msg": "Forbidden."}) + return + return f(*args, **kwargs) + return wrapped + + +def _rate_limited(sid: str) -> bool: + now = time.time() + message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW] + if len(message_timestamps[sid]) >= RATE_LIMIT: + return True + message_timestamps[sid].append(now) + return False + + +def _ts() -> str: + return time.strftime("%H:%M", time.localtime()) + + +def _do_disconnect(sid: str) -> None: + try: + socketio.server.disconnect(sid) + except Exception: + pass + + +def _issue_jwt(user_id: int, username: str) -> str: + return pyjwt.encode( + {"user_id": user_id, "username": username, + "exp": datetime.utcnow() + timedelta(days=7)}, + JWT_SECRET, algorithm="HS256", + ) + + +def _verify_jwt(token: str): + try: + return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + except Exception: + return None + + +def _save_pm(sender_id: int, recipient_id: int, + encrypted_content: str, nonce: str) -> None: + msg = Message( + sender_id=sender_id, + recipient_id=recipient_id, + encrypted_content=encrypted_content, + nonce=nonce, + ) + db.session.add(msg) + db.session.commit() + + +# --------------------------------------------------------------------------- +# SocketIO instance +# --------------------------------------------------------------------------- + +socketio = SocketIO() + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + +def create_app() -> Flask: + global _app_ref + + app = Flask(__name__, static_folder="static", template_folder=".") + app.config.update( + SECRET_KEY=SECRET_KEY, + SQLALCHEMY_DATABASE_URI=os.environ.get( + "DATABASE_URL", "sqlite:///sexchat.db" + ), + SQLALCHEMY_TRACK_MODIFICATIONS=False, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + ) + + init_db(app) + _app_ref = app + + msg_queue = ( + os.environ.get("SOCKETIO_MESSAGE_QUEUE") + or os.environ.get("REDIS_URL") + or None + ) + socketio.init_app( + app, + async_mode="eventlet", + cors_allowed_origins="*", + message_queue=msg_queue, + logger=False, + engineio_logger=False, + ) + + # Start the AI inference queue worker greenlet + eventlet.spawn(_ai_worker) + + from routes import api + app.register_blueprint(api) + + @app.route("/") + def index(): + return send_from_directory(".", "index.html") + + return app + + +# --------------------------------------------------------------------------- +# Socket – connection lifecycle +# --------------------------------------------------------------------------- + +@socketio.on("connect") +def on_connect(auth=None): + sid = request.sid + ip = request.environ.get("HTTP_X_FORWARDED_FOR", request.remote_addr) + + if ip in banned_ips: + emit("error", {"msg": "You are banned."}) + disconnect() + return False + + user_id = None; is_registered = False + has_ai_access = False; ai_used = 0; jwt_username = None + + if auth and isinstance(auth, dict) and auth.get("token"): + payload = _verify_jwt(auth["token"]) + if payload: + db_user = db.session.get(User, payload.get("user_id")) + if db_user: + user_id = db_user.id + is_registered = True + has_ai_access = db_user.has_ai_access + ai_used = db_user.ai_messages_used + jwt_username = db_user.username + + connected_users[sid] = { + "username": None, + "ip": ip, + "is_admin": False, + "joined_at": time.time(), + "user_id": user_id, + "is_registered": is_registered, + "has_ai_access": has_ai_access, + "ai_messages_used": ai_used, + "_jwt_username": jwt_username, + } + + +@socketio.on("disconnect") +def on_disconnect(): + sid = request.sid + user = connected_users.pop(sid, None) + message_timestamps.pop(sid, None) + if user and user.get("username"): + lower = user["username"].lower() + username_to_sid.pop(lower, None) + socketio.emit("system", {"msg": f"**{user['username']}** left the room.", "ts": _ts()}, to=LOBBY) + socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) + + +# --------------------------------------------------------------------------- +# Join / auth +# --------------------------------------------------------------------------- + +@socketio.on("join") +def on_join(data): + sid = request.sid + user = connected_users.get(sid) + if not user: + return + + mode = str(data.get("mode", "guest")).strip() + username = str(data.get("username", "")).strip()[:20] + password = str(data.get("password", "")).strip() + email = str(data.get("email", "")).strip()[:255] or None + token = None + db_user = None + + if mode == "register": + if not username or not username.replace("_","").replace("-","").isalnum(): + emit("error", {"msg": "Invalid username."}); return + if len(password) < 6: + emit("error", {"msg": "Password must be at least 6 characters."}); return + if username.lower() == AI_BOT_NAME.lower(): + emit("error", {"msg": "That username is reserved."}); return + if User.query.filter(db.func.lower(User.username) == username.lower()).first(): + emit("error", {"msg": "Username already registered."}); return + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + db_user = User(username=username, password_hash=hashed, email=email) + db.session.add(db_user); db.session.commit() + user.update(user_id=db_user.id, is_registered=True, + has_ai_access=False, ai_messages_used=0) + token = _issue_jwt(db_user.id, db_user.username) + + elif mode == "login": + db_user = User.query.filter( + db.func.lower(User.username) == username.lower() + ).first() + if not db_user or not bcrypt.checkpw(password.encode(), db_user.password_hash.encode()): + emit("error", {"msg": "Invalid username or password."}); return + if not db_user.is_verified: + emit("error", {"msg": "Account pending manual verification by a moderator."}); return + username = db_user.username + user["user_id"] = db_user.id + user["is_registered"] = True + user["has_ai_access"] = db_user.has_ai_access + user["ai_messages_used"] = db_user.ai_messages_used + token = _issue_jwt(db_user.id, db_user.username) + + elif mode == "restore": + if not user.get("user_id"): + emit("error", {"msg": "Session expired. Please log in again."}); return + db_user = db.session.get(User, user["user_id"]) + if not db_user: + emit("error", {"msg": "Account not found."}); return + if not db_user.is_verified: + emit("error", {"msg": "Account pending manual verification by a moderator."}); return + username = db_user.username + user["has_ai_access"] = db_user.has_ai_access + user["ai_messages_used"] = db_user.ai_messages_used + token = _issue_jwt(db_user.id, db_user.username) + + else: # guest + if not username or not username.replace("_","").replace("-","").isalnum(): + emit("error", {"msg": "Invalid username. Use letters, numbers, - or _."}); return + + lower = username.lower() + if lower in banned_usernames: + emit("error", {"msg": "That username is banned."}); return + if lower in username_to_sid and username_to_sid[lower] != sid: + emit("error", {"msg": "Username already in use."}); return + + is_admin = False + mod_pw = str(data.get("mod_password", "")).strip() + if mod_pw and mod_pw == ADMIN_PASSWORD: + is_admin = True + + user["username"] = username + user["is_admin"] = is_admin + user["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby + username_to_sid[lower] = sid + join_room(LOBBY) + + emit("joined", { + "username": username, + "is_admin": is_admin, + "is_registered": user["is_registered"], + "has_ai_access": user["has_ai_access"], + "ai_messages_used": user["ai_messages_used"], + "token": token, + "ignored_list": [u.username for u in db_user.ignoring] if db_user else [] + }) + emit("system", { + "msg": f"{'🛡️ ' if is_admin else ''}**{username}** joined the room.", + "ts": _ts(), + }, to=LOBBY) + socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) + + +# --------------------------------------------------------------------------- +# Lobby (ephemeral – never persisted) +# --------------------------------------------------------------------------- + +@socketio.on("message") +def on_message(data): + sid = request.sid + user = connected_users.get(sid) + if not user or not user.get("username"): + return + if user["username"].lower() in muted_users: + emit("error", {"msg": "You are muted."}); return + if _rate_limited(sid): + emit("error", {"msg": "Slow down!"}); return + text = str(data.get("text", "")).strip()[:MAX_MSG_LEN] + if not text: + return + emit("message", { + "username": user["username"], + "text": text, + "is_admin": user["is_admin"], + "is_registered": user.get("is_registered", False), + "ts": _ts(), + }, to=LOBBY) + + +# --------------------------------------------------------------------------- +# Private Messaging +# --------------------------------------------------------------------------- + +@socketio.on("pm_open") +def on_pm_open(data): + sid = request.sid + user = connected_users.get(sid) + if not user or not user["username"]: + return + target = str(data.get("target", "")).strip() + target_sid = username_to_sid.get(target.lower()) + + # "Violet" virtual connection + if not target_sid and target.lower() == AI_BOT_NAME.lower(): + # She's always online + pass + elif not target_sid: + emit("error", {"msg": f"{target} is not online."}); return + + # Check if target has ignored me + target_info = connected_users.get(target_sid) + if target_info and target_info.get("user_id"): + target_db = db.session.get(User, target_info["user_id"]) + me_db = User.query.filter(db.func.lower(User.username) == user["username"].lower()).first() + if target_db and me_db and me_db in target_db.ignoring: + # We don't want to tell the requester they are ignored (stealth) + # but we just don't send the invite. + # Actually, to make it clear why nothing is happening: + emit("error", {"msg": f"{target} is not accepting messages from you right now."}) + return + + room = _pm_room(user["username"], target) + join_room(room) + socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=target_sid) + emit("pm_ready", {"with": target, "room": room}) + + + +@socketio.on("pm_accept") +def on_pm_accept(data): + join_room(data.get("room")) + + +@socketio.on("pm_message") +def on_pm_message(data): + sid = request.sid + user = connected_users.get(sid) + if not user or not user["username"]: + return + room = str(data.get("room", "")) + if not room.startswith("pm:"): + return + + ciphertext = data.get("ciphertext", "") + nonce_val = data.get("nonce", "") + text = str(data.get("text", "")).strip()[:MAX_MSG_LEN] + is_encrypted = bool(ciphertext and nonce_val) + + if not is_encrypted and not text: + return + + ts = _ts() + payload = ( + {"from": user["username"], "ciphertext": ciphertext, + "nonce": nonce_val, "room": room, "ts": ts} + if is_encrypted else + {"from": user["username"], "text": text, "room": room, "ts": ts} + ) + ) + + # Route to AI if recipient is Violet + if room.endswith(f":{AI_BOT_NAME.lower()}"): + if not user.get("user_id"): + emit("error", {"msg": "You must be registered to chat with Violet."}); return + if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT: + emit("pm_message", {"from": AI_BOT_NAME, "text": "ai_limit_reached", "room": room, "system": True}, to=sid) + return + + transit_key = data.get("transit_key", "") + if not all([ciphertext, nonce_val, transit_key]): + emit("error", {"msg": "AI Private Messaging requires transit encryption."}); return + + ai_queue.put({ + "sid": sid, + "user_id": user["user_id"], + "has_ai_access": user["has_ai_access"], + "ai_messages_used": user["ai_messages_used"], + "ciphertext": ciphertext, + "nonce_val": nonce_val, + "transit_key": transit_key, + }) + return # ai_worker will handle the delivery + + emit("pm_message", payload, to=room) + + if is_encrypted and user.get("user_id"): + parts = room.split(":")[1:] + my_lower = user["username"].lower() + other_low = next((p for p in parts if p != my_lower), None) + if other_low: + other_sid = username_to_sid.get(other_low) + other_info = connected_users.get(other_sid, {}) if other_sid else {} + if other_info.get("user_id"): + _save_pm(user["user_id"], other_info["user_id"], + ciphertext, nonce_val) + + +# --------------------------------------------------------------------------- +# AI – Violet (queued Ollama inference) +# --------------------------------------------------------------------------- + +@socketio.on("ai_message") +def on_ai_message(data): + # Deprecated: use pm_message to Violet instead + pass + + +# --------------------------------------------------------------------------- +# Mod tools +# --------------------------------------------------------------------------- + +@socketio.on("mod_kick") +@_require_admin +def on_kick(data): + target = str(data.get("target", "")).strip() + target_sid = username_to_sid.get(target.lower()) + if not target_sid: + emit("error", {"msg": f"{target} is not online."}); return + socketio.emit("kicked", {"msg": "You have been kicked by a moderator."}, to=target_sid) + socketio.emit("system", {"msg": f"🚫 **{target}** was kicked.", "ts": _ts()}, to=LOBBY) + eventlet.spawn_after(0.5, _do_disconnect, target_sid) + + +@socketio.on("mod_ban") +@_require_admin +def on_ban(data): + target = str(data.get("target", "")).strip() + lower = target.lower() + banned_usernames.add(lower) + target_sid = username_to_sid.get(lower) + if target_sid: + info = connected_users.get(target_sid, {}) + if info.get("ip"): + banned_ips.add(info["ip"]) + socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid) + eventlet.spawn_after(0.5, _do_disconnect, target_sid) + socketio.emit("system", {"msg": f"🔨 **{target}** was banned.", "ts": _ts()}, to=LOBBY) + + +@socketio.on("mod_mute") +@_require_admin +def on_mute(data): + target = str(data.get("target", "")).strip() + lower = target.lower() + if lower in muted_users: + muted_users.discard(lower); action = "unmuted" + else: + muted_users.add(lower); action = "muted" + emit("system", {"msg": f"🔇 **{target}** was {action}.", "ts": _ts()}, to=LOBBY) + + +@socketio.on("mod_kickban") +@_require_admin +def on_kickban(data): + target = str(data.get("target", "")).strip() + lower = target.lower() + # Ban + banned_usernames.add(lower) + target_sid = username_to_sid.get(lower) + if target_sid: + info = connected_users.get(target_sid, {}) + if info.get("ip"): + banned_ips.add(info["ip"]) + socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid) + eventlet.spawn_after(0.5, _do_disconnect, target_sid) + # Announce + socketio.emit("system", {"msg": f"💀 **{target}** was kickbanned.", "ts": _ts()}, to=LOBBY) + + +@socketio.on("user_ignore") +def on_ignore(data): + sid = request.sid + user = connected_users.get(sid) + if not user or not user.get("user_id"): + return + + target_name = str(data.get("target", "")).strip() + target_user = User.query.filter(db.func.lower(User.username) == target_name.lower()).first() + + if target_user: + me = db.session.get(User, user["user_id"]) + if target_user not in me.ignoring: + me.ignoring.append(target_user) + db.session.commit() + emit("ignore_status", {"target": target_user.username, "ignored": True}) + + +@socketio.on("user_unignore") +def on_unignore(data): + sid = request.sid + user = connected_users.get(sid) + if not user or not user.get("user_id"): + return + + target_name = str(data.get("target", "")).strip() + me = db.session.get(User, user["user_id"]) + target_user = me.ignoring.filter(db.func.lower(User.username) == target_name.lower()).first() + + if target_user: + me.ignoring.remove(target_user) + db.session.commit() + emit("ignore_status", {"target": target_user.username, "ignored": False}) + + +@socketio.on("mod_verify") +@_require_admin +def on_verify(data): + target_name = str(data.get("target", "")).strip() + target_user = User.query.filter(db.func.lower(User.username) == target_name.lower()).first() + + if target_user: + target_user.is_verified = True + db.session.commit() + + # Update online status if target is currently online as a guest + target_sid = username_to_sid.get(target_name.lower()) + if target_sid: + target_info = connected_users.get(target_sid) + if target_info: + target_info["is_verified"] = True + + socketio.emit("system", {"msg": f"✅ **{target_user.username}** has been verified by a moderator.", "ts": _ts()}, to=LOBBY) + socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY) diff --git a/database.py b/database.py new file mode 100644 index 0000000..4b362f1 --- /dev/null +++ b/database.py @@ -0,0 +1,44 @@ +""" +database.py – SQLAlchemy + Flask-Migrate initialisation for SexyChat. + +Import the `db` object everywhere you need ORM access. +Call `init_db(app)` inside the Flask app factory. +""" + +import os +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate + +db = SQLAlchemy() +migrate = Migrate() + + +def init_db(app: "Flask") -> None: # noqa: F821 + """Bind SQLAlchemy and Migrate to the Flask application and create tables.""" + db.init_app(app) + migrate.init_app(app, db) + + with app.app_context(): + # Import models so SQLAlchemy knows about them before create_all() + import models # noqa: F401 + db.create_all() + _seed_ai_bot() + + +def _seed_ai_bot() -> None: + """Ensure the SexyAI virtual user exists (used as AI-message recipient in DB).""" + from models import User + + if User.query.filter_by(username="Violet").first(): + return + + import bcrypt, os as _os + + bot = User( + username="Violet", + password_hash=bcrypt.hashpw(_os.urandom(32), bcrypt.gensalt()).decode(), + has_ai_access=True, + ai_messages_used=0, + ) + db.session.add(bot) + db.session.commit() diff --git a/index.html b/index.html new file mode 100644 index 0000000..39b1d15 --- /dev/null +++ b/index.html @@ -0,0 +1,183 @@ + + + + + + + + Sexy Chat | Deep Purple & Neon Magenta + + + + + + + + + + + + + + + + +
+
+ + +
+ + + +
+ +
+
+
+ +
+ + + + + + +
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + diff --git a/models.py b/models.py new file mode 100644 index 0000000..fa105d9 --- /dev/null +++ b/models.py @@ -0,0 +1,82 @@ +""" +models.py – SQLAlchemy ORM models for SexyChat. + +Tables +------ +users – Registered accounts +messages – Encrypted PM history (user↔user and user↔AI) +""" + +from datetime import datetime +from database import db + + +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(20), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(128), nullable=False) + email = db.Column(db.String(255), unique=True, nullable=True) + has_ai_access = db.Column(db.Boolean, default=False, nullable=False) + ai_messages_used = db.Column(db.Integer, default=0, nullable=False) + is_verified = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + sent_messages = db.relationship( + "Message", foreign_keys="Message.sender_id", + backref="sender", lazy="dynamic", + ) + received_messages = db.relationship( + "Message", foreign_keys="Message.recipient_id", + backref="recipient", lazy="dynamic", + ) + + # Persistent "Ignore" list + ignoring = db.relationship( + "User", + secondary="user_ignores", + primaryjoin="User.id == UserIgnore.ignorer_id", + secondaryjoin="User.id == UserIgnore.ignored_id", + backref=db.backref("ignored_by", lazy="dynamic"), + lazy="dynamic" + ) + + def __repr__(self): + return f"" + + +class UserIgnore(db.Model): + __tablename__ = "user_ignores" + + id = db.Column(db.Integer, primary_key=True) + ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + ignored_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + __table_args__ = ( + db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True), + ) + + + +class Message(db.Model): + __tablename__ = "messages" + + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + recipient_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + # AES-GCM ciphertext – base64 encoded; server never stores plaintext + encrypted_content = db.Column(db.Text, nullable=False) + # AES-GCM nonce / IV – base64 encoded (12 bytes → 16 chars) + nonce = db.Column(db.String(64), nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + __table_args__ = ( + # Composite indices for the two common query patterns + db.Index("ix_msg_recipient_ts", "recipient_id", "timestamp"), + db.Index("ix_msg_sender_ts", "sender_id", "timestamp"), + ) + + def __repr__(self): + return f"" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cf51d4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +# SexyChat – Python dependencies +# Install: pip install -r requirements.txt + +# ── Core ─────────────────────────────────────────────────────────────────── +flask>=3.0,<4.0 +flask-socketio>=5.3,<6.0 +eventlet>=0.35,<1.0 +gunicorn>=21.0,<22.0 + +# ── Database ─────────────────────────────────────────────────────────────── +flask-sqlalchemy>=3.1,<4.0 +flask-migrate>=4.0,<5.0 +psycopg2-binary>=2.9,<3.0 # PostgreSQL driver + +# ── Auth & security ──────────────────────────────────────────────────────── +bcrypt>=4.0,<5.0 +PyJWT>=2.8,<3.0 + +# ── Crypto (server-side AES-GCM for transit decryption) ─────────────────── +cryptography>=42.0,<45.0 + +# ── Redis (Socket.IO adapter for multi-worker horizontal scaling) ────────── +redis>=5.0,<6.0 + +# ── Ollama HTTP client ────────────────────────────────────────────────────── +requests>=2.31,<3.0 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..c9af3a9 --- /dev/null +++ b/routes.py @@ -0,0 +1,370 @@ +""" +routes.py – REST API blueprint for SexyChat. + +Endpoints +--------- +POST /api/auth/register – Create account, return JWT +POST /api/auth/login – Verify credentials, return JWT +GET /api/pm/history – Last 500 encrypted messages for a conversation +POST /api/ai/message – Transit-decrypt, get AI response, re-encrypt, persist +POST /api/payment/success – Validate webhook secret, unlock AI, push socket event +""" + +import os +import base64 +import hmac +import random +import functools +from datetime import datetime, timedelta + +import bcrypt +import jwt as pyjwt +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from flask import Blueprint, g, jsonify, request + +from database import db +from models import User, Message + +api = Blueprint("api", __name__, url_prefix="/api") + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-jwt-secret") +JWT_EXPIRY_DAYS = 7 +AI_FREE_LIMIT = 3 +PAYMENT_SECRET = os.environ.get("PAYMENT_SECRET", "change-me-payment-secret") +MAX_HISTORY = 500 +AI_BOT_NAME = "Violet" + +AI_RESPONSES = [ + "Mmm, you have my full attention 💋", + "Oh my... keep going 😈 Don't stop there.", + "You're bold. I like that 🔥 Most guests aren't this brave.", + "I wasn't expecting that... but I'm definitely not complaining 😏", + "Well well well, aren't you something 👀 Tell me everything.", + "That's deliciously naughty 🌙 You're dangerous, aren't you?", + "You certainly know how to make an evening interesting 💜", + "I love the way you think 😌 It's... refreshing.", + "A dark club, a conversation like this... 🕯️ This is my kind of night.", + "Say that again. Slower. 💋", + "*sets down the wine glass* Is it warm in here, or is it just you? 🔥", + "You're trouble. The most delicious kind 😈", + "I don't usually admit when a guest surprises me, but here we are 🌙", + "Keep talking. I could listen to this all night 💜", +] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _issue_jwt(user_id: int, username: str) -> str: + payload = { + "user_id": user_id, + "username": username, + "exp": datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS), + } + return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +def _verify_jwt(token: str): + try: + return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + except pyjwt.PyJWTError: + return None + + +def _require_auth(f): + """Decorator – parse Bearer JWT and populate g.current_user.""" + @functools.wraps(f) + def wrapped(*args, **kwargs): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return jsonify({"error": "Unauthorized"}), 401 + payload = _verify_jwt(auth_header[7:]) + if not payload: + return jsonify({"error": "Invalid or expired token"}), 401 + user = db.session.get(User, payload["user_id"]) + if not user: + return jsonify({"error": "User not found"}), 401 + g.current_user = user + return f(*args, **kwargs) + return wrapped + + +def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple: + """Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64).""" + key_bytes = base64.b64decode(key_b64) + nonce = os.urandom(12) + ct = AESGCM(key_bytes).encrypt(nonce, plaintext.encode("utf-8"), None) + return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode() + + +def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str: + """Decrypt AES-GCM ciphertext. Raises on authentication failure.""" + key_bytes = base64.b64decode(key_b64) + ct = base64.b64decode(ciphertext_b64) + nonce = base64.b64decode(nonce_b64) + return AESGCM(key_bytes).decrypt(nonce, ct, None).decode("utf-8") + + +def _persist_message(sender_id: int, recipient_id: int, + encrypted_content: str, nonce: str) -> None: + """Save a PM to the database. Enforces MAX_HISTORY per conversation pair.""" + msg = Message( + sender_id=sender_id, + recipient_id=recipient_id, + encrypted_content=encrypted_content, + nonce=nonce, + ) + db.session.add(msg) + db.session.commit() + + # Lazy pruning: cap conversation history at MAX_HISTORY + pair_ids = [sender_id, recipient_id] + total = ( + Message.query + .filter( + Message.sender_id.in_(pair_ids), + Message.recipient_id.in_(pair_ids), + ) + .count() + ) + if total > MAX_HISTORY: + excess = total - MAX_HISTORY + oldest = ( + Message.query + .filter( + Message.sender_id.in_(pair_ids), + Message.recipient_id.in_(pair_ids), + ) + .order_by(Message.timestamp.asc()) + .limit(excess) + .all() + ) + for old_msg in oldest: + db.session.delete(old_msg) + db.session.commit() + + +def _get_ai_bot() -> User: + """Return the SexyAI virtual user, creating it if absent.""" + bot = User.query.filter_by(username=AI_BOT_NAME).first() + if not bot: + bot = User( + username=AI_BOT_NAME, + password_hash=bcrypt.hashpw(os.urandom(32), bcrypt.gensalt()).decode(), + has_ai_access=True, + ) + db.session.add(bot) + db.session.commit() + return bot + + +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + +@api.route("/auth/register", methods=["POST"]) +def register(): + data = request.get_json() or {} + username = str(data.get("username", "")).strip()[:20] + password = str(data.get("password", "")).strip() + email = str(data.get("email", "")).strip()[:255] or None + + if not username or not username.replace("_", "").replace("-", "").isalnum(): + return jsonify({"error": "Invalid username."}), 400 + if len(password) < 6: + return jsonify({"error": "Password must be at least 6 characters."}), 400 + if username.lower() == AI_BOT_NAME.lower(): + return jsonify({"error": "That username is reserved."}), 409 + if User.query.filter(db.func.lower(User.username) == username.lower()).first(): + return jsonify({"error": "Username already registered."}), 409 + + hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + user = User(username=username, password_hash=hashed, email=email) + db.session.add(user) + db.session.commit() + + token = _issue_jwt(user.id, user.username) + return jsonify({ + "token": token, + "user": { + "id": user.id, + "username": user.username, + "has_ai_access": user.has_ai_access, + "ai_messages_used": user.ai_messages_used, + }, + }), 201 + + +@api.route("/auth/login", methods=["POST"]) +def login(): + data = request.get_json() or {} + username = str(data.get("username", "")).strip() + password = str(data.get("password", "")).strip() + + user = User.query.filter( + db.func.lower(User.username) == username.lower() + ).first() + if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()): + return jsonify({"error": "Invalid username or password."}), 401 + + token = _issue_jwt(user.id, user.username) + return jsonify({ + "token": token, + "user": { + "id": user.id, + "username": user.username, + "has_ai_access": user.has_ai_access, + "ai_messages_used": user.ai_messages_used, + }, + }) + + +# --------------------------------------------------------------------------- +# PM history +# --------------------------------------------------------------------------- + +@api.route("/pm/history", methods=["GET"]) +@_require_auth +def pm_history(): + me = g.current_user + target_name = request.args.get("with", "").strip() + if not target_name: + return jsonify({"error": "Missing 'with' query parameter"}), 400 + + other = User.query.filter( + db.func.lower(User.username) == target_name.lower() + ).first() + if not other: + return jsonify({"messages": []}) + + rows = ( + Message.query + .filter( + db.or_( + db.and_(Message.sender_id == me.id, Message.recipient_id == other.id), + db.and_(Message.sender_id == other.id, Message.recipient_id == me.id), + ) + ) + .order_by(Message.timestamp.desc()) + .limit(MAX_HISTORY) + .all() + ) + rows.reverse() # return in chronological order + + return jsonify({ + "messages": [ + { + "from_me": m.sender_id == me.id, + "ciphertext": m.encrypted_content, + "nonce": m.nonce, + "ts": m.timestamp.strftime("%H:%M"), + } + for m in rows + ] + }) + + +# --------------------------------------------------------------------------- +# AI message (transit encryption – key never persisted) +# --------------------------------------------------------------------------- + +@api.route("/ai/message", methods=["POST"]) +@_require_auth +def ai_message(): + user = g.current_user + data = request.get_json() or {} + + # ── Gate ────────────────────────────────────────────────────────────────── + if not user.has_ai_access and user.ai_messages_used >= AI_FREE_LIMIT: + return jsonify({ + "error": "ai_limit_reached", + "limit": AI_FREE_LIMIT, + "ai_messages_used": user.ai_messages_used, + }), 402 + + ciphertext = data.get("ciphertext", "") + nonce_b64 = data.get("nonce", "") + transit_key = data.get("transit_key", "") + + if not all([ciphertext, nonce_b64, transit_key]): + return jsonify({"error": "Missing encryption fields"}), 400 + + # ── Transit decrypt (message readable for AI; key NOT stored) ───────────── + try: + _plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64) + except Exception: + return jsonify({"error": "Decryption failed – wrong key or corrupted data"}), 400 + + # ── AI response (mock; swap in OpenAI / Anthropic here) ────────────────── + ai_text = random.choice(AI_RESPONSES) + + # ── Persist both legs encrypted in DB (server uses transit key) ────────── + bot = _get_ai_bot() + _persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI + resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text) + _persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user + + # ── Update free trial counter ───────────────────────────────────────────── + if not user.has_ai_access: + user.ai_messages_used = min(user.ai_messages_used + 1, AI_FREE_LIMIT) + db.session.commit() + + # ── Re-encrypt AI response for transit back ─────────────────────────────── + resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text) + + return jsonify({ + "ciphertext": resp_ct_transit, + "nonce": resp_nonce_transit, + "ai_messages_used": user.ai_messages_used, + "has_ai_access": user.has_ai_access, + }) + + +# --------------------------------------------------------------------------- +# Payment webhook +# --------------------------------------------------------------------------- + +@api.route("/payment/success", methods=["POST"]) +@_require_auth +def payment_success(): + """ + Validate a payment webhook and flip user.has_ai_access. + + Expected body: { "secret": "" } + + For Stripe production: replace the secret comparison with + stripe.Webhook.construct_event() using the raw request body and + the Stripe-Signature header. + """ + data = request.get_json() or {} + secret = data.get("secret", "") + + # Constant-time comparison to prevent timing attacks + if not secret or not hmac.compare_digest( + secret.encode(), + PAYMENT_SECRET.encode(), + ): + return jsonify({"error": "Invalid or missing payment secret"}), 403 + + user = g.current_user + if not user.has_ai_access: + user.has_ai_access = True + db.session.commit() + + # Emit real-time unlock event to the user's active socket connection + try: + from app import socketio, username_to_sid + target_sid = username_to_sid.get(user.username.lower()) + if target_sid: + socketio.emit("ai_unlock", { + "msg": "🎉 AI access unlocked! Enjoy unlimited SexyAI chat.", + }, to=target_sid) + except Exception: + pass # Non-critical: UI will refresh on next request anyway + + return jsonify({"status": "ok", "has_ai_access": True}) diff --git a/start.py b/start.py new file mode 100644 index 0000000..02f8564 --- /dev/null +++ b/start.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +start.py – Gunicorn Process Manager for SexyChat. + +Usage: + python start.py start # Starts SexyChat in the background + python start.py stop # Stops the background process + python start.py restart # Restarts the background process + python start.py status # Shows if the server is running + python start.py debug # Runs in the foreground with debug logging +""" + +import os +import sys +import subprocess +import signal +import time +import eventlet + +# Monkey-patch stdlib BEFORE any other import +eventlet.monkey_patch() + +# PID file to track the daemon process +PID_FILE = "sexchat.pid" + +def get_pid(): + if os.path.exists(PID_FILE): + with open(PID_FILE, "r") as f: + try: + return int(f.read().strip()) + except ValueError: + return None + return None + +def is_running(pid): + if not pid: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + +def start_daemon(): + pid = get_pid() + if is_running(pid): + print(f"❌ SexChat is already running (PID: {pid}).") + return + + print("🚀 Starting SexChat in background...") + cmd = [ + "gunicorn", + "--worker-class", "eventlet", + "-w", "1", + "--bind", f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '5000')}", + "--daemon", + "--pid", PID_FILE, + "--access-logfile", "access.log", + "--error-logfile", "error.log", + "start:application" + ] + subprocess.run(cmd) + time.sleep(1) + + new_pid = get_pid() + if is_running(new_pid): + print(f"✅ SexChat started successfully (PID: {new_pid}).") + else: + print("❌ Failed to start SexChat. Check error.log for details.") + +def stop_daemon(): + pid = get_pid() + if not is_running(pid): + print("ℹ️ SexChat is not running.") + if os.path.exists(PID_FILE): + os.remove(PID_FILE) + return + + print(f"🛑 Stopping SexChat (PID: {pid})...") + try: + os.kill(pid, signal.SIGTERM) + # Wait for it to die + for _ in range(10): + if not is_running(pid): + break + time.sleep(0.5) + + if is_running(pid): + print("⚠️ Force killing...") + os.kill(pid, signal.SIGKILL) + + if os.path.exists(PID_FILE): + os.remove(PID_FILE) + print("✅ SexChat stopped.") + except Exception as e: + print(f"❌ Error stopping: {e}") + +def get_status(): + pid = get_pid() + if is_running(pid): + print(f"🟢 SexChat is RUNNING (PID: {pid}).") + else: + print("🔴 SexChat is STOPPED.") + +def run_debug(): + print("🛠️ Starting SexChat in DEBUG mode (foreground)...") + cmd = [ + "gunicorn", + "--worker-class", "eventlet", + "-w", "1", + "--bind", f"{os.environ.get('HOST', '0.0.0.0')}:{os.environ.get('PORT', '5000')}", + "--log-level", "debug", + "--access-logfile", "-", + "--error-logfile", "-", + "start:application" + ] + try: + subprocess.run(cmd) + except KeyboardInterrupt: + print("\n👋 Debug session ended.") + +# This is the Flask Application instance for Gunicorn +from app import create_app +application = create_app() + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + command = sys.argv[1].lower() + + if command == "start": + start_daemon() + elif command == "stop": + stop_daemon() + elif command == "restart": + stop_daemon() + time.sleep(1) + start_daemon() + elif command == "status": + get_status() + elif command == "debug": + run_debug() + else: + print(f"❌ Unknown command: {command}") + print(__doc__) + sys.exit(1) diff --git a/static/chat.js b/static/chat.js new file mode 100644 index 0000000..f321cc1 --- /dev/null +++ b/static/chat.js @@ -0,0 +1,580 @@ +/** + * chat.js – SexyChat Frontend Logic (Phase 2) + * + * Features: + * - Socket.io with JWT reconnect + * - AES-GCM Encryption (via crypto.js) + * - PM Persistence & History + * - Violet AI (Transit-encrypted) + * - Trial/Paywall management + */ + +"use strict"; + +const socket = io({ + autoConnect: false, + auth: { token: localStorage.getItem("sexychat_token") } +}); + +const state = { + username: null, + isAdmin: false, + isRegistered: false, + hasAiAccess: false, + aiMessagesUsed: 0, + currentRoom: "lobby", + pms: {}, // room -> { username, key, messages: [] } + nicklist: [], + ignoredUsers: new Set(), + cryptoKey: null, // derived from password + isSidebarOpen: window.innerWidth > 768, + authMode: "guest" +}; + +const AI_BOT_NAME = "Violet"; +const AI_FREE_LIMIT = 3; + +// ── Selectors ───────────────────────────────────────────────────────────── + +const $ = (id) => document.getElementById(id); +const joinScreen = $("join-screen"); +const chatScreen = $("chat-screen"); +const joinForm = $("join-form"); +const usernameInput = $("username-input"); +const passwordInput = $("password-input"); +const emailInput = $("email-input"); +const modPassword = $("mod-password-input"); +const joinBtn = $("join-btn"); +const joinError = $("join-error"); +const authTabs = document.querySelectorAll(".auth-tab"); +const sidebar = $("nicklist-sidebar"); +const nicklist = $("nicklist"); +const tabBar = $("tab-bar"); +const panels = $("panels"); +const messageForm = $("message-form"); +const messageInput = $("message-input"); +const logoutBtn = $("logout-btn"); +const pmModal = $("pm-modal"); +const paywallModal = $("paywall-modal"); +const contextMenu = $("context-menu"); +const trialBadge = $("violet-trial-badge"); +const violetTyping = $("violet-typing"); + +// ── Auth & Init ─────────────────────────────────────────────────────────── + +// Toggle Auth Modes +authTabs.forEach(tab => { + tab.addEventListener("click", () => { + authTabs.forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + state.authMode = tab.dataset.mode; + + // UI visibility based on mode + const isAuth = state.authMode !== "guest"; + const isReg = state.authMode === "register"; + + document.querySelectorAll(".auth-only").forEach(el => el.classList.toggle("hidden", !isAuth)); + document.querySelectorAll(".register-only").forEach(el => el.classList.toggle("hidden", !isReg)); + + usernameInput.placeholder = isAuth ? "Username" : "Choose your nickname"; + passwordInput.required = isAuth; + }); +}); + +// Join the Room +joinForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = usernameInput.value.trim(); + const password = passwordInput.value.trim(); + const email = emailInput.value.trim(); + const modPw = modPassword.value.trim(); + + if (!username) return; + if (state.authMode !== "guest" && !password) return; + + joinBtn.disabled = true; + joinBtn.innerText = "Connecting..."; + + // Derive Encryption Key if password provided + if (password) { + try { + state.cryptoKey = await SexyChato.deriveKey(password, username); + } catch (err) { + console.error("Key derivation failed", err); + } + } + + socket.connect(); + socket.emit("join", { + mode: state.authMode, + username, + password, + email, + mod_password: modPw + }); +}); + +// Handle Token Restore on Load +window.addEventListener("DOMContentLoaded", () => { + const token = localStorage.getItem("sexychat_token"); + if (token) { + // We have a token, notify the join screen but wait for user to click "Enter" + // to derive crypto key if they want to. Actually, for UX, if we have a token + // we can try a "restore" join which might skip password entry. + // But for encryption, we NEED that password to derive the key. + // Let's keep it simple: if you have a token, you still need to log in to + // re-derive your E2E key. + } +}); + +// ── Socket Events ────────────────────────────────────────────────────────── + +socket.on("joined", (data) => { + state.username = data.username; + state.isAdmin = data.is_admin; + state.isRegistered = data.is_registered; + state.hasAiAccess = data.has_ai_access; + state.aiMessagesUsed = data.ai_messages_used; + + if (data.token) localStorage.setItem("sexychat_token", data.token); + if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list); + + $("my-username-badge").innerText = state.username; + joinScreen.classList.add("hidden"); + chatScreen.classList.remove("hidden"); + updateVioletBadge(); +}); + +socket.on("error", (data) => { + joinError.innerText = data.msg; + joinBtn.disabled = false; + joinBtn.innerText = "Enter the Room"; + if (data.msg.includes("Session expired")) { + localStorage.removeItem("sexychat_token"); + } +}); + +socket.on("nicklist", (data) => { + state.nicklist = data.users; + renderNicklist(); + $("user-count-badge").innerText = `${data.users.length} online`; +}); + +socket.on("system", (data) => { + addMessage("lobby", { system: true, text: data.msg, ts: data.ts }); +}); + +socket.on("message", (data) => { + if (state.ignoredUsers.has(data.username)) return; + addMessage("lobby", { + sender: data.username, + text: data.text, + ts: data.ts, + isAdmin: data.is_admin, + isRegistered: data.is_registered, + sent: data.username === state.username + }); +}); + +// ── Private Messaging ───────────────────────────────────────────────────── + +socket.on("pm_invite", (data) => { + if (state.pms[data.room]) return; // Already accepted + $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`; + pmModal.classList.remove("hidden"); + + $("pm-accept-btn").onclick = () => { + socket.emit("pm_accept", { room: data.room }); + openPMTab(data.from, data.room); + pmModal.classList.add("hidden"); + }; + $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); +}); + +socket.on("pm_ready", (data) => { + openPMTab(data.with, data.room); +}); + +async function openPMTab(otherUser, room) { + if (state.pms[room]) { + switchTab(room); + return; + } + + state.pms[room] = { username: otherUser, messages: [] }; + + // Create Tab + let title = `👤 ${otherUser}`; + if (otherUser.toLowerCase() === "violet") title = `🤖 Violet`; + + const tab = document.createElement("button"); + tab.className = "tab-btn"; + tab.dataset.room = room; + tab.id = `tab-${room}`; + tab.innerHTML = `${title}`; + tab.onclick = () => switchTab(room); + tabBar.appendChild(tab); + + // Create Panel + const panel = document.createElement("div"); + panel.className = "panel"; + panel.id = `panel-${room}`; + panel.dataset.room = room; + panel.innerHTML = ` +
+ ${otherUser.toLowerCase() === 'violet' ? `` : ''} + `; + panels.appendChild(panel); + + switchTab(room); + + // Load History if registered + if (state.isRegistered && state.cryptoKey) { + try { + const resp = await fetch(`/api/pm/history?with=${otherUser}`, { + headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } + }); + const data = await resp.json(); + if (data.messages) { + for (const m of data.messages) { + const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce); + addMessage(room, { + sender: m.from_me ? state.username : otherUser, + text: plain, + ts: m.ts, + sent: m.from_me + }); + } + } + } catch (err) { + console.error("Failed to load PM history", err); + } + } +} + +socket.on("pm_message", async (data) => { + if (state.ignoredUsers.has(data.from)) return; + let text = data.text; + if (data.ciphertext && state.cryptoKey) { + try { + text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); + } catch (err) { + text = "[Encrypted Message - Click to login/derive key]"; + } + } + + addMessage(data.room, { + sender: data.from, + text: text, + ts: data.ts, + sent: data.from === state.username + }); + + if (state.currentRoom !== data.room) { + const tab = $(`tab-${data.room}`); + if (tab) tab.classList.add("unread"); + } +}); + +// ── Violet AI Logic ─────────────────────────────────────────────────────── + +socket.on("violet_typing", (data) => { + const room = data.room || "lobby"; + const indicator = $(`typing-${room}`); + if (indicator) { + indicator.classList.toggle("hidden", !data.busy); + } +}); + +socket.on("ai_response", async (data) => { + if (data.error === "ai_limit_reached") { + paywallModal.classList.remove("hidden"); + return; + } + + state.aiMessagesUsed = data.ai_messages_used; + state.hasAiAccess = data.has_ai_access; + updateVioletBadge(); + + let text = "[Decryption Error]"; + if (data.ciphertext && state.cryptoKey) { + text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); + } + + addMessage("ai-violet", { + sender: AI_BOT_NAME, + text: text, + ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + sent: false + }); +}); + +socket.on("ai_unlock", (data) => { + state.hasAiAccess = true; + updateVioletBadge(); + paywallModal.classList.add("hidden"); + addMessage("ai-violet", { system: true, text: data.msg }); +}); + +socket.on("ignore_status", (data) => { + if (data.ignored) state.ignoredUsers.add(data.target); + else state.ignoredUsers.delete(data.target); + renderNicklist(); +}); + +function updateVioletBadge() { + if (state.hasAiAccess) { + trialBadge.classList.add("hidden"); + } else { + const left = AI_FREE_LIMIT - state.aiMessagesUsed; + trialBadge.innerText = Math.max(0, left); + trialBadge.classList.toggle("hidden", left <= 0 && state.aiMessagesUsed < AI_FREE_LIMIT); + if (left <= 0) { + trialBadge.innerText = "!"; // Paywall indicator + } + } +} + +// ── UI Actions ──────────────────────────────────────────────────────────── + +messageForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const text = messageInput.value.trim(); + if (!text) return; + + if (state.currentRoom === "lobby") { + socket.emit("message", { text }); + } + else if (state.currentRoom.startsWith("pm:")) { + const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet"); + + if (isVioletRoom) { + // AI Transit Encryption PM Flow + const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey); + const encrypted = await SexyChato.encrypt(state.cryptoKey, text); + + socket.emit("pm_message", { + room: state.currentRoom, + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce, + transit_key: transitKeyB64 + }); + } else if (state.isRegistered && state.cryptoKey) { + // E2E PM Flow + const encrypted = await SexyChato.encrypt(state.cryptoKey, text); + socket.emit("pm_message", { + room: state.currentRoom, + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce + }); + } else { + // Guest PM (Plaintext) + socket.emit("pm_message", { room: state.currentRoom, text }); + } + } + + messageInput.value = ""; + messageInput.style.height = "auto"; +}); + +// Auto-expand textarea +messageInput.addEventListener("input", () => { + messageInput.style.height = "auto"; + messageInput.style.height = (messageInput.scrollHeight) + "px"; +}); + +function switchTab(room) { + state.currentRoom = room; + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".panel").forEach(p => p.classList.remove("active")); + + const tab = $(`tab-${room}`); + if (tab) { + tab.classList.add("active"); + tab.classList.remove("unread"); + } + $(`panel-${room}`).classList.add("active"); + + // Scroll to bottom + const box = $(`messages-${room}`); + if (box) box.scrollTop = box.scrollHeight; +} + +function addMessage(room, msg) { + const list = $(`messages-${room}`); + if (!list) return; + + const div = document.createElement("div"); + if (msg.system) { + div.className = "msg msg-system"; + div.innerHTML = msg.text.replace(/\*\*(.*?)\*\*/g, "$1"); + } else { + div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`; + div.innerHTML = ` +
${msg.ts} ${msg.sender}
+
${escapeHTML(msg.text)}
+ `; + } + + list.appendChild(div); + list.scrollTop = list.scrollHeight; +} + +function renderNicklist() { + nicklist.innerHTML = ""; + state.nicklist.forEach(u => { + const li = document.createElement("li"); + const isIgnored = state.ignoredUsers.has(u.username); + const isUnverified = u.is_registered && !u.is_verified; + + li.innerHTML = ` + + ${u.is_admin ? ' ' : ''} + ${u.username} + ${u.is_registered ? '' : ''} + ${isIgnored ? ' (ignored)' : ''} + ${isUnverified ? ' (unverified)' : ''} + + `; + + li.oncontextmenu = (e) => showContextMenu(e, u); + li.onclick = () => { + if (u.username !== state.username) socket.emit("pm_open", { target: u.username }); + }; + nicklist.appendChild(li); + }); +} + +function showContextMenu(e, user) { + e.preventDefault(); + if (user.username === state.username) return; + + // Position menu + contextMenu.style.left = `${e.pageX}px`; + contextMenu.style.top = `${e.pageY}px`; + contextMenu.classList.remove("hidden"); + + // Configure items + const pmItem = contextMenu.querySelector('[data-action="pm"]'); + const ignoreItem = contextMenu.querySelector('[data-action="ignore"]'); + const unignoreItem = contextMenu.querySelector('[data-action="unignore"]'); + const verifyItem = contextMenu.querySelector('[data-action="verify"]'); + const modItems = contextMenu.querySelectorAll(".mod-item"); + + const isIgnored = state.ignoredUsers.has(user.username); + const isUnverified = user.is_registered && !user.is_verified; + + ignoreItem.classList.toggle("hidden", !state.isRegistered || isIgnored); + unignoreItem.classList.toggle("hidden", !state.isRegistered || !isIgnored); + + // Verify option only for admins if target is unverified + const showVerify = state.isAdmin && isUnverified; + if (verifyItem) verifyItem.classList.toggle("hidden", !showVerify); + + modItems.forEach(el => { + if (el.dataset.action !== "verify") { + el.classList.toggle("hidden", !state.isAdmin); + } + }); + + // Cleanup previous listeners + const newMenu = contextMenu.cloneNode(true); + contextMenu.replaceWith(newMenu); + + // Add new listeners + newMenu.querySelectorAll(".menu-item").forEach(item => { + item.onclick = () => { + const action = item.dataset.action; + executeMenuAction(action, user.username); + newMenu.classList.add("hidden"); + }; + }); +} + +function executeMenuAction(action, target) { + switch(action) { + case "pm": + socket.emit("pm_open", { target }); + break; + case "ignore": + socket.emit("user_ignore", { target }); + break; + case "unignore": + socket.emit("user_unignore", { target }); + break; + case "kick": + socket.emit("mod_kick", { target }); + break; + case "ban": + socket.emit("mod_ban", { target }); + break; + case "kickban": + socket.emit("mod_kickban", { target }); + break; + case "verify": + socket.emit("mod_verify", { target }); + break; + } +} + +// Global click to hide context menu +window.addEventListener("click", (e) => { + const menu = $("context-menu"); + if (menu && !menu.contains(e.target)) { + menu.classList.add("hidden"); + } +}); + +// ── Admin Tools ─────────────────────────────────────────────────────────── + +window.modKick = (u) => socket.emit("mod_kick", { target: u }); +window.modBan = (u) => socket.emit("mod_ban", { target: u }); +window.modMute = (u) => socket.emit("mod_mute", { target: u }); + +// ── Modals & Misc ────────────────────────────────────────────────────────── + +$("sidebar-toggle").onclick = () => { + sidebar.classList.toggle("open"); +}; + +$("tab-ai-violet").onclick = () => switchTab("ai-violet"); +$("tab-lobby").onclick = () => switchTab("lobby"); + +$("close-paywall").onclick = () => paywallModal.classList.add("hidden"); +$("unlock-btn").onclick = async () => { + // Generate dummy secret for the stub endpoint + // In production, this would redirect to a real payment gateway (Stripe) + const secret = "change-me-payment-webhook-secret"; + const token = localStorage.getItem("sexychat_token"); + + try { + const resp = await fetch("/api/payment/success", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify({ secret }) + }); + const res = await resp.json(); + if (res.status === "ok") { + // socket event should handle UI unlock, but we can optimistically update + state.hasAiAccess = true; + updateVioletBadge(); + paywallModal.classList.add("hidden"); + } + } catch (err) { + alert("Payment simulation failed."); + } +}; + +logoutBtn.onclick = () => { + localStorage.removeItem("sexychat_token"); + location.reload(); +}; + +function escapeHTML(str) { + const p = document.createElement("p"); + p.textContent = str; + return p.innerHTML; +} diff --git a/static/crypto.js b/static/crypto.js new file mode 100644 index 0000000..9a2c080 --- /dev/null +++ b/static/crypto.js @@ -0,0 +1,131 @@ +/** + * crypto.js – SexyChat SubtleCrypto wrapper + * + * Provides PBKDF2-derived AES-GCM-256 keys for end-to-end encrypted PMs. + * + * Key facts: + * - Keys are derived deterministically from (password, username) so the + * same credentials always produce the same key across sessions. + * - Keys are marked `extractable: true` so they can be exported as raw bytes + * for the AI transit-encryption flow (sent over HTTPS, never stored). + * - The server only ever receives ciphertext + nonce. It never sees a key + * for user-to-user conversations. + */ + +"use strict"; + +const SexyChato = (() => { + const PBKDF2_ITERATIONS = 100_000; + const KEY_LENGTH_BITS = 256; + const SALT_PREFIX = "sexychat:v1:"; + + // ── Base64 helpers ──────────────────────────────────────────────────────── + + function bufToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + } + + function base64ToBuf(b64) { + const binary = atob(b64); + const buf = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); + return buf.buffer; + } + + // ── Key derivation ───────────────────────────────────────────────────────── + + /** + * Derive an AES-GCM key from a password and username. + * The username acts as a deterministic, per-account salt. + * + * @param {string} password + * @param {string} username + * @returns {Promise} + */ + async function deriveKey(password, username) { + const enc = new TextEncoder(); + const salt = enc.encode(SALT_PREFIX + username.toLowerCase()); + + const baseKey = await crypto.subtle.importKey( + "raw", + enc.encode(password), + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + baseKey, + { name: "AES-GCM", length: KEY_LENGTH_BITS }, + true, // extractable – needed for transit encryption to AI endpoint + ["encrypt", "decrypt"], + ); + } + + // ── Encrypt / decrypt ───────────────────────────────────────────────────── + + /** + * Encrypt a plaintext string with an AES-GCM key. + * + * @param {CryptoKey} key + * @param {string} plaintext + * @returns {Promise<{ ciphertext: string, nonce: string }>} both base64 + */ + async function encrypt(key, plaintext) { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(plaintext); + const cipherBuf = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: nonce }, + key, + encoded, + ); + return { + ciphertext: bufToBase64(cipherBuf), + nonce: bufToBase64(nonce), + }; + } + + /** + * Decrypt an AES-GCM ciphertext. + * + * @param {CryptoKey} key + * @param {string} ciphertext base64 + * @param {string} nonce base64 + * @returns {Promise} plaintext + */ + async function decrypt(key, ciphertext, nonce) { + const cipherBuf = base64ToBuf(ciphertext); + const nonceBuf = base64ToBuf(nonce); + const plainBuf = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: nonceBuf }, + key, + cipherBuf, + ); + return new TextDecoder().decode(plainBuf); + } + + /** + * Export a CryptoKey as a base64-encoded raw byte string. + * Used only for the AI transit flow (sent in POST body over HTTPS, + * never stored by the server). + * + * @param {CryptoKey} key + * @returns {Promise} base64 + */ + async function exportKeyBase64(key) { + const raw = await crypto.subtle.exportKey("raw", key); + return bufToBase64(raw); + } + + // ── Public API ───────────────────────────────────────────────────────────── + return { deriveKey, encrypt, decrypt, exportKeyBase64 }; +})(); diff --git a/static/socket.io.min.js b/static/socket.io.min.js new file mode 100644 index 0000000..c72110d --- /dev/null +++ b/static/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); +//# sourceMappingURL=socket.io.min.js.map diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..5f0983c --- /dev/null +++ b/static/style.css @@ -0,0 +1,584 @@ +/* + SexyChat Phase 2 Style – Midnight Purple & Neon Magenta + Deep dark backgrounds, vibrant accents, glassmorphism. +*/ + +:root { + --bg-deep: #0a0015; + --bg-card: rgba(26, 0, 48, 0.7); + --accent-magenta: #ff00ff; + --accent-purple: #8a2be2; + --text-main: #f0f0f0; + --text-dim: #b0b0b0; + --glass-border: rgba(255, 255, 255, 0.1); + --error-red: #ff3366; + --success-green: #00ffaa; + --ai-teal: #00f2ff; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--bg-deep); + background-image: + radial-gradient(circle at 20% 30%, rgba(138, 43, 226, 0.15) 0%, transparent 40%), + radial-gradient(circle at 80% 70%, rgba(255, 0, 255, 0.1) 0%, transparent 40%); + color: var(--text-main); + height: 100vh; + overflow: hidden; + line-height: 1.5; +} + +h1, h2, h3, .logo-text, .paywall-header h2 { + font-family: 'Outfit', sans-serif; +} + +.hidden { display: none !important; } + +/* ── Glassmorphism ───────────────────────────────────────────────────────── */ +.glass { + background: var(--bg-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--glass-border); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.8); +} + +/* ── Join Screen ─────────────────────────────────────────────────────────── */ +.join-screen { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.join-card { + width: 100%; + max-width: 420px; + padding: 2.5rem; + border-radius: 24px; + text-align: center; +} + +.join-logo .logo-icon { + font-size: 3rem; + display: block; + margin-bottom: 0.5rem; +} + +.logo-text { + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -1px; +} + +.logo-accent { + color: var(--accent-magenta); + text-shadow: 0 0 10px rgba(255, 0, 255, 0.5); +} + +.logo-sub { + color: var(--text-dim); + font-size: 0.9rem; + margin-bottom: 2rem; +} + +/* Auth Tabs */ +.auth-tabs { + display: flex; + background: rgba(0, 0, 0, 0.3); + padding: 4px; + border-radius: 12px; + margin-bottom: 1.5rem; +} + +.auth-tab { + flex: 1; + padding: 8px; + border: none; + background: transparent; + color: var(--text-dim); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + border-radius: 8px; + transition: all 0.2s; +} + +.auth-tab.active { + background: var(--accent-purple); + color: white; + box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3); +} + +/* Form */ +.field-group { + margin-bottom: 1rem; +} + +input { + width: 100%; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--glass-border); + padding: 12px 16px; + border-radius: 12px; + color: white; + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +input:focus { + border-color: var(--accent-magenta); +} + +.crypto-note { + font-size: 0.75rem; + color: var(--accent-magenta); + background: rgba(255, 0, 255, 0.05); + padding: 10px; + border-radius: 8px; + margin: 1rem 0; + line-height: 1.3; +} + +.btn-primary { + width: 100%; + background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta)); + color: white; + border: none; + padding: 14px; + border-radius: 12px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3); + transition: transform 0.2s, box-shadow 0.2s; + margin-top: 1rem; +} + +.btn-primary:active { transform: scale(0.98); } +.btn-primary:hover { box-shadow: 0 6px 20px rgba(255, 0, 255, 0.4); } + +.mod-login { + text-align: left; + margin: 1rem 0; + font-size: 0.85rem; + color: var(--text-dim); +} + +.mod-login summary { cursor: pointer; outline: none; } + +.error-msg { + color: var(--error-red); + font-size: 0.85rem; + margin-top: 1rem; + min-height: 1.2em; +} + +/* ── Chat Screen ─────────────────────────────────────────────────────────── */ +.chat-screen { + display: flex; + flex-direction: column; + height: 100vh; +} + +.glass-header { + height: 64px; + background: rgba(10, 0, 20, 0.8); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--glass-border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1rem; + z-index: 100; +} + +.header-title { + display: flex; + align-items: center; + gap: 10px; +} + +#room-name-header { font-weight: 700; font-family: 'Outfit'; } + +.pulse-dot { + width: 8px; + height: 8px; + background: var(--success-green); + border-radius: 50%; + box-shadow: 0 0 10px var(--success-green); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; } 50% { opacity: 0.3; } 100% { opacity: 1; } +} + +.user-badge { + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 20px; + font-size: 0.75rem; + color: var(--text-dim); +} + +.header-right { display: flex; align-items: center; gap: 12px; } +.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; } + +.btn-logout { + background: transparent; + color: var(--text-dim); + border: 1px solid var(--glass-border); + padding: 4px 10px; + border-radius: 6px; + font-size: 0.75rem; + cursor: pointer; +} + +/* ── Layout ──────────────────────────────────────────────────────────────── */ +.chat-layout { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* Nicklist */ +.nicklist-sidebar { + width: 260px; + border-right: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + transition: transform 0.3s ease; +} + +.nicklist-header { + padding: 1.2rem; + font-weight: 700; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 1px; + color: var(--text-dim); + border-bottom: 1px solid var(--glass-border); +} + +.nicklist { + list-style: none; + overflow-y: auto; + flex: 1; +} + +.nicklist li { + padding: 12px 20px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + transition: background 0.2s; + font-size: 0.95rem; +} + +.nicklist li:hover { background: rgba(255, 255, 255, 0.05); } + +.mod-star { color: #ffcc00; } +.reg-mark { color: var(--accent-teal); font-size: 0.7rem; margin-left: 2px; } +.unverified { color: var(--text-dim); opacity: 0.5; font-style: italic; } +.mod-star { color: var(--accent-magenta); margin-right: 4px; } + +/* ── Main Chat Container ─────────────────────────────────────────────────── */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + background: rgba(0,0,0,0.2); +} + +/* Tabs */ +.tab-bar { + display: flex; + gap: 4px; + padding: 8px 12px 0; + background: rgba(0,0,0,0.3); + border-bottom: 1px solid var(--glass-border); + overflow-x: auto; +} + +.tab-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + border-bottom: none; + padding: 8px 16px; + border-radius: 8px 8px 0 0; + color: var(--text-dim); + cursor: pointer; + font-size: 0.85rem; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; +} + +.tab-btn.active { + background: var(--bg-card); + color: white; + border-top: 2px solid var(--accent-magenta); +} + +.ai-tab { color: var(--ai-teal) !important; } +.tab-btn .badge { + background: var(--accent-magenta); + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 10px; + color: white; +} + +/* Panels */ +.panels { flex: 1; position: relative; overflow: hidden; } +.panel { + position: absolute; + inset: 0; + display: none; + flex-direction: column; +} +.panel.active { display: flex; } + +.messages { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Chat Bubbles */ +.msg { + max-width: 80%; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.msg-bubble { + padding: 10px 14px; + border-radius: 18px; + font-size: 0.95rem; + position: relative; + word-wrap: break-word; +} + +.msg-meta { + font-size: 0.7rem; + color: var(--text-dim); + margin-bottom: 4px; + padding: 0 4px; +} + +.msg-received { align-self: flex-start; } +.msg-received .msg-bubble { + background: rgba(255, 255, 255, 0.08); + border-bottom-left-radius: 4px; + border: 1px solid rgba(255,255,255,0.05); +} + +.msg-sent { align-self: flex-end; } +.msg-sent .msg-bubble { + background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta)); + border-bottom-right-radius: 4px; + box-shadow: 0 4px 12px rgba(255, 0, 255, 0.2); +} + +.msg-system { + align-self: center; + background: rgba(0,0,0,0.3); + padding: 4px 12px; + border-radius: 20px; + font-size: 0.8rem; + color: var(--text-dim); + border: 1px solid var(--glass-border); +} + +.msg-system strong { color: var(--accent-magenta); } + +/* ── AI Area ─────────────────────────────────────────────────────────────── */ +.chat-ai { background: radial-gradient(circle at top, rgba(0, 242, 255, 0.05), transparent 70%); } + +.ai-header { + padding: 1rem; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--glass-border); + background: rgba(0,0,0,0.2); +} + +.ai-avatar { + width: 40px; + height: 40px; + background: var(--ai-teal); + color: black; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.2rem; + box-shadow: 0 0 15px var(--ai-teal); +} + +.ai-meta { line-height: 1.2; } +.status-indicator { display: block; font-size: 0.7rem; color: var(--success-green); } + +.typing-indicator { + padding: 8px 1.5rem; + font-size: 0.8rem; + color: var(--ai-teal); + font-style: italic; +} + +/* ── Input Area ──────────────────────────────────────────────────────────── */ +.message-form { + padding: 1rem; + display: flex; + align-items: flex-end; + gap: 10px; + background: rgba(10, 0, 20, 0.8); + border-top: 1px solid var(--glass-border); +} + +textarea { + flex: 1; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + padding: 12px; + border-radius: 12px; + color: white; + font-family: inherit; + font-size: 0.95rem; + outline: none; + resize: none; + max-height: 120px; +} + +.btn-send { + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--accent-magenta); + border: none; + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 0 15px rgba(255, 0, 255, 0.4); +} + +/* ── Modals ──────────────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + backdrop-filter: blur(8px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-card { + padding: 2.5rem; + border-radius: 24px; + max-width: 400px; + text-align: center; +} + +.paywall-card { + border: 2px solid var(--ai-teal); + box-shadow: 0 0 40px rgba(0, 242, 255, 0.2); +} + +.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; } + +.paywall-price { + font-size: 3rem; + font-weight: 700; + color: var(--ai-teal); + margin: 1.5rem 0; + font-family: 'Outfit'; +} + +.benefits { text-align: left; margin-bottom: 2rem; color: var(--text-dim); font-size: 0.9rem; } +.benefits p { margin-bottom: 8px; } + +.btn-text { background: transparent; border: none; color: var(--text-dim); cursor: pointer; margin-top: 1rem; } + +/* ── Context Menu ────────────────────────────────────────────────────────── */ +.context-menu { + position: fixed; + z-index: 3000; + min-width: 160px; + padding: 6px 0; + border-radius: 12px; + background: rgba(15, 0, 30, 0.95); + border: 1px solid var(--glass-border); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px rgba(138, 43, 226, 0.2); + display: flex; + flex-direction: column; +} + +.menu-item { + padding: 10px 16px; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; +} + +.menu-item:hover { + background: var(--accent-purple); + color: white; +} + +.menu-item.red { color: var(--error-red); } +.menu-item.red:hover { background: var(--error-red); } +.menu-item.bold { font-weight: 800; } + +.menu-divider { + height: 1px; + background: var(--glass-border); + margin: 4px 0; +} + +/* ── Mobile Overrides ─────────────────────────────────────────────────────── */ +@media (max-width: 768px) { + .nicklist-sidebar { + position: absolute; + top: 0; bottom: 0; left: 0; + z-index: 50; + transform: translateX(-100%); + background: var(--bg-deep); + } + .nicklist-sidebar.open { transform: translateX(0); } + + .join-card { padding: 1.5rem; } + .msg-bubble { font-size: 0.9rem; } +}