Compare commits

...

22 Commits

Author SHA1 Message Date
End3r 8cce8e6c2e security: add CSRF protection, input sanitization, security logging, and JWT expiry reduction 2026-04-12 18:43:39 -05:00
3nd3r e86d69ce35 Rewrite README from scratch with comprehensive project documentation
- Complete feature inventory (lobby, PMs, Violet AI, moderation, roles, themes, settings)
- Full architecture diagram and tech stack table
- All 6 database models with column definitions
- Complete socket events reference (connection, lobby, PM, Violet, moderation, admin panel)
- REST API endpoints with request/response examples
- Role system hierarchy and admin panel documentation
- Encryption design (PM encryption + Violet transit encryption)
- Security measures overview
- All 10 themes listed
- Settings panel (4 tabs) documentation
- Violet AI companion behavior, commands, guest handling, fallback
- Premium/paywall flow
- Dependencies table
- Configuration reference with defaults
- Setup and installation instructions
2026-04-12 15:08:20 -05:00
3nd3r 4e3583ef9a Violet replies to unregistered guests with friendly signup prompt
Instead of a silent error event, guests who PM Violet now see their
message echoed back plus a flirty reply telling them to register.
2026-04-12 14:50:31 -05:00
3nd3r fa030a32b7 Fix admin panel: live AI access toggle, auto-refresh after actions
- Grant/Revoke AI button now notifies target user live via ai_unlock event
- ai_unlock handler updated to support both grant and revoke
- Admin panel auto-refreshes user/ban/mute lists after any action
2026-04-12 14:41:42 -05:00
3nd3r 887482d3db Add role-based admin panel with root/admin/mod/user hierarchy
- User model: new 'role' column (root > admin > mod > user)
- End3r (id=2) set as 'root' (GOD admin)
- Admin panel modal: Users tab (search, set roles, verify, grant AI),
  Bans tab (list/unban), Mutes tab (list/unmute)
- Role-based permission checks: root can set admins, admins set mods,
  mods can kick/ban/mute/verify
- Shield icon in header (visible to mod+) opens admin panel
- Nicklist shows role icons: crown (root), swords (admin), shield (mod)
- Context menu: added Mute/Unmute action
- Live role_updated event pushes role changes to online users
- role_power hierarchy prevents privilege escalation
2026-04-12 14:39:43 -05:00
3nd3r 064f6bf0ba Add 10 color themes to Chat settings
Themes: Midnight Purple (default), Crimson Noir, Ocean Deep, Ember,
Neon Green, Cyberpunk, Rose Gold, Arctic, Daylight, Midnight Blue

- Convert hardcoded rgba accent colors to CSS custom properties
- Add data-theme attribute switching with CSS variable overrides
- Theme picker grid with gradient swatches in Settings > Chat tab
- Theme preference persisted in localStorage
2026-04-12 14:28:06 -05:00
3nd3r d5e942d06d Add settings panel with Account, Chat, Violet, and Premium tabs
- Gear icon in header opens settings modal
- Account: view username/email, change password (server-side bcrypt)
- Chat: font size slider, 12h/24h timestamps, enter-to-send toggle, sounds toggle
- Violet: view AI access status, reset conversation memory
- Premium: feature showcase with upgrade CTA (payment coming soon)
- All chat prefs saved to localStorage
- Font size applied via CSS custom property
- Timestamp format conversion (24h server -> 12h client option)
- Icon button styling for gear and hamburger menu
2026-04-12 14:17:52 -05:00
3nd3r 8cd76ff72d Add per-user conversation memory for Violet AI
- VioletHistory model: stores plaintext turns (user/assistant) per user_id
- AI worker loads last 20 turns into Ollama prompt for context
- Saves both user message and AI response after each exchange
- /reset command in Violet PM clears conversation memory
- Fix ai_limit_reached: emit ai_response event instead of raw pm_message
- ai_response handler uses correct PM room and supports plaintext text field
- Remove debug print statements
2026-04-12 13:58:44 -05:00
3nd3r 389415f04d Fix Violet PM: echo user message + add session restore on refresh 2026-04-12 13:33:46 -05:00
3nd3r 1d6413cfd6 Switch Violet model to sadiq-bd/llama3.2-3b-uncensored (installed) 2026-04-12 13:25:28 -05:00
3nd3r 8214f9c244 Enter key sends messages; admins can chat with Violet
- Add keydown listener: Enter submits, Shift+Enter inserts newline
- Allow admin users (even guests) to PM Violet
2026-04-12 13:03:34 -05:00
3nd3r 01c6c4a1b0 Fix #11: Replace deprecated datetime.utcnow with timezone-aware UTC
- Import timezone from datetime
- Add _utcnow() helper using datetime.now(timezone.utc)
- Replace all default=datetime.utcnow column defaults
- Fixes deprecation warnings on Python 3.12+
2026-04-12 13:00:03 -05:00
3nd3r 9570283ad8 Fix #10: Persist bans and mutes to database
- Add Ban and Mute models to models.py
- Load persisted bans/mutes from DB on app startup in create_app()
- Persist ban to DB on mod_ban and mod_kickban
- Persist/delete mute to DB on mod_mute toggle
- Bans and mutes now survive server restarts
2026-04-12 12:59:20 -05:00
3nd3r 496701c713 Fix #9: Fix context menu breaking after first use
- Remove cloneNode/replaceWith pattern that orphaned the reference
- Re-bind onclick handlers directly on the existing DOM node
- Context menu now works reliably on every right-click
2026-04-12 12:56:33 -05:00
3nd3r b38eb01e27 Fix #7: Replace CORS wildcard with configurable origins
- cors_allowed_origins now uses CORS_ORIGINS from config (default: None)
- None restricts to same-origin only, blocking cross-site WebSocket hijacking
- Set CORS_ORIGINS in config.json or env var to allow specific domains
2026-04-12 12:55:58 -05:00
3nd3r 46ba1d7273 Fix #6: Add missing DOM elements, fix null references
- Add violet-trial-badge span to index.html header
- Add null guard in updateVioletBadge() for safety
- Remove dead $('tab-ai-violet') reference (Violet tab is dynamic)
- Fix duplicate trialBadge declaration and restore hidden logic
2026-04-12 12:55:20 -05:00
3nd3r cdfbb666b9 Fix #5: Fix broken E2E encryption for user-to-user PMs
- User-to-user PMs now use a server-derived shared room key (HMAC-SHA256)
  instead of each user's personal PBKDF2 key (which differed per user,
  making cross-user decryption impossible)
- Server sends room_key in pm_ready, pm_invite, and pm/history responses
- crypto.js: add importKeyBase64() for importing server-provided keys
- chat.js: use sharedKey for encrypt/decrypt in user-to-user PMs
- Violet AI transit encryption still uses personal key (unchanged)
- PM history decryption now handles errors gracefully per-message
- Encodes otherUser in history URL to prevent injection
2026-04-12 12:54:09 -05:00
3nd3r a0a96addb6 Fix #4: Don't broadcast PM invite when target is Violet
- Only emit pm_invite when target_sid exists (real user)
- Violet has no socket session, so emitting to None would broadcast
  to all connected clients, leaking who is chatting with the AI
2026-04-12 12:51:55 -05:00
3nd3r be3503b31b Fix #3: Remove client-exploitable payment endpoint
- Payment endpoint no longer uses @_require_auth (not client-callable)
- Identifies user from webhook payload user_id instead of client JWT
- Removed hardcoded payment secret from chat.js
- Client now shows placeholder message directing to admin
- Webhook secret + user_id must come from payment provider server
2026-04-12 12:51:31 -05:00
3nd3r 8da91ebf70 Fix #2: Validate PM room join authorization
- Track pending PM invitations per socket session
- pm_accept now rejects room joins unless user has a valid invite
- Clean up pending invites on disconnect
- Prevents eavesdropping on other users' PM conversations
2026-04-12 12:50:47 -05:00
3nd3r 99859f009f 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
2026-04-12 12:49:44 -05:00
3nd3r 1c17a9bcf0 Add detailed README with full security audit 2026-04-12 12:31:27 -05:00
10 changed files with 2845 additions and 338 deletions

681
README.md Normal file
View File

@ -0,0 +1,681 @@
# 💋 SexyChat (Aphrodite)
A real-time encrypted chat platform with an AI companion, role-based moderation, and a glassmorphism UI. Built with Flask-SocketIO, AES-GCM encryption, and a local Ollama-powered AI named **Violet**.
---
## Table of Contents
- [Features](#features)
- [Architecture](#architecture)
- [Project Structure](#project-structure)
- [Setup & Installation](#setup--installation)
- [Configuration](#configuration)
- [Running the Server](#running-the-server)
- [Database Models](#database-models)
- [Socket Events Reference](#socket-events-reference)
- [REST API Endpoints](#rest-api-endpoints)
- [Role System & Admin Panel](#role-system--admin-panel)
- [Encryption & Security](#encryption--security)
- [Theme System](#theme-system)
- [Settings Panel](#settings-panel)
- [Violet AI Companion](#violet-ai-companion)
- [Premium / Paywall](#premium--paywall)
---
## Features
### Core Chat
- **Public Lobby** — Real-time ephemeral group chat. Messages are never persisted; they live only in connected clients' browsers.
- **Private Messaging** — AES-GCM-256 encrypted PMs with persistent history (up to 500 messages per conversation).
- **Guest Mode** — Join and chat anonymously without an account.
- **Registration & Login** — Bcrypt password hashing, JWT token auth (7-day expiry), automatic session restore on page refresh.
- **Rate Limiting** — 6 messages per 5-second window per session.
### Violet AI Companion
- Local Ollama inference (`sadiq-bd/llama3.2-3b-uncensored:latest` by default).
- Transit-encrypted messages — decrypted server-side only for inference, never stored in plaintext.
- Per-user conversation memory (last 20 turns), persisted across sessions. `/reset` command to clear.
- Free trial (configurable, default: 3 messages), then paywall — admins can grant unlimited access.
- Real-time typing indicator while Violet is generating a response.
- Friendly signup prompt for unregistered guests who try to PM Violet.
### Moderation & Roles
- **4-tier role hierarchy:** `root` (👑) > `admin` (⚔️) > `mod` (🛡️) > `user`
- Kick, ban (username + IP), kickban, mute/unmute, verify/unverify.
- Persistent bans and mutes survive server restarts (stored in DB, loaded on startup).
- Admin panel with three tabs: Users, Bans, Mutes.
- Privilege escalation prevention — you can't modify users at or above your own power level.
### User Features
- Persistent ignore list (stealth blocking — ignored users receive no indication).
- Context menu (right-click a username) for PM, ignore, and mod actions.
- Settings panel with four tabs: Account, Chat, Violet, Premium.
- Password change from settings (requires current password).
- Unread PM indicators on conversation tabs.
### UI/UX
- Glassmorphism dark theme with CSS custom property theming.
- **10 color themes** (dark, light, neon, cyberpunk, and more).
- Responsive mobile layout with collapsible sidebar.
- Auto-expanding message textarea.
- Enter-to-send toggle (configurable).
- Google Fonts (Inter + Outfit).
---
## Architecture
```
┌─────────────┐ WebSocket / HTTP ┌──────────────────────────────┐
│ Browser │ ◄────────────────────► │ Gunicorn + Eventlet │
│ (chat.js │ │ Flask-SocketIO │
│ crypto.js) │ │ │
└─────────────┘ │ ┌────────────────────────┐ │
│ │ REST Blueprint │ │
│ │ (routes.py) │ │
│ └────────────────────────┘ │
│ │
│ ┌────────────────────────┐ │
│ │ AI Worker (greenlet) │ │
│ │ Serialized Ollama queue │ │
│ └───────────┬────────────┘ │
└──────────────┼───────────────┘
┌──────────────▼───────────────┐
│ Ollama (localhost:11434) │
│ LLaMA 3.2 3B Uncensored │
└──────────────────────────────┘
┌──────────────▼───────────────┐
│ SQLite (instance/sexchat.db)│
│ Users, Messages, Bans, │
│ Mutes, VioletHistory │
└──────────────────────────────┘
```
### Key Design Decisions
- **Single-process, single-worker** — Eventlet async mode with one Gunicorn worker. All in-memory state (connected users, rooms, rate limits) lives in-process.
- **AI inference queue** — A dedicated eventlet greenlet serialises Ollama requests one at a time, broadcasting `violet_typing` indicators while busy. Runs within Flask app context for DB access.
- **Lobby is ephemeral** — No lobby messages are ever written to the database.
- **PMs are encrypted at rest** — The server stores only AES-GCM ciphertext and nonces for registered-user PMs.
- **Shared config module**`config.py` centralises all constants, config loading, AES-GCM helpers, and JWT helpers so `app.py` and `routes.py` share a single source of truth.
### Tech Stack
| Layer | Technology |
|-------|-----------|
| Web server | Gunicorn 21.x with eventlet worker |
| Framework | Flask 3.x + Flask-SocketIO 5.x |
| Concurrency | Eventlet (monkey-patched greenlets) |
| Database | SQLite (default) or PostgreSQL via `DATABASE_URL` |
| ORM | SQLAlchemy 3.x + Flask-Migrate 4.x (Alembic) |
| AI | Ollama HTTP API (local inference) |
| Auth | JWT (PyJWT 2.x) + bcrypt 4.x |
| Encryption | AES-GCM-256 (server: `cryptography` 42.x, client: Web Crypto API) |
| Frontend | Vanilla JS, Socket.IO 4 client, custom CSS |
| Scaling | Optional Redis message queue for multi-worker deployments |
---
## Project Structure
```
aprhodite/
├── app.py # Flask-SocketIO app: socket events, AI worker, admin panel, moderation
├── config.py # Shared config loader, constants, AES-GCM helpers, JWT helpers, Violet prompt
├── database.py # SQLAlchemy + Flask-Migrate init, Violet bot user seeding
├── models.py # ORM models: User, Message, Ban, Mute, UserIgnore, VioletHistory
├── routes.py # REST API blueprint: auth, PM history, AI message, payment webhook
├── start.py # Process manager: start / stop / restart / status / debug
├── gunicorn.conf.py # Gunicorn configuration (workers, bind, worker class)
├── config.json # Runtime secrets & overrides (gitignored)
├── requirements.txt # Python dependencies
├── index.html # Single-page app: join screen, chat, settings modal, admin panel modal
├── static/
│ ├── chat.js # Frontend logic: socket events, PMs, settings, admin panel, themes
│ ├── crypto.js # Client-side AES-GCM encryption (PBKDF2 key derivation, SubtleCrypto)
│ ├── style.css # All styles: glassmorphism, 10 themes, admin panel, responsive layout
│ └── socket.io.min.js# Socket.IO v4 client library
└── instance/
└── sexchat.db # SQLite database (auto-created on first run)
```
---
## Setup & Installation
### Prerequisites
- **Python 3.11+**
- **[Ollama](https://ollama.ai)** (required for Violet AI)
### Install
```bash
git clone https://git.computertech.dev/lord3nd3r/aprhodite.git
cd aprhodite
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### Pull the AI Model
```bash
ollama pull sadiq-bd/llama3.2-3b-uncensored:latest
```
### Create `config.json`
```json
{
"SECRET_KEY": "your-random-secret-key",
"JWT_SECRET": "your-random-jwt-secret",
"ADMIN_PASSWORD": "a-strong-moderator-password",
"AI_FREE_LIMIT": 3,
"HOST": "0.0.0.0",
"PORT": 5000
}
```
> **Tip:** Generate secrets with `python -c "import uuid; print(uuid.uuid4().hex)"`.
### Database
On first run, `database.py` calls `db.create_all()` and seeds the **Violet** bot user automatically. No manual migration is needed for a fresh install.
For schema changes after the initial setup:
```bash
flask db migrate -m "description of changes"
flask db upgrade
```
---
## Configuration
Configuration is resolved with the following precedence: **Environment Variable → `config.json` → Default Value**.
All config loading is centralised in `config.py`.
| Key | Default | Description |
|-----|---------|-------------|
| `SECRET_KEY` | Random UUID hex | Flask session secret |
| `JWT_SECRET` | Random UUID hex | JWT HMAC signing key |
| `ADMIN_PASSWORD` | `"admin1234"` | Shared mod password for guest elevation |
| `DATABASE_URL` | `"sqlite:///sexchat.db"` | SQLAlchemy database URI |
| `PAYMENT_SECRET` | `"change-me-payment-secret"` | Payment webhook validation secret |
| `CORS_ORIGINS` | `None` | Socket.IO CORS allowlist |
| `HOST` | `"0.0.0.0"` | Server bind address |
| `PORT` | `5000` | Server bind port |
| `OLLAMA_URL` | `"http://localhost:11434"` | Ollama API endpoint |
| `VIOLET_MODEL` | `"sadiq-bd/llama3.2-3b-uncensored:latest"` | Ollama model tag for Violet |
| `AI_FREE_LIMIT` | `3` | Free Violet messages before paywall |
| `SOCKETIO_MESSAGE_QUEUE` / `REDIS_URL` | `None` | Redis URL for multi-worker Socket.IO |
### Hardcoded Constants (in `config.py`)
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_MSG_LEN` | 500 chars | Maximum message length |
| `MAX_HISTORY` | 500 messages | PM history cap per conversation pair |
| `JWT_EXPIRY_DAYS` | 7 days | JWT token lifespan |
| `AI_BOT_NAME` | `"Violet"` | AI bot display name |
| `PBKDF2_ITERATIONS` | 100,000 | Client-side key derivation iterations (`crypto.js`) |
| Rate limit | 6 msgs / 5 sec | Per-session lobby rate limit (`app.py`) |
| `MAX_HISTORY_PER_USER` | 20 turns | Violet conversation memory depth (`app.py`) |
---
## Running the Server
### Debug Mode (foreground, full logging)
```bash
python start.py debug
```
### Daemon Mode
```bash
python start.py start # Start in background (PID written to .pid file)
python start.py stop # Graceful shutdown
python start.py restart # Stop + start
python start.py status # Check if running
```
### Direct Gunicorn
```bash
gunicorn --worker-class eventlet -w 1 --bind 0.0.0.0:5000 start:application
```
The server will be available at `http://localhost:5000`.
---
## Database Models
Six models defined in `models.py`. All timestamps use `datetime.now(timezone.utc)`.
### `users`
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | Auto-increment |
| `username` | String(20), unique, indexed | Alphanumeric + `-` + `_` |
| `password_hash` | String(128) | Bcrypt hash |
| `email` | String(255), unique, nullable | Optional email |
| `role` | String(10), default `"user"` | `root`, `admin`, `mod`, or `user` |
| `has_ai_access` | Boolean, default `False` | Unlimited Violet access flag |
| `ai_messages_used` | Integer, default `0` | Free trial counter |
| `is_verified` | Boolean, default `False` | Manual verification status |
| `created_at` | DateTime | UTC timestamp |
### `messages` (Encrypted PM History)
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | |
| `sender_id` | FK → `users.id` | |
| `recipient_id` | FK → `users.id` | |
| `encrypted_content` | Text | Base64-encoded AES-GCM ciphertext |
| `nonce` | String(64) | Base64-encoded 12-byte IV |
| `timestamp` | DateTime | Indexed with sender/recipient for query performance |
### `bans`
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | |
| `username` | String(20), indexed | Banned username (lowercase) |
| `ip` | String(45), nullable, indexed | Banned IP address |
| `reason` | String(255), nullable | Ban reason |
| `created_at` | DateTime | |
### `mutes`
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | |
| `username` | String(20), unique, indexed | Muted username |
| `created_at` | DateTime | |
### `user_ignores`
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | |
| `ignorer_id` | FK → `users.id` | User doing the ignoring |
| `ignored_id` | FK → `users.id` | User being ignored |
| `created_at` | DateTime | |
Unique composite index on `(ignorer_id, ignored_id)`.
### `violet_history`
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer, PK | |
| `user_id` | FK → `users.id` | Owning user |
| `role` | String(10) | `"user"` or `"assistant"` |
| `text` | Text | Plaintext conversation turn |
| `timestamp` | DateTime | |
---
## Socket Events Reference
### Connection & Auth
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `join` | `{ mode, username, password?, email?, mod_password? }` | Authenticate and enter lobby. `mode`: `guest`, `login`, `register`, or `restore`. |
| S→C | `joined` | `{ username, is_admin, role, is_registered, has_ai_access, ai_messages_used, email?, token?, ignored_list? }` | Auth success — includes JWT token for session restore. |
| S→C | `error` | `{ msg }` | Error message. |
| S→C | `kicked` | `{ msg }` | User was kicked/banned. |
### Lobby Chat
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `message` | `{ text }` | Send lobby message (rate-limited). |
| S→C | `message` | `{ username, text, is_admin, is_registered, ts }` | Broadcast lobby message. |
| S→C | `system` | `{ msg, ts }` | System notification (joins, parts, mod actions). |
| S→C | `nicklist` | `{ users: [{ username, is_admin, is_registered, is_verified, role }] }` | Full online user list (includes Violet bot). |
### Private Messaging
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `pm_open` | `{ target }` | Initiate PM with target user. |
| C→S | `pm_accept` | `{ room }` | Accept an incoming PM invitation (validated against pending invites). |
| C→S | `pm_message` | `{ room, text? }` or `{ room, ciphertext, nonce, transit_key? }` | Send PM — plaintext (guests) or encrypted (registered). Include `transit_key` for Violet. |
| S→C | `pm_invite` | `{ from, room, room_key }` | Incoming PM invitation with server-derived room key. |
| S→C | `pm_ready` | `{ with, room, room_key }` | PM room opened — sent to the initiator. |
| S→C | `pm_message` | `{ from, text?, ciphertext?, nonce?, room, ts }` | Receive a PM. |
### Violet AI
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `pm_message` (to Violet room) | `{ room, ciphertext, nonce, transit_key }` | Encrypted message routed to Violet via AI worker. |
| C→S | `violet_reset` | _(none)_ | Clear your conversation memory with Violet. |
| S→C | `violet_typing` | `{ busy, room? }` | AI processing indicator (shown while Ollama generates). |
| S→C | `ai_response` | `{ error: "ai_limit_reached", room }` | Free trial exhausted. |
| S→C | `ai_unlock` | `{ has_ai_access, msg? }` | Premium access granted or revoked (pushed live). |
### Moderation
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `mod_kick` | `{ target }` | Kick user (mod+). |
| C→S | `mod_ban` | `{ target }` | Ban username + IP (mod+). |
| C→S | `mod_kickban` | `{ target }` | Kick + ban simultaneously (mod+). |
| C→S | `mod_mute` | `{ target }` | Toggle mute on user (mod+). |
| C→S | `mod_verify` | `{ target }` | Toggle verification on user (mod+). |
### User Actions
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `user_ignore` | `{ target }` | Ignore a user (registered only). |
| C→S | `user_unignore` | `{ target }` | Unignore a user. |
| C→S | `change_password` | `{ old_password, new_password }` | Change password (6-char minimum). |
| S→C | `ignore_status` | `{ target, ignored }` | Ignore list update confirmation. |
| S→C | `password_changed` | `{ success, msg? }` | Password change result. |
| S→C | `role_updated` | `{ role }` | Live notification when your role is changed by an admin. |
### Admin Panel
| Direction | Event | Data | Description |
|-----------|-------|------|-------------|
| C→S | `admin_get_users` | _(none)_ | Request full user list (mod+). |
| C→S | `admin_get_bans` | _(none)_ | Request ban list (mod+). |
| C→S | `admin_get_mutes` | _(none)_ | Request mute list (mod+). |
| C→S | `admin_set_role` | `{ user_id, role }` | Change a user's role (admin+ only, can't set ≥ own role). |
| C→S | `admin_verify_user` | `{ user_id }` | Toggle verification (mod+). |
| C→S | `admin_toggle_ai` | `{ user_id }` | Toggle unlimited AI access (admin+). Notifies user in real-time. |
| C→S | `admin_unban` | `{ ban_id }` | Remove a ban (mod+). |
| C→S | `admin_unmute` | `{ mute_id }` | Remove a mute (mod+). |
| S→C | `admin_users` | `{ users: [{ id, username, role, is_verified, has_ai_access, email, created_at, online }] }` | User list with online status. |
| S→C | `admin_bans` | `{ bans: [{ id, username, ip?, reason?, created_at }] }` | Ban list. |
| S→C | `admin_mutes` | `{ mutes: [{ id, username, created_at }] }` | Mute list. |
| S→C | `admin_action_ok` | `{ msg }` | Action success toast (panel auto-refreshes). |
---
## REST API Endpoints
All endpoints are under the `/api` prefix.
### `POST /api/auth/register`
Create a new account.
```json
// Request
{ "username": "alice", "password": "hunter2", "email": "alice@example.com" }
// Response 201
{
"token": "eyJ...",
"user": { "id": 3, "username": "alice", "has_ai_access": false, "ai_messages_used": 0 }
}
```
### `POST /api/auth/login`
Authenticate and receive a JWT.
```json
// Request
{ "username": "alice", "password": "hunter2" }
// Response 200
{ "token": "eyJ...", "user": { "id": 3, "username": "alice", ... } }
```
### `GET /api/pm/history?with={username}`
Fetch encrypted PM history with another user. Requires `Authorization: Bearer <jwt>`.
```json
// Response 200
{
"room_key": "base64...",
"messages": [
{ "from_me": true, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." },
{ "from_me": false, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." }
]
}
```
### `POST /api/ai/message`
Send an encrypted message to Violet via REST (alternative to the socket path).
```json
// Request (Authorization: Bearer <jwt>)
{ "ciphertext": "...", "nonce": "...", "transit_key": "..." }
// Response 200
{ "ciphertext": "...", "nonce": "...", "ai_messages_used": 2, "has_ai_access": false }
```
### `POST /api/payment/success`
Payment webhook (server-to-server). Validates `secret` with constant-time HMAC comparison.
```json
// Request
{ "secret": "PAYMENT_SECRET", "user_id": 3 }
// Response 200
{ "status": "ok", "has_ai_access": true }
```
---
## Role System & Admin Panel
### Hierarchy
| Role | Power Level | Badge | Capabilities |
|------|-------------|-------|-------------|
| `root` | 3 | 👑 | Everything. Can promote to admin. Cannot be demoted. |
| `admin` | 2 | ⚔️ | Set roles (mod/user), toggle AI access, all mod actions. |
| `mod` | 1 | 🛡️ | Kick, ban, mute, verify, view admin panel. |
| `user` | 0 | — | Standard chat, PMs, Violet AI (free trial). |
**Rules:**
- You cannot modify a user whose power level is ≥ your own.
- You cannot assign a role whose power level is ≥ your own.
- The first registered human user should be set to `root` in the database.
- **Legacy:** Guests can enter the shared mod password at login to gain temporary `mod` session access.
### Admin Panel
Accessible via the shield icon (🛡️) in the header (visible to `mod` and above). Three tabs:
| Tab | Features |
|-----|----------|
| **Users** | Searchable user list. Change roles (dropdown), toggle verification (checkmark), grant/revoke AI access (brain icon). Shows online status. |
| **Bans** | All active bans with username, IP, and date. One-click unban button. |
| **Mutes** | All active mutes. One-click unmute button. |
All actions trigger a system message in the lobby, auto-refresh the panel, and (where applicable) push live updates to the affected user's session.
---
## Encryption & Security
### PM Encryption (AES-GCM-256)
```
1. Server generates a deterministic room key: HMAC-SHA256(JWT_SECRET, room_name)
2. Room key is delivered to both clients via pm_invite / pm_ready events
3. Client encrypts each message with AES-GCM-256 using the room key + random 12-byte nonce
4. Server stores only base64(ciphertext) + base64(nonce) — never sees plaintext
5. Recipient decrypts client-side with the same room key
```
### Violet AI Transit Encryption
```
1. Client generates a random AES-256 transit key
2. Client encrypts message with transit key → sends { ciphertext, nonce, transit_key }
3. Server decrypts with transit key → sends plaintext to Ollama for inference
4. Server encrypts Violet's response with the same transit key → delivers to client
5. Transit key is ephemeral — used only in memory, never stored
```
### Security Measures
| Category | Implementation |
|----------|---------------|
| **Password hashing** | Bcrypt with adaptive cost factor |
| **Authentication** | JWT tokens (HMAC-SHA256), 7-day expiry |
| **Session restore** | JWT stored in `localStorage`, validated on reconnect |
| **Input validation** | Regex-enforced usernames (`[a-zA-Z0-9_-]`, 120 chars), message length limits, SQLAlchemy parameterized queries |
| **Rate limiting** | 6 messages per 5-second window per session |
| **RBAC** | `@_require_role()` decorator with power-level hierarchy checks |
| **Webhook auth** | Constant-time HMAC comparison for payment secret |
| **Ban persistence** | Username + IP stored in DB `bans` table, loaded into memory on startup |
| **Mute persistence** | Stored in DB `mutes` table, loaded on startup |
| **PM invite validation** | Server-side pending invite mapping — `pm_accept` only works for legitimately invited rooms |
---
## Theme System
10 themes available in **Settings → Chat → Theme**. Each theme is a set of CSS custom properties applied via the `data-theme` attribute on the document root.
| Theme | Style | Palette |
|-------|-------|---------|
| **Midnight Purple** | Dark | Deep purple + neon magenta *(default)* |
| **Crimson Noir** | Dark | Black + crimson red |
| **Ocean Deep** | Dark | Navy + cyan / teal |
| **Ember** | Dark | Dark brown + orange / amber |
| **Neon Green** | Dark | Black + bright lime |
| **Cyberpunk** | Dark | Purple + electric yellow |
| **Rose Gold** | Dark | Dark rose + blush pink |
| **Arctic** | Light | Cool blue-white + indigo accents |
| **Daylight** | Light | White + pink / purple accents |
| **Midnight Blue** | Dark | Deep navy + blue glow |
Theme preference is saved to `localStorage` (`sc_theme`) and applied immediately on page load — no flash of default theme.
---
## Settings Panel
Accessed via the gear icon (⚙️) in the header. Four tabs:
### Account
- View your username and email.
- Change password (requires current password, 6-character minimum for the new one).
### Chat
- **Theme** — 10-option grid with gradient color swatches.
- **Font Size** — 1220px slider.
- **Timestamp Format** — 12-hour / 24-hour toggle.
- **Enter to Send** — On/Off. When off, Enter inserts a newline and Shift+Enter sends.
- **Notification Sounds** — On/Off.
### Violet
- AI access status display (Unlimited ✓ or X remaining free messages).
- Messages used counter.
- Reset conversation memory button.
### Premium
- Feature showcase (Unlimited Violet, Encrypted PMs, Priority Access, Custom Themes).
- Pricing display ($10/month).
- Coming soon placeholder.
All preferences are persisted in `localStorage` with the `sc_` prefix (e.g., `sc_theme`, `sc_fontSize`, `sc_enterSend`).
---
## Violet AI Companion
Violet is an AI chat companion powered by a local Ollama model. She's configured as a *"flirtatious and sophisticated nightclub hostess at an exclusive, dimly-lit members-only club"* via her system prompt in `config.py`.
### How It Works
1. User clicks **Violet** in the nicklist → PM room opens automatically (no invite needed for the bot).
2. User's message is transit-encrypted (AES-GCM) and queued to a single-threaded inference greenlet.
3. The AI worker decrypts the message, builds a prompt from the last 20 conversation turns (`violet_history` table), and calls Ollama's `/api/generate` endpoint.
4. The response is transit-encrypted with the same key and delivered as a PM back to the user.
5. Both the user's message and Violet's reply are saved to `violet_history` for context continuity across sessions.
### Commands
| Command | Description |
|---------|-------------|
| `/reset` | Clear Violet's memory of your conversation (deletes all `violet_history` rows for your user). |
### Guest Behavior
Unregistered guests who PM Violet receive a friendly plaintext reply:
> *"Hey hun 💜 You'll need to register an account before we can chat privately. Go back to the join screen and sign up — I'll be waiting for you! 😘"*
### Free Trial & Paywall
Users get `AI_FREE_LIMIT` (default: 3) free messages. After that, a paywall modal is shown. Admins can grant unlimited access via the admin panel's "Grant AI" button — the user is notified in real-time via `ai_unlock`.
### Fallback
If Ollama is unreachable or returns an error, Violet sends a graceful fallback message instead of crashing.
---
## Premium / Paywall
When a user exhausts their free Violet messages, a paywall modal appears with pricing and feature highlights. Premium access (`has_ai_access = True`) can be granted two ways:
1. **Admin Panel** — Any `admin`+ can click the brain icon (🧠) on a user in the Users tab to toggle unlimited AI access. The user receives a real-time notification.
2. **Payment Webhook**`POST /api/payment/success` with the payment secret and user ID. Validated with constant-time comparison.
Premium unlocks unlimited Violet messages. The `ai_unlock` event is pushed to the user's active socket session immediately.
> **Note:** The payment flow is currently a stub. For production, replace the secret-comparison logic with a proper payment provider webhook (e.g., Stripe's `stripe.Webhook.construct_event()`).
---
## Dependencies
From `requirements.txt`:
| Package | Version | Purpose |
|---------|---------|---------|
| `flask` | ≥3.0 | Web framework |
| `flask-socketio` | ≥5.3 | WebSocket support |
| `eventlet` | ≥0.35 | Async concurrency |
| `gunicorn` | ≥21.0 | Production WSGI server |
| `flask-sqlalchemy` | ≥3.1 | ORM |
| `flask-migrate` | ≥4.0 | Database migrations (Alembic) |
| `psycopg2-binary` | ≥2.9 | PostgreSQL driver (optional) |
| `bcrypt` | ≥4.0 | Password hashing |
| `PyJWT` | ≥2.8 | JWT auth tokens |
| `cryptography` | ≥42.0 | Server-side AES-GCM |
| `redis` | ≥5.0 | Socket.IO multi-worker adapter (optional) |
| `requests` | ≥2.31 | Ollama HTTP client |
---
## License
Private project. All rights reserved.
---
## Credits
Built by **End3r** — [git.computertech.dev/lord3nd3r/aprhodite](https://git.computertech.dev/lord3nd3r/aprhodite)

633
app.py
View File

@ -39,16 +39,14 @@ Socket events (server → client)
"""
import os
import json
import time
import uuid
import base64
import hmac
import hashlib
import functools
import logging
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
@ -56,65 +54,45 @@ 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 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):
# 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."
from models import User, Message, UserIgnore, Ban, Mute, VioletHistory
from config import (
SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS,
MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME,
OLLAMA_URL, VIOLET_MODEL, VIOLET_SYSTEM,
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
)
# ─────────────────────────────────────────────────────────────────────────
# Security Logging Setup
# ─────────────────────────────────────────────────────────────────────────
security_logger = logging.getLogger("security")
security_logger.setLevel(logging.INFO)
if not security_logger.handlers:
handler = logging.FileHandler("security.log")
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - [%(name)s] - %(message)s"
)
handler.setFormatter(formatter)
security_logger.addHandler(handler)
# ---------------------------------------------------------------------------
# In-process state
# ---------------------------------------------------------------------------
# sid → { username, ip, is_admin, joined_at, user_id, is_registered,
# sid → { username, ip, is_admin, role, joined_at, user_id, is_registered,
# has_ai_access, ai_messages_used }
connected_users: dict = {}
# role hierarchy higher number = more power
ROLE_POWER = {"user": 0, "mod": 1, "admin": 2, "root": 3}
username_to_sid: dict = {} # lowercase_name → sid
muted_users: set = set()
banned_usernames: set = set()
banned_ips: set = set()
message_timestamps: dict = defaultdict(list)
pending_pm_invites: dict = {} # sid → set of room names they were invited to
RATE_LIMIT = 6
RATE_WINDOW = 5
@ -123,42 +101,21 @@ RATE_WINDOW = 5
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."""
MAX_HISTORY_PER_USER = 20 # last N turns loaded into Violet prompt
def call_ollama(messages: list) -> str:
"""Call the local Ollama API with a full messages list. 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},
],
"messages": messages,
"stream": False,
"options": {"temperature": 0.88, "num_predict": 120},
},
@ -171,6 +128,24 @@ def call_ollama(user_message: str) -> str:
return "Give me just a moment, darling... 💜"
def _load_violet_history(user_id: int) -> list:
"""Load recent conversation turns from DB. Returns list of {role, content} dicts."""
rows = (
VioletHistory.query
.filter_by(user_id=user_id)
.order_by(VioletHistory.id.desc())
.limit(MAX_HISTORY_PER_USER)
.all()
)
return [{"role": r.role, "content": r.text} for r in reversed(rows)]
def _save_violet_turn(user_id: int, role: str, text: str) -> None:
"""Persist a single conversation turn."""
db.session.add(VioletHistory(user_id=user_id, role=role, text=text))
db.session.commit()
# ---------------------------------------------------------------------------
# AI inference queue worker (single greenlet, serialises Ollama calls)
# ---------------------------------------------------------------------------
@ -178,34 +153,53 @@ def call_ollama(user_message: str) -> str:
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
sid = task["sid"]
try:
# Derive room name (needs app context for DB lookup)
with _app_ref.app_context():
if task.get("user_id"):
db_user = db.session.get(User, task["user_id"])
room = _pm_room(db_user.username, AI_BOT_NAME) if db_user else None
else:
uname = connected_users.get(sid, {}).get("username", "unknown")
room = _pm_room(uname, AI_BOT_NAME)
# ── Announce Violet is busy ───────────────────────────────────────
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(
# ── Decrypt user message (transit; key never stored) ──────────────
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 💜"
# ── Build messages array with history ─────────────────────────────
messages = [{"role": "system", "content": VIOLET_SYSTEM}]
if task.get("user_id"):
with _app_ref.app_context():
messages.extend(_load_violet_history(task["user_id"]))
messages.append({"role": "user", "content": plaintext})
# ── Re-encrypt AI response ────────────────────────────────────────────
resp_ct, resp_nonce = _aesgcm_encrypt(task["transit_key"], ai_text)
ai_text = call_ollama(messages)
# ── Save conversation turns ───────────────────────────────────────
if task.get("user_id"):
with _app_ref.app_context():
_save_violet_turn(task["user_id"], "user", plaintext)
_save_violet_turn(task["user_id"], "assistant", ai_text)
# ── 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) ─────────────
# ── 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"):
@ -225,16 +219,19 @@ def _ai_worker() -> None:
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)
# ── Emit response to originating client ───────────────────────────
if task.get("plaintext_mode"):
socketio.emit("pm_message", {
"from": AI_BOT_NAME,
"text": ai_text,
"room": room,
"ts": _ts()
}, to=room)
else:
socketio.emit("pm_message", {
"from": AI_BOT_NAME,
"ciphertext": resp_ct,
@ -244,10 +241,24 @@ def _ai_worker() -> None:
}, 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
except Exception as exc:
import traceback; traceback.print_exc()
# Try to send error feedback to user
try:
uname = connected_users.get(sid, {}).get("username", "unknown")
room = _pm_room(uname, AI_BOT_NAME)
socketio.emit("pm_message", {
"from": AI_BOT_NAME,
"text": "Mmm, something went wrong, darling 💜",
"room": room,
"ts": _ts()
}, to=room)
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
except Exception:
pass
finally:
ai_queue.task_done()
# ---------------------------------------------------------------------------
@ -258,6 +269,20 @@ def _pm_room(a: str, b: str) -> str:
return "pm:" + ":".join(sorted([a.lower(), b.lower()]))
def _pm_room_key(room: str) -> str:
"""Derive a deterministic AES-256 key for a PM room.
Uses HMAC-SHA256 keyed with JWT_SECRET so the same room always gets
the same key (allowing history to be decrypted across sessions).
The server mediates the key this is NOT end-to-end, but it fixes
the broken cross-user decryption while matching the existing trust model.
"""
from config import JWT_SECRET
raw = hmac.new(JWT_SECRET.encode(), room.encode(), hashlib.sha256).digest()
import base64
return base64.b64encode(raw).decode()
def _get_nicklist() -> list:
users = []
for info in connected_users.values():
@ -268,6 +293,7 @@ def _get_nicklist() -> list:
"is_admin": info["is_admin"],
"is_registered": info.get("is_registered", False),
"is_verified": info.get("is_verified", False),
"role": info.get("role", "user"),
})
# Static "Violet" AI user
users.append({
@ -275,7 +301,8 @@ def _get_nicklist() -> list:
"is_admin": False,
"is_registered": True,
"is_verified": True,
"is_ai": True
"is_ai": True,
"role": "user",
})
return sorted(users, key=lambda u: u["username"].lower())
@ -291,6 +318,23 @@ def _require_admin(f):
return wrapped
def _require_role(min_role):
"""Decorator: require at least min_role power level."""
def decorator(f):
@functools.wraps(f)
def wrapped(*args, **kwargs):
user = connected_users.get(request.sid)
if not user:
emit("error", {"msg": "Forbidden."}); return
user_power = ROLE_POWER.get(user.get("role", "user"), 0)
needed = ROLE_POWER.get(min_role, 0)
if user_power < needed:
emit("error", {"msg": "Forbidden."}); return
return f(*args, **kwargs)
return wrapped
return decorator
def _rate_limited(sid: str) -> bool:
now = time.time()
message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW]
@ -311,21 +355,6 @@ def _do_disconnect(sid: str) -> None:
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(
@ -355,9 +384,7 @@ def create_app() -> Flask:
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_DATABASE_URI=DATABASE_URL,
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
@ -366,6 +393,15 @@ def create_app() -> Flask:
init_db(app)
_app_ref = app
# Load persisted bans and mutes from the database
with app.app_context():
for ban in Ban.query.all():
banned_usernames.add(ban.username.lower())
if ban.ip:
banned_ips.add(ban.ip)
for mute in Mute.query.all():
muted_users.add(mute.username.lower())
msg_queue = (
os.environ.get("SOCKETIO_MESSAGE_QUEUE")
or os.environ.get("REDIS_URL")
@ -374,7 +410,7 @@ def create_app() -> Flask:
socketio.init_app(
app,
async_mode="eventlet",
cors_allowed_origins="*",
cors_allowed_origins=CORS_ORIGINS,
message_queue=msg_queue,
logger=False,
engineio_logger=False,
@ -411,7 +447,7 @@ def on_connect(auth=None):
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"])
payload = verify_jwt(auth["token"])
if payload:
db_user = db.session.get(User, payload.get("user_id"))
if db_user:
@ -425,6 +461,7 @@ def on_connect(auth=None):
"username": None,
"ip": ip,
"is_admin": False,
"role": "user",
"joined_at": time.time(),
"user_id": user_id,
"is_registered": is_registered,
@ -439,6 +476,7 @@ def on_disconnect():
sid = request.sid
user = connected_users.pop(sid, None)
message_timestamps.pop(sid, None)
pending_pm_invites.pop(sid, None)
if user and user.get("username"):
lower = user["username"].lower()
username_to_sid.pop(lower, None)
@ -466,34 +504,40 @@ def on_join(data):
if mode == "register":
if not username or not username.replace("_","").replace("-","").isalnum():
security_logger.warning(f"REGISTER_FAIL: Invalid username format from IP {request.remote_addr}")
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():
security_logger.info(f"REGISTER_FAIL: Duplicate username {username}")
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)
token = issue_jwt(db_user.id, db_user.username)
security_logger.info(f"REGISTER_SUCCESS: {username} from IP {request.remote_addr}")
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()):
security_logger.warning(f"LOGIN_FAIL: Invalid credentials for {username} from IP {request.remote_addr}")
emit("error", {"msg": "Invalid username or password."}); return
if not db_user.is_verified:
security_logger.info(f"LOGIN_FAIL: Unverified account {username}")
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)
token = issue_jwt(db_user.id, db_user.username)
security_logger.info(f"LOGIN_SUCCESS: {username} from IP {request.remote_addr}")
elif mode == "restore":
if not user.get("user_id"):
@ -506,7 +550,7 @@ def on_join(data):
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)
token = issue_jwt(db_user.id, db_user.username)
else: # guest
if not username or not username.replace("_","").replace("-","").isalnum():
@ -518,28 +562,40 @@ def on_join(data):
if lower in username_to_sid and username_to_sid[lower] != sid:
emit("error", {"msg": "Username already in use."}); return
is_admin = False
# Derive role from DB (root/admin/mod grant is_admin automatically)
db_role = db_user.role if db_user else "user"
is_admin = ROLE_POWER.get(db_role, 0) >= ROLE_POWER["mod"]
# Legacy mod-password fallback for guests (temporary mod access)
mod_pw = str(data.get("mod_password", "")).strip()
if mod_pw and mod_pw == ADMIN_PASSWORD:
if mod_pw and mod_pw == ADMIN_PASSWORD and not is_admin:
is_admin = True
if db_role == "user":
db_role = "mod" # temporary elevation for the session
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
user["role"] = db_role
user["is_verified"] = db_user.is_verified if db_user else True
username_to_sid[lower] = sid
join_room(LOBBY)
# Role badge for join message
role_icon = {"root": "👑 ", "admin": "⚔️ ", "mod": "🛡️ "}.get(db_role, "")
emit("joined", {
"username": username,
"is_admin": is_admin,
"role": db_role,
"is_registered": user["is_registered"],
"has_ai_access": user["has_ai_access"],
"ai_messages_used": user["ai_messages_used"],
"email": db_user.email if db_user else None,
"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.",
"msg": f"{role_icon}**{username}** joined the room.",
"ts": _ts(),
}, to=LOBBY)
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
@ -605,14 +661,24 @@ def on_pm_open(data):
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})
room_key = _pm_room_key(room)
if target_sid:
pending_pm_invites.setdefault(target_sid, set()).add(room)
socketio.emit("pm_invite", {"from": user["username"], "room": room, "room_key": room_key}, to=target_sid)
emit("pm_ready", {"with": target, "room": room, "room_key": room_key})
@socketio.on("pm_accept")
def on_pm_accept(data):
join_room(data.get("room"))
sid = request.sid
room = str(data.get("room", ""))
allowed = pending_pm_invites.get(sid, set())
if room not in allowed:
emit("error", {"msg": "Invalid or expired PM invitation."})
return
allowed.discard(room)
join_room(room)
@socketio.on("pm_message")
@ -643,15 +709,45 @@ def on_pm_message(data):
# 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)
if not user.get("user_id") and not user.get("is_admin"):
# Echo their message, then reply as Violet
emit("pm_message", payload, to=sid)
emit("pm_message", {
"from": AI_BOT_NAME,
"text": "Hey hun 💜 You'll need to register an account before we can chat. "
"Go back and sign up — I'll be waiting for you! 😘",
"room": room,
"ts": _ts(),
}, to=sid)
return
if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT:
username = user.get("username", "unknown")
security_logger.warning(f"AI_LIMIT_REACHED: {username} tried to use AI after free trial exhausted")
emit("ai_response", {"error": "ai_limit_reached", "room": room}, to=sid)
return
# Echo the user's own message back so it appears in their chat
emit("pm_message", payload, to=sid)
transit_key = data.get("transit_key", "")
if not all([ciphertext, nonce_val, transit_key]):
emit("error", {"msg": "AI Private Messaging requires transit encryption."}); return
# Plaintext fallback (e.g. session restore without crypto key)
if text:
import base64 as _b64
transit_key = _b64.b64encode(os.urandom(32)).decode()
ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text)
ai_queue.put({
"sid": sid,
"user_id": user.get("user_id"),
"has_ai_access": user.get("has_ai_access", False),
"ai_messages_used": user.get("ai_messages_used", 0),
"ciphertext": ciphertext_new,
"nonce_val": nonce_new,
"transit_key": transit_key,
"plaintext_mode": True,
})
return
emit("error", {"msg": "Message cannot be empty."}); return
ai_queue.put({
"sid": sid,
@ -688,6 +784,24 @@ def on_ai_message(data):
pass
@socketio.on("violet_reset")
def on_violet_reset(_data=None):
sid = request.sid
user = connected_users.get(sid)
if not user or not user.get("user_id"):
emit("error", {"msg": "You must be registered to reset Violet history."}); return
user_id = user["user_id"]
VioletHistory.query.filter_by(user_id=user_id).delete()
db.session.commit()
room = _pm_room(user["username"], AI_BOT_NAME)
emit("pm_message", {
"from": AI_BOT_NAME,
"text": "Memory cleared, darling. Let's start fresh! 💜",
"room": room,
"ts": _ts(),
}, to=sid)
# ---------------------------------------------------------------------------
# Mod tools
# ---------------------------------------------------------------------------
@ -699,6 +813,11 @@ def on_kick(data):
target_sid = username_to_sid.get(target.lower())
if not target_sid:
emit("error", {"msg": f"{target} is not online."}); return
# Security logging
mod_name = connected_users.get(request.sid, {}).get("username", "unknown")
security_logger.warning(f"MOD_KICK: {mod_name} kicked {target}")
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)
@ -710,13 +829,24 @@ def on_ban(data):
target = str(data.get("target", "")).strip()
lower = target.lower()
banned_usernames.add(lower)
ip = None
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"])
ip = info["ip"]
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
# Security logging
mod_name = connected_users.get(request.sid, {}).get("username", "unknown")
security_logger.warning(f"MOD_BAN: {mod_name} banned {target} (IP: {ip})")
# Persist to DB
if not Ban.query.filter_by(username=lower).first():
db.session.add(Ban(username=lower, ip=ip))
db.session.commit()
socketio.emit("system", {"msg": f"🔨 **{target}** was banned.", "ts": _ts()}, to=LOBBY)
@ -726,9 +856,16 @@ def on_mute(data):
target = str(data.get("target", "")).strip()
lower = target.lower()
if lower in muted_users:
muted_users.discard(lower); action = "unmuted"
muted_users.discard(lower)
Mute.query.filter_by(username=lower).delete()
db.session.commit()
action = "unmuted"
else:
muted_users.add(lower); action = "muted"
muted_users.add(lower)
if not Mute.query.filter_by(username=lower).first():
db.session.add(Mute(username=lower))
db.session.commit()
action = "muted"
emit("system", {"msg": f"🔇 **{target}** was {action}.", "ts": _ts()}, to=LOBBY)
@ -739,13 +876,19 @@ def on_kickban(data):
lower = target.lower()
# Ban
banned_usernames.add(lower)
ip = None
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"])
ip = info["ip"]
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
# Persist to DB
if not Ban.query.filter_by(username=lower).first():
db.session.add(Ban(username=lower, ip=ip))
db.session.commit()
# Announce
socketio.emit("system", {"msg": f"💀 **{target}** was kickbanned.", "ts": _ts()}, to=LOBBY)
@ -804,3 +947,219 @@ def on_verify(data):
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)
# ---------------------------------------------------------------------------
# Account management
# ---------------------------------------------------------------------------
@socketio.on("change_password")
def on_change_password(data):
sid = request.sid
user = connected_users.get(sid)
if not user or not user.get("user_id"):
emit("password_changed", {"success": False, "msg": "You must be registered."})
return
old_pw = str(data.get("old_password", ""))
new_pw = str(data.get("new_password", ""))
if not old_pw or not new_pw:
emit("password_changed", {"success": False, "msg": "Both fields are required."})
return
if len(new_pw) < 6:
emit("password_changed", {"success": False, "msg": "Password must be at least 6 characters."})
return
db_user = db.session.get(User, user["user_id"])
if not db_user:
emit("password_changed", {"success": False, "msg": "User not found."})
return
if not bcrypt.checkpw(old_pw.encode("utf-8"), db_user.password_hash.encode("utf-8")):
emit("password_changed", {"success": False, "msg": "Current password is incorrect."})
return
db_user.password_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
db.session.commit()
emit("password_changed", {"success": True})
# ---------------------------------------------------------------------------
# Admin panel
# ---------------------------------------------------------------------------
@socketio.on("admin_get_users")
@_require_role("mod")
def on_admin_get_users(_data=None):
"""Send the full user list to the admin panel."""
users = User.query.order_by(User.id).all()
result = []
for u in users:
if u.username == AI_BOT_NAME:
continue
online_sid = username_to_sid.get(u.username.lower())
result.append({
"id": u.id,
"username": u.username,
"role": u.role,
"is_verified": u.is_verified,
"has_ai_access": u.has_ai_access,
"email": u.email or "",
"created_at": u.created_at.strftime("%Y-%m-%d"),
"online": online_sid is not None,
})
emit("admin_users", {"users": result})
@socketio.on("admin_get_bans")
@_require_role("mod")
def on_admin_get_bans(_data=None):
bans = Ban.query.order_by(Ban.created_at.desc()).all()
emit("admin_bans", {"bans": [
{"id": b.id, "username": b.username, "ip": b.ip or "", "reason": b.reason or "",
"created_at": b.created_at.strftime("%Y-%m-%d")} for b in bans
]})
@socketio.on("admin_get_mutes")
@_require_role("mod")
def on_admin_get_mutes(_data=None):
mutes = Mute.query.order_by(Mute.created_at.desc()).all()
emit("admin_mutes", {"mutes": [
{"id": m.id, "username": m.username,
"created_at": m.created_at.strftime("%Y-%m-%d")} for m in mutes
]})
@socketio.on("admin_set_role")
@_require_role("admin")
def on_admin_set_role(data):
"""Change a user's role. Only root can set admin/root. Admins can set mod/user."""
sid = request.sid
me = connected_users.get(sid)
my_power = ROLE_POWER.get(me.get("role", "user"), 0)
target_id = int(data.get("user_id", 0))
new_role = str(data.get("role", "")).strip().lower()
if new_role not in ROLE_POWER:
emit("error", {"msg": "Invalid role."}); return
target_power = ROLE_POWER[new_role]
if target_power >= my_power:
emit("error", {"msg": "Cannot assign a role equal/above your own."}); return
target_user = db.session.get(User, target_id)
if not target_user:
emit("error", {"msg": "User not found."}); return
# Can't change someone with equal or higher power
if ROLE_POWER.get(target_user.role, 0) >= my_power:
emit("error", {"msg": "Cannot modify a user with equal/higher privileges."}); return
target_user.role = new_role
db.session.commit()
# Update live session if they're online
target_sid = username_to_sid.get(target_user.username.lower())
if target_sid and target_sid in connected_users:
connected_users[target_sid]["role"] = new_role
connected_users[target_sid]["is_admin"] = ROLE_POWER[new_role] >= ROLE_POWER["mod"]
# Notify the target user of their new role
socketio.emit("role_updated", {"role": new_role}, to=target_sid)
socketio.emit("system", {
"msg": f"⚙️ **{target_user.username}** is now **{new_role}**.",
"ts": _ts()
}, to=LOBBY)
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
emit("admin_action_ok", {"msg": f"{target_user.username}{new_role}"})
@socketio.on("admin_verify_user")
@_require_role("mod")
def on_admin_verify(data):
target_id = int(data.get("user_id", 0))
target_user = db.session.get(User, target_id)
if not target_user:
emit("error", {"msg": "User not found."}); return
target_user.is_verified = not target_user.is_verified
db.session.commit()
status = "verified" if target_user.is_verified else "unverified"
target_sid = username_to_sid.get(target_user.username.lower())
if target_sid and target_sid in connected_users:
connected_users[target_sid]["is_verified"] = target_user.is_verified
socketio.emit("system", {
"msg": f"{'' if target_user.is_verified else ''} **{target_user.username}** was {status}.",
"ts": _ts()
}, to=LOBBY)
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
emit("admin_action_ok", {"msg": f"{target_user.username}{status}"})
@socketio.on("admin_toggle_ai")
@_require_role("admin")
def on_admin_toggle_ai(data):
target_id = int(data.get("user_id", 0))
target_user = db.session.get(User, target_id)
if not target_user:
emit("error", {"msg": "User not found."}); return
target_user.has_ai_access = not target_user.has_ai_access
db.session.commit()
target_sid = username_to_sid.get(target_user.username.lower())
if target_sid and target_sid in connected_users:
connected_users[target_sid]["has_ai_access"] = target_user.has_ai_access
# Notify target so their UI updates immediately
socketio.emit("ai_unlock", {
"has_ai_access": target_user.has_ai_access,
"msg": "Premium access granted! Unlimited Violet." if target_user.has_ai_access else "Premium access revoked."
}, to=target_sid)
status = "granted" if target_user.has_ai_access else "revoked"
emit("admin_action_ok", {"msg": f"AI access {status} for {target_user.username}"})
@socketio.on("admin_unban")
@_require_role("mod")
def on_admin_unban(data):
ban_id = int(data.get("ban_id", 0))
ban = db.session.get(Ban, ban_id)
if not ban:
emit("error", {"msg": "Ban not found."}); return
banned_usernames.discard(ban.username.lower())
if ban.ip:
banned_ips.discard(ban.ip)
db.session.delete(ban)
db.session.commit()
socketio.emit("system", {
"msg": f"🔓 **{ban.username}** was unbanned.",
"ts": _ts()
}, to=LOBBY)
emit("admin_action_ok", {"msg": f"Unbanned {ban.username}"})
@socketio.on("admin_unmute")
@_require_role("mod")
def on_admin_unmute(data):
mute_id = int(data.get("mute_id", 0))
mute = db.session.get(Mute, mute_id)
if not mute:
emit("error", {"msg": "Mute not found."}); return
muted_users.discard(mute.username.lower())
db.session.delete(mute)
db.session.commit()
socketio.emit("system", {
"msg": f"🔊 **{mute.username}** was unmuted.",
"ts": _ts()
}, to=LOBBY)
emit("admin_action_ok", {"msg": f"Unmuted {mute.username}"})

124
config.py Normal file
View File

@ -0,0 +1,124 @@
"""
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", None) # Must be set in production
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 = 1 # 24-hour expiry for security
JWT_EXPIRY_SECS = 60 # 60-second refresh token expiry
MAX_HISTORY = 500
CSRF_TOKEN_LEN = 32 # CSRF token length in bytes
# Ollama
OLLAMA_URL = get_conf("OLLAMA_URL", "http://localhost:11434")
VIOLET_MODEL = get_conf("VIOLET_MODEL", "sadiq-bd/llama3.2-3b-uncensored:latest")
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
def generate_csrf_token() -> str:
"""Generate a CSRF token for REST API requests."""
import secrets
return secrets.token_urlsafe(CSRF_TOKEN_LEN)
def sanitize_user_input(text: str, max_len: int = MAX_MSG_LEN) -> str:
"""Sanitize user input to prevent prompt injection and buffer overflow."""
if not isinstance(text, str):
return ""
# Remove null bytes and other control characters
sanitized = "".join(c for c in text if ord(c) >= 32 or c in "\n\r\t")
# Truncate to max length
return sanitized[:max_len].strip()

View File

@ -87,7 +87,18 @@
</div>
<div class="header-right">
<span id="violet-trial-badge" class="violet-badge hidden"></span>
<span id="my-username-badge" class="my-badge"></span>
<button id="admin-btn" class="icon-btn hidden" aria-label="Admin Panel" title="Admin Panel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</button>
<button id="settings-btn" class="icon-btn" aria-label="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button id="logout-btn" class="btn-logout">Exit</button>
</div>
</header>
@ -164,6 +175,221 @@
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal-overlay hidden">
<div class="settings-card glass">
<div class="settings-header">
<h2>Settings</h2>
<button id="close-settings" class="settings-close">&times;</button>
</div>
<div class="settings-tabs">
<button class="settings-tab active" data-stab="account">Account</button>
<button class="settings-tab" data-stab="chat">Chat</button>
<button class="settings-tab" data-stab="violet">Violet</button>
<button class="settings-tab" data-stab="premium">Premium</button>
</div>
<div class="settings-body">
<!-- Account Tab -->
<div class="settings-pane active" id="stab-account">
<div class="settings-group">
<label>Username</label>
<div id="settings-username" class="settings-value"></div>
</div>
<div class="settings-group">
<label>Email</label>
<div id="settings-email" class="settings-value"></div>
</div>
<div class="settings-group registered-only hidden">
<label>Change Password</label>
<input id="settings-old-pw" type="password" placeholder="Current password" class="settings-input" />
<input id="settings-new-pw" type="password" placeholder="New password" class="settings-input" />
<input id="settings-confirm-pw" type="password" placeholder="Confirm new password" class="settings-input" />
<button id="settings-change-pw" class="btn-primary btn-sm">Update Password</button>
</div>
<div id="settings-pw-msg" class="settings-msg hidden"></div>
</div>
<!-- Chat Tab -->
<div class="settings-pane" id="stab-chat">
<div class="settings-group">
<label>Theme</label>
<div class="theme-grid" id="theme-grid">
<button class="theme-swatch active" data-theme="midnight-purple" title="Midnight Purple">
<span class="swatch-fill" style="background:linear-gradient(135deg,#8a2be2,#ff00ff)"></span>
<span class="swatch-label">Midnight</span>
</button>
<button class="theme-swatch" data-theme="crimson-noir" title="Crimson Noir">
<span class="swatch-fill" style="background:linear-gradient(135deg,#b71c1c,#ff1744)"></span>
<span class="swatch-label">Crimson</span>
</button>
<button class="theme-swatch" data-theme="ocean-deep" title="Ocean Deep">
<span class="swatch-fill" style="background:linear-gradient(135deg,#0277bd,#00bcd4)"></span>
<span class="swatch-label">Ocean</span>
</button>
<button class="theme-swatch" data-theme="ember" title="Ember">
<span class="swatch-fill" style="background:linear-gradient(135deg,#e65100,#ff9800)"></span>
<span class="swatch-label">Ember</span>
</button>
<button class="theme-swatch" data-theme="neon-green" title="Neon Green">
<span class="swatch-fill" style="background:linear-gradient(135deg,#00c853,#00ff41)"></span>
<span class="swatch-label">Neon</span>
</button>
<button class="theme-swatch" data-theme="cyberpunk" title="Cyberpunk">
<span class="swatch-fill" style="background:linear-gradient(135deg,#e040fb,#f5ee28)"></span>
<span class="swatch-label">Cyber</span>
</button>
<button class="theme-swatch" data-theme="rose-gold" title="Rose Gold">
<span class="swatch-fill" style="background:linear-gradient(135deg,#c2185b,#f48fb1)"></span>
<span class="swatch-label">Rosé</span>
</button>
<button class="theme-swatch" data-theme="arctic" title="Arctic">
<span class="swatch-fill" style="background:linear-gradient(135deg,#3949ab,#f5f7fa)"></span>
<span class="swatch-label">Arctic</span>
</button>
<button class="theme-swatch" data-theme="daylight" title="Daylight">
<span class="swatch-fill" style="background:linear-gradient(135deg,#9c27b0,#fafafa)"></span>
<span class="swatch-label">Light</span>
</button>
<button class="theme-swatch" data-theme="midnight-blue" title="Midnight Blue">
<span class="swatch-fill" style="background:linear-gradient(135deg,#1a237e,#448aff)"></span>
<span class="swatch-label">Navy</span>
</button>
</div>
</div>
<div class="settings-group">
<label>Font Size</label>
<div class="settings-row">
<input id="settings-fontsize" type="range" min="12" max="20" value="14" class="settings-range" />
<span id="settings-fontsize-val">14px</span>
</div>
</div>
<div class="settings-group">
<label>Timestamp Format</label>
<div class="settings-row">
<button class="settings-toggle-btn active" data-tf="12h">12h</button>
<button class="settings-toggle-btn" data-tf="24h">24h</button>
</div>
</div>
<div class="settings-group">
<label>Enter to Send</label>
<div class="settings-row">
<button id="settings-enter-send" class="settings-toggle active">On</button>
</div>
</div>
<div class="settings-group">
<label>Notification Sounds</label>
<div class="settings-row">
<button id="settings-sounds" class="settings-toggle active">On</button>
</div>
</div>
</div>
<!-- Violet Tab -->
<div class="settings-pane" id="stab-violet">
<div class="settings-group">
<label>AI Access</label>
<div id="settings-ai-status" class="settings-value"></div>
</div>
<div class="settings-group">
<label>Messages Used</label>
<div id="settings-ai-used" class="settings-value"></div>
</div>
<div class="settings-group">
<label>Conversation Memory</label>
<p class="settings-hint">Violet remembers your last 20 messages. Reset to start fresh.</p>
<button id="settings-violet-reset" class="btn-primary btn-sm btn-danger">Reset Violet Memory</button>
</div>
</div>
<!-- Premium Tab -->
<div class="settings-pane" id="stab-premium">
<div class="premium-hero">
<div class="premium-icon">👑</div>
<h3>SexyChat Premium</h3>
<p class="premium-tagline">Unlock the full experience</p>
</div>
<div class="premium-features">
<div class="premium-feature">
<span class="pf-icon">💜</span>
<div>
<strong>Unlimited Violet</strong>
<p>No message limits — talk to Violet as much as you want</p>
</div>
</div>
<div class="premium-feature">
<span class="pf-icon">🔒</span>
<div>
<strong>Encrypted PMs</strong>
<p>End-to-end encrypted private messages with anyone</p>
</div>
</div>
<div class="premium-feature">
<span class="pf-icon"></span>
<div>
<strong>Priority Access</strong>
<p>Skip the queue — your messages to Violet go first</p>
</div>
</div>
<div class="premium-feature">
<span class="pf-icon">🎨</span>
<div>
<strong>Custom Themes</strong>
<p>Exclusive color schemes and chat customization</p>
</div>
</div>
</div>
<div class="premium-cta">
<div class="premium-price">$10<span>/month</span></div>
<button id="settings-upgrade" class="btn-primary glow btn-lg">Upgrade to Premium</button>
<p class="premium-note">Coming soon — be the first to know!</p>
</div>
</div>
</div>
</div>
</div>
<!-- Admin Panel Modal -->
<div id="admin-modal" class="modal-overlay hidden">
<div class="admin-card glass">
<div class="admin-header">
<h2>👑 Admin Panel</h2>
<button id="close-admin" class="settings-close">&times;</button>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-atab="users">Users</button>
<button class="admin-tab" data-atab="bans">Bans</button>
<button class="admin-tab" data-atab="mutes">Mutes</button>
</div>
<div class="admin-body">
<!-- Users pane -->
<div class="admin-pane active" id="atab-users">
<div id="admin-user-search-wrap" class="admin-search-wrap">
<input id="admin-user-search" type="text" class="settings-input" placeholder="Search users..." />
</div>
<div id="admin-user-list" class="admin-list"></div>
</div>
<!-- Bans pane -->
<div class="admin-pane" id="atab-bans">
<div id="admin-ban-list" class="admin-list"></div>
<p id="admin-no-bans" class="admin-empty">No active bans.</p>
</div>
<!-- Mutes pane -->
<div class="admin-pane" id="atab-mutes">
<div id="admin-mute-list" class="admin-list"></div>
<p id="admin-no-mutes" class="admin-empty">No active mutes.</p>
</div>
</div>
<div id="admin-toast" class="admin-toast hidden"></div>
</div>
</div>
<!-- Context Menu -->
<div id="context-menu" class="context-menu glass hidden">
<div class="menu-item" data-action="pm">Private Message</div>
@ -173,7 +399,7 @@
<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 hidden" data-action="mute">Mute / Unmute</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>

View File

@ -7,10 +7,14 @@ users Registered accounts
messages Encrypted PM history (useruser and userAI)
"""
from datetime import datetime
from datetime import datetime, timezone
from database import db
def _utcnow():
return datetime.now(timezone.utc)
class User(db.Model):
__tablename__ = "users"
@ -18,10 +22,11 @@ class User(db.Model):
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)
role = db.Column(db.String(10), default="user", nullable=False) # root, admin, mod, user
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)
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
sent_messages = db.relationship(
"Message", foreign_keys="Message.sender_id",
@ -52,7 +57,7 @@ class UserIgnore(db.Model):
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)
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
__table_args__ = (
db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True),
@ -70,7 +75,7 @@ class Message(db.Model):
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)
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
__table_args__ = (
# Composite indices for the two common query patterns
@ -80,3 +85,38 @@ class Message(db.Model):
def __repr__(self):
return f"<Message {self.sender_id}{self.recipient_id} @ {self.timestamp}>"
class Ban(db.Model):
"""Persisted ban entry survives server restarts."""
__tablename__ = "bans"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), nullable=False, index=True)
ip = db.Column(db.String(45), nullable=True, index=True)
reason = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
class Mute(db.Model):
"""Persisted mute entry survives server restarts."""
__tablename__ = "mutes"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
class VioletHistory(db.Model):
"""Per-user plaintext conversation history with Violet AI."""
__tablename__ = "violet_history"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
role = db.Column(db.String(10), nullable=False) # 'user' or 'assistant'
text = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
__table_args__ = (
db.Index("ix_violet_hist_user_ts", "user_id", "timestamp"),
)

105
routes.py
View File

@ -11,19 +11,22 @@ POST /api/payment/success Validate webhook secret, unlock AI, push socke
"""
import os
import base64
import hmac
import hashlib
import random
import functools
from datetime import datetime, timedelta
import base64
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
from config import (
AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET,
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
generate_csrf_token, sanitize_user_input,
)
api = Blueprint("api", __name__, url_prefix="/api")
@ -31,13 +34,6 @@ 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.",
@ -60,22 +56,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):
"""Decorator parse Bearer JWT and populate g.current_user."""
@functools.wraps(f)
@ -83,7 +63,7 @@ def _require_auth(f):
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401
payload = _verify_jwt(auth_header[7:])
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"])
@ -94,20 +74,24 @@ def _require_auth(f):
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 _require_csrf(f):
"""Decorator validate CSRF token from request header."""
@functools.wraps(f)
def wrapped(*args, **kwargs):
csrf_token = request.headers.get("X-CSRF-Token", "")
session_csrf = request.headers.get("X-Session-CSRF", "")
# CSRF check: token must match session token (simple HMAC validation)
if not csrf_token or not session_csrf:
return jsonify({"error": "Missing CSRF tokens"}), 403
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")
# For this implementation, we just ensure token is non-empty
# In production, validate against server-side session store
if len(csrf_token) < 20:
return jsonify({"error": "Invalid CSRF token"}), 403
return f(*args, **kwargs)
return wrapped
def _persist_message(sender_id: int, recipient_id: int,
@ -188,9 +172,11 @@ def register():
db.session.add(user)
db.session.commit()
token = _issue_jwt(user.id, user.username)
token = issue_jwt(user.id, user.username)
csrf_token = generate_csrf_token()
return jsonify({
"token": token,
"csrf_token": csrf_token,
"user": {
"id": user.id,
"username": user.username,
@ -212,9 +198,11 @@ def login():
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)
token = issue_jwt(user.id, user.username)
csrf_token = generate_csrf_token()
return jsonify({
"token": token,
"csrf_token": csrf_token,
"user": {
"id": user.id,
"username": user.username,
@ -242,6 +230,13 @@ def pm_history():
if not other:
return jsonify({"messages": []})
# Derive the room key so the client can decrypt history
pair = ":".join(sorted([me.username.lower(), other.username.lower()]))
room_name = "pm:" + pair
room_key = base64.b64encode(
hmac.new(JWT_SECRET.encode(), room_name.encode(), hashlib.sha256).digest()
).decode()
rows = (
Message.query
.filter(
@ -257,6 +252,7 @@ def pm_history():
rows.reverse() # return in chronological order
return jsonify({
"room_key": room_key,
"messages": [
{
"from_me": m.sender_id == me.id,
@ -275,6 +271,7 @@ def pm_history():
@api.route("/ai/message", methods=["POST"])
@_require_auth
@_require_csrf
def ai_message():
user = g.current_user
data = request.get_json() or {}
@ -296,7 +293,9 @@ def ai_message():
# ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
try:
_plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
# Sanitize before using in AI prompt
plaintext = sanitize_user_input(plaintext)
except Exception:
return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400
@ -306,7 +305,7 @@ def ai_message():
# ── 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)
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 ─────────────────────────────────────────────
@ -315,7 +314,7 @@ def ai_message():
db.session.commit()
# ── 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({
"ciphertext": resp_ct_transit,
@ -330,12 +329,12 @@ def ai_message():
# ---------------------------------------------------------------------------
@api.route("/payment/success", methods=["POST"])
@_require_auth
def payment_success():
"""
Validate a payment webhook and flip user.has_ai_access.
Server-side payment webhook NOT callable by clients.
Expected body: { "secret": "<PAYMENT_SECRET>" }
Validates the webhook secret and unlocks AI access for the user
identified by the 'user_id' field in the JSON body.
For Stripe production: replace the secret comparison with
stripe.Webhook.construct_event() using the raw request body and
@ -351,7 +350,15 @@ def payment_success():
):
return jsonify({"error": "Invalid or missing payment secret"}), 403
user = g.current_user
# Identify the user from the webhook payload (NOT from client auth)
user_id = data.get("user_id")
if not user_id:
return jsonify({"error": "Missing user_id in webhook payload"}), 400
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
if not user.has_ai_access:
user.has_ai_access = True
db.session.commit()

View File

@ -12,7 +12,6 @@ Usage:
import os
import sys
import json
import subprocess
import signal
import time
@ -21,24 +20,11 @@ import eventlet
# Monkey-patch stdlib BEFORE any other import
eventlet.monkey_patch()
from config import get_conf
# PID file to track the daemon process
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():
if os.path.exists(PID_FILE):
with open(PID_FILE, "r") as f:
@ -68,7 +54,7 @@ def start_daemon():
"gunicorn",
"--worker-class", "eventlet",
"-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",
"--pid", PID_FILE,
"--access-logfile", "access.log",
@ -124,7 +110,7 @@ def run_debug():
"gunicorn",
"--worker-class", "eventlet",
"-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",
"--access-logfile", "-",
"--error-logfile", "-",

View File

@ -19,6 +19,7 @@ const socket = io({
const state = {
username: null,
isAdmin: false,
role: "user",
isRegistered: false,
hasAiAccess: false,
aiMessagesUsed: 0,
@ -59,6 +60,7 @@ const paywallModal = $("paywall-modal");
const contextMenu = $("context-menu");
const trialBadge = $("violet-trial-badge");
const violetTyping = $("violet-typing");
const settingsModal = $("settings-modal");
// ── Auth & Init ───────────────────────────────────────────────────────────
@ -116,14 +118,37 @@ joinForm.addEventListener("submit", async (e) => {
// Handle Token Restore on Load
window.addEventListener("DOMContentLoaded", () => {
// ── Restore Theme from localStorage ────────────────────────────────────
const savedTheme = localStorage.getItem("sexychat_theme") || "midnight-purple";
document.documentElement.setAttribute("data-theme", savedTheme);
// Update active theme button if it exists
const themeButtons = document.querySelectorAll("[data-theme-button]");
themeButtons.forEach(btn => {
btn.classList.toggle("active", btn.dataset.themeButton === savedTheme);
});
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.
// Auto-restore session from stored JWT
joinBtn.disabled = true;
joinBtn.innerText = "Restoring session...";
socket.connect();
socket.emit("join", { mode: "restore" });
// If restore fails, reset the form so user can log in manually
const restoreTimeout = setTimeout(() => {
joinBtn.disabled = false;
joinBtn.innerText = "Enter the Room";
}, 5000);
const origJoined = socket.listeners("joined");
socket.once("joined", () => clearTimeout(restoreTimeout));
socket.once("error", () => {
clearTimeout(restoreTimeout);
joinBtn.disabled = false;
joinBtn.innerText = "Enter the Room";
});
}
});
@ -132,9 +157,11 @@ window.addEventListener("DOMContentLoaded", () => {
socket.on("joined", (data) => {
state.username = data.username;
state.isAdmin = data.is_admin;
state.role = data.role || "user";
state.isRegistered = data.is_registered;
state.hasAiAccess = data.has_ai_access;
state.aiMessagesUsed = data.ai_messages_used;
state.email = data.email || null;
if (data.token) localStorage.setItem("sexychat_token", data.token);
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
@ -143,6 +170,10 @@ socket.on("joined", (data) => {
joinScreen.classList.add("hidden");
chatScreen.classList.remove("hidden");
updateVioletBadge();
// Show admin panel button for mods+
const adminBtn = $("admin-btn");
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
});
socket.on("error", (data) => {
@ -178,30 +209,45 @@ socket.on("message", (data) => {
// ── Private Messaging ─────────────────────────────────────────────────────
socket.on("pm_invite", (data) => {
socket.on("pm_invite", async (data) => {
if (state.pms[data.room]) return; // Already accepted
// Store the room key for later use
if (data.room_key) {
state._pendingRoomKeys = state._pendingRoomKeys || {};
state._pendingRoomKeys[data.room] = data.room_key;
}
$("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);
openPMTab(data.from, data.room, data.room_key);
pmModal.classList.add("hidden");
};
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
});
socket.on("pm_ready", (data) => {
openPMTab(data.with, data.room);
openPMTab(data.with, data.room, data.room_key);
});
async function openPMTab(otherUser, room) {
async function openPMTab(otherUser, room, roomKeyB64) {
if (state.pms[room]) {
switchTab(room);
return;
}
state.pms[room] = { username: otherUser, messages: [] };
state.pms[room] = { username: otherUser, messages: [], sharedKey: null };
// Import the server-provided room key for user-to-user PMs
const isViolet = otherUser.toLowerCase() === "violet";
if (!isViolet && roomKeyB64) {
try {
state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64);
} catch (err) {
console.error("Failed to import room key", err);
}
}
// Create Tab
let title = `👤 ${otherUser}`;
@ -231,19 +277,41 @@ async function openPMTab(otherUser, room) {
// Load History if registered
if (state.isRegistered && state.cryptoKey) {
try {
const resp = await fetch(`/api/pm/history?with=${otherUser}`, {
const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(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);
const histData = await resp.json();
// Use room_key from history response if we don't have one yet
if (!isViolet && histData.room_key && !state.pms[room].sharedKey) {
try {
state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key);
} catch (err) {
console.error("Failed to import history room key", err);
}
}
// Pick the right decryption key: room key for users, personal key for Violet
const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey);
if (histData.messages) {
for (const m of histData.messages) {
try {
const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce);
addMessage(room, {
sender: m.from_me ? state.username : otherUser,
text: plain,
ts: m.ts,
sent: m.from_me
});
} catch (err) {
addMessage(room, {
sender: m.from_me ? state.username : otherUser,
text: "[Could not decrypt message]",
ts: m.ts,
sent: m.from_me
});
}
}
}
} catch (err) {
@ -255,11 +323,18 @@ async function openPMTab(otherUser, room) {
socket.on("pm_message", async (data) => {
if (state.ignoredUsers.has(data.from)) return;
let text = data.text;
if (data.ciphertext && state.cryptoKey) {
if (data.ciphertext) {
// Pick the right key: room shared key for user-to-user, personal key for Violet
const pm = state.pms[data.room];
const decryptKey = pm?.sharedKey || state.cryptoKey;
if (decryptKey) {
try {
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
} catch (err) {
text = "[Encrypted Message - Click to login/derive key]";
text = "[Encrypted Message - Could not decrypt]";
}
} else {
text = "[Encrypted Message - No key available]";
}
}
@ -296,24 +371,25 @@ socket.on("ai_response", async (data) => {
state.hasAiAccess = data.has_ai_access;
updateVioletBadge();
let text = "[Decryption Error]";
const room = data.room || "ai-violet";
let text = data.text || "[Decryption Error]";
if (data.ciphertext && state.cryptoKey) {
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
}
addMessage("ai-violet", {
addMessage(room, {
sender: AI_BOT_NAME,
text: text,
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
sent: false
});
});
socket.on("ai_unlock", (data) => {
state.hasAiAccess = true;
state.hasAiAccess = data.has_ai_access !== undefined ? data.has_ai_access : true;
updateVioletBadge();
paywallModal.classList.add("hidden");
addMessage("ai-violet", { system: true, text: data.msg });
if (data.msg) addMessage("ai-violet", { system: true, text: data.msg });
});
socket.on("ignore_status", (data) => {
@ -323,6 +399,7 @@ socket.on("ignore_status", (data) => {
});
function updateVioletBadge() {
if (!trialBadge) return;
if (state.hasAiAccess) {
trialBadge.classList.add("hidden");
} else {
@ -348,10 +425,16 @@ messageForm.addEventListener("submit", async (e) => {
else if (state.currentRoom.startsWith("pm:")) {
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet");
// /reset command in Violet PM clears conversation memory
if (isVioletRoom && text.toLowerCase() === "/reset") {
socket.emit("violet_reset");
messageInput.value = "";
messageInput.style.height = "auto";
return;
}
if (isVioletRoom) {
if (!state.isRegistered || !state.cryptoKey) {
addMessage(state.currentRoom, { system: true, text: "You must be logged in to chat with Violet." });
} else {
if (state.isRegistered && state.cryptoKey) {
// AI Transit Encryption PM Flow
const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey);
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
@ -362,10 +445,15 @@ messageForm.addEventListener("submit", async (e) => {
nonce: encrypted.nonce,
transit_key: transitKeyB64
});
} else {
// Guest/admin plaintext fallback
socket.emit("pm_message", { room: state.currentRoom, text });
}
} else if (state.isRegistered && state.cryptoKey) {
// E2E PM Flow
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
// User-to-user encrypted PM: use the shared room key if available
const pm = state.pms[state.currentRoom];
const encryptKey = pm?.sharedKey || state.cryptoKey;
const encrypted = await SexyChato.encrypt(encryptKey, text);
socket.emit("pm_message", {
room: state.currentRoom,
ciphertext: encrypted.ciphertext,
@ -381,6 +469,14 @@ messageForm.addEventListener("submit", async (e) => {
messageInput.style.height = "auto";
});
// Enter to send, Shift+Enter for newline
messageInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && prefs.enterToSend) {
e.preventDefault();
messageForm.requestSubmit();
}
});
// Auto-expand textarea
messageInput.addEventListener("input", () => {
messageInput.style.height = "auto";
@ -404,6 +500,18 @@ function switchTab(room) {
if (box) box.scrollTop = box.scrollHeight;
}
function formatTs(ts) {
if (!ts || prefs.timeFormat === "24h") return ts || "";
// Convert HH:MM (24h) to 12h
const parts = (ts || "").match(/^(\d{1,2}):(\d{2})$/);
if (!parts) return ts;
let h = parseInt(parts[1], 10);
const m = parts[2];
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${h}:${m} ${ampm}`;
}
function addMessage(room, msg) {
const list = $(`messages-${room}`);
if (!list) return;
@ -415,7 +523,7 @@ function addMessage(room, msg) {
} else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = `
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
<div class="msg-meta">${formatTs(msg.ts)} ${msg.sender}</div>
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
`;
}
@ -430,10 +538,11 @@ function renderNicklist() {
const li = document.createElement("li");
const isIgnored = state.ignoredUsers.has(u.username);
const isUnverified = u.is_registered && !u.is_verified;
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
li.innerHTML = `
<span class="${isUnverified ? 'unverified' : ''}">
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''}
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
${isIgnored ? ' <small>(ignored)</small>' : ''}
@ -481,16 +590,15 @@ function showContextMenu(e, user) {
}
});
// Cleanup previous listeners
const newMenu = contextMenu.cloneNode(true);
contextMenu.replaceWith(newMenu);
// Store target for click handler (uses event delegation below)
contextMenu._targetUser = user.username;
// Add new listeners
newMenu.querySelectorAll(".menu-item").forEach(item => {
// Remove old inline onclick handlers and re-bind
contextMenu.querySelectorAll(".menu-item").forEach(item => {
item.onclick = () => {
const action = item.dataset.action;
executeMenuAction(action, user.username);
newMenu.classList.add("hidden");
executeMenuAction(action, contextMenu._targetUser);
contextMenu.classList.add("hidden");
};
});
}
@ -509,6 +617,9 @@ function executeMenuAction(action, target) {
case "kick":
socket.emit("mod_kick", { target });
break;
case "mute":
socket.emit("mod_mute", { target });
break;
case "ban":
socket.emit("mod_ban", { target });
break;
@ -541,35 +652,15 @@ $("sidebar-toggle").onclick = () => {
sidebar.classList.toggle("open");
};
$("tab-ai-violet").onclick = () => switchTab("ai-violet");
// tab-ai-violet is created dynamically when user opens Violet PM
$("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.");
}
// In production, this redirects to a real payment gateway (Stripe Checkout).
// The server-side webhook will unlock AI access after payment confirmation.
// For now, show a placeholder message.
alert("Payment integration coming soon. Contact the administrator to unlock Violet.");
};
logoutBtn.onclick = () => {
@ -577,8 +668,358 @@ logoutBtn.onclick = () => {
location.reload();
};
// ── Settings Panel ─────────────────────────────────────────────────────────
// Load saved preferences from localStorage
const prefs = {
fontSize: parseInt(localStorage.getItem("sc_fontsize") || "14"),
timeFormat: localStorage.getItem("sc_timeformat") || "12h",
enterToSend: localStorage.getItem("sc_enter_send") !== "false",
sounds: localStorage.getItem("sc_sounds") !== "false",
theme: localStorage.getItem("sc_theme") || "midnight-purple",
};
// Apply saved font size on load
document.documentElement.style.setProperty("--chat-font-size", prefs.fontSize + "px");
// Apply saved theme on load
document.documentElement.setAttribute("data-theme", prefs.theme);
$("settings-btn").onclick = () => {
// Populate current values
$("settings-username").textContent = state.username || "—";
$("settings-email").textContent = state.email || "Not set";
$("settings-ai-status").textContent = state.hasAiAccess ? "✓ Unlimited" : `Free (${AI_FREE_LIMIT - state.aiMessagesUsed} left)`;
$("settings-ai-used").textContent = state.aiMessagesUsed.toString();
// Show password change only for registered users
document.querySelectorAll(".registered-only").forEach(el =>
el.classList.toggle("hidden", !state.isRegistered)
);
// Sync toggle states
$("settings-fontsize").value = prefs.fontSize;
$("settings-fontsize-val").textContent = prefs.fontSize + "px";
document.querySelectorAll("[data-tf]").forEach(b => {
b.classList.toggle("active", b.dataset.tf === prefs.timeFormat);
});
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
$("settings-sounds").classList.toggle("active", prefs.sounds);
// Sync theme picker
document.querySelectorAll(".theme-swatch").forEach(s => {
s.classList.toggle("active", s.dataset.theme === prefs.theme);
});
settingsModal.classList.remove("hidden");
};
$("close-settings").onclick = () => settingsModal.classList.add("hidden");
settingsModal.addEventListener("click", (e) => {
if (e.target === settingsModal) settingsModal.classList.add("hidden");
});
// Tab switching
document.querySelectorAll(".settings-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".settings-pane").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
$("stab-" + tab.dataset.stab).classList.add("active");
});
});
// Font size slider
$("settings-fontsize").addEventListener("input", (e) => {
const val = parseInt(e.target.value);
prefs.fontSize = val;
localStorage.setItem("sc_fontsize", val);
$("settings-fontsize-val").textContent = val + "px";
document.documentElement.style.setProperty("--chat-font-size", val + "px");
});
// Theme picker
document.querySelectorAll(".theme-swatch").forEach(swatch => {
swatch.addEventListener("click", () => {
const theme = swatch.dataset.theme;
prefs.theme = theme;
localStorage.setItem("sc_theme", theme);
document.documentElement.setAttribute("data-theme", theme);
document.querySelectorAll(".theme-swatch").forEach(s => s.classList.remove("active"));
swatch.classList.add("active");
});
});
// Timestamp format toggle
document.querySelectorAll("[data-tf]").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll("[data-tf]").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
prefs.timeFormat = btn.dataset.tf;
localStorage.setItem("sc_timeformat", btn.dataset.tf);
});
});
// Enter-to-send toggle
$("settings-enter-send").addEventListener("click", () => {
prefs.enterToSend = !prefs.enterToSend;
localStorage.setItem("sc_enter_send", prefs.enterToSend);
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
});
// Sounds toggle
$("settings-sounds").addEventListener("click", () => {
prefs.sounds = !prefs.sounds;
localStorage.setItem("sc_sounds", prefs.sounds);
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
$("settings-sounds").classList.toggle("active", prefs.sounds);
});
// Password change
$("settings-change-pw").addEventListener("click", () => {
const oldPw = $("settings-old-pw").value;
const newPw = $("settings-new-pw").value;
const confirmPw = $("settings-confirm-pw").value;
const msgEl = $("settings-pw-msg");
if (!oldPw || !newPw) {
msgEl.textContent = "Please fill in both fields.";
msgEl.className = "settings-msg error";
msgEl.classList.remove("hidden");
return;
}
if (newPw.length < 6) {
msgEl.textContent = "New password must be at least 6 characters.";
msgEl.className = "settings-msg error";
msgEl.classList.remove("hidden");
return;
}
if (newPw !== confirmPw) {
msgEl.textContent = "Passwords do not match.";
msgEl.className = "settings-msg error";
msgEl.classList.remove("hidden");
return;
}
socket.emit("change_password", { old_password: oldPw, new_password: newPw });
});
socket.on("password_changed", (data) => {
const msgEl = $("settings-pw-msg");
if (data.success) {
msgEl.textContent = "✓ Password updated successfully.";
msgEl.className = "settings-msg success";
$("settings-old-pw").value = "";
$("settings-new-pw").value = "";
$("settings-confirm-pw").value = "";
} else {
msgEl.textContent = data.msg || "Failed to change password.";
msgEl.className = "settings-msg error";
}
msgEl.classList.remove("hidden");
});
// Violet memory reset from settings
$("settings-violet-reset").addEventListener("click", () => {
socket.emit("violet_reset");
$("settings-violet-reset").textContent = "Memory Cleared!";
setTimeout(() => { $("settings-violet-reset").textContent = "Reset Violet Memory"; }, 2000);
});
// Premium button (placeholder)
$("settings-upgrade").addEventListener("click", () => {
alert("Premium subscriptions are coming soon! Stay tuned.");
});
function escapeHTML(str) {
const p = document.createElement("p");
p.textContent = str;
return p.innerHTML;
}
// ── Role updated (live notification) ──────────────────────────────────────
socket.on("role_updated", (data) => {
state.role = data.role;
state.isAdmin = ["mod", "admin", "root"].includes(data.role);
const adminBtn = $("admin-btn");
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
});
// ── Admin Panel ───────────────────────────────────────────────────────────
const adminModal = $("admin-modal");
const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 };
if ($("admin-btn")) {
$("admin-btn").onclick = () => {
adminModal.classList.remove("hidden");
socket.emit("admin_get_users");
socket.emit("admin_get_bans");
socket.emit("admin_get_mutes");
};
}
$("close-admin").onclick = () => adminModal.classList.add("hidden");
adminModal.addEventListener("click", (e) => {
if (e.target === adminModal) adminModal.classList.add("hidden");
});
// Admin tab switching
document.querySelectorAll(".admin-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
$("atab-" + tab.dataset.atab).classList.add("active");
});
});
// Admin toast helper
function adminToast(msg) {
const t = $("admin-toast");
t.textContent = msg;
t.classList.remove("hidden");
setTimeout(() => t.classList.add("hidden"), 2500);
}
socket.on("admin_action_ok", (data) => {
adminToast(data.msg);
// Refresh the admin user list so buttons update
socket.emit("admin_get_users");
socket.emit("admin_get_bans");
socket.emit("admin_get_mutes");
});
// ── Users pane ───────────────────────────────────────────────────────────
let adminUserCache = [];
socket.on("admin_users", (data) => {
adminUserCache = data.users;
renderAdminUsers(adminUserCache);
});
$("admin-user-search").addEventListener("input", (e) => {
const q = e.target.value.toLowerCase();
renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q)));
});
function renderAdminUsers(users) {
const list = $("admin-user-list");
list.innerHTML = "";
const myPower = ROLE_POWER[state.role] || 0;
users.forEach(u => {
const row = document.createElement("div");
row.className = "admin-user-row";
const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin;
const canVerify = myPower >= ROLE_POWER.mod;
const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root";
// Build role selector
let roleHTML = "";
if (canEditRole) {
const opts = ["user", "mod"];
if (myPower >= ROLE_POWER.root) opts.push("admin");
roleHTML = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
</select>`;
} else {
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
}
row.innerHTML = `
<span class="au-name">${escapeHTML(u.username)}</span>
${roleHTML}
<span class="au-badges">
${u.online ? '<span class="au-badge online">online</span>' : ''}
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
</span>
<span class="au-actions">
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
</span>
`;
list.appendChild(row);
});
// Event delegation for actions
list.onclick = (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const uid = parseInt(btn.dataset.uid);
const action = btn.dataset.action;
if (action === "verify") socket.emit("admin_verify_user", { user_id: uid });
else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid });
};
list.onchange = (e) => {
const sel = e.target.closest("[data-action='set-role']");
if (!sel) return;
const uid = parseInt(sel.dataset.uid);
socket.emit("admin_set_role", { user_id: uid, role: sel.value });
};
}
// ── Bans pane ────────────────────────────────────────────────────────────
socket.on("admin_bans", (data) => {
const list = $("admin-ban-list");
const empty = $("admin-no-bans");
list.innerHTML = "";
empty.classList.toggle("hidden", data.bans.length > 0);
data.bans.forEach(b => {
const row = document.createElement("div");
row.className = "admin-ban-row";
row.innerHTML = `
<div>
<span class="ab-name">${escapeHTML(b.username)}</span>
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
</div>
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
`;
list.appendChild(row);
});
list.onclick = (e) => {
const btn = e.target.closest("[data-bid]");
if (!btn) return;
socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) });
btn.closest(".admin-ban-row").remove();
};
});
// ── Mutes pane ───────────────────────────────────────────────────────────
socket.on("admin_mutes", (data) => {
const list = $("admin-mute-list");
const empty = $("admin-no-mutes");
list.innerHTML = "";
empty.classList.toggle("hidden", data.mutes.length > 0);
data.mutes.forEach(m => {
const row = document.createElement("div");
row.className = "admin-mute-row";
row.innerHTML = `
<div>
<span class="am-name">${escapeHTML(m.username)}</span>
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
</div>
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
`;
list.appendChild(row);
});
list.onclick = (e) => {
const btn = e.target.closest("[data-mid]");
if (!btn) return;
socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) });
btn.closest(".admin-mute-row").remove();
};
});

View File

@ -126,6 +126,23 @@ const SexyChato = (() => {
return bufToBase64(raw);
}
/**
* Import a base64-encoded raw AES-GCM key (e.g. server-provided room key).
*
* @param {string} b64 base64-encoded 256-bit key
* @returns {Promise<CryptoKey>}
*/
async function importKeyBase64(b64) {
const buf = base64ToBuf(b64);
return crypto.subtle.importKey(
"raw",
buf,
{ name: "AES-GCM", length: KEY_LENGTH_BITS },
true,
["encrypt", "decrypt"],
);
}
// ── Public API ─────────────────────────────────────────────────────────────
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 };
})();

View File

@ -6,14 +6,123 @@
:root {
--bg-deep: #0a0015;
--bg-card: rgba(26, 0, 48, 0.7);
--bg-header: rgba(10, 0, 20, 0.8);
--accent-magenta: #ff00ff;
--accent-purple: #8a2be2;
--accent-glow: rgba(255, 0, 255, 0.3);
--accent-glow-strong: rgba(255, 0, 255, 0.5);
--bg-gradient-1: rgba(138, 43, 226, 0.15);
--bg-gradient-2: rgba(255, 0, 255, 0.1);
--text-main: #f0f0f0;
--text-dim: #b0b0b0;
--glass-border: rgba(255, 255, 255, 0.1);
--error-red: #ff3366;
--success-green: #00ffaa;
--ai-teal: #00f2ff;
--msg-received-bg: rgba(255, 255, 255, 0.08);
--msg-received-border: rgba(255, 255, 255, 0.05);
}
/* ── Themes ──────────────────────────────────────────────────────────────── */
[data-theme="midnight-purple"] {
--bg-deep: #0a0015; --bg-card: rgba(26, 0, 48, 0.7); --bg-header: rgba(10, 0, 20, 0.8);
--accent-magenta: #ff00ff; --accent-purple: #8a2be2;
--accent-glow: rgba(255, 0, 255, 0.3); --accent-glow-strong: rgba(255, 0, 255, 0.5);
--bg-gradient-1: rgba(138, 43, 226, 0.15); --bg-gradient-2: rgba(255, 0, 255, 0.1);
--text-main: #f0f0f0; --text-dim: #b0b0b0;
--msg-received-bg: rgba(255, 255, 255, 0.08); --msg-received-border: rgba(255, 255, 255, 0.05);
}
[data-theme="crimson-noir"] {
--bg-deep: #0d0000; --bg-card: rgba(40, 0, 0, 0.7); --bg-header: rgba(15, 0, 0, 0.85);
--accent-magenta: #ff1744; --accent-purple: #b71c1c;
--accent-glow: rgba(255, 23, 68, 0.3); --accent-glow-strong: rgba(255, 23, 68, 0.5);
--bg-gradient-1: rgba(183, 28, 28, 0.15); --bg-gradient-2: rgba(255, 23, 68, 0.1);
--text-main: #f0e8e8; --text-dim: #b09090;
--msg-received-bg: rgba(255, 200, 200, 0.06); --msg-received-border: rgba(255, 200, 200, 0.04);
--ai-teal: #ff6e6e;
}
[data-theme="ocean-deep"] {
--bg-deep: #000a14; --bg-card: rgba(0, 20, 48, 0.7); --bg-header: rgba(0, 8, 20, 0.85);
--accent-magenta: #00bcd4; --accent-purple: #0277bd;
--accent-glow: rgba(0, 188, 212, 0.3); --accent-glow-strong: rgba(0, 188, 212, 0.5);
--bg-gradient-1: rgba(2, 119, 189, 0.15); --bg-gradient-2: rgba(0, 188, 212, 0.1);
--text-main: #e0f0f0; --text-dim: #90b0b0;
--msg-received-bg: rgba(200, 255, 255, 0.06); --msg-received-border: rgba(200, 255, 255, 0.04);
--ai-teal: #4dd0e1;
}
[data-theme="ember"] {
--bg-deep: #0d0800; --bg-card: rgba(40, 20, 0, 0.7); --bg-header: rgba(15, 8, 0, 0.85);
--accent-magenta: #ff9800; --accent-purple: #e65100;
--accent-glow: rgba(255, 152, 0, 0.3); --accent-glow-strong: rgba(255, 152, 0, 0.5);
--bg-gradient-1: rgba(230, 81, 0, 0.15); --bg-gradient-2: rgba(255, 152, 0, 0.1);
--text-main: #f0e8e0; --text-dim: #b09880;
--msg-received-bg: rgba(255, 230, 200, 0.06); --msg-received-border: rgba(255, 230, 200, 0.04);
--ai-teal: #ffb74d;
}
[data-theme="neon-green"] {
--bg-deep: #000a00; --bg-card: rgba(0, 30, 0, 0.7); --bg-header: rgba(0, 10, 0, 0.85);
--accent-magenta: #00ff41; --accent-purple: #00c853;
--accent-glow: rgba(0, 255, 65, 0.3); --accent-glow-strong: rgba(0, 255, 65, 0.5);
--bg-gradient-1: rgba(0, 200, 83, 0.12); --bg-gradient-2: rgba(0, 255, 65, 0.08);
--text-main: #e0ffe0; --text-dim: #80b080;
--msg-received-bg: rgba(200, 255, 200, 0.06); --msg-received-border: rgba(200, 255, 200, 0.04);
--ai-teal: #69f0ae;
}
[data-theme="cyberpunk"] {
--bg-deep: #0a0014; --bg-card: rgba(20, 0, 40, 0.75); --bg-header: rgba(10, 0, 20, 0.85);
--accent-magenta: #f5ee28; --accent-purple: #e040fb;
--accent-glow: rgba(245, 238, 40, 0.3); --accent-glow-strong: rgba(245, 238, 40, 0.5);
--bg-gradient-1: rgba(224, 64, 251, 0.15); --bg-gradient-2: rgba(245, 238, 40, 0.08);
--text-main: #f0f0f0; --text-dim: #b0a0c0;
--msg-received-bg: rgba(245, 238, 40, 0.06); --msg-received-border: rgba(245, 238, 40, 0.04);
--ai-teal: #e040fb;
}
[data-theme="rose-gold"] {
--bg-deep: #0d0508; --bg-card: rgba(40, 15, 20, 0.7); --bg-header: rgba(15, 5, 8, 0.85);
--accent-magenta: #f48fb1; --accent-purple: #c2185b;
--accent-glow: rgba(244, 143, 177, 0.3); --accent-glow-strong: rgba(244, 143, 177, 0.5);
--bg-gradient-1: rgba(194, 24, 91, 0.12); --bg-gradient-2: rgba(244, 143, 177, 0.08);
--text-main: #f5e8ed; --text-dim: #c0a0a8;
--msg-received-bg: rgba(255, 200, 220, 0.06); --msg-received-border: rgba(255, 200, 220, 0.04);
--ai-teal: #f48fb1;
}
[data-theme="arctic"] {
--bg-deep: #f5f7fa; --bg-card: rgba(255, 255, 255, 0.85); --bg-header: rgba(240, 242, 248, 0.95);
--accent-magenta: #5c6bc0; --accent-purple: #3949ab;
--accent-glow: rgba(92, 107, 192, 0.25); --accent-glow-strong: rgba(92, 107, 192, 0.4);
--bg-gradient-1: rgba(57, 73, 171, 0.08); --bg-gradient-2: rgba(92, 107, 192, 0.06);
--text-main: #1a1a2e; --text-dim: #666680;
--glass-border: rgba(0, 0, 0, 0.1);
--msg-received-bg: rgba(0, 0, 0, 0.05); --msg-received-border: rgba(0, 0, 0, 0.08);
--ai-teal: #5c6bc0; --success-green: #43a047;
}
[data-theme="daylight"] {
--bg-deep: #fafafa; --bg-card: rgba(255, 255, 255, 0.9); --bg-header: rgba(245, 245, 245, 0.95);
--accent-magenta: #e91e63; --accent-purple: #9c27b0;
--accent-glow: rgba(233, 30, 99, 0.2); --accent-glow-strong: rgba(233, 30, 99, 0.35);
--bg-gradient-1: rgba(156, 39, 176, 0.06); --bg-gradient-2: rgba(233, 30, 99, 0.05);
--text-main: #212121; --text-dim: #757575;
--glass-border: rgba(0, 0, 0, 0.12);
--msg-received-bg: rgba(0, 0, 0, 0.04); --msg-received-border: rgba(0, 0, 0, 0.06);
--ai-teal: #e91e63; --success-green: #2e7d32;
}
[data-theme="midnight-blue"] {
--bg-deep: #0a0a1a; --bg-card: rgba(10, 10, 40, 0.75); --bg-header: rgba(5, 5, 20, 0.85);
--accent-magenta: #448aff; --accent-purple: #1a237e;
--accent-glow: rgba(68, 138, 255, 0.3); --accent-glow-strong: rgba(68, 138, 255, 0.5);
--bg-gradient-1: rgba(26, 35, 126, 0.15); --bg-gradient-2: rgba(68, 138, 255, 0.1);
--text-main: #e0e8f0; --text-dim: #8090b0;
--msg-received-bg: rgba(200, 220, 255, 0.06); --msg-received-border: rgba(200, 220, 255, 0.04);
--ai-teal: #82b1ff;
}
* {
@ -27,8 +136,8 @@ 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%);
radial-gradient(circle at 20% 30%, var(--bg-gradient-1) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, var(--bg-gradient-2) 0%, transparent 40%);
color: var(--text-main);
height: 100vh;
overflow: hidden;
@ -83,7 +192,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
.logo-accent {
color: var(--accent-magenta);
text-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
text-shadow: 0 0 10px var(--accent-glow-strong);
}
.logo-sub {
@ -117,7 +226,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
.auth-tab.active {
background: var(--accent-purple);
color: white;
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
box-shadow: 0 4px 12px var(--accent-glow);
}
/* Form */
@ -144,7 +253,7 @@ input:focus {
.crypto-note {
font-size: 0.75rem;
color: var(--accent-magenta);
background: rgba(255, 0, 255, 0.05);
background: color-mix(in srgb, var(--accent-magenta) 5%, transparent);
padding: 10px;
border-radius: 8px;
margin: 1rem 0;
@ -161,13 +270,13 @@ input:focus {
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3);
box-shadow: 0 4px 15px var(--accent-glow);
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); }
.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow-strong); }
.mod-login {
text-align: left;
@ -194,7 +303,7 @@ input:focus {
.glass-header {
height: 64px;
background: rgba(10, 0, 20, 0.8);
background: var(--bg-header);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border);
@ -237,6 +346,23 @@ input:focus {
.header-right { display: flex; align-items: center; gap: 12px; }
.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; }
.icon-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s, background 0.2s;
}
.icon-btn:hover {
color: var(--accent-magenta);
background: rgba(255, 255, 255, 0.05);
}
.btn-logout {
background: transparent;
color: var(--text-dim);
@ -378,7 +504,7 @@ input:focus {
.msg-bubble {
padding: 10px 14px;
border-radius: 18px;
font-size: 0.95rem;
font-size: var(--chat-font-size, 0.95rem);
position: relative;
word-wrap: break-word;
}
@ -392,16 +518,16 @@ input:focus {
.msg-received { align-self: flex-start; }
.msg-received .msg-bubble {
background: rgba(255, 255, 255, 0.08);
background: var(--msg-received-bg);
border-bottom-left-radius: 4px;
border: 1px solid rgba(255,255,255,0.05);
border: 1px solid var(--msg-received-border);
}
.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);
box-shadow: 0 4px 12px var(--accent-glow);
}
.msg-system {
@ -417,7 +543,7 @@ input:focus {
.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%); }
.chat-ai { background: radial-gradient(circle at top, color-mix(in srgb, var(--ai-teal) 5%, transparent), transparent 70%); }
.ai-header {
padding: 1rem;
@ -458,7 +584,7 @@ input:focus {
display: flex;
align-items: flex-end;
gap: 10px;
background: rgba(10, 0, 20, 0.8);
background: var(--bg-header);
border-top: 1px solid var(--glass-border);
}
@ -487,7 +613,7 @@ textarea {
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 0 15px rgba(255, 0, 255, 0.4);
box-shadow: 0 0 15px var(--accent-glow-strong);
}
/* ── Modals ──────────────────────────────────────────────────────────────── */
@ -512,7 +638,7 @@ textarea {
.paywall-card {
border: 2px solid var(--ai-teal);
box-shadow: 0 0 40px rgba(0, 242, 255, 0.2);
box-shadow: 0 0 40px color-mix(in srgb, var(--ai-teal) 20%, transparent);
}
.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; }
@ -539,7 +665,7 @@ textarea {
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);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px color-mix(in srgb, var(--accent-purple) 20%, transparent);
display: flex;
flex-direction: column;
}
@ -568,7 +694,507 @@ textarea {
margin: 4px 0;
}
/* ── Settings Modal ───────────────────────────────────────────────────────── */
.settings-card {
width: 100%;
max-width: 520px;
max-height: 85vh;
border-radius: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem 0;
}
.settings-header h2 {
font-size: 1.4rem;
font-weight: 700;
}
.settings-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.8rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
transition: color 0.2s;
}
.settings-close:hover { color: var(--accent-magenta); }
.settings-tabs {
display: flex;
margin: 1rem 2rem 0;
background: rgba(0, 0, 0, 0.3);
padding: 3px;
border-radius: 10px;
gap: 2px;
}
.settings-tab {
flex: 1;
padding: 7px 4px;
border: none;
background: transparent;
color: var(--text-dim);
font-weight: 600;
font-size: 0.78rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
white-space: nowrap;
}
.settings-tab.active {
background: var(--accent-purple);
color: white;
box-shadow: 0 2px 8px var(--accent-glow);
}
.settings-body {
padding: 1.5rem 2rem 2rem;
overflow-y: auto;
flex: 1;
}
.settings-pane { display: none; }
.settings-pane.active { display: block; }
.settings-group {
margin-bottom: 1.25rem;
}
.settings-group label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.settings-value {
font-size: 0.95rem;
color: var(--text-main);
padding: 6px 0;
}
.settings-input {
width: 100%;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--glass-border);
border-radius: 10px;
color: var(--text-main);
font-size: 0.9rem;
margin-bottom: 8px;
outline: none;
transition: border-color 0.2s;
}
.settings-input:focus { border-color: var(--accent-purple); }
.settings-row {
display: flex;
align-items: center;
gap: 10px;
}
.settings-range {
flex: 1;
accent-color: var(--accent-magenta);
}
.settings-toggle-btn {
padding: 6px 14px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-dim);
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
}
.settings-toggle-btn.active {
background: var(--accent-purple);
color: white;
border-color: var(--accent-purple);
}
.settings-toggle {
padding: 6px 18px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-dim);
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
}
.settings-toggle.active {
background: var(--success-green);
color: #000;
border-color: var(--success-green);
}
.settings-hint {
font-size: 0.8rem;
color: var(--text-dim);
margin-bottom: 10px;
line-height: 1.4;
}
.settings-msg {
padding: 8px 12px;
border-radius: 8px;
font-size: 0.85rem;
margin-top: 8px;
}
.settings-msg.success { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
.settings-msg.error { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
.btn-sm {
padding: 8px 18px;
font-size: 0.85rem;
border-radius: 10px;
}
.btn-lg {
padding: 14px 32px;
font-size: 1.1rem;
border-radius: 14px;
width: 100%;
}
.btn-danger {
background: linear-gradient(135deg, #ff3366 0%, #cc0033 100%);
}
.btn-danger:hover {
box-shadow: 0 6px 20px rgba(255, 51, 102, 0.4);
}
/* Premium tab */
.premium-hero {
text-align: center;
margin-bottom: 1.5rem;
}
.premium-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.premium-hero h3 {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #ffd700, #ff8c00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.premium-tagline {
color: var(--text-dim);
font-size: 0.9rem;
margin-top: 4px;
}
.premium-features {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 2rem;
}
.premium-feature {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: rgba(255, 215, 0, 0.05);
border: 1px solid rgba(255, 215, 0, 0.15);
border-radius: 12px;
}
.pf-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: 2px; }
.premium-feature strong {
display: block;
font-size: 0.9rem;
color: var(--text-main);
margin-bottom: 2px;
}
.premium-feature p {
font-size: 0.8rem;
color: var(--text-dim);
line-height: 1.3;
margin: 0;
}
.premium-cta {
text-align: center;
}
.premium-price {
font-size: 2.5rem;
font-weight: 700;
font-family: 'Outfit', sans-serif;
background: linear-gradient(135deg, #ffd700, #ff8c00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 1rem;
}
.premium-price span {
font-size: 1rem;
font-weight: 400;
}
.premium-note {
color: var(--text-dim);
font-size: 0.8rem;
margin-top: 12px;
font-style: italic;
}
/* ── Mobile Overrides ─────────────────────────────────────────────────────── */
/* ── Admin Panel ──────────────────────────────────────────────────────────── */
.admin-card {
width: 100%;
max-width: 640px;
max-height: 85vh;
border-radius: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem 0;
}
.admin-header h2 { font-size: 1.4rem; font-weight: 700; }
.admin-tabs {
display: flex;
margin: 1rem 2rem 0;
background: rgba(0, 0, 0, 0.3);
padding: 3px;
border-radius: 10px;
gap: 2px;
}
.admin-tab {
flex: 1;
padding: 7px 4px;
border: none;
background: transparent;
color: var(--text-dim);
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s;
}
.admin-tab.active {
background: var(--accent-purple);
color: white;
box-shadow: 0 2px 8px var(--accent-glow);
}
.admin-body {
padding: 1rem 2rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.admin-pane { display: none; }
.admin-pane.active { display: block; }
.admin-search-wrap { margin-bottom: 0.75rem; }
.admin-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 50vh;
overflow-y: auto;
}
.admin-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--glass-border);
border-radius: 12px;
font-size: 0.85rem;
flex-wrap: wrap;
}
.admin-user-row .au-name {
font-weight: 700;
color: var(--text-main);
min-width: 90px;
}
.admin-user-row .au-role {
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.au-role.root { background: rgba(255, 215, 0, 0.2); color: #ffd700; }
.au-role.admin { background: rgba(255, 0, 255, 0.15); color: var(--accent-magenta); }
.au-role.mod { background: rgba(0, 200, 83, 0.15); color: #00c853; }
.au-role.user { background: rgba(255, 255, 255, 0.08); color: var(--text-dim); }
.admin-user-row .au-badges {
display: flex;
gap: 4px;
flex: 1;
}
.au-badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
}
.au-badge.online { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
.au-badge.verified { background: rgba(0, 200, 255, 0.1); color: var(--ai-teal); }
.au-badge.unverified { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
.au-badge.ai-on { background: rgba(138, 43, 226, 0.15); color: var(--accent-purple); }
.admin-user-row .au-actions {
display: flex;
gap: 4px;
margin-left: auto;
}
.au-btn {
padding: 4px 10px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-dim);
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.au-btn:hover { background: var(--accent-purple); color: white; border-color: var(--accent-purple); }
.au-btn.danger:hover { background: var(--error-red); border-color: var(--error-red); }
.au-select {
padding: 3px 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--glass-border);
color: var(--text-main);
border-radius: 6px;
font-size: 0.7rem;
cursor: pointer;
outline: none;
}
.admin-ban-row, .admin-mute-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--glass-border);
border-radius: 12px;
font-size: 0.85rem;
}
.admin-ban-row .ab-name, .admin-mute-row .am-name {
font-weight: 700;
color: var(--error-red);
}
.admin-empty {
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
padding: 2rem 0;
}
.admin-toast {
margin: 0 2rem 1rem;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.85rem;
background: rgba(0, 255, 170, 0.15);
color: var(--success-green);
text-align: center;
transition: opacity 0.3s;
}
/* ── Theme Picker ─────────────────────────────────────────────────────────── */
.theme-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
.theme-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
background: none;
border: 2px solid transparent;
border-radius: 12px;
padding: 6px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s;
}
.theme-swatch:hover { transform: scale(1.08); }
.theme-swatch.active {
border-color: var(--accent-magenta);
box-shadow: 0 0 12px var(--accent-glow);
}
.swatch-fill {
width: 36px;
height: 36px;
border-radius: 50%;
display: block;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.swatch-label {
font-size: 0.65rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@media (max-width: 768px) {
.nicklist-sidebar {
position: absolute;