From ad510c57e15119ce5c583be7dbec8e41e5c34a26 Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Sun, 12 Apr 2026 17:55:40 +0100 Subject: [PATCH] Initial commit: SexyChat (Aphrodite) v1.0 --- .env.example | 32 ++ .gitignore | 36 ++ app.py | 785 ++++++++++++++++++++++++++++++++++++++++ database.py | 44 +++ index.html | 183 ++++++++++ models.py | 82 +++++ requirements.txt | 26 ++ routes.py | 370 +++++++++++++++++++ start.py | 148 ++++++++ static/chat.js | 580 +++++++++++++++++++++++++++++ static/crypto.js | 131 +++++++ static/socket.io.min.js | 7 + static/style.css | 584 ++++++++++++++++++++++++++++++ 13 files changed, 3008 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app.py create mode 100644 database.py create mode 100644 index.html create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routes.py create mode 100644 start.py create mode 100644 static/chat.js create mode 100644 static/crypto.js create mode 100644 static/socket.io.min.js create mode 100644 static/style.css 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; } +}