Fix #1+#8: Extract shared config module, unify JWT secret

- Create config.py with shared constants, AES-GCM helpers, and JWT helpers
- app.py and routes.py now import from the single source of truth
- Eliminates JWT secret mismatch (routes.py had hardcoded default)
- Removes all duplicate _issue_jwt, _verify_jwt, _aesgcm_encrypt,
  _aesgcm_decrypt definitions
- start.py also uses shared config loader
This commit is contained in:
3nd3r 2026-04-12 12:49:44 -05:00
parent 1c17a9bcf0
commit 99859f009f
4 changed files with 134 additions and 160 deletions

107
app.py
View File

@ -39,16 +39,11 @@ Socket events (server → client)
""" """
import os import os
import json
import time import time
import uuid
import base64
import functools import functools
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
import bcrypt import bcrypt
import jwt as pyjwt
import eventlet # noqa monkey-patched in start.py before any other import import eventlet # noqa monkey-patched in start.py before any other import
from eventlet.queue import Queue as EvQueue from eventlet.queue import Queue as EvQueue
@ -57,52 +52,15 @@ from flask_socketio import SocketIO, emit, join_room, disconnect
from database import db, init_db from database import db, init_db
from models import User, Message, UserIgnore from models import User, Message, UserIgnore
from config import (
# --------------------------------------------------------------------------- SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL,
# Configuration Loader MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME,
# --------------------------------------------------------------------------- OLLAMA_URL, VIOLET_MODEL, VIOLET_SYSTEM,
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
def load_config():
conf = {}
config_path = os.path.join(os.path.dirname(__file__), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
conf = json.load(f)
except Exception as e:
print(f"⚠️ Warning: Failed to load config.json: {e}")
return conf
_CONFIG = load_config()
def _get_conf(key, default=None):
# Order: Env Var > Config File > Default
return os.environ.get(key, _CONFIG.get(key, default))
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SECRET_KEY = _get_conf("SECRET_KEY", uuid.uuid4().hex)
JWT_SECRET = _get_conf("JWT_SECRET", uuid.uuid4().hex)
ADMIN_PASSWORD = _get_conf("ADMIN_PASSWORD", "admin1234")
MAX_MSG_LEN = 500
LOBBY = "lobby"
AI_FREE_LIMIT = int(_get_conf("AI_FREE_LIMIT", 3))
AI_BOT_NAME = "Violet"
# Ollama
OLLAMA_URL = _get_conf("OLLAMA_URL", "http://localhost:11434")
VIOLET_MODEL = _get_conf("VIOLET_MODEL", "sam860/dolphin3-llama3.2:3b")
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 13 "
"sentences maximum. You are in a private conversation with a special "
"guest who has caught your eye."
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-process state # In-process state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -123,26 +81,6 @@ RATE_WINDOW = 5
ai_queue: EvQueue = EvQueue() ai_queue: EvQueue = EvQueue()
_app_ref = None # set in create_app() for greenlet app-context access _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 # Ollama integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -191,7 +129,7 @@ def _ai_worker() -> None:
# ── Decrypt user message (transit; key never stored) ────────────────── # ── Decrypt user message (transit; key never stored) ──────────────────
try: try:
plaintext = _aesgcm_decrypt( plaintext = aesgcm_decrypt(
task["transit_key"], task["ciphertext"], task["nonce_val"] task["transit_key"], task["ciphertext"], task["nonce_val"]
) )
ai_text = call_ollama(plaintext) ai_text = call_ollama(plaintext)
@ -200,7 +138,7 @@ def _ai_worker() -> None:
ai_text = "Mmm, something went wrong, darling 💜" ai_text = "Mmm, something went wrong, darling 💜"
# ── Re-encrypt AI response ──────────────────────────────────────────── # ── Re-encrypt AI response ────────────────────────────────────────────
resp_ct, resp_nonce = _aesgcm_encrypt(task["transit_key"], ai_text) resp_ct, resp_nonce = aesgcm_encrypt(task["transit_key"], ai_text)
ai_messages_used = task.get("ai_messages_used", 0) ai_messages_used = task.get("ai_messages_used", 0)
has_ai_access = task.get("has_ai_access", False) has_ai_access = task.get("has_ai_access", False)
@ -311,21 +249,6 @@ def _do_disconnect(sid: str) -> None:
pass 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, def _save_pm(sender_id: int, recipient_id: int,
encrypted_content: str, nonce: str) -> None: encrypted_content: str, nonce: str) -> None:
msg = Message( msg = Message(
@ -355,9 +278,7 @@ def create_app() -> Flask:
app = Flask(__name__, static_folder="static", template_folder=".") app = Flask(__name__, static_folder="static", template_folder=".")
app.config.update( app.config.update(
SECRET_KEY=SECRET_KEY, SECRET_KEY=SECRET_KEY,
SQLALCHEMY_DATABASE_URI=os.environ.get( SQLALCHEMY_DATABASE_URI=DATABASE_URL,
"DATABASE_URL", "sqlite:///sexchat.db"
),
SQLALCHEMY_TRACK_MODIFICATIONS=False, SQLALCHEMY_TRACK_MODIFICATIONS=False,
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
@ -411,7 +332,7 @@ def on_connect(auth=None):
has_ai_access = False; ai_used = 0; jwt_username = None has_ai_access = False; ai_used = 0; jwt_username = None
if auth and isinstance(auth, dict) and auth.get("token"): if auth and isinstance(auth, dict) and auth.get("token"):
payload = _verify_jwt(auth["token"]) payload = verify_jwt(auth["token"])
if payload: if payload:
db_user = db.session.get(User, payload.get("user_id")) db_user = db.session.get(User, payload.get("user_id"))
if db_user: if db_user:
@ -478,7 +399,7 @@ def on_join(data):
db.session.add(db_user); db.session.commit() db.session.add(db_user); db.session.commit()
user.update(user_id=db_user.id, is_registered=True, user.update(user_id=db_user.id, is_registered=True,
has_ai_access=False, ai_messages_used=0) has_ai_access=False, ai_messages_used=0)
token = _issue_jwt(db_user.id, db_user.username) token = issue_jwt(db_user.id, db_user.username)
elif mode == "login": elif mode == "login":
db_user = User.query.filter( db_user = User.query.filter(
@ -493,7 +414,7 @@ def on_join(data):
user["is_registered"] = True user["is_registered"] = True
user["has_ai_access"] = db_user.has_ai_access user["has_ai_access"] = db_user.has_ai_access
user["ai_messages_used"] = db_user.ai_messages_used user["ai_messages_used"] = db_user.ai_messages_used
token = _issue_jwt(db_user.id, db_user.username) token = issue_jwt(db_user.id, db_user.username)
elif mode == "restore": elif mode == "restore":
if not user.get("user_id"): if not user.get("user_id"):
@ -506,7 +427,7 @@ def on_join(data):
username = db_user.username username = db_user.username
user["has_ai_access"] = db_user.has_ai_access user["has_ai_access"] = db_user.has_ai_access
user["ai_messages_used"] = db_user.ai_messages_used user["ai_messages_used"] = db_user.ai_messages_used
token = _issue_jwt(db_user.id, db_user.username) token = issue_jwt(db_user.id, db_user.username)
else: # guest else: # guest
if not username or not username.replace("_","").replace("-","").isalnum(): if not username or not username.replace("_","").replace("-","").isalnum():

106
config.py Normal file
View File

@ -0,0 +1,106 @@
"""
config.py Shared configuration and utilities for SexyChat.
Centralises constants, config loading, AES-GCM helpers, and JWT helpers
so that app.py and routes.py share a single source of truth.
"""
import os
import json
import uuid
import base64
from datetime import datetime, timezone, timedelta
import jwt as pyjwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# ---------------------------------------------------------------------------
# Configuration Loader
# ---------------------------------------------------------------------------
def load_config():
conf = {}
config_path = os.path.join(os.path.dirname(__file__), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
conf = json.load(f)
except Exception as e:
print(f"⚠️ Warning: Failed to load config.json: {e}")
return conf
_CONFIG = load_config()
def get_conf(key, default=None):
"""Resolve a config value: Env Var → config.json → default."""
return os.environ.get(key, _CONFIG.get(key, default))
# ---------------------------------------------------------------------------
# Shared Constants
# ---------------------------------------------------------------------------
SECRET_KEY = get_conf("SECRET_KEY", uuid.uuid4().hex)
JWT_SECRET = get_conf("JWT_SECRET", uuid.uuid4().hex)
ADMIN_PASSWORD = get_conf("ADMIN_PASSWORD", "admin1234")
DATABASE_URL = get_conf("DATABASE_URL", "sqlite:///sexchat.db")
PAYMENT_SECRET = get_conf("PAYMENT_SECRET", "change-me-payment-secret")
CORS_ORIGINS = get_conf("CORS_ORIGINS", None)
MAX_MSG_LEN = 500
LOBBY = "lobby"
AI_FREE_LIMIT = int(get_conf("AI_FREE_LIMIT", 3))
AI_BOT_NAME = "Violet"
JWT_EXPIRY_DAYS = 7
MAX_HISTORY = 500
# Ollama
OLLAMA_URL = get_conf("OLLAMA_URL", "http://localhost:11434")
VIOLET_MODEL = get_conf("VIOLET_MODEL", "sam860/dolphin3-llama3.2:3b")
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 13 "
"sentences maximum. You are in a private conversation with a special "
"guest who has caught your eye."
)
# ---------------------------------------------------------------------------
# AES-GCM Helpers
# ---------------------------------------------------------------------------
def aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
"""Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
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:
"""Decrypt AES-GCM ciphertext. Raises on authentication failure."""
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")
# ---------------------------------------------------------------------------
# JWT Helpers
# ---------------------------------------------------------------------------
def issue_jwt(user_id: int, username: str) -> str:
"""Issue a signed JWT with user_id and username claims."""
payload = {
"user_id": user_id,
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY_DAYS),
}
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
def verify_jwt(token: str):
"""Decode and verify a JWT. Returns payload dict or None."""
try:
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except pyjwt.PyJWTError:
return None

View File

@ -11,19 +11,19 @@ POST /api/payment/success Validate webhook secret, unlock AI, push socke
""" """
import os import os
import base64
import hmac import hmac
import random import random
import functools import functools
from datetime import datetime, timedelta
import bcrypt import bcrypt
import jwt as pyjwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flask import Blueprint, g, jsonify, request from flask import Blueprint, g, jsonify, request
from database import db from database import db
from models import User, Message from models import User, Message
from config import (
AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY,
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
)
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")
@ -31,13 +31,6 @@ api = Blueprint("api", __name__, url_prefix="/api")
# Config # 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 = [ AI_RESPONSES = [
"Mmm, you have my full attention 💋", "Mmm, you have my full attention 💋",
"Oh my... keep going 😈 Don't stop there.", "Oh my... keep going 😈 Don't stop there.",
@ -60,22 +53,6 @@ AI_RESPONSES = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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): def _require_auth(f):
"""Decorator parse Bearer JWT and populate g.current_user.""" """Decorator parse Bearer JWT and populate g.current_user."""
@functools.wraps(f) @functools.wraps(f)
@ -83,7 +60,7 @@ def _require_auth(f):
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "): if not auth_header.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401 return jsonify({"error": "Unauthorized"}), 401
payload = _verify_jwt(auth_header[7:]) payload = verify_jwt(auth_header[7:])
if not payload: if not payload:
return jsonify({"error": "Invalid or expired token"}), 401 return jsonify({"error": "Invalid or expired token"}), 401
user = db.session.get(User, payload["user_id"]) user = db.session.get(User, payload["user_id"])
@ -94,22 +71,6 @@ def _require_auth(f):
return wrapped 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, def _persist_message(sender_id: int, recipient_id: int,
encrypted_content: str, nonce: str) -> None: encrypted_content: str, nonce: str) -> None:
"""Save a PM to the database. Enforces MAX_HISTORY per conversation pair.""" """Save a PM to the database. Enforces MAX_HISTORY per conversation pair."""
@ -188,7 +149,7 @@ def register():
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
token = _issue_jwt(user.id, user.username) token = issue_jwt(user.id, user.username)
return jsonify({ return jsonify({
"token": token, "token": token,
"user": { "user": {
@ -212,7 +173,7 @@ def login():
if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()): if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
return jsonify({"error": "Invalid username or password."}), 401 return jsonify({"error": "Invalid username or password."}), 401
token = _issue_jwt(user.id, user.username) token = issue_jwt(user.id, user.username)
return jsonify({ return jsonify({
"token": token, "token": token,
"user": { "user": {
@ -296,7 +257,7 @@ def ai_message():
# ── Transit decrypt (message readable for AI; key NOT stored) ───────────── # ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
try: try:
_plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64) _plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
except Exception: except Exception:
return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400 return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400
@ -306,7 +267,7 @@ def ai_message():
# ── Persist both legs encrypted in DB (server uses transit key) ────────── # ── Persist both legs encrypted in DB (server uses transit key) ──────────
bot = _get_ai_bot() bot = _get_ai_bot()
_persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI _persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI
resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text) resp_ct, resp_nonce = aesgcm_encrypt(transit_key, ai_text)
_persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user _persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user
# ── Update free trial counter ───────────────────────────────────────────── # ── Update free trial counter ─────────────────────────────────────────────
@ -315,7 +276,7 @@ def ai_message():
db.session.commit() db.session.commit()
# ── Re-encrypt AI response for transit back ─────────────────────────────── # ── Re-encrypt AI response for transit back ───────────────────────────────
resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text) resp_ct_transit, resp_nonce_transit = aesgcm_encrypt(transit_key, ai_text)
return jsonify({ return jsonify({
"ciphertext": resp_ct_transit, "ciphertext": resp_ct_transit,

View File

@ -12,7 +12,6 @@ Usage:
import os import os
import sys import sys
import json
import subprocess import subprocess
import signal import signal
import time import time
@ -21,24 +20,11 @@ import eventlet
# Monkey-patch stdlib BEFORE any other import # Monkey-patch stdlib BEFORE any other import
eventlet.monkey_patch() eventlet.monkey_patch()
from config import get_conf
# PID file to track the daemon process # PID file to track the daemon process
PID_FILE = "sexchat.pid" PID_FILE = "sexchat.pid"
def load_config():
conf = {}
config_path = os.path.join(os.path.dirname(__file__), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
conf = json.load(f)
except Exception:
pass
return conf
def _get_conf(key, default=None):
conf = load_config()
return os.environ.get(key, conf.get(key, default))
def get_pid(): def get_pid():
if os.path.exists(PID_FILE): if os.path.exists(PID_FILE):
with open(PID_FILE, "r") as f: with open(PID_FILE, "r") as f:
@ -68,7 +54,7 @@ def start_daemon():
"gunicorn", "gunicorn",
"--worker-class", "eventlet", "--worker-class", "eventlet",
"-w", "1", "-w", "1",
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}", "--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
"--daemon", "--daemon",
"--pid", PID_FILE, "--pid", PID_FILE,
"--access-logfile", "access.log", "--access-logfile", "access.log",
@ -124,7 +110,7 @@ def run_debug():
"gunicorn", "gunicorn",
"--worker-class", "eventlet", "--worker-class", "eventlet",
"-w", "1", "-w", "1",
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}", "--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
"--log-level", "debug", "--log-level", "debug",
"--access-logfile", "-", "--access-logfile", "-",
"--error-logfile", "-", "--error-logfile", "-",