Initial commit: SexyChat (Aphrodite) v1.0
This commit is contained in:
commit
ad510c57e1
|
|
@ -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=...
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="Sexy – The hottest adult chat room on the internet. Connect, flirt, and vibe with Violet." />
|
||||
<meta name="theme-color" content="#1a0030" />
|
||||
<title>Sexy Chat | Deep Purple & Neon Magenta</title>
|
||||
|
||||
<!-- Google Fonts: Inter and Outfit -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Socket.IO v4 client -->
|
||||
<script src="/static/socket.io.min.js"></script>
|
||||
<!-- Crypto Library -->
|
||||
<script src="/static/crypto.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Join Screen -->
|
||||
<div id="join-screen" class="join-screen">
|
||||
<div class="join-card glass">
|
||||
<div class="join-logo">
|
||||
<span class="logo-icon">💋</span>
|
||||
<h1 class="logo-text">Sexy<span class="logo-accent">Chat</span></h1>
|
||||
<p class="logo-sub">Midnight Whispers & Neon Dreams</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" data-mode="guest">Guest</button>
|
||||
<button class="auth-tab" data-mode="login">Login</button>
|
||||
<button class="auth-tab" data-mode="register">Register</button>
|
||||
</div>
|
||||
|
||||
<form id="join-form" class="join-form" autocomplete="off">
|
||||
<div class="field-container">
|
||||
<div class="field-group">
|
||||
<input id="username-input" type="text" placeholder="Username" maxlength="20" required />
|
||||
</div>
|
||||
|
||||
<div class="field-group auth-only hidden">
|
||||
<input id="password-input" type="password" placeholder="Password" />
|
||||
</div>
|
||||
|
||||
<div class="field-group register-only hidden">
|
||||
<input id="email-input" type="email" placeholder="Email (optional)" />
|
||||
</div>
|
||||
|
||||
<details class="mod-login">
|
||||
<summary>🛡️ Moderator login</summary>
|
||||
<div class="field-group" style="margin-top:0.75rem">
|
||||
<input id="mod-password-input" type="password" placeholder="Mod password" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="crypto-note auth-only hidden">
|
||||
<p>🔒 Your encryption key is derived from your password. Zero-knowledge privacy.</p>
|
||||
</div>
|
||||
|
||||
<button id="join-btn" type="submit" class="btn-primary">
|
||||
Enter the Room <span class="btn-arrow">→</span>
|
||||
</button>
|
||||
<p id="join-error" class="error-msg" role="alert"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Screen -->
|
||||
<div id="chat-screen" class="chat-screen hidden">
|
||||
<!-- Header -->
|
||||
<header class="chat-header glass-header">
|
||||
<button id="sidebar-toggle" class="icon-btn" aria-label="Toggle nicklist">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="header-title">
|
||||
<span class="pulse-dot"></span>
|
||||
<span id="room-name-header">💋 Sexy</span>
|
||||
<span id="user-count-badge" class="user-badge">0 online</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span id="my-username-badge" class="my-badge"></span>
|
||||
<button id="logout-btn" class="btn-logout">Exit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main layout -->
|
||||
<div class="chat-layout">
|
||||
<!-- Nicklist sidebar -->
|
||||
<aside id="nicklist-sidebar" class="nicklist-sidebar glass">
|
||||
<div class="nicklist-header">Users</div>
|
||||
<ul id="nicklist" class="nicklist"></ul>
|
||||
</aside>
|
||||
|
||||
<!-- Chat area -->
|
||||
<div class="chat-main">
|
||||
<!-- Tab bar -->
|
||||
<div id="tab-bar" class="tab-bar">
|
||||
<button class="tab-btn active" data-room="lobby" id="tab-lobby">
|
||||
<span>💋 Lobby</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Message panels -->
|
||||
<div id="panels" class="panels">
|
||||
<div id="panel-lobby" class="panel active" data-room="lobby">
|
||||
<div id="messages-lobby" class="messages" role="log"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Input row -->
|
||||
<form id="message-form" class="message-form glass-input">
|
||||
<textarea
|
||||
id="message-input"
|
||||
placeholder="Say something hot…"
|
||||
maxlength="500"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button id="send-btn" type="submit" class="btn-send">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PM Invite Modal -->
|
||||
<div id="pm-modal" class="modal-overlay hidden">
|
||||
<div class="modal-card glass">
|
||||
<p id="pm-modal-title" class="modal-msg"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="pm-accept-btn" class="btn-primary">Accept</button>
|
||||
<button id="pm-decline-btn" class="btn-ghost">Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violet Paywall Modal -->
|
||||
<div id="paywall-modal" class="modal-overlay hidden">
|
||||
<div class="paywall-card glass">
|
||||
<div class="paywall-header">
|
||||
<div class="ai-avatar large">V</div>
|
||||
<h2>Unlock Violet Forever</h2>
|
||||
</div>
|
||||
<p class="paywall-text">My time is valuable, darling. I only reserve it for my most dedicated guests.</p>
|
||||
<div class="paywall-price">$10.00</div>
|
||||
<div class="benefits">
|
||||
<p>✓ Unlimited private conversations</p>
|
||||
<p>✓ Sophisticated & flirtatious companionship</p>
|
||||
</div>
|
||||
<button id="unlock-btn" class="btn-primary glow">Unlock Violet for $10</button>
|
||||
<button id="close-paywall" class="btn-text">Maybe later</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu glass hidden">
|
||||
<div class="menu-item" data-action="pm">Private Message</div>
|
||||
<div class="menu-item" data-action="ignore">Ignore User</div>
|
||||
<div class="menu-item" data-action="unignore">Unignore User</div>
|
||||
<!-- Mod items -->
|
||||
<div class="menu-divider mod-item hidden"></div>
|
||||
<div class="menu-item mod-item hidden" data-action="verify">Verify User</div>
|
||||
<div class="menu-item mod-item hidden" data-action="kick">Kick</div>
|
||||
|
||||
<div class="menu-item mod-item red hidden" data-action="ban">Ban</div>
|
||||
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"<User {self.username!r}>"
|
||||
|
||||
|
||||
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"<Message {self.sender_id}→{self.recipient_id} @ {self.timestamp}>"
|
||||
|
|
@ -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
|
||||
|
|
@ -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": "<PAYMENT_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})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 = `<span>${title}</span>`;
|
||||
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 = `
|
||||
<div id="messages-${room}" class="messages" role="log"></div>
|
||||
${otherUser.toLowerCase() === 'violet' ? `<div id="typing-${room}" class="typing-indicator hidden">Violet is typing...</div>` : ''}
|
||||
`;
|
||||
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, "<strong>$1</strong>");
|
||||
} else {
|
||||
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
|
||||
div.innerHTML = `
|
||||
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
|
||||
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<span class="${isUnverified ? 'unverified' : ''}">
|
||||
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
|
||||
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
|
||||
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
|
||||
${isIgnored ? ' <small>(ignored)</small>' : ''}
|
||||
${isUnverified ? ' <small>(unverified)</small>' : ''}
|
||||
</span>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<CryptoKey>}
|
||||
*/
|
||||
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<string>} 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<string>} base64
|
||||
*/
|
||||
async function exportKeyBase64(key) {
|
||||
const raw = await crypto.subtle.exportKey("raw", key);
|
||||
return bufToBase64(raw);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue