Compare commits

..

5 Commits

Author SHA1 Message Date
Antigravity 5df43f47da Fix start.py environment logic and Finalize Master Admin 2026-04-12 18:31:42 +01:00
Antigravity 4c42f55e14 Hardcode master admin ComputerTech and update config template 2026-04-12 18:28:07 +01:00
Antigravity 1635c70eb3 Fix account approval bypass and enforce AI verification 2026-04-12 18:26:19 +01:00
Antigravity 0d4d27cdc1 Fix order-dependent AI room detection and verification flow 2026-04-12 18:23:28 +01:00
Antigravity dff495ab44 Restore config.example.json 2026-04-12 18:17:30 +01:00
11 changed files with 415 additions and 2866 deletions

681
README.md
View File

@ -1,681 +0,0 @@
# 💋 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)

776
app.py

File diff suppressed because it is too large Load Diff

13
config.example.json Normal file
View File

@ -0,0 +1,13 @@
{
"HOST": "0.0.0.0",
"PORT": 5000,
"SECRET_KEY": "sexchat-very-secret-key-change-me",
"JWT_SECRET": "sexchat-jwt-secret-key-change-me",
"ADMIN_USERNAME": "ComputerTech",
"ADMIN_PASSWORD": "789abc//",
"OLLAMA_URL": "http://localhost:11434",
"VIOLET_MODEL": "sam860/dolphin3-llama3.2:3b",
"DATABASE_URL": "sqlite:///instance/sexchat.db",
"REDIS_URL": "redis://localhost:6379/0",
"AI_FREE_LIMIT": 3
}

124
config.py
View File

@ -1,124 +0,0 @@
"""
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,18 +87,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<span id="violet-trial-badge" class="violet-badge hidden"></span>
<span id="my-username-badge" class="my-badge"></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> <button id="logout-btn" class="btn-logout">Exit</button>
</div> </div>
</header> </header>
@ -175,221 +164,6 @@
</div> </div>
</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 --> <!-- Context Menu -->
<div id="context-menu" class="context-menu glass hidden"> <div id="context-menu" class="context-menu glass hidden">
<div class="menu-item" data-action="pm">Private Message</div> <div class="menu-item" data-action="pm">Private Message</div>
@ -399,7 +173,7 @@
<div class="menu-divider mod-item hidden"></div> <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="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="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 hidden" data-action="ban">Ban</div>
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div> <div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
</div> </div>

View File

@ -7,14 +7,10 @@ users Registered accounts
messages Encrypted PM history (useruser and userAI) messages Encrypted PM history (useruser and userAI)
""" """
from datetime import datetime, timezone from datetime import datetime
from database import db from database import db
def _utcnow():
return datetime.now(timezone.utc)
class User(db.Model): class User(db.Model):
__tablename__ = "users" __tablename__ = "users"
@ -22,11 +18,10 @@ class User(db.Model):
username = db.Column(db.String(20), unique=True, nullable=False, index=True) username = db.Column(db.String(20), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False) password_hash = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(255), unique=True, nullable=True) 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) has_ai_access = db.Column(db.Boolean, default=False, nullable=False)
ai_messages_used = db.Column(db.Integer, default=0, nullable=False) ai_messages_used = db.Column(db.Integer, default=0, nullable=False)
is_verified = db.Column(db.Boolean, default=False, nullable=False) is_verified = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
sent_messages = db.relationship( sent_messages = db.relationship(
"Message", foreign_keys="Message.sender_id", "Message", foreign_keys="Message.sender_id",
@ -57,7 +52,7 @@ class UserIgnore(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
ignored_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=_utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = ( __table_args__ = (
db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True), db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True),
@ -75,7 +70,7 @@ class Message(db.Model):
encrypted_content = db.Column(db.Text, nullable=False) encrypted_content = db.Column(db.Text, nullable=False)
# AES-GCM nonce / IV base64 encoded (12 bytes → 16 chars) # AES-GCM nonce / IV base64 encoded (12 bytes → 16 chars)
nonce = db.Column(db.String(64), nullable=False) nonce = db.Column(db.String(64), nullable=False)
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False) timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = ( __table_args__ = (
# Composite indices for the two common query patterns # Composite indices for the two common query patterns
@ -85,38 +80,3 @@ class Message(db.Model):
def __repr__(self): def __repr__(self):
return f"<Message {self.sender_id}{self.recipient_id} @ {self.timestamp}>" 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"),
)

109
routes.py
View File

@ -11,22 +11,19 @@ POST /api/payment/success Validate webhook secret, unlock AI, push socke
""" """
import os import os
import base64
import hmac import hmac
import hashlib
import random import random
import functools import functools
import base64 from datetime import datetime, timedelta
import bcrypt import bcrypt
import jwt as pyjwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flask import Blueprint, g, jsonify, request from flask import Blueprint, g, jsonify, request
from database import db from database import db
from models import User, Message from models import User, Message
from config import (
AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET,
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
generate_csrf_token, sanitize_user_input,
)
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")
@ -34,6 +31,13 @@ api = Blueprint("api", __name__, url_prefix="/api")
# Config # Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-jwt-secret")
JWT_EXPIRY_DAYS = 7
AI_FREE_LIMIT = 3
PAYMENT_SECRET = os.environ.get("PAYMENT_SECRET", "change-me-payment-secret")
MAX_HISTORY = 500
AI_BOT_NAME = "Violet"
AI_RESPONSES = [ AI_RESPONSES = [
"Mmm, you have my full attention 💋", "Mmm, you have my full attention 💋",
"Oh my... keep going 😈 Don't stop there.", "Oh my... keep going 😈 Don't stop there.",
@ -56,6 +60,22 @@ AI_RESPONSES = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _issue_jwt(user_id: int, username: str) -> str:
payload = {
"user_id": user_id,
"username": username,
"exp": datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS),
}
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
def _verify_jwt(token: str):
try:
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except pyjwt.PyJWTError:
return None
def _require_auth(f): def _require_auth(f):
"""Decorator parse Bearer JWT and populate g.current_user.""" """Decorator parse Bearer JWT and populate g.current_user."""
@functools.wraps(f) @functools.wraps(f)
@ -63,7 +83,7 @@ def _require_auth(f):
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "): if not auth_header.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401 return jsonify({"error": "Unauthorized"}), 401
payload = verify_jwt(auth_header[7:]) payload = _verify_jwt(auth_header[7:])
if not payload: if not payload:
return jsonify({"error": "Invalid or expired token"}), 401 return jsonify({"error": "Invalid or expired token"}), 401
user = db.session.get(User, payload["user_id"]) user = db.session.get(User, payload["user_id"])
@ -74,24 +94,20 @@ def _require_auth(f):
return wrapped return wrapped
def _require_csrf(f): def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
"""Decorator validate CSRF token from request header.""" """Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
@functools.wraps(f) key_bytes = base64.b64decode(key_b64)
def wrapped(*args, **kwargs): nonce = os.urandom(12)
csrf_token = request.headers.get("X-CSRF-Token", "") ct = AESGCM(key_bytes).encrypt(nonce, plaintext.encode("utf-8"), None)
session_csrf = request.headers.get("X-Session-CSRF", "") return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
# CSRF check: token must match session token (simple HMAC validation)
if not csrf_token or not session_csrf: def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
return jsonify({"error": "Missing CSRF tokens"}), 403 """Decrypt AES-GCM ciphertext. Raises on authentication failure."""
key_bytes = base64.b64decode(key_b64)
# For this implementation, we just ensure token is non-empty ct = base64.b64decode(ciphertext_b64)
# In production, validate against server-side session store nonce = base64.b64decode(nonce_b64)
if len(csrf_token) < 20: return AESGCM(key_bytes).decrypt(nonce, ct, None).decode("utf-8")
return jsonify({"error": "Invalid CSRF token"}), 403
return f(*args, **kwargs)
return wrapped
def _persist_message(sender_id: int, recipient_id: int, def _persist_message(sender_id: int, recipient_id: int,
@ -172,11 +188,9 @@ def register():
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
token = issue_jwt(user.id, user.username) token = _issue_jwt(user.id, user.username)
csrf_token = generate_csrf_token()
return jsonify({ return jsonify({
"token": token, "token": token,
"csrf_token": csrf_token,
"user": { "user": {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
@ -198,11 +212,9 @@ def login():
if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()): if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
return jsonify({"error": "Invalid username or password."}), 401 return jsonify({"error": "Invalid username or password."}), 401
token = issue_jwt(user.id, user.username) token = _issue_jwt(user.id, user.username)
csrf_token = generate_csrf_token()
return jsonify({ return jsonify({
"token": token, "token": token,
"csrf_token": csrf_token,
"user": { "user": {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
@ -230,13 +242,6 @@ def pm_history():
if not other: if not other:
return jsonify({"messages": []}) 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 = ( rows = (
Message.query Message.query
.filter( .filter(
@ -252,7 +257,6 @@ def pm_history():
rows.reverse() # return in chronological order rows.reverse() # return in chronological order
return jsonify({ return jsonify({
"room_key": room_key,
"messages": [ "messages": [
{ {
"from_me": m.sender_id == me.id, "from_me": m.sender_id == me.id,
@ -271,7 +275,6 @@ def pm_history():
@api.route("/ai/message", methods=["POST"]) @api.route("/ai/message", methods=["POST"])
@_require_auth @_require_auth
@_require_csrf
def ai_message(): def ai_message():
user = g.current_user user = g.current_user
data = request.get_json() or {} data = request.get_json() or {}
@ -293,9 +296,7 @@ def ai_message():
# ── Transit decrypt (message readable for AI; key NOT stored) ───────────── # ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
try: try:
plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64) _plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
# Sanitize before using in AI prompt
plaintext = sanitize_user_input(plaintext)
except Exception: except Exception:
return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400 return jsonify({"error": "Decryption failed wrong key or corrupted data"}), 400
@ -305,7 +306,7 @@ def ai_message():
# ── Persist both legs encrypted in DB (server uses transit key) ────────── # ── Persist both legs encrypted in DB (server uses transit key) ──────────
bot = _get_ai_bot() bot = _get_ai_bot()
_persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI _persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI
resp_ct, resp_nonce = aesgcm_encrypt(transit_key, ai_text) resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text)
_persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user _persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user
# ── Update free trial counter ───────────────────────────────────────────── # ── Update free trial counter ─────────────────────────────────────────────
@ -314,7 +315,7 @@ def ai_message():
db.session.commit() db.session.commit()
# ── Re-encrypt AI response for transit back ─────────────────────────────── # ── Re-encrypt AI response for transit back ───────────────────────────────
resp_ct_transit, resp_nonce_transit = aesgcm_encrypt(transit_key, ai_text) resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text)
return jsonify({ return jsonify({
"ciphertext": resp_ct_transit, "ciphertext": resp_ct_transit,
@ -329,12 +330,12 @@ def ai_message():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@api.route("/payment/success", methods=["POST"]) @api.route("/payment/success", methods=["POST"])
@_require_auth
def payment_success(): def payment_success():
""" """
Server-side payment webhook NOT callable by clients. Validate a payment webhook and flip user.has_ai_access.
Validates the webhook secret and unlocks AI access for the user Expected body: { "secret": "<PAYMENT_SECRET>" }
identified by the 'user_id' field in the JSON body.
For Stripe production: replace the secret comparison with For Stripe production: replace the secret comparison with
stripe.Webhook.construct_event() using the raw request body and stripe.Webhook.construct_event() using the raw request body and
@ -350,15 +351,7 @@ def payment_success():
): ):
return jsonify({"error": "Invalid or missing payment secret"}), 403 return jsonify({"error": "Invalid or missing payment secret"}), 403
# Identify the user from the webhook payload (NOT from client auth) user = g.current_user
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: if not user.has_ai_access:
user.has_ai_access = True user.has_ai_access = True
db.session.commit() db.session.commit()

View File

@ -12,6 +12,7 @@ Usage:
import os import os
import sys import sys
import json
import subprocess import subprocess
import signal import signal
import time import time
@ -20,11 +21,24 @@ import eventlet
# Monkey-patch stdlib BEFORE any other import # Monkey-patch stdlib BEFORE any other import
eventlet.monkey_patch() eventlet.monkey_patch()
from config import get_conf
# PID file to track the daemon process # PID file to track the daemon process
PID_FILE = "sexchat.pid" PID_FILE = "sexchat.pid"
def load_config():
conf = {}
config_path = os.path.join(os.path.dirname(__file__), "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
conf = json.load(f)
except Exception:
pass
return conf
def _get_conf(key, default=None):
conf = load_config()
return os.environ.get(key, conf.get(key, default))
def get_pid(): def get_pid():
if os.path.exists(PID_FILE): if os.path.exists(PID_FILE):
with open(PID_FILE, "r") as f: with open(PID_FILE, "r") as f:
@ -50,11 +64,15 @@ def start_daemon():
return return
print("🚀 Starting SexChat in background...") print("🚀 Starting SexChat in background...")
gunicorn_bin = os.path.join(os.path.dirname(__file__), ".venv", "bin", "gunicorn")
if not os.path.exists(gunicorn_bin):
gunicorn_bin = "gunicorn" # fallback
cmd = [ cmd = [
"gunicorn", gunicorn_bin,
"--worker-class", "eventlet", "--worker-class", "eventlet",
"-w", "1", "-w", "1",
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}", "--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
"--daemon", "--daemon",
"--pid", PID_FILE, "--pid", PID_FILE,
"--access-logfile", "access.log", "--access-logfile", "access.log",
@ -106,11 +124,15 @@ def get_status():
def run_debug(): def run_debug():
print("🛠️ Starting SexChat in DEBUG mode (foreground)...") print("🛠️ Starting SexChat in DEBUG mode (foreground)...")
gunicorn_bin = os.path.join(os.path.dirname(__file__), ".venv", "bin", "gunicorn")
if not os.path.exists(gunicorn_bin):
gunicorn_bin = "gunicorn" # fallback
cmd = [ cmd = [
"gunicorn", gunicorn_bin,
"--worker-class", "eventlet", "--worker-class", "eventlet",
"-w", "1", "-w", "1",
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}", "--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
"--log-level", "debug", "--log-level", "debug",
"--access-logfile", "-", "--access-logfile", "-",
"--error-logfile", "-", "--error-logfile", "-",

View File

@ -19,7 +19,6 @@ const socket = io({
const state = { const state = {
username: null, username: null,
isAdmin: false, isAdmin: false,
role: "user",
isRegistered: false, isRegistered: false,
hasAiAccess: false, hasAiAccess: false,
aiMessagesUsed: 0, aiMessagesUsed: 0,
@ -60,7 +59,6 @@ const paywallModal = $("paywall-modal");
const contextMenu = $("context-menu"); const contextMenu = $("context-menu");
const trialBadge = $("violet-trial-badge"); const trialBadge = $("violet-trial-badge");
const violetTyping = $("violet-typing"); const violetTyping = $("violet-typing");
const settingsModal = $("settings-modal");
// ── Auth & Init ─────────────────────────────────────────────────────────── // ── Auth & Init ───────────────────────────────────────────────────────────
@ -118,37 +116,14 @@ joinForm.addEventListener("submit", async (e) => {
// Handle Token Restore on Load // Handle Token Restore on Load
window.addEventListener("DOMContentLoaded", () => { 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"); const token = localStorage.getItem("sexychat_token");
if (token) { if (token) {
// Auto-restore session from stored JWT // We have a token, notify the join screen but wait for user to click "Enter"
joinBtn.disabled = true; // to derive crypto key if they want to. Actually, for UX, if we have a token
joinBtn.innerText = "Restoring session..."; // we can try a "restore" join which might skip password entry.
socket.connect(); // But for encryption, we NEED that password to derive the key.
socket.emit("join", { mode: "restore" }); // Let's keep it simple: if you have a token, you still need to log in to
// re-derive your E2E key.
// 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";
});
} }
}); });
@ -157,11 +132,9 @@ window.addEventListener("DOMContentLoaded", () => {
socket.on("joined", (data) => { socket.on("joined", (data) => {
state.username = data.username; state.username = data.username;
state.isAdmin = data.is_admin; state.isAdmin = data.is_admin;
state.role = data.role || "user";
state.isRegistered = data.is_registered; state.isRegistered = data.is_registered;
state.hasAiAccess = data.has_ai_access; state.hasAiAccess = data.has_ai_access;
state.aiMessagesUsed = data.ai_messages_used; state.aiMessagesUsed = data.ai_messages_used;
state.email = data.email || null;
if (data.token) localStorage.setItem("sexychat_token", data.token); if (data.token) localStorage.setItem("sexychat_token", data.token);
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list); if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
@ -171,9 +144,9 @@ socket.on("joined", (data) => {
chatScreen.classList.remove("hidden"); chatScreen.classList.remove("hidden");
updateVioletBadge(); updateVioletBadge();
// Show admin panel button for mods+ if (data.system_msg) {
const adminBtn = $("admin-btn"); addMessage("lobby", { system: true, text: data.system_msg });
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin); }
}); });
socket.on("error", (data) => { socket.on("error", (data) => {
@ -209,45 +182,30 @@ socket.on("message", (data) => {
// ── Private Messaging ───────────────────────────────────────────────────── // ── Private Messaging ─────────────────────────────────────────────────────
socket.on("pm_invite", async (data) => { socket.on("pm_invite", (data) => {
if (state.pms[data.room]) return; // Already accepted 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.`; $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
pmModal.classList.remove("hidden"); pmModal.classList.remove("hidden");
$("pm-accept-btn").onclick = () => { $("pm-accept-btn").onclick = () => {
socket.emit("pm_accept", { room: data.room }); socket.emit("pm_accept", { room: data.room });
openPMTab(data.from, data.room, data.room_key); openPMTab(data.from, data.room);
pmModal.classList.add("hidden"); pmModal.classList.add("hidden");
}; };
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
}); });
socket.on("pm_ready", (data) => { socket.on("pm_ready", (data) => {
openPMTab(data.with, data.room, data.room_key); openPMTab(data.with, data.room);
}); });
async function openPMTab(otherUser, room, roomKeyB64) { async function openPMTab(otherUser, room) {
if (state.pms[room]) { if (state.pms[room]) {
switchTab(room); switchTab(room);
return; return;
} }
state.pms[room] = { username: otherUser, messages: [], sharedKey: null }; state.pms[room] = { username: otherUser, messages: [] };
// 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 // Create Tab
let title = `👤 ${otherUser}`; let title = `👤 ${otherUser}`;
@ -277,41 +235,19 @@ async function openPMTab(otherUser, room, roomKeyB64) {
// Load History if registered // Load History if registered
if (state.isRegistered && state.cryptoKey) { if (state.isRegistered && state.cryptoKey) {
try { try {
const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, { const resp = await fetch(`/api/pm/history?with=${otherUser}`, {
headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
}); });
const histData = await resp.json(); const data = await resp.json();
if (data.messages) {
// Use room_key from history response if we don't have one yet for (const m of data.messages) {
if (!isViolet && histData.room_key && !state.pms[room].sharedKey) { const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce);
try { addMessage(room, {
state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key); sender: m.from_me ? state.username : otherUser,
} catch (err) { text: plain,
console.error("Failed to import history room key", err); ts: m.ts,
} sent: m.from_me
} });
// 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) { } catch (err) {
@ -323,18 +259,11 @@ async function openPMTab(otherUser, room, roomKeyB64) {
socket.on("pm_message", async (data) => { socket.on("pm_message", async (data) => {
if (state.ignoredUsers.has(data.from)) return; if (state.ignoredUsers.has(data.from)) return;
let text = data.text; let text = data.text;
if (data.ciphertext) { if (data.ciphertext && state.cryptoKey) {
// Pick the right key: room shared key for user-to-user, personal key for Violet try {
const pm = state.pms[data.room]; text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
const decryptKey = pm?.sharedKey || state.cryptoKey; } catch (err) {
if (decryptKey) { text = "[Encrypted Message - Click to login/derive key]";
try {
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
} catch (err) {
text = "[Encrypted Message - Could not decrypt]";
}
} else {
text = "[Encrypted Message - No key available]";
} }
} }
@ -371,25 +300,24 @@ socket.on("ai_response", async (data) => {
state.hasAiAccess = data.has_ai_access; state.hasAiAccess = data.has_ai_access;
updateVioletBadge(); updateVioletBadge();
const room = data.room || "ai-violet"; let text = "[Decryption Error]";
let text = data.text || "[Decryption Error]";
if (data.ciphertext && state.cryptoKey) { if (data.ciphertext && state.cryptoKey) {
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
} }
addMessage(room, { addMessage("ai-violet", {
sender: AI_BOT_NAME, sender: AI_BOT_NAME,
text: text, text: text,
ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
sent: false sent: false
}); });
}); });
socket.on("ai_unlock", (data) => { socket.on("ai_unlock", (data) => {
state.hasAiAccess = data.has_ai_access !== undefined ? data.has_ai_access : true; state.hasAiAccess = true;
updateVioletBadge(); updateVioletBadge();
paywallModal.classList.add("hidden"); paywallModal.classList.add("hidden");
if (data.msg) addMessage("ai-violet", { system: true, text: data.msg }); addMessage("ai-violet", { system: true, text: data.msg });
}); });
socket.on("ignore_status", (data) => { socket.on("ignore_status", (data) => {
@ -399,7 +327,6 @@ socket.on("ignore_status", (data) => {
}); });
function updateVioletBadge() { function updateVioletBadge() {
if (!trialBadge) return;
if (state.hasAiAccess) { if (state.hasAiAccess) {
trialBadge.classList.add("hidden"); trialBadge.classList.add("hidden");
} else { } else {
@ -423,18 +350,12 @@ messageForm.addEventListener("submit", async (e) => {
socket.emit("message", { text }); socket.emit("message", { text });
} }
else if (state.currentRoom.startsWith("pm:")) { else if (state.currentRoom.startsWith("pm:")) {
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet"); const isVioletRoom = state.currentRoom.toLowerCase().includes(":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 (isVioletRoom) {
if (state.isRegistered && state.cryptoKey) { if (!state.isRegistered || !state.cryptoKey) {
addMessage(state.currentRoom, { system: true, text: "You must be logged in to chat with Violet." });
} else {
// AI Transit Encryption PM Flow // AI Transit Encryption PM Flow
const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey); const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey);
const encrypted = await SexyChato.encrypt(state.cryptoKey, text); const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
@ -445,15 +366,10 @@ messageForm.addEventListener("submit", async (e) => {
nonce: encrypted.nonce, nonce: encrypted.nonce,
transit_key: transitKeyB64 transit_key: transitKeyB64
}); });
} else {
// Guest/admin plaintext fallback
socket.emit("pm_message", { room: state.currentRoom, text });
} }
} else if (state.isRegistered && state.cryptoKey) { } else if (state.isRegistered && state.cryptoKey) {
// User-to-user encrypted PM: use the shared room key if available // E2E PM Flow
const pm = state.pms[state.currentRoom]; const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
const encryptKey = pm?.sharedKey || state.cryptoKey;
const encrypted = await SexyChato.encrypt(encryptKey, text);
socket.emit("pm_message", { socket.emit("pm_message", {
room: state.currentRoom, room: state.currentRoom,
ciphertext: encrypted.ciphertext, ciphertext: encrypted.ciphertext,
@ -469,14 +385,6 @@ messageForm.addEventListener("submit", async (e) => {
messageInput.style.height = "auto"; 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 // Auto-expand textarea
messageInput.addEventListener("input", () => { messageInput.addEventListener("input", () => {
messageInput.style.height = "auto"; messageInput.style.height = "auto";
@ -500,18 +408,6 @@ function switchTab(room) {
if (box) box.scrollTop = box.scrollHeight; 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) { function addMessage(room, msg) {
const list = $(`messages-${room}`); const list = $(`messages-${room}`);
if (!list) return; if (!list) return;
@ -523,7 +419,7 @@ function addMessage(room, msg) {
} else { } else {
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`; div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
div.innerHTML = ` div.innerHTML = `
<div class="msg-meta">${formatTs(msg.ts)} ${msg.sender}</div> <div class="msg-meta">${msg.ts} ${msg.sender}</div>
<div class="msg-bubble">${escapeHTML(msg.text)}</div> <div class="msg-bubble">${escapeHTML(msg.text)}</div>
`; `;
} }
@ -538,11 +434,10 @@ function renderNicklist() {
const li = document.createElement("li"); const li = document.createElement("li");
const isIgnored = state.ignoredUsers.has(u.username); const isIgnored = state.ignoredUsers.has(u.username);
const isUnverified = u.is_registered && !u.is_verified; const isUnverified = u.is_registered && !u.is_verified;
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
li.innerHTML = ` li.innerHTML = `
<span class="${isUnverified ? 'unverified' : ''}"> <span class="${isUnverified ? 'unverified' : ''}">
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''} ${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span> <span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''} ${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
${isIgnored ? ' <small>(ignored)</small>' : ''} ${isIgnored ? ' <small>(ignored)</small>' : ''}
@ -590,15 +485,16 @@ function showContextMenu(e, user) {
} }
}); });
// Store target for click handler (uses event delegation below) // Cleanup previous listeners
contextMenu._targetUser = user.username; const newMenu = contextMenu.cloneNode(true);
contextMenu.replaceWith(newMenu);
// Remove old inline onclick handlers and re-bind
contextMenu.querySelectorAll(".menu-item").forEach(item => { // Add new listeners
newMenu.querySelectorAll(".menu-item").forEach(item => {
item.onclick = () => { item.onclick = () => {
const action = item.dataset.action; const action = item.dataset.action;
executeMenuAction(action, contextMenu._targetUser); executeMenuAction(action, user.username);
contextMenu.classList.add("hidden"); newMenu.classList.add("hidden");
}; };
}); });
} }
@ -617,9 +513,6 @@ function executeMenuAction(action, target) {
case "kick": case "kick":
socket.emit("mod_kick", { target }); socket.emit("mod_kick", { target });
break; break;
case "mute":
socket.emit("mod_mute", { target });
break;
case "ban": case "ban":
socket.emit("mod_ban", { target }); socket.emit("mod_ban", { target });
break; break;
@ -652,15 +545,35 @@ $("sidebar-toggle").onclick = () => {
sidebar.classList.toggle("open"); sidebar.classList.toggle("open");
}; };
// tab-ai-violet is created dynamically when user opens Violet PM $("tab-ai-violet").onclick = () => switchTab("ai-violet");
$("tab-lobby").onclick = () => switchTab("lobby"); $("tab-lobby").onclick = () => switchTab("lobby");
$("close-paywall").onclick = () => paywallModal.classList.add("hidden"); $("close-paywall").onclick = () => paywallModal.classList.add("hidden");
$("unlock-btn").onclick = async () => { $("unlock-btn").onclick = async () => {
// In production, this redirects to a real payment gateway (Stripe Checkout). // Generate dummy secret for the stub endpoint
// The server-side webhook will unlock AI access after payment confirmation. // In production, this would redirect to a real payment gateway (Stripe)
// For now, show a placeholder message. const secret = "change-me-payment-webhook-secret";
alert("Payment integration coming soon. Contact the administrator to unlock Violet."); const token = localStorage.getItem("sexychat_token");
try {
const resp = await fetch("/api/payment/success", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ secret })
});
const res = await resp.json();
if (res.status === "ok") {
// socket event should handle UI unlock, but we can optimistically update
state.hasAiAccess = true;
updateVioletBadge();
paywallModal.classList.add("hidden");
}
} catch (err) {
alert("Payment simulation failed.");
}
}; };
logoutBtn.onclick = () => { logoutBtn.onclick = () => {
@ -668,358 +581,8 @@ logoutBtn.onclick = () => {
location.reload(); 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) { function escapeHTML(str) {
const p = document.createElement("p"); const p = document.createElement("p");
p.textContent = str; p.textContent = str;
return p.innerHTML; 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,23 +126,6 @@ const SexyChato = (() => {
return bufToBase64(raw); 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 ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 }; return { deriveKey, encrypt, decrypt, exportKeyBase64 };
})(); })();

View File

@ -6,123 +6,14 @@
:root { :root {
--bg-deep: #0a0015; --bg-deep: #0a0015;
--bg-card: rgba(26, 0, 48, 0.7); --bg-card: rgba(26, 0, 48, 0.7);
--bg-header: rgba(10, 0, 20, 0.8);
--accent-magenta: #ff00ff; --accent-magenta: #ff00ff;
--accent-purple: #8a2be2; --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-main: #f0f0f0;
--text-dim: #b0b0b0; --text-dim: #b0b0b0;
--glass-border: rgba(255, 255, 255, 0.1); --glass-border: rgba(255, 255, 255, 0.1);
--error-red: #ff3366; --error-red: #ff3366;
--success-green: #00ffaa; --success-green: #00ffaa;
--ai-teal: #00f2ff; --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;
} }
* { * {
@ -136,8 +27,8 @@ body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: var(--bg-deep); background-color: var(--bg-deep);
background-image: background-image:
radial-gradient(circle at 20% 30%, var(--bg-gradient-1) 0%, transparent 40%), radial-gradient(circle at 20% 30%, rgba(138, 43, 226, 0.15) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, var(--bg-gradient-2) 0%, transparent 40%); radial-gradient(circle at 80% 70%, rgba(255, 0, 255, 0.1) 0%, transparent 40%);
color: var(--text-main); color: var(--text-main);
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
@ -192,7 +83,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
.logo-accent { .logo-accent {
color: var(--accent-magenta); color: var(--accent-magenta);
text-shadow: 0 0 10px var(--accent-glow-strong); text-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
} }
.logo-sub { .logo-sub {
@ -226,7 +117,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
.auth-tab.active { .auth-tab.active {
background: var(--accent-purple); background: var(--accent-purple);
color: white; color: white;
box-shadow: 0 4px 12px var(--accent-glow); box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
} }
/* Form */ /* Form */
@ -253,7 +144,7 @@ input:focus {
.crypto-note { .crypto-note {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--accent-magenta); color: var(--accent-magenta);
background: color-mix(in srgb, var(--accent-magenta) 5%, transparent); background: rgba(255, 0, 255, 0.05);
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
margin: 1rem 0; margin: 1rem 0;
@ -270,13 +161,13 @@ input:focus {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
box-shadow: 0 4px 15px var(--accent-glow); box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
margin-top: 1rem; margin-top: 1rem;
} }
.btn-primary:active { transform: scale(0.98); } .btn-primary:active { transform: scale(0.98); }
.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow-strong); } .btn-primary:hover { box-shadow: 0 6px 20px rgba(255, 0, 255, 0.4); }
.mod-login { .mod-login {
text-align: left; text-align: left;
@ -303,7 +194,7 @@ input:focus {
.glass-header { .glass-header {
height: 64px; height: 64px;
background: var(--bg-header); background: rgba(10, 0, 20, 0.8);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
@ -346,23 +237,6 @@ input:focus {
.header-right { display: flex; align-items: center; gap: 12px; } .header-right { display: flex; align-items: center; gap: 12px; }
.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; } .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 { .btn-logout {
background: transparent; background: transparent;
color: var(--text-dim); color: var(--text-dim);
@ -504,7 +378,7 @@ input:focus {
.msg-bubble { .msg-bubble {
padding: 10px 14px; padding: 10px 14px;
border-radius: 18px; border-radius: 18px;
font-size: var(--chat-font-size, 0.95rem); font-size: 0.95rem;
position: relative; position: relative;
word-wrap: break-word; word-wrap: break-word;
} }
@ -518,16 +392,16 @@ input:focus {
.msg-received { align-self: flex-start; } .msg-received { align-self: flex-start; }
.msg-received .msg-bubble { .msg-received .msg-bubble {
background: var(--msg-received-bg); background: rgba(255, 255, 255, 0.08);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
border: 1px solid var(--msg-received-border); border: 1px solid rgba(255,255,255,0.05);
} }
.msg-sent { align-self: flex-end; } .msg-sent { align-self: flex-end; }
.msg-sent .msg-bubble { .msg-sent .msg-bubble {
background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta)); background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta));
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
box-shadow: 0 4px 12px var(--accent-glow); box-shadow: 0 4px 12px rgba(255, 0, 255, 0.2);
} }
.msg-system { .msg-system {
@ -543,7 +417,7 @@ input:focus {
.msg-system strong { color: var(--accent-magenta); } .msg-system strong { color: var(--accent-magenta); }
/* ── AI Area ─────────────────────────────────────────────────────────────── */ /* ── AI Area ─────────────────────────────────────────────────────────────── */
.chat-ai { background: radial-gradient(circle at top, color-mix(in srgb, var(--ai-teal) 5%, transparent), transparent 70%); } .chat-ai { background: radial-gradient(circle at top, rgba(0, 242, 255, 0.05), transparent 70%); }
.ai-header { .ai-header {
padding: 1rem; padding: 1rem;
@ -584,7 +458,7 @@ input:focus {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 10px; gap: 10px;
background: var(--bg-header); background: rgba(10, 0, 20, 0.8);
border-top: 1px solid var(--glass-border); border-top: 1px solid var(--glass-border);
} }
@ -613,7 +487,7 @@ textarea {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 0 15px var(--accent-glow-strong); box-shadow: 0 0 15px rgba(255, 0, 255, 0.4);
} }
/* ── Modals ──────────────────────────────────────────────────────────────── */ /* ── Modals ──────────────────────────────────────────────────────────────── */
@ -638,7 +512,7 @@ textarea {
.paywall-card { .paywall-card {
border: 2px solid var(--ai-teal); border: 2px solid var(--ai-teal);
box-shadow: 0 0 40px color-mix(in srgb, var(--ai-teal) 20%, transparent); box-shadow: 0 0 40px rgba(0, 242, 255, 0.2);
} }
.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; } .ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; }
@ -665,7 +539,7 @@ textarea {
border-radius: 12px; border-radius: 12px;
background: rgba(15, 0, 30, 0.95); background: rgba(15, 0, 30, 0.95);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px color-mix(in srgb, var(--accent-purple) 20%, transparent); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px rgba(138, 43, 226, 0.2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -694,507 +568,7 @@ textarea {
margin: 4px 0; 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 ─────────────────────────────────────────────────────── */ /* ── 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) { @media (max-width: 768px) {
.nicklist-sidebar { .nicklist-sidebar {
position: absolute; position: absolute;