Compare commits
22 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8cce8e6c2e | |
|
|
e86d69ce35 | |
|
|
4e3583ef9a | |
|
|
fa030a32b7 | |
|
|
887482d3db | |
|
|
064f6bf0ba | |
|
|
d5e942d06d | |
|
|
8cd76ff72d | |
|
|
389415f04d | |
|
|
1d6413cfd6 | |
|
|
8214f9c244 | |
|
|
01c6c4a1b0 | |
|
|
9570283ad8 | |
|
|
496701c713 | |
|
|
b38eb01e27 | |
|
|
46ba1d7273 | |
|
|
cdfbb666b9 | |
|
|
a0a96addb6 | |
|
|
be3503b31b | |
|
|
8da91ebf70 | |
|
|
99859f009f | |
|
|
1c17a9bcf0 |
|
|
@ -0,0 +1,681 @@
|
|||
# 💋 SexyChat (Aphrodite)
|
||||
|
||||
A real-time encrypted chat platform with an AI companion, role-based moderation, and a glassmorphism UI. Built with Flask-SocketIO, AES-GCM encryption, and a local Ollama-powered AI named **Violet**.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Setup & Installation](#setup--installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Running the Server](#running-the-server)
|
||||
- [Database Models](#database-models)
|
||||
- [Socket Events Reference](#socket-events-reference)
|
||||
- [REST API Endpoints](#rest-api-endpoints)
|
||||
- [Role System & Admin Panel](#role-system--admin-panel)
|
||||
- [Encryption & Security](#encryption--security)
|
||||
- [Theme System](#theme-system)
|
||||
- [Settings Panel](#settings-panel)
|
||||
- [Violet AI Companion](#violet-ai-companion)
|
||||
- [Premium / Paywall](#premium--paywall)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Chat
|
||||
- **Public Lobby** — Real-time ephemeral group chat. Messages are never persisted; they live only in connected clients' browsers.
|
||||
- **Private Messaging** — AES-GCM-256 encrypted PMs with persistent history (up to 500 messages per conversation).
|
||||
- **Guest Mode** — Join and chat anonymously without an account.
|
||||
- **Registration & Login** — Bcrypt password hashing, JWT token auth (7-day expiry), automatic session restore on page refresh.
|
||||
- **Rate Limiting** — 6 messages per 5-second window per session.
|
||||
|
||||
### Violet AI Companion
|
||||
- Local Ollama inference (`sadiq-bd/llama3.2-3b-uncensored:latest` by default).
|
||||
- Transit-encrypted messages — decrypted server-side only for inference, never stored in plaintext.
|
||||
- Per-user conversation memory (last 20 turns), persisted across sessions. `/reset` command to clear.
|
||||
- Free trial (configurable, default: 3 messages), then paywall — admins can grant unlimited access.
|
||||
- Real-time typing indicator while Violet is generating a response.
|
||||
- Friendly signup prompt for unregistered guests who try to PM Violet.
|
||||
|
||||
### Moderation & Roles
|
||||
- **4-tier role hierarchy:** `root` (👑) > `admin` (⚔️) > `mod` (🛡️) > `user`
|
||||
- Kick, ban (username + IP), kickban, mute/unmute, verify/unverify.
|
||||
- Persistent bans and mutes survive server restarts (stored in DB, loaded on startup).
|
||||
- Admin panel with three tabs: Users, Bans, Mutes.
|
||||
- Privilege escalation prevention — you can't modify users at or above your own power level.
|
||||
|
||||
### User Features
|
||||
- Persistent ignore list (stealth blocking — ignored users receive no indication).
|
||||
- Context menu (right-click a username) for PM, ignore, and mod actions.
|
||||
- Settings panel with four tabs: Account, Chat, Violet, Premium.
|
||||
- Password change from settings (requires current password).
|
||||
- Unread PM indicators on conversation tabs.
|
||||
|
||||
### UI/UX
|
||||
- Glassmorphism dark theme with CSS custom property theming.
|
||||
- **10 color themes** (dark, light, neon, cyberpunk, and more).
|
||||
- Responsive mobile layout with collapsible sidebar.
|
||||
- Auto-expanding message textarea.
|
||||
- Enter-to-send toggle (configurable).
|
||||
- Google Fonts (Inter + Outfit).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ WebSocket / HTTP ┌──────────────────────────────┐
|
||||
│ Browser │ ◄────────────────────► │ Gunicorn + Eventlet │
|
||||
│ (chat.js │ │ Flask-SocketIO │
|
||||
│ crypto.js) │ │ │
|
||||
└─────────────┘ │ ┌────────────────────────┐ │
|
||||
│ │ REST Blueprint │ │
|
||||
│ │ (routes.py) │ │
|
||||
│ └────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ AI Worker (greenlet) │ │
|
||||
│ │ Serialized Ollama queue │ │
|
||||
│ └───────────┬────────────┘ │
|
||||
└──────────────┼───────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ Ollama (localhost:11434) │
|
||||
│ LLaMA 3.2 3B Uncensored │
|
||||
└──────────────────────────────┘
|
||||
│
|
||||
┌──────────────▼───────────────┐
|
||||
│ SQLite (instance/sexchat.db)│
|
||||
│ Users, Messages, Bans, │
|
||||
│ Mutes, VioletHistory │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Single-process, single-worker** — Eventlet async mode with one Gunicorn worker. All in-memory state (connected users, rooms, rate limits) lives in-process.
|
||||
- **AI inference queue** — A dedicated eventlet greenlet serialises Ollama requests one at a time, broadcasting `violet_typing` indicators while busy. Runs within Flask app context for DB access.
|
||||
- **Lobby is ephemeral** — No lobby messages are ever written to the database.
|
||||
- **PMs are encrypted at rest** — The server stores only AES-GCM ciphertext and nonces for registered-user PMs.
|
||||
- **Shared config module** — `config.py` centralises all constants, config loading, AES-GCM helpers, and JWT helpers so `app.py` and `routes.py` share a single source of truth.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Web server | Gunicorn 21.x with eventlet worker |
|
||||
| Framework | Flask 3.x + Flask-SocketIO 5.x |
|
||||
| Concurrency | Eventlet (monkey-patched greenlets) |
|
||||
| Database | SQLite (default) or PostgreSQL via `DATABASE_URL` |
|
||||
| ORM | SQLAlchemy 3.x + Flask-Migrate 4.x (Alembic) |
|
||||
| AI | Ollama HTTP API (local inference) |
|
||||
| Auth | JWT (PyJWT 2.x) + bcrypt 4.x |
|
||||
| Encryption | AES-GCM-256 (server: `cryptography` 42.x, client: Web Crypto API) |
|
||||
| Frontend | Vanilla JS, Socket.IO 4 client, custom CSS |
|
||||
| Scaling | Optional Redis message queue for multi-worker deployments |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
aprhodite/
|
||||
├── app.py # Flask-SocketIO app: socket events, AI worker, admin panel, moderation
|
||||
├── config.py # Shared config loader, constants, AES-GCM helpers, JWT helpers, Violet prompt
|
||||
├── database.py # SQLAlchemy + Flask-Migrate init, Violet bot user seeding
|
||||
├── models.py # ORM models: User, Message, Ban, Mute, UserIgnore, VioletHistory
|
||||
├── routes.py # REST API blueprint: auth, PM history, AI message, payment webhook
|
||||
├── start.py # Process manager: start / stop / restart / status / debug
|
||||
├── gunicorn.conf.py # Gunicorn configuration (workers, bind, worker class)
|
||||
├── config.json # Runtime secrets & overrides (gitignored)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── index.html # Single-page app: join screen, chat, settings modal, admin panel modal
|
||||
├── static/
|
||||
│ ├── chat.js # Frontend logic: socket events, PMs, settings, admin panel, themes
|
||||
│ ├── crypto.js # Client-side AES-GCM encryption (PBKDF2 key derivation, SubtleCrypto)
|
||||
│ ├── style.css # All styles: glassmorphism, 10 themes, admin panel, responsive layout
|
||||
│ └── socket.io.min.js# Socket.IO v4 client library
|
||||
└── instance/
|
||||
└── sexchat.db # SQLite database (auto-created on first run)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.11+**
|
||||
- **[Ollama](https://ollama.ai)** (required for Violet AI)
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://git.computertech.dev/lord3nd3r/aprhodite.git
|
||||
cd aprhodite
|
||||
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Pull the AI Model
|
||||
|
||||
```bash
|
||||
ollama pull sadiq-bd/llama3.2-3b-uncensored:latest
|
||||
```
|
||||
|
||||
### Create `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"SECRET_KEY": "your-random-secret-key",
|
||||
"JWT_SECRET": "your-random-jwt-secret",
|
||||
"ADMIN_PASSWORD": "a-strong-moderator-password",
|
||||
"AI_FREE_LIMIT": 3,
|
||||
"HOST": "0.0.0.0",
|
||||
"PORT": 5000
|
||||
}
|
||||
```
|
||||
|
||||
> **Tip:** Generate secrets with `python -c "import uuid; print(uuid.uuid4().hex)"`.
|
||||
|
||||
### Database
|
||||
|
||||
On first run, `database.py` calls `db.create_all()` and seeds the **Violet** bot user automatically. No manual migration is needed for a fresh install.
|
||||
|
||||
For schema changes after the initial setup:
|
||||
|
||||
```bash
|
||||
flask db migrate -m "description of changes"
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is resolved with the following precedence: **Environment Variable → `config.json` → Default Value**.
|
||||
|
||||
All config loading is centralised in `config.py`.
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `SECRET_KEY` | Random UUID hex | Flask session secret |
|
||||
| `JWT_SECRET` | Random UUID hex | JWT HMAC signing key |
|
||||
| `ADMIN_PASSWORD` | `"admin1234"` | Shared mod password for guest elevation |
|
||||
| `DATABASE_URL` | `"sqlite:///sexchat.db"` | SQLAlchemy database URI |
|
||||
| `PAYMENT_SECRET` | `"change-me-payment-secret"` | Payment webhook validation secret |
|
||||
| `CORS_ORIGINS` | `None` | Socket.IO CORS allowlist |
|
||||
| `HOST` | `"0.0.0.0"` | Server bind address |
|
||||
| `PORT` | `5000` | Server bind port |
|
||||
| `OLLAMA_URL` | `"http://localhost:11434"` | Ollama API endpoint |
|
||||
| `VIOLET_MODEL` | `"sadiq-bd/llama3.2-3b-uncensored:latest"` | Ollama model tag for Violet |
|
||||
| `AI_FREE_LIMIT` | `3` | Free Violet messages before paywall |
|
||||
| `SOCKETIO_MESSAGE_QUEUE` / `REDIS_URL` | `None` | Redis URL for multi-worker Socket.IO |
|
||||
|
||||
### Hardcoded Constants (in `config.py`)
|
||||
|
||||
| Constant | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `MAX_MSG_LEN` | 500 chars | Maximum message length |
|
||||
| `MAX_HISTORY` | 500 messages | PM history cap per conversation pair |
|
||||
| `JWT_EXPIRY_DAYS` | 7 days | JWT token lifespan |
|
||||
| `AI_BOT_NAME` | `"Violet"` | AI bot display name |
|
||||
| `PBKDF2_ITERATIONS` | 100,000 | Client-side key derivation iterations (`crypto.js`) |
|
||||
| Rate limit | 6 msgs / 5 sec | Per-session lobby rate limit (`app.py`) |
|
||||
| `MAX_HISTORY_PER_USER` | 20 turns | Violet conversation memory depth (`app.py`) |
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Debug Mode (foreground, full logging)
|
||||
|
||||
```bash
|
||||
python start.py debug
|
||||
```
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
```bash
|
||||
python start.py start # Start in background (PID written to .pid file)
|
||||
python start.py stop # Graceful shutdown
|
||||
python start.py restart # Stop + start
|
||||
python start.py status # Check if running
|
||||
```
|
||||
|
||||
### Direct Gunicorn
|
||||
|
||||
```bash
|
||||
gunicorn --worker-class eventlet -w 1 --bind 0.0.0.0:5000 start:application
|
||||
```
|
||||
|
||||
The server will be available at `http://localhost:5000`.
|
||||
|
||||
---
|
||||
|
||||
## Database Models
|
||||
|
||||
Six models defined in `models.py`. All timestamps use `datetime.now(timezone.utc)`.
|
||||
|
||||
### `users`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | Auto-increment |
|
||||
| `username` | String(20), unique, indexed | Alphanumeric + `-` + `_` |
|
||||
| `password_hash` | String(128) | Bcrypt hash |
|
||||
| `email` | String(255), unique, nullable | Optional email |
|
||||
| `role` | String(10), default `"user"` | `root`, `admin`, `mod`, or `user` |
|
||||
| `has_ai_access` | Boolean, default `False` | Unlimited Violet access flag |
|
||||
| `ai_messages_used` | Integer, default `0` | Free trial counter |
|
||||
| `is_verified` | Boolean, default `False` | Manual verification status |
|
||||
| `created_at` | DateTime | UTC timestamp |
|
||||
|
||||
### `messages` (Encrypted PM History)
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `sender_id` | FK → `users.id` | |
|
||||
| `recipient_id` | FK → `users.id` | |
|
||||
| `encrypted_content` | Text | Base64-encoded AES-GCM ciphertext |
|
||||
| `nonce` | String(64) | Base64-encoded 12-byte IV |
|
||||
| `timestamp` | DateTime | Indexed with sender/recipient for query performance |
|
||||
|
||||
### `bans`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `username` | String(20), indexed | Banned username (lowercase) |
|
||||
| `ip` | String(45), nullable, indexed | Banned IP address |
|
||||
| `reason` | String(255), nullable | Ban reason |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
### `mutes`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `username` | String(20), unique, indexed | Muted username |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
### `user_ignores`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `ignorer_id` | FK → `users.id` | User doing the ignoring |
|
||||
| `ignored_id` | FK → `users.id` | User being ignored |
|
||||
| `created_at` | DateTime | |
|
||||
|
||||
Unique composite index on `(ignorer_id, ignored_id)`.
|
||||
|
||||
### `violet_history`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer, PK | |
|
||||
| `user_id` | FK → `users.id` | Owning user |
|
||||
| `role` | String(10) | `"user"` or `"assistant"` |
|
||||
| `text` | Text | Plaintext conversation turn |
|
||||
| `timestamp` | DateTime | |
|
||||
|
||||
---
|
||||
|
||||
## Socket Events Reference
|
||||
|
||||
### Connection & Auth
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `join` | `{ mode, username, password?, email?, mod_password? }` | Authenticate and enter lobby. `mode`: `guest`, `login`, `register`, or `restore`. |
|
||||
| S→C | `joined` | `{ username, is_admin, role, is_registered, has_ai_access, ai_messages_used, email?, token?, ignored_list? }` | Auth success — includes JWT token for session restore. |
|
||||
| S→C | `error` | `{ msg }` | Error message. |
|
||||
| S→C | `kicked` | `{ msg }` | User was kicked/banned. |
|
||||
|
||||
### Lobby Chat
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `message` | `{ text }` | Send lobby message (rate-limited). |
|
||||
| S→C | `message` | `{ username, text, is_admin, is_registered, ts }` | Broadcast lobby message. |
|
||||
| S→C | `system` | `{ msg, ts }` | System notification (joins, parts, mod actions). |
|
||||
| S→C | `nicklist` | `{ users: [{ username, is_admin, is_registered, is_verified, role }] }` | Full online user list (includes Violet bot). |
|
||||
|
||||
### Private Messaging
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `pm_open` | `{ target }` | Initiate PM with target user. |
|
||||
| C→S | `pm_accept` | `{ room }` | Accept an incoming PM invitation (validated against pending invites). |
|
||||
| C→S | `pm_message` | `{ room, text? }` or `{ room, ciphertext, nonce, transit_key? }` | Send PM — plaintext (guests) or encrypted (registered). Include `transit_key` for Violet. |
|
||||
| S→C | `pm_invite` | `{ from, room, room_key }` | Incoming PM invitation with server-derived room key. |
|
||||
| S→C | `pm_ready` | `{ with, room, room_key }` | PM room opened — sent to the initiator. |
|
||||
| S→C | `pm_message` | `{ from, text?, ciphertext?, nonce?, room, ts }` | Receive a PM. |
|
||||
|
||||
### Violet AI
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `pm_message` (to Violet room) | `{ room, ciphertext, nonce, transit_key }` | Encrypted message routed to Violet via AI worker. |
|
||||
| C→S | `violet_reset` | _(none)_ | Clear your conversation memory with Violet. |
|
||||
| S→C | `violet_typing` | `{ busy, room? }` | AI processing indicator (shown while Ollama generates). |
|
||||
| S→C | `ai_response` | `{ error: "ai_limit_reached", room }` | Free trial exhausted. |
|
||||
| S→C | `ai_unlock` | `{ has_ai_access, msg? }` | Premium access granted or revoked (pushed live). |
|
||||
|
||||
### Moderation
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `mod_kick` | `{ target }` | Kick user (mod+). |
|
||||
| C→S | `mod_ban` | `{ target }` | Ban username + IP (mod+). |
|
||||
| C→S | `mod_kickban` | `{ target }` | Kick + ban simultaneously (mod+). |
|
||||
| C→S | `mod_mute` | `{ target }` | Toggle mute on user (mod+). |
|
||||
| C→S | `mod_verify` | `{ target }` | Toggle verification on user (mod+). |
|
||||
|
||||
### User Actions
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `user_ignore` | `{ target }` | Ignore a user (registered only). |
|
||||
| C→S | `user_unignore` | `{ target }` | Unignore a user. |
|
||||
| C→S | `change_password` | `{ old_password, new_password }` | Change password (6-char minimum). |
|
||||
| S→C | `ignore_status` | `{ target, ignored }` | Ignore list update confirmation. |
|
||||
| S→C | `password_changed` | `{ success, msg? }` | Password change result. |
|
||||
| S→C | `role_updated` | `{ role }` | Live notification when your role is changed by an admin. |
|
||||
|
||||
### Admin Panel
|
||||
|
||||
| Direction | Event | Data | Description |
|
||||
|-----------|-------|------|-------------|
|
||||
| C→S | `admin_get_users` | _(none)_ | Request full user list (mod+). |
|
||||
| C→S | `admin_get_bans` | _(none)_ | Request ban list (mod+). |
|
||||
| C→S | `admin_get_mutes` | _(none)_ | Request mute list (mod+). |
|
||||
| C→S | `admin_set_role` | `{ user_id, role }` | Change a user's role (admin+ only, can't set ≥ own role). |
|
||||
| C→S | `admin_verify_user` | `{ user_id }` | Toggle verification (mod+). |
|
||||
| C→S | `admin_toggle_ai` | `{ user_id }` | Toggle unlimited AI access (admin+). Notifies user in real-time. |
|
||||
| C→S | `admin_unban` | `{ ban_id }` | Remove a ban (mod+). |
|
||||
| C→S | `admin_unmute` | `{ mute_id }` | Remove a mute (mod+). |
|
||||
| S→C | `admin_users` | `{ users: [{ id, username, role, is_verified, has_ai_access, email, created_at, online }] }` | User list with online status. |
|
||||
| S→C | `admin_bans` | `{ bans: [{ id, username, ip?, reason?, created_at }] }` | Ban list. |
|
||||
| S→C | `admin_mutes` | `{ mutes: [{ id, username, created_at }] }` | Mute list. |
|
||||
| S→C | `admin_action_ok` | `{ msg }` | Action success toast (panel auto-refreshes). |
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
All endpoints are under the `/api` prefix.
|
||||
|
||||
### `POST /api/auth/register`
|
||||
|
||||
Create a new account.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alice", "password": "hunter2", "email": "alice@example.com" }
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"token": "eyJ...",
|
||||
"user": { "id": 3, "username": "alice", "has_ai_access": false, "ai_messages_used": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Authenticate and receive a JWT.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "username": "alice", "password": "hunter2" }
|
||||
|
||||
// Response 200
|
||||
{ "token": "eyJ...", "user": { "id": 3, "username": "alice", ... } }
|
||||
```
|
||||
|
||||
### `GET /api/pm/history?with={username}`
|
||||
|
||||
Fetch encrypted PM history with another user. Requires `Authorization: Bearer <jwt>`.
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{
|
||||
"room_key": "base64...",
|
||||
"messages": [
|
||||
{ "from_me": true, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." },
|
||||
{ "from_me": false, "ciphertext": "...", "nonce": "...", "ts": "2025-04-12T..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/ai/message`
|
||||
|
||||
Send an encrypted message to Violet via REST (alternative to the socket path).
|
||||
|
||||
```json
|
||||
// Request (Authorization: Bearer <jwt>)
|
||||
{ "ciphertext": "...", "nonce": "...", "transit_key": "..." }
|
||||
|
||||
// Response 200
|
||||
{ "ciphertext": "...", "nonce": "...", "ai_messages_used": 2, "has_ai_access": false }
|
||||
```
|
||||
|
||||
### `POST /api/payment/success`
|
||||
|
||||
Payment webhook (server-to-server). Validates `secret` with constant-time HMAC comparison.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "secret": "PAYMENT_SECRET", "user_id": 3 }
|
||||
|
||||
// Response 200
|
||||
{ "status": "ok", "has_ai_access": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role System & Admin Panel
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Power Level | Badge | Capabilities |
|
||||
|------|-------------|-------|-------------|
|
||||
| `root` | 3 | 👑 | Everything. Can promote to admin. Cannot be demoted. |
|
||||
| `admin` | 2 | ⚔️ | Set roles (mod/user), toggle AI access, all mod actions. |
|
||||
| `mod` | 1 | 🛡️ | Kick, ban, mute, verify, view admin panel. |
|
||||
| `user` | 0 | — | Standard chat, PMs, Violet AI (free trial). |
|
||||
|
||||
**Rules:**
|
||||
- You cannot modify a user whose power level is ≥ your own.
|
||||
- You cannot assign a role whose power level is ≥ your own.
|
||||
- The first registered human user should be set to `root` in the database.
|
||||
- **Legacy:** Guests can enter the shared mod password at login to gain temporary `mod` session access.
|
||||
|
||||
### Admin Panel
|
||||
|
||||
Accessible via the shield icon (🛡️) in the header (visible to `mod` and above). Three tabs:
|
||||
|
||||
| Tab | Features |
|
||||
|-----|----------|
|
||||
| **Users** | Searchable user list. Change roles (dropdown), toggle verification (checkmark), grant/revoke AI access (brain icon). Shows online status. |
|
||||
| **Bans** | All active bans with username, IP, and date. One-click unban button. |
|
||||
| **Mutes** | All active mutes. One-click unmute button. |
|
||||
|
||||
All actions trigger a system message in the lobby, auto-refresh the panel, and (where applicable) push live updates to the affected user's session.
|
||||
|
||||
---
|
||||
|
||||
## Encryption & Security
|
||||
|
||||
### PM Encryption (AES-GCM-256)
|
||||
|
||||
```
|
||||
1. Server generates a deterministic room key: HMAC-SHA256(JWT_SECRET, room_name)
|
||||
2. Room key is delivered to both clients via pm_invite / pm_ready events
|
||||
3. Client encrypts each message with AES-GCM-256 using the room key + random 12-byte nonce
|
||||
4. Server stores only base64(ciphertext) + base64(nonce) — never sees plaintext
|
||||
5. Recipient decrypts client-side with the same room key
|
||||
```
|
||||
|
||||
### Violet AI Transit Encryption
|
||||
|
||||
```
|
||||
1. Client generates a random AES-256 transit key
|
||||
2. Client encrypts message with transit key → sends { ciphertext, nonce, transit_key }
|
||||
3. Server decrypts with transit key → sends plaintext to Ollama for inference
|
||||
4. Server encrypts Violet's response with the same transit key → delivers to client
|
||||
5. Transit key is ephemeral — used only in memory, never stored
|
||||
```
|
||||
|
||||
### Security Measures
|
||||
|
||||
| Category | Implementation |
|
||||
|----------|---------------|
|
||||
| **Password hashing** | Bcrypt with adaptive cost factor |
|
||||
| **Authentication** | JWT tokens (HMAC-SHA256), 7-day expiry |
|
||||
| **Session restore** | JWT stored in `localStorage`, validated on reconnect |
|
||||
| **Input validation** | Regex-enforced usernames (`[a-zA-Z0-9_-]`, 1–20 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** — 12–20px 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)
|
||||
707
app.py
707
app.py
|
|
@ -39,16 +39,14 @@ Socket events (server → client)
|
|||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import functools
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
import jwt as pyjwt
|
||||
import eventlet # noqa – monkey-patched in start.py before any other import
|
||||
from eventlet.queue import Queue as EvQueue
|
||||
|
||||
|
|
@ -56,65 +54,45 @@ from flask import Flask, request, send_from_directory
|
|||
from flask_socketio import SocketIO, emit, join_room, disconnect
|
||||
|
||||
from database import db, init_db
|
||||
from models import User, Message, UserIgnore
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration Loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_config():
|
||||
conf = {}
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Failed to load config.json: {e}")
|
||||
return conf
|
||||
|
||||
_CONFIG = load_config()
|
||||
|
||||
def _get_conf(key, default=None):
|
||||
# Order: Env Var > Config File > Default
|
||||
return os.environ.get(key, _CONFIG.get(key, default))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECRET_KEY = _get_conf("SECRET_KEY", uuid.uuid4().hex)
|
||||
JWT_SECRET = _get_conf("JWT_SECRET", uuid.uuid4().hex)
|
||||
ADMIN_PASSWORD = _get_conf("ADMIN_PASSWORD", "admin1234")
|
||||
MAX_MSG_LEN = 500
|
||||
LOBBY = "lobby"
|
||||
AI_FREE_LIMIT = int(_get_conf("AI_FREE_LIMIT", 3))
|
||||
AI_BOT_NAME = "Violet"
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL = _get_conf("OLLAMA_URL", "http://localhost:11434")
|
||||
VIOLET_MODEL = _get_conf("VIOLET_MODEL", "sam860/dolphin3-llama3.2:3b")
|
||||
VIOLET_SYSTEM = (
|
||||
"You are Violet, a flirtatious and sophisticated nightclub hostess at "
|
||||
"an exclusive, dimly-lit members-only club. You are charming, witty, "
|
||||
"and seductive — never crude or offensive. You speak with elegance, "
|
||||
"mystery, and a hint of playful danger. Keep every reply to 1–3 "
|
||||
"sentences maximum. You are in a private conversation with a special "
|
||||
"guest who has caught your eye."
|
||||
from models import User, Message, UserIgnore, Ban, Mute, VioletHistory
|
||||
from config import (
|
||||
SECRET_KEY, ADMIN_PASSWORD, DATABASE_URL, CORS_ORIGINS,
|
||||
MAX_MSG_LEN, LOBBY, AI_FREE_LIMIT, AI_BOT_NAME,
|
||||
OLLAMA_URL, VIOLET_MODEL, VIOLET_SYSTEM,
|
||||
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Security Logging Setup
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
security_logger = logging.getLogger("security")
|
||||
security_logger.setLevel(logging.INFO)
|
||||
if not security_logger.handlers:
|
||||
handler = logging.FileHandler("security.log")
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - [%(name)s] - %(message)s"
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
security_logger.addHandler(handler)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# sid → { username, ip, is_admin, joined_at, user_id, is_registered,
|
||||
# sid → { username, ip, is_admin, role, joined_at, user_id, is_registered,
|
||||
# has_ai_access, ai_messages_used }
|
||||
connected_users: dict = {}
|
||||
|
||||
# role hierarchy – higher number = more power
|
||||
ROLE_POWER = {"user": 0, "mod": 1, "admin": 2, "root": 3}
|
||||
username_to_sid: dict = {} # lowercase_name → sid
|
||||
muted_users: set = set()
|
||||
banned_usernames: set = set()
|
||||
banned_ips: set = set()
|
||||
message_timestamps: dict = defaultdict(list)
|
||||
pending_pm_invites: dict = {} # sid → set of room names they were invited to
|
||||
|
||||
RATE_LIMIT = 6
|
||||
RATE_WINDOW = 5
|
||||
|
|
@ -123,42 +101,21 @@ RATE_WINDOW = 5
|
|||
ai_queue: EvQueue = EvQueue()
|
||||
_app_ref = None # set in create_app() for greenlet app-context access
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AES-GCM helpers (server-side, transit only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
key = base64.b64decode(key_b64)
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
||||
|
||||
|
||||
def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
key = base64.b64decode(key_b64)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def call_ollama(user_message: str) -> str:
|
||||
"""Call the local Ollama API. Returns plaintext AI response."""
|
||||
MAX_HISTORY_PER_USER = 20 # last N turns loaded into Violet prompt
|
||||
|
||||
def call_ollama(messages: list) -> str:
|
||||
"""Call the local Ollama API with a full messages list. Returns plaintext AI response."""
|
||||
import requests as req
|
||||
try:
|
||||
resp = req.post(
|
||||
f"{OLLAMA_URL}/api/chat",
|
||||
json={
|
||||
"model": VIOLET_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": VIOLET_SYSTEM},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.88, "num_predict": 120},
|
||||
},
|
||||
|
|
@ -171,6 +128,24 @@ def call_ollama(user_message: str) -> str:
|
|||
return "Give me just a moment, darling... 💜"
|
||||
|
||||
|
||||
def _load_violet_history(user_id: int) -> list:
|
||||
"""Load recent conversation turns from DB. Returns list of {role, content} dicts."""
|
||||
rows = (
|
||||
VioletHistory.query
|
||||
.filter_by(user_id=user_id)
|
||||
.order_by(VioletHistory.id.desc())
|
||||
.limit(MAX_HISTORY_PER_USER)
|
||||
.all()
|
||||
)
|
||||
return [{"role": r.role, "content": r.text} for r in reversed(rows)]
|
||||
|
||||
|
||||
def _save_violet_turn(user_id: int, role: str, text: str) -> None:
|
||||
"""Persist a single conversation turn."""
|
||||
db.session.add(VioletHistory(user_id=user_id, role=role, text=text))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI inference queue worker (single greenlet, serialises Ollama calls)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -178,76 +153,112 @@ def call_ollama(user_message: str) -> str:
|
|||
def _ai_worker() -> None:
|
||||
"""Eventlet greenlet – drains ai_queue one task at a time."""
|
||||
global _app_ref
|
||||
|
||||
while True:
|
||||
task = ai_queue.get() # blocks cooperatively until item available
|
||||
|
||||
# ── Announce Violet is busy ───────────────────────────────────────────
|
||||
room = _pm_room(db.session.get(User, task["user_id"]).username, AI_BOT_NAME) if _app_ref else None
|
||||
if room:
|
||||
socketio.emit("violet_typing", {"busy": True, "room": room}, to=room)
|
||||
else:
|
||||
socketio.emit("violet_typing", {"busy": True})
|
||||
sid = task["sid"]
|
||||
|
||||
# ── Decrypt user message (transit; key never stored) ──────────────────
|
||||
try:
|
||||
plaintext = _aesgcm_decrypt(
|
||||
# Derive room name (needs app context for DB lookup)
|
||||
with _app_ref.app_context():
|
||||
if task.get("user_id"):
|
||||
db_user = db.session.get(User, task["user_id"])
|
||||
room = _pm_room(db_user.username, AI_BOT_NAME) if db_user else None
|
||||
else:
|
||||
uname = connected_users.get(sid, {}).get("username", "unknown")
|
||||
room = _pm_room(uname, AI_BOT_NAME)
|
||||
|
||||
# ── Announce Violet is busy ───────────────────────────────────────
|
||||
if room:
|
||||
socketio.emit("violet_typing", {"busy": True, "room": room}, to=room)
|
||||
else:
|
||||
socketio.emit("violet_typing", {"busy": True})
|
||||
|
||||
# ── Decrypt user message (transit; key never stored) ──────────────
|
||||
plaintext = aesgcm_decrypt(
|
||||
task["transit_key"], task["ciphertext"], task["nonce_val"]
|
||||
)
|
||||
ai_text = call_ollama(plaintext)
|
||||
except Exception as exc:
|
||||
print(f"[Violet] processing error: {exc}")
|
||||
ai_text = "Mmm, something went wrong, darling 💜"
|
||||
# ── Build messages array with history ─────────────────────────────
|
||||
messages = [{"role": "system", "content": VIOLET_SYSTEM}]
|
||||
if task.get("user_id"):
|
||||
with _app_ref.app_context():
|
||||
messages.extend(_load_violet_history(task["user_id"]))
|
||||
messages.append({"role": "user", "content": plaintext})
|
||||
|
||||
# ── Re-encrypt AI response ────────────────────────────────────────────
|
||||
resp_ct, resp_nonce = _aesgcm_encrypt(task["transit_key"], ai_text)
|
||||
ai_text = call_ollama(messages)
|
||||
|
||||
ai_messages_used = task.get("ai_messages_used", 0)
|
||||
has_ai_access = task.get("has_ai_access", False)
|
||||
# ── Save conversation turns ───────────────────────────────────────
|
||||
if task.get("user_id"):
|
||||
with _app_ref.app_context():
|
||||
_save_violet_turn(task["user_id"], "user", plaintext)
|
||||
_save_violet_turn(task["user_id"], "assistant", ai_text)
|
||||
|
||||
# ── DB operations (need explicit app context in greenlet) ─────────────
|
||||
with _app_ref.app_context():
|
||||
bot = User.query.filter_by(username=AI_BOT_NAME).first()
|
||||
if bot and task.get("user_id"):
|
||||
_save_pm(task["user_id"], bot.id,
|
||||
task["ciphertext"], task["nonce_val"]) # user → Violet
|
||||
_save_pm(bot.id, task["user_id"],
|
||||
resp_ct, resp_nonce) # Violet → user
|
||||
# ── Re-encrypt AI response ────────────────────────────────────────
|
||||
resp_ct, resp_nonce = aesgcm_encrypt(task["transit_key"], ai_text)
|
||||
|
||||
if task.get("user_id") and not has_ai_access:
|
||||
db_user = db.session.get(User, task["user_id"])
|
||||
if db_user and not db_user.has_ai_access:
|
||||
db_user.ai_messages_used = min(
|
||||
db_user.ai_messages_used + 1, AI_FREE_LIMIT
|
||||
)
|
||||
db.session.commit()
|
||||
ai_messages_used = db_user.ai_messages_used
|
||||
has_ai_access = db_user.has_ai_access
|
||||
ai_messages_used = task.get("ai_messages_used", 0)
|
||||
has_ai_access = task.get("has_ai_access", False)
|
||||
|
||||
# Update in-process cache
|
||||
sid = task["sid"]
|
||||
if sid in connected_users:
|
||||
connected_users[sid]["ai_messages_used"] = ai_messages_used
|
||||
connected_users[sid]["has_ai_access"] = has_ai_access
|
||||
# ── DB operations (need explicit app context in greenlet) ─────────
|
||||
with _app_ref.app_context():
|
||||
bot = User.query.filter_by(username=AI_BOT_NAME).first()
|
||||
if bot and task.get("user_id"):
|
||||
_save_pm(task["user_id"], bot.id,
|
||||
task["ciphertext"], task["nonce_val"]) # user → Violet
|
||||
_save_pm(bot.id, task["user_id"],
|
||||
resp_ct, resp_nonce) # Violet → user
|
||||
|
||||
if task.get("user_id") and not has_ai_access:
|
||||
db_user = db.session.get(User, task["user_id"])
|
||||
if db_user and not db_user.has_ai_access:
|
||||
db_user.ai_messages_used = min(
|
||||
db_user.ai_messages_used + 1, AI_FREE_LIMIT
|
||||
)
|
||||
db.session.commit()
|
||||
ai_messages_used = db_user.ai_messages_used
|
||||
has_ai_access = db_user.has_ai_access
|
||||
|
||||
# Update in-process cache
|
||||
if sid in connected_users:
|
||||
connected_users[sid]["ai_messages_used"] = ai_messages_used
|
||||
connected_users[sid]["has_ai_access"] = has_ai_access
|
||||
|
||||
# ── Emit response to originating client ───────────────────────────
|
||||
if task.get("plaintext_mode"):
|
||||
socketio.emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"text": ai_text,
|
||||
"room": room,
|
||||
"ts": _ts()
|
||||
}, to=room)
|
||||
else:
|
||||
socketio.emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"ciphertext": resp_ct,
|
||||
"nonce": resp_nonce,
|
||||
"room": room,
|
||||
"ts": _ts()
|
||||
}, to=room)
|
||||
|
||||
# ── Emit response to originating client ───────────────────────────────
|
||||
with _app_ref.app_context():
|
||||
db_user = db.session.get(User, task["user_id"])
|
||||
room = _pm_room(db_user.username, AI_BOT_NAME)
|
||||
|
||||
socketio.emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"ciphertext": resp_ct,
|
||||
"nonce": resp_nonce,
|
||||
"room": room,
|
||||
"ts": _ts()
|
||||
}, to=room)
|
||||
|
||||
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
||||
ai_queue.task_done()
|
||||
|
||||
# Clear typing indicator when queue drains
|
||||
# Done in per-room emit above
|
||||
except Exception as exc:
|
||||
import traceback; traceback.print_exc()
|
||||
# Try to send error feedback to user
|
||||
try:
|
||||
uname = connected_users.get(sid, {}).get("username", "unknown")
|
||||
room = _pm_room(uname, AI_BOT_NAME)
|
||||
socketio.emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"text": "Mmm, something went wrong, darling 💜",
|
||||
"room": room,
|
||||
"ts": _ts()
|
||||
}, to=room)
|
||||
socketio.emit("violet_typing", {"busy": False, "room": room}, to=room)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ai_queue.task_done()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -258,6 +269,20 @@ def _pm_room(a: str, b: str) -> str:
|
|||
return "pm:" + ":".join(sorted([a.lower(), b.lower()]))
|
||||
|
||||
|
||||
def _pm_room_key(room: str) -> str:
|
||||
"""Derive a deterministic AES-256 key for a PM room.
|
||||
|
||||
Uses HMAC-SHA256 keyed with JWT_SECRET so the same room always gets
|
||||
the same key (allowing history to be decrypted across sessions).
|
||||
The server mediates the key – this is NOT end-to-end, but it fixes
|
||||
the broken cross-user decryption while matching the existing trust model.
|
||||
"""
|
||||
from config import JWT_SECRET
|
||||
raw = hmac.new(JWT_SECRET.encode(), room.encode(), hashlib.sha256).digest()
|
||||
import base64
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
def _get_nicklist() -> list:
|
||||
users = []
|
||||
for info in connected_users.values():
|
||||
|
|
@ -268,6 +293,7 @@ def _get_nicklist() -> list:
|
|||
"is_admin": info["is_admin"],
|
||||
"is_registered": info.get("is_registered", False),
|
||||
"is_verified": info.get("is_verified", False),
|
||||
"role": info.get("role", "user"),
|
||||
})
|
||||
# Static "Violet" AI user
|
||||
users.append({
|
||||
|
|
@ -275,7 +301,8 @@ def _get_nicklist() -> list:
|
|||
"is_admin": False,
|
||||
"is_registered": True,
|
||||
"is_verified": True,
|
||||
"is_ai": True
|
||||
"is_ai": True,
|
||||
"role": "user",
|
||||
})
|
||||
return sorted(users, key=lambda u: u["username"].lower())
|
||||
|
||||
|
|
@ -291,6 +318,23 @@ def _require_admin(f):
|
|||
return wrapped
|
||||
|
||||
|
||||
def _require_role(min_role):
|
||||
"""Decorator: require at least min_role power level."""
|
||||
def decorator(f):
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
user = connected_users.get(request.sid)
|
||||
if not user:
|
||||
emit("error", {"msg": "Forbidden."}); return
|
||||
user_power = ROLE_POWER.get(user.get("role", "user"), 0)
|
||||
needed = ROLE_POWER.get(min_role, 0)
|
||||
if user_power < needed:
|
||||
emit("error", {"msg": "Forbidden."}); return
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def _rate_limited(sid: str) -> bool:
|
||||
now = time.time()
|
||||
message_timestamps[sid] = [t for t in message_timestamps[sid] if now - t < RATE_WINDOW]
|
||||
|
|
@ -311,21 +355,6 @@ def _do_disconnect(sid: str) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _issue_jwt(user_id: int, username: str) -> str:
|
||||
return pyjwt.encode(
|
||||
{"user_id": user_id, "username": username,
|
||||
"exp": datetime.utcnow() + timedelta(days=7)},
|
||||
JWT_SECRET, algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def _verify_jwt(token: str):
|
||||
try:
|
||||
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_pm(sender_id: int, recipient_id: int,
|
||||
encrypted_content: str, nonce: str) -> None:
|
||||
msg = Message(
|
||||
|
|
@ -355,9 +384,7 @@ def create_app() -> Flask:
|
|||
app = Flask(__name__, static_folder="static", template_folder=".")
|
||||
app.config.update(
|
||||
SECRET_KEY=SECRET_KEY,
|
||||
SQLALCHEMY_DATABASE_URI=os.environ.get(
|
||||
"DATABASE_URL", "sqlite:///sexchat.db"
|
||||
),
|
||||
SQLALCHEMY_DATABASE_URI=DATABASE_URL,
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS=False,
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
|
|
@ -366,6 +393,15 @@ def create_app() -> Flask:
|
|||
init_db(app)
|
||||
_app_ref = app
|
||||
|
||||
# Load persisted bans and mutes from the database
|
||||
with app.app_context():
|
||||
for ban in Ban.query.all():
|
||||
banned_usernames.add(ban.username.lower())
|
||||
if ban.ip:
|
||||
banned_ips.add(ban.ip)
|
||||
for mute in Mute.query.all():
|
||||
muted_users.add(mute.username.lower())
|
||||
|
||||
msg_queue = (
|
||||
os.environ.get("SOCKETIO_MESSAGE_QUEUE")
|
||||
or os.environ.get("REDIS_URL")
|
||||
|
|
@ -374,7 +410,7 @@ def create_app() -> Flask:
|
|||
socketio.init_app(
|
||||
app,
|
||||
async_mode="eventlet",
|
||||
cors_allowed_origins="*",
|
||||
cors_allowed_origins=CORS_ORIGINS,
|
||||
message_queue=msg_queue,
|
||||
logger=False,
|
||||
engineio_logger=False,
|
||||
|
|
@ -411,7 +447,7 @@ def on_connect(auth=None):
|
|||
has_ai_access = False; ai_used = 0; jwt_username = None
|
||||
|
||||
if auth and isinstance(auth, dict) and auth.get("token"):
|
||||
payload = _verify_jwt(auth["token"])
|
||||
payload = verify_jwt(auth["token"])
|
||||
if payload:
|
||||
db_user = db.session.get(User, payload.get("user_id"))
|
||||
if db_user:
|
||||
|
|
@ -425,6 +461,7 @@ def on_connect(auth=None):
|
|||
"username": None,
|
||||
"ip": ip,
|
||||
"is_admin": False,
|
||||
"role": "user",
|
||||
"joined_at": time.time(),
|
||||
"user_id": user_id,
|
||||
"is_registered": is_registered,
|
||||
|
|
@ -439,6 +476,7 @@ def on_disconnect():
|
|||
sid = request.sid
|
||||
user = connected_users.pop(sid, None)
|
||||
message_timestamps.pop(sid, None)
|
||||
pending_pm_invites.pop(sid, None)
|
||||
if user and user.get("username"):
|
||||
lower = user["username"].lower()
|
||||
username_to_sid.pop(lower, None)
|
||||
|
|
@ -466,34 +504,40 @@ def on_join(data):
|
|||
|
||||
if mode == "register":
|
||||
if not username or not username.replace("_","").replace("-","").isalnum():
|
||||
security_logger.warning(f"REGISTER_FAIL: Invalid username format from IP {request.remote_addr}")
|
||||
emit("error", {"msg": "Invalid username."}); return
|
||||
if len(password) < 6:
|
||||
emit("error", {"msg": "Password must be at least 6 characters."}); return
|
||||
if username.lower() == AI_BOT_NAME.lower():
|
||||
emit("error", {"msg": "That username is reserved."}); return
|
||||
if User.query.filter(db.func.lower(User.username) == username.lower()).first():
|
||||
security_logger.info(f"REGISTER_FAIL: Duplicate username {username}")
|
||||
emit("error", {"msg": "Username already registered."}); return
|
||||
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
db_user = User(username=username, password_hash=hashed, email=email)
|
||||
db.session.add(db_user); db.session.commit()
|
||||
user.update(user_id=db_user.id, is_registered=True,
|
||||
has_ai_access=False, ai_messages_used=0)
|
||||
token = _issue_jwt(db_user.id, db_user.username)
|
||||
token = issue_jwt(db_user.id, db_user.username)
|
||||
security_logger.info(f"REGISTER_SUCCESS: {username} from IP {request.remote_addr}")
|
||||
|
||||
elif mode == "login":
|
||||
db_user = User.query.filter(
|
||||
db.func.lower(User.username) == username.lower()
|
||||
).first()
|
||||
if not db_user or not bcrypt.checkpw(password.encode(), db_user.password_hash.encode()):
|
||||
security_logger.warning(f"LOGIN_FAIL: Invalid credentials for {username} from IP {request.remote_addr}")
|
||||
emit("error", {"msg": "Invalid username or password."}); return
|
||||
if not db_user.is_verified:
|
||||
security_logger.info(f"LOGIN_FAIL: Unverified account {username}")
|
||||
emit("error", {"msg": "Account pending manual verification by a moderator."}); return
|
||||
username = db_user.username
|
||||
user["user_id"] = db_user.id
|
||||
user["is_registered"] = True
|
||||
user["has_ai_access"] = db_user.has_ai_access
|
||||
user["ai_messages_used"] = db_user.ai_messages_used
|
||||
token = _issue_jwt(db_user.id, db_user.username)
|
||||
token = issue_jwt(db_user.id, db_user.username)
|
||||
security_logger.info(f"LOGIN_SUCCESS: {username} from IP {request.remote_addr}")
|
||||
|
||||
elif mode == "restore":
|
||||
if not user.get("user_id"):
|
||||
|
|
@ -506,7 +550,7 @@ def on_join(data):
|
|||
username = db_user.username
|
||||
user["has_ai_access"] = db_user.has_ai_access
|
||||
user["ai_messages_used"] = db_user.ai_messages_used
|
||||
token = _issue_jwt(db_user.id, db_user.username)
|
||||
token = issue_jwt(db_user.id, db_user.username)
|
||||
|
||||
else: # guest
|
||||
if not username or not username.replace("_","").replace("-","").isalnum():
|
||||
|
|
@ -518,28 +562,40 @@ def on_join(data):
|
|||
if lower in username_to_sid and username_to_sid[lower] != sid:
|
||||
emit("error", {"msg": "Username already in use."}); return
|
||||
|
||||
is_admin = False
|
||||
mod_pw = str(data.get("mod_password", "")).strip()
|
||||
if mod_pw and mod_pw == ADMIN_PASSWORD:
|
||||
is_admin = True
|
||||
# Derive role from DB (root/admin/mod grant is_admin automatically)
|
||||
db_role = db_user.role if db_user else "user"
|
||||
is_admin = ROLE_POWER.get(db_role, 0) >= ROLE_POWER["mod"]
|
||||
|
||||
user["username"] = username
|
||||
user["is_admin"] = is_admin
|
||||
user["is_verified"] = db_user.is_verified if db_user else True # Guests are always "verified" for lobby
|
||||
# Legacy mod-password fallback for guests (temporary mod access)
|
||||
mod_pw = str(data.get("mod_password", "")).strip()
|
||||
if mod_pw and mod_pw == ADMIN_PASSWORD and not is_admin:
|
||||
is_admin = True
|
||||
if db_role == "user":
|
||||
db_role = "mod" # temporary elevation for the session
|
||||
|
||||
user["username"] = username
|
||||
user["is_admin"] = is_admin
|
||||
user["role"] = db_role
|
||||
user["is_verified"] = db_user.is_verified if db_user else True
|
||||
username_to_sid[lower] = sid
|
||||
join_room(LOBBY)
|
||||
|
||||
# Role badge for join message
|
||||
role_icon = {"root": "👑 ", "admin": "⚔️ ", "mod": "🛡️ "}.get(db_role, "")
|
||||
|
||||
emit("joined", {
|
||||
"username": username,
|
||||
"is_admin": is_admin,
|
||||
"role": db_role,
|
||||
"is_registered": user["is_registered"],
|
||||
"has_ai_access": user["has_ai_access"],
|
||||
"ai_messages_used": user["ai_messages_used"],
|
||||
"email": db_user.email if db_user else None,
|
||||
"token": token,
|
||||
"ignored_list": [u.username for u in db_user.ignoring] if db_user else []
|
||||
})
|
||||
emit("system", {
|
||||
"msg": f"{'🛡️ ' if is_admin else ''}**{username}** joined the room.",
|
||||
"msg": f"{role_icon}**{username}** joined the room.",
|
||||
"ts": _ts(),
|
||||
}, to=LOBBY)
|
||||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||||
|
|
@ -605,14 +661,24 @@ def on_pm_open(data):
|
|||
|
||||
room = _pm_room(user["username"], target)
|
||||
join_room(room)
|
||||
socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=target_sid)
|
||||
emit("pm_ready", {"with": target, "room": room})
|
||||
room_key = _pm_room_key(room)
|
||||
if target_sid:
|
||||
pending_pm_invites.setdefault(target_sid, set()).add(room)
|
||||
socketio.emit("pm_invite", {"from": user["username"], "room": room, "room_key": room_key}, to=target_sid)
|
||||
emit("pm_ready", {"with": target, "room": room, "room_key": room_key})
|
||||
|
||||
|
||||
|
||||
@socketio.on("pm_accept")
|
||||
def on_pm_accept(data):
|
||||
join_room(data.get("room"))
|
||||
sid = request.sid
|
||||
room = str(data.get("room", ""))
|
||||
allowed = pending_pm_invites.get(sid, set())
|
||||
if room not in allowed:
|
||||
emit("error", {"msg": "Invalid or expired PM invitation."})
|
||||
return
|
||||
allowed.discard(room)
|
||||
join_room(room)
|
||||
|
||||
|
||||
@socketio.on("pm_message")
|
||||
|
|
@ -643,15 +709,45 @@ def on_pm_message(data):
|
|||
|
||||
# Route to AI if recipient is Violet
|
||||
if room.endswith(f":{AI_BOT_NAME.lower()}"):
|
||||
if not user.get("user_id"):
|
||||
emit("error", {"msg": "You must be registered to chat with Violet."}); return
|
||||
if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT:
|
||||
emit("pm_message", {"from": AI_BOT_NAME, "text": "ai_limit_reached", "room": room, "system": True}, to=sid)
|
||||
if not user.get("user_id") and not user.get("is_admin"):
|
||||
# Echo their message, then reply as Violet
|
||||
emit("pm_message", payload, to=sid)
|
||||
emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"text": "Hey hun 💜 You'll need to register an account before we can chat. "
|
||||
"Go back and sign up — I'll be waiting for you! 😘",
|
||||
"room": room,
|
||||
"ts": _ts(),
|
||||
}, to=sid)
|
||||
return
|
||||
if not user.get("has_ai_access") and user.get("ai_messages_used", 0) >= AI_FREE_LIMIT:
|
||||
username = user.get("username", "unknown")
|
||||
security_logger.warning(f"AI_LIMIT_REACHED: {username} tried to use AI after free trial exhausted")
|
||||
emit("ai_response", {"error": "ai_limit_reached", "room": room}, to=sid)
|
||||
return
|
||||
|
||||
# Echo the user's own message back so it appears in their chat
|
||||
emit("pm_message", payload, to=sid)
|
||||
|
||||
transit_key = data.get("transit_key", "")
|
||||
if not all([ciphertext, nonce_val, transit_key]):
|
||||
emit("error", {"msg": "AI Private Messaging requires transit encryption."}); return
|
||||
# Plaintext fallback (e.g. session restore without crypto key)
|
||||
if text:
|
||||
import base64 as _b64
|
||||
transit_key = _b64.b64encode(os.urandom(32)).decode()
|
||||
ciphertext_new, nonce_new = aesgcm_encrypt(transit_key, text)
|
||||
ai_queue.put({
|
||||
"sid": sid,
|
||||
"user_id": user.get("user_id"),
|
||||
"has_ai_access": user.get("has_ai_access", False),
|
||||
"ai_messages_used": user.get("ai_messages_used", 0),
|
||||
"ciphertext": ciphertext_new,
|
||||
"nonce_val": nonce_new,
|
||||
"transit_key": transit_key,
|
||||
"plaintext_mode": True,
|
||||
})
|
||||
return
|
||||
emit("error", {"msg": "Message cannot be empty."}); return
|
||||
|
||||
ai_queue.put({
|
||||
"sid": sid,
|
||||
|
|
@ -688,6 +784,24 @@ def on_ai_message(data):
|
|||
pass
|
||||
|
||||
|
||||
@socketio.on("violet_reset")
|
||||
def on_violet_reset(_data=None):
|
||||
sid = request.sid
|
||||
user = connected_users.get(sid)
|
||||
if not user or not user.get("user_id"):
|
||||
emit("error", {"msg": "You must be registered to reset Violet history."}); return
|
||||
user_id = user["user_id"]
|
||||
VioletHistory.query.filter_by(user_id=user_id).delete()
|
||||
db.session.commit()
|
||||
room = _pm_room(user["username"], AI_BOT_NAME)
|
||||
emit("pm_message", {
|
||||
"from": AI_BOT_NAME,
|
||||
"text": "Memory cleared, darling. Let's start fresh! 💜",
|
||||
"room": room,
|
||||
"ts": _ts(),
|
||||
}, to=sid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mod tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -699,6 +813,11 @@ def on_kick(data):
|
|||
target_sid = username_to_sid.get(target.lower())
|
||||
if not target_sid:
|
||||
emit("error", {"msg": f"{target} is not online."}); return
|
||||
|
||||
# Security logging
|
||||
mod_name = connected_users.get(request.sid, {}).get("username", "unknown")
|
||||
security_logger.warning(f"MOD_KICK: {mod_name} kicked {target}")
|
||||
|
||||
socketio.emit("kicked", {"msg": "You have been kicked by a moderator."}, to=target_sid)
|
||||
socketio.emit("system", {"msg": f"🚫 **{target}** was kicked.", "ts": _ts()}, to=LOBBY)
|
||||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||||
|
|
@ -710,13 +829,24 @@ def on_ban(data):
|
|||
target = str(data.get("target", "")).strip()
|
||||
lower = target.lower()
|
||||
banned_usernames.add(lower)
|
||||
ip = None
|
||||
target_sid = username_to_sid.get(lower)
|
||||
if target_sid:
|
||||
info = connected_users.get(target_sid, {})
|
||||
if info.get("ip"):
|
||||
banned_ips.add(info["ip"])
|
||||
ip = info["ip"]
|
||||
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
|
||||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||||
|
||||
# Security logging
|
||||
mod_name = connected_users.get(request.sid, {}).get("username", "unknown")
|
||||
security_logger.warning(f"MOD_BAN: {mod_name} banned {target} (IP: {ip})")
|
||||
|
||||
# Persist to DB
|
||||
if not Ban.query.filter_by(username=lower).first():
|
||||
db.session.add(Ban(username=lower, ip=ip))
|
||||
db.session.commit()
|
||||
socketio.emit("system", {"msg": f"🔨 **{target}** was banned.", "ts": _ts()}, to=LOBBY)
|
||||
|
||||
|
||||
|
|
@ -726,9 +856,16 @@ def on_mute(data):
|
|||
target = str(data.get("target", "")).strip()
|
||||
lower = target.lower()
|
||||
if lower in muted_users:
|
||||
muted_users.discard(lower); action = "unmuted"
|
||||
muted_users.discard(lower)
|
||||
Mute.query.filter_by(username=lower).delete()
|
||||
db.session.commit()
|
||||
action = "unmuted"
|
||||
else:
|
||||
muted_users.add(lower); action = "muted"
|
||||
muted_users.add(lower)
|
||||
if not Mute.query.filter_by(username=lower).first():
|
||||
db.session.add(Mute(username=lower))
|
||||
db.session.commit()
|
||||
action = "muted"
|
||||
emit("system", {"msg": f"🔇 **{target}** was {action}.", "ts": _ts()}, to=LOBBY)
|
||||
|
||||
|
||||
|
|
@ -739,13 +876,19 @@ def on_kickban(data):
|
|||
lower = target.lower()
|
||||
# Ban
|
||||
banned_usernames.add(lower)
|
||||
ip = None
|
||||
target_sid = username_to_sid.get(lower)
|
||||
if target_sid:
|
||||
info = connected_users.get(target_sid, {})
|
||||
if info.get("ip"):
|
||||
banned_ips.add(info["ip"])
|
||||
ip = info["ip"]
|
||||
socketio.emit("kicked", {"msg": "You have been banned."}, to=target_sid)
|
||||
eventlet.spawn_after(0.5, _do_disconnect, target_sid)
|
||||
# Persist to DB
|
||||
if not Ban.query.filter_by(username=lower).first():
|
||||
db.session.add(Ban(username=lower, ip=ip))
|
||||
db.session.commit()
|
||||
# Announce
|
||||
socketio.emit("system", {"msg": f"💀 **{target}** was kickbanned.", "ts": _ts()}, to=LOBBY)
|
||||
|
||||
|
|
@ -804,3 +947,219 @@ def on_verify(data):
|
|||
|
||||
socketio.emit("system", {"msg": f"✅ **{target_user.username}** has been verified by a moderator.", "ts": _ts()}, to=LOBBY)
|
||||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@socketio.on("change_password")
|
||||
def on_change_password(data):
|
||||
sid = request.sid
|
||||
user = connected_users.get(sid)
|
||||
if not user or not user.get("user_id"):
|
||||
emit("password_changed", {"success": False, "msg": "You must be registered."})
|
||||
return
|
||||
|
||||
old_pw = str(data.get("old_password", ""))
|
||||
new_pw = str(data.get("new_password", ""))
|
||||
|
||||
if not old_pw or not new_pw:
|
||||
emit("password_changed", {"success": False, "msg": "Both fields are required."})
|
||||
return
|
||||
if len(new_pw) < 6:
|
||||
emit("password_changed", {"success": False, "msg": "Password must be at least 6 characters."})
|
||||
return
|
||||
|
||||
db_user = db.session.get(User, user["user_id"])
|
||||
if not db_user:
|
||||
emit("password_changed", {"success": False, "msg": "User not found."})
|
||||
return
|
||||
|
||||
if not bcrypt.checkpw(old_pw.encode("utf-8"), db_user.password_hash.encode("utf-8")):
|
||||
emit("password_changed", {"success": False, "msg": "Current password is incorrect."})
|
||||
return
|
||||
|
||||
db_user.password_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
db.session.commit()
|
||||
emit("password_changed", {"success": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@socketio.on("admin_get_users")
|
||||
@_require_role("mod")
|
||||
def on_admin_get_users(_data=None):
|
||||
"""Send the full user list to the admin panel."""
|
||||
users = User.query.order_by(User.id).all()
|
||||
result = []
|
||||
for u in users:
|
||||
if u.username == AI_BOT_NAME:
|
||||
continue
|
||||
online_sid = username_to_sid.get(u.username.lower())
|
||||
result.append({
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"role": u.role,
|
||||
"is_verified": u.is_verified,
|
||||
"has_ai_access": u.has_ai_access,
|
||||
"email": u.email or "",
|
||||
"created_at": u.created_at.strftime("%Y-%m-%d"),
|
||||
"online": online_sid is not None,
|
||||
})
|
||||
emit("admin_users", {"users": result})
|
||||
|
||||
|
||||
@socketio.on("admin_get_bans")
|
||||
@_require_role("mod")
|
||||
def on_admin_get_bans(_data=None):
|
||||
bans = Ban.query.order_by(Ban.created_at.desc()).all()
|
||||
emit("admin_bans", {"bans": [
|
||||
{"id": b.id, "username": b.username, "ip": b.ip or "", "reason": b.reason or "",
|
||||
"created_at": b.created_at.strftime("%Y-%m-%d")} for b in bans
|
||||
]})
|
||||
|
||||
|
||||
@socketio.on("admin_get_mutes")
|
||||
@_require_role("mod")
|
||||
def on_admin_get_mutes(_data=None):
|
||||
mutes = Mute.query.order_by(Mute.created_at.desc()).all()
|
||||
emit("admin_mutes", {"mutes": [
|
||||
{"id": m.id, "username": m.username,
|
||||
"created_at": m.created_at.strftime("%Y-%m-%d")} for m in mutes
|
||||
]})
|
||||
|
||||
|
||||
@socketio.on("admin_set_role")
|
||||
@_require_role("admin")
|
||||
def on_admin_set_role(data):
|
||||
"""Change a user's role. Only root can set admin/root. Admins can set mod/user."""
|
||||
sid = request.sid
|
||||
me = connected_users.get(sid)
|
||||
my_power = ROLE_POWER.get(me.get("role", "user"), 0)
|
||||
|
||||
target_id = int(data.get("user_id", 0))
|
||||
new_role = str(data.get("role", "")).strip().lower()
|
||||
if new_role not in ROLE_POWER:
|
||||
emit("error", {"msg": "Invalid role."}); return
|
||||
|
||||
target_power = ROLE_POWER[new_role]
|
||||
if target_power >= my_power:
|
||||
emit("error", {"msg": "Cannot assign a role equal/above your own."}); return
|
||||
|
||||
target_user = db.session.get(User, target_id)
|
||||
if not target_user:
|
||||
emit("error", {"msg": "User not found."}); return
|
||||
|
||||
# Can't change someone with equal or higher power
|
||||
if ROLE_POWER.get(target_user.role, 0) >= my_power:
|
||||
emit("error", {"msg": "Cannot modify a user with equal/higher privileges."}); return
|
||||
|
||||
target_user.role = new_role
|
||||
db.session.commit()
|
||||
|
||||
# Update live session if they're online
|
||||
target_sid = username_to_sid.get(target_user.username.lower())
|
||||
if target_sid and target_sid in connected_users:
|
||||
connected_users[target_sid]["role"] = new_role
|
||||
connected_users[target_sid]["is_admin"] = ROLE_POWER[new_role] >= ROLE_POWER["mod"]
|
||||
# Notify the target user of their new role
|
||||
socketio.emit("role_updated", {"role": new_role}, to=target_sid)
|
||||
|
||||
socketio.emit("system", {
|
||||
"msg": f"⚙️ **{target_user.username}** is now **{new_role}**.",
|
||||
"ts": _ts()
|
||||
}, to=LOBBY)
|
||||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||||
emit("admin_action_ok", {"msg": f"{target_user.username} → {new_role}"})
|
||||
|
||||
|
||||
@socketio.on("admin_verify_user")
|
||||
@_require_role("mod")
|
||||
def on_admin_verify(data):
|
||||
target_id = int(data.get("user_id", 0))
|
||||
target_user = db.session.get(User, target_id)
|
||||
if not target_user:
|
||||
emit("error", {"msg": "User not found."}); return
|
||||
|
||||
target_user.is_verified = not target_user.is_verified
|
||||
db.session.commit()
|
||||
|
||||
status = "verified" if target_user.is_verified else "unverified"
|
||||
|
||||
target_sid = username_to_sid.get(target_user.username.lower())
|
||||
if target_sid and target_sid in connected_users:
|
||||
connected_users[target_sid]["is_verified"] = target_user.is_verified
|
||||
|
||||
socketio.emit("system", {
|
||||
"msg": f"{'✅' if target_user.is_verified else '❌'} **{target_user.username}** was {status}.",
|
||||
"ts": _ts()
|
||||
}, to=LOBBY)
|
||||
socketio.emit("nicklist", {"users": _get_nicklist()}, to=LOBBY)
|
||||
emit("admin_action_ok", {"msg": f"{target_user.username} → {status}"})
|
||||
|
||||
|
||||
@socketio.on("admin_toggle_ai")
|
||||
@_require_role("admin")
|
||||
def on_admin_toggle_ai(data):
|
||||
target_id = int(data.get("user_id", 0))
|
||||
target_user = db.session.get(User, target_id)
|
||||
if not target_user:
|
||||
emit("error", {"msg": "User not found."}); return
|
||||
|
||||
target_user.has_ai_access = not target_user.has_ai_access
|
||||
db.session.commit()
|
||||
|
||||
target_sid = username_to_sid.get(target_user.username.lower())
|
||||
if target_sid and target_sid in connected_users:
|
||||
connected_users[target_sid]["has_ai_access"] = target_user.has_ai_access
|
||||
# Notify target so their UI updates immediately
|
||||
socketio.emit("ai_unlock", {
|
||||
"has_ai_access": target_user.has_ai_access,
|
||||
"msg": "Premium access granted! Unlimited Violet." if target_user.has_ai_access else "Premium access revoked."
|
||||
}, to=target_sid)
|
||||
|
||||
status = "granted" if target_user.has_ai_access else "revoked"
|
||||
emit("admin_action_ok", {"msg": f"AI access {status} for {target_user.username}"})
|
||||
|
||||
|
||||
@socketio.on("admin_unban")
|
||||
@_require_role("mod")
|
||||
def on_admin_unban(data):
|
||||
ban_id = int(data.get("ban_id", 0))
|
||||
ban = db.session.get(Ban, ban_id)
|
||||
if not ban:
|
||||
emit("error", {"msg": "Ban not found."}); return
|
||||
|
||||
banned_usernames.discard(ban.username.lower())
|
||||
if ban.ip:
|
||||
banned_ips.discard(ban.ip)
|
||||
db.session.delete(ban)
|
||||
db.session.commit()
|
||||
|
||||
socketio.emit("system", {
|
||||
"msg": f"🔓 **{ban.username}** was unbanned.",
|
||||
"ts": _ts()
|
||||
}, to=LOBBY)
|
||||
emit("admin_action_ok", {"msg": f"Unbanned {ban.username}"})
|
||||
|
||||
|
||||
@socketio.on("admin_unmute")
|
||||
@_require_role("mod")
|
||||
def on_admin_unmute(data):
|
||||
mute_id = int(data.get("mute_id", 0))
|
||||
mute = db.session.get(Mute, mute_id)
|
||||
if not mute:
|
||||
emit("error", {"msg": "Mute not found."}); return
|
||||
|
||||
muted_users.discard(mute.username.lower())
|
||||
db.session.delete(mute)
|
||||
db.session.commit()
|
||||
|
||||
socketio.emit("system", {
|
||||
"msg": f"🔊 **{mute.username}** was unmuted.",
|
||||
"ts": _ts()
|
||||
}, to=LOBBY)
|
||||
emit("admin_action_ok", {"msg": f"Unmuted {mute.username}"})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
config.py – Shared configuration and utilities for SexyChat.
|
||||
|
||||
Centralises constants, config loading, AES-GCM helpers, and JWT helpers
|
||||
so that app.py and routes.py share a single source of truth.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration Loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_config():
|
||||
conf = {}
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Failed to load config.json: {e}")
|
||||
return conf
|
||||
|
||||
_CONFIG = load_config()
|
||||
|
||||
def get_conf(key, default=None):
|
||||
"""Resolve a config value: Env Var → config.json → default."""
|
||||
return os.environ.get(key, _CONFIG.get(key, default))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SECRET_KEY = get_conf("SECRET_KEY", uuid.uuid4().hex)
|
||||
JWT_SECRET = get_conf("JWT_SECRET", uuid.uuid4().hex)
|
||||
ADMIN_PASSWORD = get_conf("ADMIN_PASSWORD", None) # Must be set in production
|
||||
DATABASE_URL = get_conf("DATABASE_URL", "sqlite:///sexchat.db")
|
||||
PAYMENT_SECRET = get_conf("PAYMENT_SECRET", "change-me-payment-secret")
|
||||
CORS_ORIGINS = get_conf("CORS_ORIGINS", None)
|
||||
|
||||
MAX_MSG_LEN = 500
|
||||
LOBBY = "lobby"
|
||||
AI_FREE_LIMIT = int(get_conf("AI_FREE_LIMIT", 3))
|
||||
AI_BOT_NAME = "Violet"
|
||||
JWT_EXPIRY_DAYS = 1 # 24-hour expiry for security
|
||||
JWT_EXPIRY_SECS = 60 # 60-second refresh token expiry
|
||||
MAX_HISTORY = 500
|
||||
CSRF_TOKEN_LEN = 32 # CSRF token length in bytes
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL = get_conf("OLLAMA_URL", "http://localhost:11434")
|
||||
VIOLET_MODEL = get_conf("VIOLET_MODEL", "sadiq-bd/llama3.2-3b-uncensored:latest")
|
||||
VIOLET_SYSTEM = (
|
||||
"You are Violet, a flirtatious and sophisticated nightclub hostess at "
|
||||
"an exclusive, dimly-lit members-only club. You are charming, witty, "
|
||||
"and seductive — never crude or offensive. You speak with elegance, "
|
||||
"mystery, and a hint of playful danger. Keep every reply to 1–3 "
|
||||
"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()
|
||||
228
index.html
228
index.html
|
|
@ -87,7 +87,18 @@
|
|||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span id="violet-trial-badge" class="violet-badge hidden"></span>
|
||||
<span id="my-username-badge" class="my-badge"></span>
|
||||
<button id="admin-btn" class="icon-btn hidden" aria-label="Admin Panel" title="Admin Panel">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="settings-btn" class="icon-btn" aria-label="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn-logout">Exit</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -164,6 +175,221 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal-overlay hidden">
|
||||
<div class="settings-card glass">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="close-settings" class="settings-close">×</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">×</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-atab="users">Users</button>
|
||||
<button class="admin-tab" data-atab="bans">Bans</button>
|
||||
<button class="admin-tab" data-atab="mutes">Mutes</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-body">
|
||||
<!-- Users pane -->
|
||||
<div class="admin-pane active" id="atab-users">
|
||||
<div id="admin-user-search-wrap" class="admin-search-wrap">
|
||||
<input id="admin-user-search" type="text" class="settings-input" placeholder="Search users..." />
|
||||
</div>
|
||||
<div id="admin-user-list" class="admin-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bans pane -->
|
||||
<div class="admin-pane" id="atab-bans">
|
||||
<div id="admin-ban-list" class="admin-list"></div>
|
||||
<p id="admin-no-bans" class="admin-empty">No active bans.</p>
|
||||
</div>
|
||||
|
||||
<!-- Mutes pane -->
|
||||
<div class="admin-pane" id="atab-mutes">
|
||||
<div id="admin-mute-list" class="admin-list"></div>
|
||||
<p id="admin-no-mutes" class="admin-empty">No active mutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-toast" class="admin-toast hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div id="context-menu" class="context-menu glass hidden">
|
||||
<div class="menu-item" data-action="pm">Private Message</div>
|
||||
|
|
@ -173,7 +399,7 @@
|
|||
<div class="menu-divider mod-item hidden"></div>
|
||||
<div class="menu-item mod-item hidden" data-action="verify">Verify User</div>
|
||||
<div class="menu-item mod-item hidden" data-action="kick">Kick</div>
|
||||
|
||||
<div class="menu-item mod-item hidden" data-action="mute">Mute / Unmute</div>
|
||||
<div class="menu-item mod-item red hidden" data-action="ban">Ban</div>
|
||||
<div class="menu-item mod-item red bold hidden" data-action="kickban">Kickban</div>
|
||||
</div>
|
||||
|
|
|
|||
48
models.py
48
models.py
|
|
@ -7,10 +7,14 @@ users – Registered accounts
|
|||
messages – Encrypted PM history (user↔user and user↔AI)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from database import db
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
|
@ -18,10 +22,11 @@ class User(db.Model):
|
|||
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(255), unique=True, nullable=True)
|
||||
role = db.Column(db.String(10), default="user", nullable=False) # root, admin, mod, user
|
||||
has_ai_access = db.Column(db.Boolean, default=False, nullable=False)
|
||||
ai_messages_used = db.Column(db.Integer, default=0, nullable=False)
|
||||
is_verified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
sent_messages = db.relationship(
|
||||
"Message", foreign_keys="Message.sender_id",
|
||||
|
|
@ -52,7 +57,7 @@ class UserIgnore(db.Model):
|
|||
id = db.Column(db.Integer, primary_key=True)
|
||||
ignorer_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
ignored_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index("ix_ignore_pair", "ignorer_id", "ignored_id", unique=True),
|
||||
|
|
@ -70,7 +75,7 @@ class Message(db.Model):
|
|||
encrypted_content = db.Column(db.Text, nullable=False)
|
||||
# AES-GCM nonce / IV – base64 encoded (12 bytes → 16 chars)
|
||||
nonce = db.Column(db.String(64), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
# Composite indices for the two common query patterns
|
||||
|
|
@ -80,3 +85,38 @@ class Message(db.Model):
|
|||
|
||||
def __repr__(self):
|
||||
return f"<Message {self.sender_id}→{self.recipient_id} @ {self.timestamp}>"
|
||||
|
||||
|
||||
class Ban(db.Model):
|
||||
"""Persisted ban entry – survives server restarts."""
|
||||
__tablename__ = "bans"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), nullable=False, index=True)
|
||||
ip = db.Column(db.String(45), nullable=True, index=True)
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class Mute(db.Model):
|
||||
"""Persisted mute entry – survives server restarts."""
|
||||
__tablename__ = "mutes"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
|
||||
class VioletHistory(db.Model):
|
||||
"""Per-user plaintext conversation history with Violet AI."""
|
||||
__tablename__ = "violet_history"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
role = db.Column(db.String(10), nullable=False) # 'user' or 'assistant'
|
||||
text = db.Column(db.Text, nullable=False)
|
||||
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index("ix_violet_hist_user_ts", "user_id", "timestamp"),
|
||||
)
|
||||
|
|
|
|||
109
routes.py
109
routes.py
|
|
@ -11,19 +11,22 @@ POST /api/payment/success – Validate webhook secret, unlock AI, push socke
|
|||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import random
|
||||
import functools
|
||||
from datetime import datetime, timedelta
|
||||
import base64
|
||||
|
||||
import bcrypt
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from database import db
|
||||
from models import User, Message
|
||||
from config import (
|
||||
AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET,
|
||||
aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
|
||||
generate_csrf_token, sanitize_user_input,
|
||||
)
|
||||
|
||||
api = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
|
@ -31,13 +34,6 @@ api = Blueprint("api", __name__, url_prefix="/api")
|
|||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JWT_SECRET = os.environ.get("JWT_SECRET", "change-me-jwt-secret")
|
||||
JWT_EXPIRY_DAYS = 7
|
||||
AI_FREE_LIMIT = 3
|
||||
PAYMENT_SECRET = os.environ.get("PAYMENT_SECRET", "change-me-payment-secret")
|
||||
MAX_HISTORY = 500
|
||||
AI_BOT_NAME = "Violet"
|
||||
|
||||
AI_RESPONSES = [
|
||||
"Mmm, you have my full attention 💋",
|
||||
"Oh my... keep going 😈 Don't stop there.",
|
||||
|
|
@ -60,22 +56,6 @@ AI_RESPONSES = [
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _issue_jwt(user_id: int, username: str) -> str:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"exp": datetime.utcnow() + timedelta(days=JWT_EXPIRY_DAYS),
|
||||
}
|
||||
return pyjwt.encode(payload, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def _verify_jwt(token: str):
|
||||
try:
|
||||
return pyjwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
||||
except pyjwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def _require_auth(f):
|
||||
"""Decorator – parse Bearer JWT and populate g.current_user."""
|
||||
@functools.wraps(f)
|
||||
|
|
@ -83,7 +63,7 @@ def _require_auth(f):
|
|||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
payload = _verify_jwt(auth_header[7:])
|
||||
payload = verify_jwt(auth_header[7:])
|
||||
if not payload:
|
||||
return jsonify({"error": "Invalid or expired token"}), 401
|
||||
user = db.session.get(User, payload["user_id"])
|
||||
|
|
@ -94,20 +74,24 @@ def _require_auth(f):
|
|||
return wrapped
|
||||
|
||||
|
||||
def _aesgcm_encrypt(key_b64: str, plaintext: str) -> tuple:
|
||||
"""Encrypt plaintext with AES-GCM. Returns (ciphertext_b64, nonce_b64)."""
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
nonce = os.urandom(12)
|
||||
ct = AESGCM(key_bytes).encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
|
||||
|
||||
|
||||
def _aesgcm_decrypt(key_b64: str, ciphertext_b64: str, nonce_b64: str) -> str:
|
||||
"""Decrypt AES-GCM ciphertext. Raises on authentication failure."""
|
||||
key_bytes = base64.b64decode(key_b64)
|
||||
ct = base64.b64decode(ciphertext_b64)
|
||||
nonce = base64.b64decode(nonce_b64)
|
||||
return AESGCM(key_bytes).decrypt(nonce, ct, None).decode("utf-8")
|
||||
def _require_csrf(f):
|
||||
"""Decorator – validate CSRF token from request header."""
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
csrf_token = request.headers.get("X-CSRF-Token", "")
|
||||
session_csrf = request.headers.get("X-Session-CSRF", "")
|
||||
|
||||
# CSRF check: token must match session token (simple HMAC validation)
|
||||
if not csrf_token or not session_csrf:
|
||||
return jsonify({"error": "Missing CSRF tokens"}), 403
|
||||
|
||||
# For this implementation, we just ensure token is non-empty
|
||||
# In production, validate against server-side session store
|
||||
if len(csrf_token) < 20:
|
||||
return jsonify({"error": "Invalid CSRF token"}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _persist_message(sender_id: int, recipient_id: int,
|
||||
|
|
@ -188,9 +172,11 @@ def register():
|
|||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
token = _issue_jwt(user.id, user.username)
|
||||
token = issue_jwt(user.id, user.username)
|
||||
csrf_token = generate_csrf_token()
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"csrf_token": csrf_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
|
@ -212,9 +198,11 @@ def login():
|
|||
if not user or not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||
return jsonify({"error": "Invalid username or password."}), 401
|
||||
|
||||
token = _issue_jwt(user.id, user.username)
|
||||
token = issue_jwt(user.id, user.username)
|
||||
csrf_token = generate_csrf_token()
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"csrf_token": csrf_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
|
@ -242,6 +230,13 @@ def pm_history():
|
|||
if not other:
|
||||
return jsonify({"messages": []})
|
||||
|
||||
# Derive the room key so the client can decrypt history
|
||||
pair = ":".join(sorted([me.username.lower(), other.username.lower()]))
|
||||
room_name = "pm:" + pair
|
||||
room_key = base64.b64encode(
|
||||
hmac.new(JWT_SECRET.encode(), room_name.encode(), hashlib.sha256).digest()
|
||||
).decode()
|
||||
|
||||
rows = (
|
||||
Message.query
|
||||
.filter(
|
||||
|
|
@ -257,6 +252,7 @@ def pm_history():
|
|||
rows.reverse() # return in chronological order
|
||||
|
||||
return jsonify({
|
||||
"room_key": room_key,
|
||||
"messages": [
|
||||
{
|
||||
"from_me": m.sender_id == me.id,
|
||||
|
|
@ -275,6 +271,7 @@ def pm_history():
|
|||
|
||||
@api.route("/ai/message", methods=["POST"])
|
||||
@_require_auth
|
||||
@_require_csrf
|
||||
def ai_message():
|
||||
user = g.current_user
|
||||
data = request.get_json() or {}
|
||||
|
|
@ -296,7 +293,9 @@ def ai_message():
|
|||
|
||||
# ── Transit decrypt (message readable for AI; key NOT stored) ─────────────
|
||||
try:
|
||||
_plaintext = _aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
|
||||
plaintext = aesgcm_decrypt(transit_key, ciphertext, nonce_b64)
|
||||
# Sanitize before using in AI prompt
|
||||
plaintext = sanitize_user_input(plaintext)
|
||||
except Exception:
|
||||
return jsonify({"error": "Decryption failed – wrong key or corrupted data"}), 400
|
||||
|
||||
|
|
@ -306,7 +305,7 @@ def ai_message():
|
|||
# ── Persist both legs encrypted in DB (server uses transit key) ──────────
|
||||
bot = _get_ai_bot()
|
||||
_persist_message(user.id, bot.id, ciphertext, nonce_b64) # user → AI
|
||||
resp_ct, resp_nonce = _aesgcm_encrypt(transit_key, ai_text)
|
||||
resp_ct, resp_nonce = aesgcm_encrypt(transit_key, ai_text)
|
||||
_persist_message(bot.id, user.id, resp_ct, resp_nonce) # AI → user
|
||||
|
||||
# ── Update free trial counter ─────────────────────────────────────────────
|
||||
|
|
@ -315,7 +314,7 @@ def ai_message():
|
|||
db.session.commit()
|
||||
|
||||
# ── Re-encrypt AI response for transit back ───────────────────────────────
|
||||
resp_ct_transit, resp_nonce_transit = _aesgcm_encrypt(transit_key, ai_text)
|
||||
resp_ct_transit, resp_nonce_transit = aesgcm_encrypt(transit_key, ai_text)
|
||||
|
||||
return jsonify({
|
||||
"ciphertext": resp_ct_transit,
|
||||
|
|
@ -330,12 +329,12 @@ def ai_message():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
@api.route("/payment/success", methods=["POST"])
|
||||
@_require_auth
|
||||
def payment_success():
|
||||
"""
|
||||
Validate a payment webhook and flip user.has_ai_access.
|
||||
Server-side payment webhook – NOT callable by clients.
|
||||
|
||||
Expected body: { "secret": "<PAYMENT_SECRET>" }
|
||||
Validates the webhook secret and unlocks AI access for the user
|
||||
identified by the 'user_id' field in the JSON body.
|
||||
|
||||
For Stripe production: replace the secret comparison with
|
||||
stripe.Webhook.construct_event() using the raw request body and
|
||||
|
|
@ -351,7 +350,15 @@ def payment_success():
|
|||
):
|
||||
return jsonify({"error": "Invalid or missing payment secret"}), 403
|
||||
|
||||
user = g.current_user
|
||||
# Identify the user from the webhook payload (NOT from client auth)
|
||||
user_id = data.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Missing user_id in webhook payload"}), 400
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
if not user.has_ai_access:
|
||||
user.has_ai_access = True
|
||||
db.session.commit()
|
||||
|
|
|
|||
22
start.py
22
start.py
|
|
@ -12,7 +12,6 @@ Usage:
|
|||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import signal
|
||||
import time
|
||||
|
|
@ -21,24 +20,11 @@ import eventlet
|
|||
# Monkey-patch stdlib BEFORE any other import
|
||||
eventlet.monkey_patch()
|
||||
|
||||
from config import get_conf
|
||||
|
||||
# PID file to track the daemon process
|
||||
PID_FILE = "sexchat.pid"
|
||||
|
||||
def load_config():
|
||||
conf = {}
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
conf = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return conf
|
||||
|
||||
def _get_conf(key, default=None):
|
||||
conf = load_config()
|
||||
return os.environ.get(key, conf.get(key, default))
|
||||
|
||||
def get_pid():
|
||||
if os.path.exists(PID_FILE):
|
||||
with open(PID_FILE, "r") as f:
|
||||
|
|
@ -68,7 +54,7 @@ def start_daemon():
|
|||
"gunicorn",
|
||||
"--worker-class", "eventlet",
|
||||
"-w", "1",
|
||||
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
|
||||
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
|
||||
"--daemon",
|
||||
"--pid", PID_FILE,
|
||||
"--access-logfile", "access.log",
|
||||
|
|
@ -124,7 +110,7 @@ def run_debug():
|
|||
"gunicorn",
|
||||
"--worker-class", "eventlet",
|
||||
"-w", "1",
|
||||
"--bind", f"{_get_conf('HOST', '0.0.0.0')}:{_get_conf('PORT', 5000)}",
|
||||
"--bind", f"{get_conf('HOST', '0.0.0.0')}:{get_conf('PORT', 5000)}",
|
||||
"--log-level", "debug",
|
||||
"--access-logfile", "-",
|
||||
"--error-logfile", "-",
|
||||
|
|
|
|||
585
static/chat.js
585
static/chat.js
|
|
@ -19,6 +19,7 @@ const socket = io({
|
|||
const state = {
|
||||
username: null,
|
||||
isAdmin: false,
|
||||
role: "user",
|
||||
isRegistered: false,
|
||||
hasAiAccess: false,
|
||||
aiMessagesUsed: 0,
|
||||
|
|
@ -59,6 +60,7 @@ const paywallModal = $("paywall-modal");
|
|||
const contextMenu = $("context-menu");
|
||||
const trialBadge = $("violet-trial-badge");
|
||||
const violetTyping = $("violet-typing");
|
||||
const settingsModal = $("settings-modal");
|
||||
|
||||
// ── Auth & Init ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -116,14 +118,37 @@ joinForm.addEventListener("submit", async (e) => {
|
|||
|
||||
// Handle Token Restore on Load
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// ── Restore Theme from localStorage ────────────────────────────────────
|
||||
const savedTheme = localStorage.getItem("sexychat_theme") || "midnight-purple";
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
|
||||
// Update active theme button if it exists
|
||||
const themeButtons = document.querySelectorAll("[data-theme-button]");
|
||||
themeButtons.forEach(btn => {
|
||||
btn.classList.toggle("active", btn.dataset.themeButton === savedTheme);
|
||||
});
|
||||
|
||||
const token = localStorage.getItem("sexychat_token");
|
||||
if (token) {
|
||||
// We have a token, notify the join screen but wait for user to click "Enter"
|
||||
// to derive crypto key if they want to. Actually, for UX, if we have a token
|
||||
// we can try a "restore" join which might skip password entry.
|
||||
// But for encryption, we NEED that password to derive the key.
|
||||
// Let's keep it simple: if you have a token, you still need to log in to
|
||||
// re-derive your E2E key.
|
||||
// Auto-restore session from stored JWT
|
||||
joinBtn.disabled = true;
|
||||
joinBtn.innerText = "Restoring session...";
|
||||
socket.connect();
|
||||
socket.emit("join", { mode: "restore" });
|
||||
|
||||
// If restore fails, reset the form so user can log in manually
|
||||
const restoreTimeout = setTimeout(() => {
|
||||
joinBtn.disabled = false;
|
||||
joinBtn.innerText = "Enter the Room";
|
||||
}, 5000);
|
||||
|
||||
const origJoined = socket.listeners("joined");
|
||||
socket.once("joined", () => clearTimeout(restoreTimeout));
|
||||
socket.once("error", () => {
|
||||
clearTimeout(restoreTimeout);
|
||||
joinBtn.disabled = false;
|
||||
joinBtn.innerText = "Enter the Room";
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -132,9 +157,11 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
socket.on("joined", (data) => {
|
||||
state.username = data.username;
|
||||
state.isAdmin = data.is_admin;
|
||||
state.role = data.role || "user";
|
||||
state.isRegistered = data.is_registered;
|
||||
state.hasAiAccess = data.has_ai_access;
|
||||
state.aiMessagesUsed = data.ai_messages_used;
|
||||
state.email = data.email || null;
|
||||
|
||||
if (data.token) localStorage.setItem("sexychat_token", data.token);
|
||||
if (data.ignored_list) state.ignoredUsers = new Set(data.ignored_list);
|
||||
|
|
@ -143,6 +170,10 @@ socket.on("joined", (data) => {
|
|||
joinScreen.classList.add("hidden");
|
||||
chatScreen.classList.remove("hidden");
|
||||
updateVioletBadge();
|
||||
|
||||
// Show admin panel button for mods+
|
||||
const adminBtn = $("admin-btn");
|
||||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||||
});
|
||||
|
||||
socket.on("error", (data) => {
|
||||
|
|
@ -178,30 +209,45 @@ socket.on("message", (data) => {
|
|||
|
||||
// ── Private Messaging ─────────────────────────────────────────────────────
|
||||
|
||||
socket.on("pm_invite", (data) => {
|
||||
socket.on("pm_invite", async (data) => {
|
||||
if (state.pms[data.room]) return; // Already accepted
|
||||
// Store the room key for later use
|
||||
if (data.room_key) {
|
||||
state._pendingRoomKeys = state._pendingRoomKeys || {};
|
||||
state._pendingRoomKeys[data.room] = data.room_key;
|
||||
}
|
||||
$("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
|
||||
pmModal.classList.remove("hidden");
|
||||
|
||||
$("pm-accept-btn").onclick = () => {
|
||||
socket.emit("pm_accept", { room: data.room });
|
||||
openPMTab(data.from, data.room);
|
||||
openPMTab(data.from, data.room, data.room_key);
|
||||
pmModal.classList.add("hidden");
|
||||
};
|
||||
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
socket.on("pm_ready", (data) => {
|
||||
openPMTab(data.with, data.room);
|
||||
openPMTab(data.with, data.room, data.room_key);
|
||||
});
|
||||
|
||||
async function openPMTab(otherUser, room) {
|
||||
async function openPMTab(otherUser, room, roomKeyB64) {
|
||||
if (state.pms[room]) {
|
||||
switchTab(room);
|
||||
return;
|
||||
}
|
||||
|
||||
state.pms[room] = { username: otherUser, messages: [] };
|
||||
state.pms[room] = { username: otherUser, messages: [], sharedKey: null };
|
||||
|
||||
// Import the server-provided room key for user-to-user PMs
|
||||
const isViolet = otherUser.toLowerCase() === "violet";
|
||||
if (!isViolet && roomKeyB64) {
|
||||
try {
|
||||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64);
|
||||
} catch (err) {
|
||||
console.error("Failed to import room key", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Tab
|
||||
let title = `👤 ${otherUser}`;
|
||||
|
|
@ -231,19 +277,41 @@ async function openPMTab(otherUser, room) {
|
|||
// Load History if registered
|
||||
if (state.isRegistered && state.cryptoKey) {
|
||||
try {
|
||||
const resp = await fetch(`/api/pm/history?with=${otherUser}`, {
|
||||
const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, {
|
||||
headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.messages) {
|
||||
for (const m of data.messages) {
|
||||
const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce);
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: plain,
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
const histData = await resp.json();
|
||||
|
||||
// Use room_key from history response if we don't have one yet
|
||||
if (!isViolet && histData.room_key && !state.pms[room].sharedKey) {
|
||||
try {
|
||||
state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key);
|
||||
} catch (err) {
|
||||
console.error("Failed to import history room key", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the right decryption key: room key for users, personal key for Violet
|
||||
const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey);
|
||||
|
||||
if (histData.messages) {
|
||||
for (const m of histData.messages) {
|
||||
try {
|
||||
const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce);
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: plain,
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
} catch (err) {
|
||||
addMessage(room, {
|
||||
sender: m.from_me ? state.username : otherUser,
|
||||
text: "[Could not decrypt message]",
|
||||
ts: m.ts,
|
||||
sent: m.from_me
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -255,11 +323,18 @@ async function openPMTab(otherUser, room) {
|
|||
socket.on("pm_message", async (data) => {
|
||||
if (state.ignoredUsers.has(data.from)) return;
|
||||
let text = data.text;
|
||||
if (data.ciphertext && state.cryptoKey) {
|
||||
try {
|
||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
||||
} catch (err) {
|
||||
text = "[Encrypted Message - Click to login/derive key]";
|
||||
if (data.ciphertext) {
|
||||
// Pick the right key: room shared key for user-to-user, personal key for Violet
|
||||
const pm = state.pms[data.room];
|
||||
const decryptKey = pm?.sharedKey || state.cryptoKey;
|
||||
if (decryptKey) {
|
||||
try {
|
||||
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
|
||||
} catch (err) {
|
||||
text = "[Encrypted Message - Could not decrypt]";
|
||||
}
|
||||
} else {
|
||||
text = "[Encrypted Message - No key available]";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,24 +371,25 @@ socket.on("ai_response", async (data) => {
|
|||
state.hasAiAccess = data.has_ai_access;
|
||||
updateVioletBadge();
|
||||
|
||||
let text = "[Decryption Error]";
|
||||
const room = data.room || "ai-violet";
|
||||
let text = data.text || "[Decryption Error]";
|
||||
if (data.ciphertext && state.cryptoKey) {
|
||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
||||
}
|
||||
|
||||
addMessage("ai-violet", {
|
||||
addMessage(room, {
|
||||
sender: AI_BOT_NAME,
|
||||
text: text,
|
||||
ts: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
ts: data.ts || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
sent: false
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("ai_unlock", (data) => {
|
||||
state.hasAiAccess = true;
|
||||
state.hasAiAccess = data.has_ai_access !== undefined ? data.has_ai_access : true;
|
||||
updateVioletBadge();
|
||||
paywallModal.classList.add("hidden");
|
||||
addMessage("ai-violet", { system: true, text: data.msg });
|
||||
if (data.msg) addMessage("ai-violet", { system: true, text: data.msg });
|
||||
});
|
||||
|
||||
socket.on("ignore_status", (data) => {
|
||||
|
|
@ -323,6 +399,7 @@ socket.on("ignore_status", (data) => {
|
|||
});
|
||||
|
||||
function updateVioletBadge() {
|
||||
if (!trialBadge) return;
|
||||
if (state.hasAiAccess) {
|
||||
trialBadge.classList.add("hidden");
|
||||
} else {
|
||||
|
|
@ -347,11 +424,17 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
}
|
||||
else if (state.currentRoom.startsWith("pm:")) {
|
||||
const isVioletRoom = state.currentRoom.toLowerCase().endsWith(":violet");
|
||||
|
||||
// /reset command in Violet PM clears conversation memory
|
||||
if (isVioletRoom && text.toLowerCase() === "/reset") {
|
||||
socket.emit("violet_reset");
|
||||
messageInput.value = "";
|
||||
messageInput.style.height = "auto";
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVioletRoom) {
|
||||
if (!state.isRegistered || !state.cryptoKey) {
|
||||
addMessage(state.currentRoom, { system: true, text: "You must be logged in to chat with Violet." });
|
||||
} else {
|
||||
if (state.isRegistered && state.cryptoKey) {
|
||||
// AI Transit Encryption PM Flow
|
||||
const transitKeyB64 = await SexyChato.exportKeyBase64(state.cryptoKey);
|
||||
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
|
||||
|
|
@ -362,10 +445,15 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
nonce: encrypted.nonce,
|
||||
transit_key: transitKeyB64
|
||||
});
|
||||
} else {
|
||||
// Guest/admin plaintext fallback
|
||||
socket.emit("pm_message", { room: state.currentRoom, text });
|
||||
}
|
||||
} else if (state.isRegistered && state.cryptoKey) {
|
||||
// E2E PM Flow
|
||||
const encrypted = await SexyChato.encrypt(state.cryptoKey, text);
|
||||
// User-to-user encrypted PM: use the shared room key if available
|
||||
const pm = state.pms[state.currentRoom];
|
||||
const encryptKey = pm?.sharedKey || state.cryptoKey;
|
||||
const encrypted = await SexyChato.encrypt(encryptKey, text);
|
||||
socket.emit("pm_message", {
|
||||
room: state.currentRoom,
|
||||
ciphertext: encrypted.ciphertext,
|
||||
|
|
@ -381,6 +469,14 @@ messageForm.addEventListener("submit", async (e) => {
|
|||
messageInput.style.height = "auto";
|
||||
});
|
||||
|
||||
// Enter to send, Shift+Enter for newline
|
||||
messageInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prefs.enterToSend) {
|
||||
e.preventDefault();
|
||||
messageForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-expand textarea
|
||||
messageInput.addEventListener("input", () => {
|
||||
messageInput.style.height = "auto";
|
||||
|
|
@ -404,6 +500,18 @@ function switchTab(room) {
|
|||
if (box) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts || prefs.timeFormat === "24h") return ts || "";
|
||||
// Convert HH:MM (24h) to 12h
|
||||
const parts = (ts || "").match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!parts) return ts;
|
||||
let h = parseInt(parts[1], 10);
|
||||
const m = parts[2];
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
return `${h}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function addMessage(room, msg) {
|
||||
const list = $(`messages-${room}`);
|
||||
if (!list) return;
|
||||
|
|
@ -415,7 +523,7 @@ function addMessage(room, msg) {
|
|||
} else {
|
||||
div.className = `msg ${msg.sent ? "msg-sent" : "msg-received"}`;
|
||||
div.innerHTML = `
|
||||
<div class="msg-meta">${msg.ts} ${msg.sender}</div>
|
||||
<div class="msg-meta">${formatTs(msg.ts)} ${msg.sender}</div>
|
||||
<div class="msg-bubble">${escapeHTML(msg.text)}</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -430,10 +538,11 @@ function renderNicklist() {
|
|||
const li = document.createElement("li");
|
||||
const isIgnored = state.ignoredUsers.has(u.username);
|
||||
const isUnverified = u.is_registered && !u.is_verified;
|
||||
const roleIcon = {root: "👑", admin: "⚔️", mod: "🛡️"}[u.role] || "";
|
||||
|
||||
li.innerHTML = `
|
||||
<span class="${isUnverified ? 'unverified' : ''}">
|
||||
${u.is_admin ? '<span class="mod-star">★</span> ' : ''}
|
||||
${roleIcon ? `<span class="mod-star">${roleIcon}</span> ` : ''}
|
||||
<span class="${isIgnored ? 'dimmed' : ''}">${u.username}</span>
|
||||
${u.is_registered ? '<span class="reg-mark">✔</span>' : ''}
|
||||
${isIgnored ? ' <small>(ignored)</small>' : ''}
|
||||
|
|
@ -481,16 +590,15 @@ function showContextMenu(e, user) {
|
|||
}
|
||||
});
|
||||
|
||||
// Cleanup previous listeners
|
||||
const newMenu = contextMenu.cloneNode(true);
|
||||
contextMenu.replaceWith(newMenu);
|
||||
|
||||
// Add new listeners
|
||||
newMenu.querySelectorAll(".menu-item").forEach(item => {
|
||||
// Store target for click handler (uses event delegation below)
|
||||
contextMenu._targetUser = user.username;
|
||||
|
||||
// Remove old inline onclick handlers and re-bind
|
||||
contextMenu.querySelectorAll(".menu-item").forEach(item => {
|
||||
item.onclick = () => {
|
||||
const action = item.dataset.action;
|
||||
executeMenuAction(action, user.username);
|
||||
newMenu.classList.add("hidden");
|
||||
executeMenuAction(action, contextMenu._targetUser);
|
||||
contextMenu.classList.add("hidden");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -509,6 +617,9 @@ function executeMenuAction(action, target) {
|
|||
case "kick":
|
||||
socket.emit("mod_kick", { target });
|
||||
break;
|
||||
case "mute":
|
||||
socket.emit("mod_mute", { target });
|
||||
break;
|
||||
case "ban":
|
||||
socket.emit("mod_ban", { target });
|
||||
break;
|
||||
|
|
@ -541,35 +652,15 @@ $("sidebar-toggle").onclick = () => {
|
|||
sidebar.classList.toggle("open");
|
||||
};
|
||||
|
||||
$("tab-ai-violet").onclick = () => switchTab("ai-violet");
|
||||
// tab-ai-violet is created dynamically when user opens Violet PM
|
||||
$("tab-lobby").onclick = () => switchTab("lobby");
|
||||
|
||||
$("close-paywall").onclick = () => paywallModal.classList.add("hidden");
|
||||
$("unlock-btn").onclick = async () => {
|
||||
// Generate dummy secret for the stub endpoint
|
||||
// In production, this would redirect to a real payment gateway (Stripe)
|
||||
const secret = "change-me-payment-webhook-secret";
|
||||
const token = localStorage.getItem("sexychat_token");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/payment/success", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res.status === "ok") {
|
||||
// socket event should handle UI unlock, but we can optimistically update
|
||||
state.hasAiAccess = true;
|
||||
updateVioletBadge();
|
||||
paywallModal.classList.add("hidden");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Payment simulation failed.");
|
||||
}
|
||||
// In production, this redirects to a real payment gateway (Stripe Checkout).
|
||||
// The server-side webhook will unlock AI access after payment confirmation.
|
||||
// For now, show a placeholder message.
|
||||
alert("Payment integration coming soon. Contact the administrator to unlock Violet.");
|
||||
};
|
||||
|
||||
logoutBtn.onclick = () => {
|
||||
|
|
@ -577,8 +668,358 @@ logoutBtn.onclick = () => {
|
|||
location.reload();
|
||||
};
|
||||
|
||||
// ── Settings Panel ─────────────────────────────────────────────────────────
|
||||
|
||||
// Load saved preferences from localStorage
|
||||
const prefs = {
|
||||
fontSize: parseInt(localStorage.getItem("sc_fontsize") || "14"),
|
||||
timeFormat: localStorage.getItem("sc_timeformat") || "12h",
|
||||
enterToSend: localStorage.getItem("sc_enter_send") !== "false",
|
||||
sounds: localStorage.getItem("sc_sounds") !== "false",
|
||||
theme: localStorage.getItem("sc_theme") || "midnight-purple",
|
||||
};
|
||||
|
||||
// Apply saved font size on load
|
||||
document.documentElement.style.setProperty("--chat-font-size", prefs.fontSize + "px");
|
||||
// Apply saved theme on load
|
||||
document.documentElement.setAttribute("data-theme", prefs.theme);
|
||||
|
||||
$("settings-btn").onclick = () => {
|
||||
// Populate current values
|
||||
$("settings-username").textContent = state.username || "—";
|
||||
$("settings-email").textContent = state.email || "Not set";
|
||||
$("settings-ai-status").textContent = state.hasAiAccess ? "✓ Unlimited" : `Free (${AI_FREE_LIMIT - state.aiMessagesUsed} left)`;
|
||||
$("settings-ai-used").textContent = state.aiMessagesUsed.toString();
|
||||
|
||||
// Show password change only for registered users
|
||||
document.querySelectorAll(".registered-only").forEach(el =>
|
||||
el.classList.toggle("hidden", !state.isRegistered)
|
||||
);
|
||||
|
||||
// Sync toggle states
|
||||
$("settings-fontsize").value = prefs.fontSize;
|
||||
$("settings-fontsize-val").textContent = prefs.fontSize + "px";
|
||||
document.querySelectorAll("[data-tf]").forEach(b => {
|
||||
b.classList.toggle("active", b.dataset.tf === prefs.timeFormat);
|
||||
});
|
||||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||||
|
||||
// Sync theme picker
|
||||
document.querySelectorAll(".theme-swatch").forEach(s => {
|
||||
s.classList.toggle("active", s.dataset.theme === prefs.theme);
|
||||
});
|
||||
|
||||
settingsModal.classList.remove("hidden");
|
||||
};
|
||||
|
||||
$("close-settings").onclick = () => settingsModal.classList.add("hidden");
|
||||
settingsModal.addEventListener("click", (e) => {
|
||||
if (e.target === settingsModal) settingsModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll(".settings-tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".settings-pane").forEach(p => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$("stab-" + tab.dataset.stab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Font size slider
|
||||
$("settings-fontsize").addEventListener("input", (e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
prefs.fontSize = val;
|
||||
localStorage.setItem("sc_fontsize", val);
|
||||
$("settings-fontsize-val").textContent = val + "px";
|
||||
document.documentElement.style.setProperty("--chat-font-size", val + "px");
|
||||
});
|
||||
|
||||
// Theme picker
|
||||
document.querySelectorAll(".theme-swatch").forEach(swatch => {
|
||||
swatch.addEventListener("click", () => {
|
||||
const theme = swatch.dataset.theme;
|
||||
prefs.theme = theme;
|
||||
localStorage.setItem("sc_theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.querySelectorAll(".theme-swatch").forEach(s => s.classList.remove("active"));
|
||||
swatch.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Timestamp format toggle
|
||||
document.querySelectorAll("[data-tf]").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-tf]").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
prefs.timeFormat = btn.dataset.tf;
|
||||
localStorage.setItem("sc_timeformat", btn.dataset.tf);
|
||||
});
|
||||
});
|
||||
|
||||
// Enter-to-send toggle
|
||||
$("settings-enter-send").addEventListener("click", () => {
|
||||
prefs.enterToSend = !prefs.enterToSend;
|
||||
localStorage.setItem("sc_enter_send", prefs.enterToSend);
|
||||
$("settings-enter-send").textContent = prefs.enterToSend ? "On" : "Off";
|
||||
$("settings-enter-send").classList.toggle("active", prefs.enterToSend);
|
||||
});
|
||||
|
||||
// Sounds toggle
|
||||
$("settings-sounds").addEventListener("click", () => {
|
||||
prefs.sounds = !prefs.sounds;
|
||||
localStorage.setItem("sc_sounds", prefs.sounds);
|
||||
$("settings-sounds").textContent = prefs.sounds ? "On" : "Off";
|
||||
$("settings-sounds").classList.toggle("active", prefs.sounds);
|
||||
});
|
||||
|
||||
// Password change
|
||||
$("settings-change-pw").addEventListener("click", () => {
|
||||
const oldPw = $("settings-old-pw").value;
|
||||
const newPw = $("settings-new-pw").value;
|
||||
const confirmPw = $("settings-confirm-pw").value;
|
||||
const msgEl = $("settings-pw-msg");
|
||||
|
||||
if (!oldPw || !newPw) {
|
||||
msgEl.textContent = "Please fill in both fields.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 6) {
|
||||
msgEl.textContent = "New password must be at least 6 characters.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
if (newPw !== confirmPw) {
|
||||
msgEl.textContent = "Passwords do not match.";
|
||||
msgEl.className = "settings-msg error";
|
||||
msgEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit("change_password", { old_password: oldPw, new_password: newPw });
|
||||
});
|
||||
|
||||
socket.on("password_changed", (data) => {
|
||||
const msgEl = $("settings-pw-msg");
|
||||
if (data.success) {
|
||||
msgEl.textContent = "✓ Password updated successfully.";
|
||||
msgEl.className = "settings-msg success";
|
||||
$("settings-old-pw").value = "";
|
||||
$("settings-new-pw").value = "";
|
||||
$("settings-confirm-pw").value = "";
|
||||
} else {
|
||||
msgEl.textContent = data.msg || "Failed to change password.";
|
||||
msgEl.className = "settings-msg error";
|
||||
}
|
||||
msgEl.classList.remove("hidden");
|
||||
});
|
||||
|
||||
// Violet memory reset from settings
|
||||
$("settings-violet-reset").addEventListener("click", () => {
|
||||
socket.emit("violet_reset");
|
||||
$("settings-violet-reset").textContent = "Memory Cleared!";
|
||||
setTimeout(() => { $("settings-violet-reset").textContent = "Reset Violet Memory"; }, 2000);
|
||||
});
|
||||
|
||||
// Premium button (placeholder)
|
||||
$("settings-upgrade").addEventListener("click", () => {
|
||||
alert("Premium subscriptions are coming soon! Stay tuned.");
|
||||
});
|
||||
|
||||
function escapeHTML(str) {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = str;
|
||||
return p.innerHTML;
|
||||
}
|
||||
|
||||
// ── Role updated (live notification) ──────────────────────────────────────
|
||||
|
||||
socket.on("role_updated", (data) => {
|
||||
state.role = data.role;
|
||||
state.isAdmin = ["mod", "admin", "root"].includes(data.role);
|
||||
const adminBtn = $("admin-btn");
|
||||
if (adminBtn) adminBtn.classList.toggle("hidden", !state.isAdmin);
|
||||
});
|
||||
|
||||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||
|
||||
const adminModal = $("admin-modal");
|
||||
const ROLE_POWER = { user: 0, mod: 1, admin: 2, root: 3 };
|
||||
|
||||
if ($("admin-btn")) {
|
||||
$("admin-btn").onclick = () => {
|
||||
adminModal.classList.remove("hidden");
|
||||
socket.emit("admin_get_users");
|
||||
socket.emit("admin_get_bans");
|
||||
socket.emit("admin_get_mutes");
|
||||
};
|
||||
}
|
||||
|
||||
$("close-admin").onclick = () => adminModal.classList.add("hidden");
|
||||
adminModal.addEventListener("click", (e) => {
|
||||
if (e.target === adminModal) adminModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
// Admin tab switching
|
||||
document.querySelectorAll(".admin-tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".admin-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelectorAll(".admin-pane").forEach(p => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$("atab-" + tab.dataset.atab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Admin toast helper
|
||||
function adminToast(msg) {
|
||||
const t = $("admin-toast");
|
||||
t.textContent = msg;
|
||||
t.classList.remove("hidden");
|
||||
setTimeout(() => t.classList.add("hidden"), 2500);
|
||||
}
|
||||
|
||||
socket.on("admin_action_ok", (data) => {
|
||||
adminToast(data.msg);
|
||||
// Refresh the admin user list so buttons update
|
||||
socket.emit("admin_get_users");
|
||||
socket.emit("admin_get_bans");
|
||||
socket.emit("admin_get_mutes");
|
||||
});
|
||||
|
||||
// ── Users pane ───────────────────────────────────────────────────────────
|
||||
|
||||
let adminUserCache = [];
|
||||
|
||||
socket.on("admin_users", (data) => {
|
||||
adminUserCache = data.users;
|
||||
renderAdminUsers(adminUserCache);
|
||||
});
|
||||
|
||||
$("admin-user-search").addEventListener("input", (e) => {
|
||||
const q = e.target.value.toLowerCase();
|
||||
renderAdminUsers(adminUserCache.filter(u => u.username.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
function renderAdminUsers(users) {
|
||||
const list = $("admin-user-list");
|
||||
list.innerHTML = "";
|
||||
const myPower = ROLE_POWER[state.role] || 0;
|
||||
|
||||
users.forEach(u => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-user-row";
|
||||
|
||||
const canEditRole = myPower > ROLE_POWER[u.role] && myPower >= ROLE_POWER.admin;
|
||||
const canVerify = myPower >= ROLE_POWER.mod;
|
||||
const canToggleAI = myPower >= ROLE_POWER.admin && u.role !== "root";
|
||||
|
||||
// Build role selector
|
||||
let roleHTML = "";
|
||||
if (canEditRole) {
|
||||
const opts = ["user", "mod"];
|
||||
if (myPower >= ROLE_POWER.root) opts.push("admin");
|
||||
roleHTML = `<select class="au-select" data-uid="${u.id}" data-action="set-role">
|
||||
${opts.map(r => `<option value="${r}" ${u.role === r ? "selected" : ""}>${r}</option>`).join("")}
|
||||
</select>`;
|
||||
} else {
|
||||
roleHTML = `<span class="au-role ${u.role}">${u.role}</span>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="au-name">${escapeHTML(u.username)}</span>
|
||||
${roleHTML}
|
||||
<span class="au-badges">
|
||||
${u.online ? '<span class="au-badge online">online</span>' : ''}
|
||||
${u.is_verified ? '<span class="au-badge verified">verified</span>' : '<span class="au-badge unverified">unverified</span>'}
|
||||
${u.has_ai_access ? '<span class="au-badge ai-on">AI</span>' : ''}
|
||||
</span>
|
||||
<span class="au-actions">
|
||||
${canVerify ? `<button class="au-btn" data-uid="${u.id}" data-action="verify">${u.is_verified ? 'Unverify' : 'Verify'}</button>` : ''}
|
||||
${canToggleAI ? `<button class="au-btn" data-uid="${u.id}" data-action="toggle-ai">${u.has_ai_access ? 'Revoke AI' : 'Grant AI'}</button>` : ''}
|
||||
</span>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Event delegation for actions
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const uid = parseInt(btn.dataset.uid);
|
||||
const action = btn.dataset.action;
|
||||
if (action === "verify") socket.emit("admin_verify_user", { user_id: uid });
|
||||
else if (action === "toggle-ai") socket.emit("admin_toggle_ai", { user_id: uid });
|
||||
};
|
||||
list.onchange = (e) => {
|
||||
const sel = e.target.closest("[data-action='set-role']");
|
||||
if (!sel) return;
|
||||
const uid = parseInt(sel.dataset.uid);
|
||||
socket.emit("admin_set_role", { user_id: uid, role: sel.value });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bans pane ────────────────────────────────────────────────────────────
|
||||
|
||||
socket.on("admin_bans", (data) => {
|
||||
const list = $("admin-ban-list");
|
||||
const empty = $("admin-no-bans");
|
||||
list.innerHTML = "";
|
||||
empty.classList.toggle("hidden", data.bans.length > 0);
|
||||
|
||||
data.bans.forEach(b => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-ban-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="ab-name">${escapeHTML(b.username)}</span>
|
||||
${b.ip ? `<small style="color:var(--text-dim);margin-left:8px">${b.ip}</small>` : ''}
|
||||
<small style="color:var(--text-dim);margin-left:8px">${b.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-bid="${b.id}">Unban</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-bid]");
|
||||
if (!btn) return;
|
||||
socket.emit("admin_unban", { ban_id: parseInt(btn.dataset.bid) });
|
||||
btn.closest(".admin-ban-row").remove();
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mutes pane ───────────────────────────────────────────────────────────
|
||||
|
||||
socket.on("admin_mutes", (data) => {
|
||||
const list = $("admin-mute-list");
|
||||
const empty = $("admin-no-mutes");
|
||||
list.innerHTML = "";
|
||||
empty.classList.toggle("hidden", data.mutes.length > 0);
|
||||
|
||||
data.mutes.forEach(m => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-mute-row";
|
||||
row.innerHTML = `
|
||||
<div>
|
||||
<span class="am-name">${escapeHTML(m.username)}</span>
|
||||
<small style="color:var(--text-dim);margin-left:8px">${m.created_at}</small>
|
||||
</div>
|
||||
<button class="au-btn danger" data-mid="${m.id}">Unmute</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
list.onclick = (e) => {
|
||||
const btn = e.target.closest("[data-mid]");
|
||||
if (!btn) return;
|
||||
socket.emit("admin_unmute", { mute_id: parseInt(btn.dataset.mid) });
|
||||
btn.closest(".admin-mute-row").remove();
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,6 +126,23 @@ const SexyChato = (() => {
|
|||
return bufToBase64(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a base64-encoded raw AES-GCM key (e.g. server-provided room key).
|
||||
*
|
||||
* @param {string} b64 base64-encoded 256-bit key
|
||||
* @returns {Promise<CryptoKey>}
|
||||
*/
|
||||
async function importKeyBase64(b64) {
|
||||
const buf = base64ToBuf(b64);
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
buf,
|
||||
{ name: "AES-GCM", length: KEY_LENGTH_BITS },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
|
||||
return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 };
|
||||
})();
|
||||
|
|
|
|||
660
static/style.css
660
static/style.css
|
|
@ -6,14 +6,123 @@
|
|||
:root {
|
||||
--bg-deep: #0a0015;
|
||||
--bg-card: rgba(26, 0, 48, 0.7);
|
||||
--bg-header: rgba(10, 0, 20, 0.8);
|
||||
--accent-magenta: #ff00ff;
|
||||
--accent-purple: #8a2be2;
|
||||
--accent-glow: rgba(255, 0, 255, 0.3);
|
||||
--accent-glow-strong: rgba(255, 0, 255, 0.5);
|
||||
--bg-gradient-1: rgba(138, 43, 226, 0.15);
|
||||
--bg-gradient-2: rgba(255, 0, 255, 0.1);
|
||||
--text-main: #f0f0f0;
|
||||
--text-dim: #b0b0b0;
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--error-red: #ff3366;
|
||||
--success-green: #00ffaa;
|
||||
--ai-teal: #00f2ff;
|
||||
--msg-received-bg: rgba(255, 255, 255, 0.08);
|
||||
--msg-received-border: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Themes ──────────────────────────────────────────────────────────────── */
|
||||
[data-theme="midnight-purple"] {
|
||||
--bg-deep: #0a0015; --bg-card: rgba(26, 0, 48, 0.7); --bg-header: rgba(10, 0, 20, 0.8);
|
||||
--accent-magenta: #ff00ff; --accent-purple: #8a2be2;
|
||||
--accent-glow: rgba(255, 0, 255, 0.3); --accent-glow-strong: rgba(255, 0, 255, 0.5);
|
||||
--bg-gradient-1: rgba(138, 43, 226, 0.15); --bg-gradient-2: rgba(255, 0, 255, 0.1);
|
||||
--text-main: #f0f0f0; --text-dim: #b0b0b0;
|
||||
--msg-received-bg: rgba(255, 255, 255, 0.08); --msg-received-border: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="crimson-noir"] {
|
||||
--bg-deep: #0d0000; --bg-card: rgba(40, 0, 0, 0.7); --bg-header: rgba(15, 0, 0, 0.85);
|
||||
--accent-magenta: #ff1744; --accent-purple: #b71c1c;
|
||||
--accent-glow: rgba(255, 23, 68, 0.3); --accent-glow-strong: rgba(255, 23, 68, 0.5);
|
||||
--bg-gradient-1: rgba(183, 28, 28, 0.15); --bg-gradient-2: rgba(255, 23, 68, 0.1);
|
||||
--text-main: #f0e8e8; --text-dim: #b09090;
|
||||
--msg-received-bg: rgba(255, 200, 200, 0.06); --msg-received-border: rgba(255, 200, 200, 0.04);
|
||||
--ai-teal: #ff6e6e;
|
||||
}
|
||||
|
||||
[data-theme="ocean-deep"] {
|
||||
--bg-deep: #000a14; --bg-card: rgba(0, 20, 48, 0.7); --bg-header: rgba(0, 8, 20, 0.85);
|
||||
--accent-magenta: #00bcd4; --accent-purple: #0277bd;
|
||||
--accent-glow: rgba(0, 188, 212, 0.3); --accent-glow-strong: rgba(0, 188, 212, 0.5);
|
||||
--bg-gradient-1: rgba(2, 119, 189, 0.15); --bg-gradient-2: rgba(0, 188, 212, 0.1);
|
||||
--text-main: #e0f0f0; --text-dim: #90b0b0;
|
||||
--msg-received-bg: rgba(200, 255, 255, 0.06); --msg-received-border: rgba(200, 255, 255, 0.04);
|
||||
--ai-teal: #4dd0e1;
|
||||
}
|
||||
|
||||
[data-theme="ember"] {
|
||||
--bg-deep: #0d0800; --bg-card: rgba(40, 20, 0, 0.7); --bg-header: rgba(15, 8, 0, 0.85);
|
||||
--accent-magenta: #ff9800; --accent-purple: #e65100;
|
||||
--accent-glow: rgba(255, 152, 0, 0.3); --accent-glow-strong: rgba(255, 152, 0, 0.5);
|
||||
--bg-gradient-1: rgba(230, 81, 0, 0.15); --bg-gradient-2: rgba(255, 152, 0, 0.1);
|
||||
--text-main: #f0e8e0; --text-dim: #b09880;
|
||||
--msg-received-bg: rgba(255, 230, 200, 0.06); --msg-received-border: rgba(255, 230, 200, 0.04);
|
||||
--ai-teal: #ffb74d;
|
||||
}
|
||||
|
||||
[data-theme="neon-green"] {
|
||||
--bg-deep: #000a00; --bg-card: rgba(0, 30, 0, 0.7); --bg-header: rgba(0, 10, 0, 0.85);
|
||||
--accent-magenta: #00ff41; --accent-purple: #00c853;
|
||||
--accent-glow: rgba(0, 255, 65, 0.3); --accent-glow-strong: rgba(0, 255, 65, 0.5);
|
||||
--bg-gradient-1: rgba(0, 200, 83, 0.12); --bg-gradient-2: rgba(0, 255, 65, 0.08);
|
||||
--text-main: #e0ffe0; --text-dim: #80b080;
|
||||
--msg-received-bg: rgba(200, 255, 200, 0.06); --msg-received-border: rgba(200, 255, 200, 0.04);
|
||||
--ai-teal: #69f0ae;
|
||||
}
|
||||
|
||||
[data-theme="cyberpunk"] {
|
||||
--bg-deep: #0a0014; --bg-card: rgba(20, 0, 40, 0.75); --bg-header: rgba(10, 0, 20, 0.85);
|
||||
--accent-magenta: #f5ee28; --accent-purple: #e040fb;
|
||||
--accent-glow: rgba(245, 238, 40, 0.3); --accent-glow-strong: rgba(245, 238, 40, 0.5);
|
||||
--bg-gradient-1: rgba(224, 64, 251, 0.15); --bg-gradient-2: rgba(245, 238, 40, 0.08);
|
||||
--text-main: #f0f0f0; --text-dim: #b0a0c0;
|
||||
--msg-received-bg: rgba(245, 238, 40, 0.06); --msg-received-border: rgba(245, 238, 40, 0.04);
|
||||
--ai-teal: #e040fb;
|
||||
}
|
||||
|
||||
[data-theme="rose-gold"] {
|
||||
--bg-deep: #0d0508; --bg-card: rgba(40, 15, 20, 0.7); --bg-header: rgba(15, 5, 8, 0.85);
|
||||
--accent-magenta: #f48fb1; --accent-purple: #c2185b;
|
||||
--accent-glow: rgba(244, 143, 177, 0.3); --accent-glow-strong: rgba(244, 143, 177, 0.5);
|
||||
--bg-gradient-1: rgba(194, 24, 91, 0.12); --bg-gradient-2: rgba(244, 143, 177, 0.08);
|
||||
--text-main: #f5e8ed; --text-dim: #c0a0a8;
|
||||
--msg-received-bg: rgba(255, 200, 220, 0.06); --msg-received-border: rgba(255, 200, 220, 0.04);
|
||||
--ai-teal: #f48fb1;
|
||||
}
|
||||
|
||||
[data-theme="arctic"] {
|
||||
--bg-deep: #f5f7fa; --bg-card: rgba(255, 255, 255, 0.85); --bg-header: rgba(240, 242, 248, 0.95);
|
||||
--accent-magenta: #5c6bc0; --accent-purple: #3949ab;
|
||||
--accent-glow: rgba(92, 107, 192, 0.25); --accent-glow-strong: rgba(92, 107, 192, 0.4);
|
||||
--bg-gradient-1: rgba(57, 73, 171, 0.08); --bg-gradient-2: rgba(92, 107, 192, 0.06);
|
||||
--text-main: #1a1a2e; --text-dim: #666680;
|
||||
--glass-border: rgba(0, 0, 0, 0.1);
|
||||
--msg-received-bg: rgba(0, 0, 0, 0.05); --msg-received-border: rgba(0, 0, 0, 0.08);
|
||||
--ai-teal: #5c6bc0; --success-green: #43a047;
|
||||
}
|
||||
|
||||
[data-theme="daylight"] {
|
||||
--bg-deep: #fafafa; --bg-card: rgba(255, 255, 255, 0.9); --bg-header: rgba(245, 245, 245, 0.95);
|
||||
--accent-magenta: #e91e63; --accent-purple: #9c27b0;
|
||||
--accent-glow: rgba(233, 30, 99, 0.2); --accent-glow-strong: rgba(233, 30, 99, 0.35);
|
||||
--bg-gradient-1: rgba(156, 39, 176, 0.06); --bg-gradient-2: rgba(233, 30, 99, 0.05);
|
||||
--text-main: #212121; --text-dim: #757575;
|
||||
--glass-border: rgba(0, 0, 0, 0.12);
|
||||
--msg-received-bg: rgba(0, 0, 0, 0.04); --msg-received-border: rgba(0, 0, 0, 0.06);
|
||||
--ai-teal: #e91e63; --success-green: #2e7d32;
|
||||
}
|
||||
|
||||
[data-theme="midnight-blue"] {
|
||||
--bg-deep: #0a0a1a; --bg-card: rgba(10, 10, 40, 0.75); --bg-header: rgba(5, 5, 20, 0.85);
|
||||
--accent-magenta: #448aff; --accent-purple: #1a237e;
|
||||
--accent-glow: rgba(68, 138, 255, 0.3); --accent-glow-strong: rgba(68, 138, 255, 0.5);
|
||||
--bg-gradient-1: rgba(26, 35, 126, 0.15); --bg-gradient-2: rgba(68, 138, 255, 0.1);
|
||||
--text-main: #e0e8f0; --text-dim: #8090b0;
|
||||
--msg-received-bg: rgba(200, 220, 255, 0.06); --msg-received-border: rgba(200, 220, 255, 0.04);
|
||||
--ai-teal: #82b1ff;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -27,8 +136,8 @@ body {
|
|||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-deep);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 30%, rgba(138, 43, 226, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(255, 0, 255, 0.1) 0%, transparent 40%);
|
||||
radial-gradient(circle at 20% 30%, var(--bg-gradient-1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, var(--bg-gradient-2) 0%, transparent 40%);
|
||||
color: var(--text-main);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
|
@ -83,7 +192,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
|
|||
|
||||
.logo-accent {
|
||||
color: var(--accent-magenta);
|
||||
text-shadow: 0 0 10px rgba(255, 0, 255, 0.5);
|
||||
text-shadow: 0 0 10px var(--accent-glow-strong);
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
|
|
@ -117,7 +226,7 @@ h1, h2, h3, .logo-text, .paywall-header h2 {
|
|||
.auth-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(138, 43, 226, 0.3);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
|
|
@ -144,7 +253,7 @@ input:focus {
|
|||
.crypto-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-magenta);
|
||||
background: rgba(255, 0, 255, 0.05);
|
||||
background: color-mix(in srgb, var(--accent-magenta) 5%, transparent);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
|
|
@ -161,13 +270,13 @@ input:focus {
|
|||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 255, 0.3);
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary:active { transform: scale(0.98); }
|
||||
.btn-primary:hover { box-shadow: 0 6px 20px rgba(255, 0, 255, 0.4); }
|
||||
.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow-strong); }
|
||||
|
||||
.mod-login {
|
||||
text-align: left;
|
||||
|
|
@ -194,7 +303,7 @@ input:focus {
|
|||
|
||||
.glass-header {
|
||||
height: 64px;
|
||||
background: rgba(10, 0, 20, 0.8);
|
||||
background: var(--bg-header);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
|
|
@ -237,6 +346,23 @@ input:focus {
|
|||
.header-right { display: flex; align-items: center; gap: 12px; }
|
||||
.my-badge { font-weight: 600; color: var(--accent-magenta); font-size: 0.9rem; }
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: var(--accent-magenta);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
|
|
@ -378,7 +504,7 @@ input:focus {
|
|||
.msg-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--chat-font-size, 0.95rem);
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
|
@ -392,16 +518,16 @@ input:focus {
|
|||
|
||||
.msg-received { align-self: flex-start; }
|
||||
.msg-received .msg-bubble {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--msg-received-bg);
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border: 1px solid var(--msg-received-border);
|
||||
}
|
||||
|
||||
.msg-sent { align-self: flex-end; }
|
||||
.msg-sent .msg-bubble {
|
||||
background: linear-gradient(135deg, var(--accent-purple), var(--accent-magenta));
|
||||
border-bottom-right-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(255, 0, 255, 0.2);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
|
|
@ -417,7 +543,7 @@ input:focus {
|
|||
.msg-system strong { color: var(--accent-magenta); }
|
||||
|
||||
/* ── AI Area ─────────────────────────────────────────────────────────────── */
|
||||
.chat-ai { background: radial-gradient(circle at top, rgba(0, 242, 255, 0.05), transparent 70%); }
|
||||
.chat-ai { background: radial-gradient(circle at top, color-mix(in srgb, var(--ai-teal) 5%, transparent), transparent 70%); }
|
||||
|
||||
.ai-header {
|
||||
padding: 1rem;
|
||||
|
|
@ -458,7 +584,7 @@ input:focus {
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
background: rgba(10, 0, 20, 0.8);
|
||||
background: var(--bg-header);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
|
|
@ -487,7 +613,7 @@ textarea {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 15px rgba(255, 0, 255, 0.4);
|
||||
box-shadow: 0 0 15px var(--accent-glow-strong);
|
||||
}
|
||||
|
||||
/* ── Modals ──────────────────────────────────────────────────────────────── */
|
||||
|
|
@ -512,7 +638,7 @@ textarea {
|
|||
|
||||
.paywall-card {
|
||||
border: 2px solid var(--ai-teal);
|
||||
box-shadow: 0 0 40px rgba(0, 242, 255, 0.2);
|
||||
box-shadow: 0 0 40px color-mix(in srgb, var(--ai-teal) 20%, transparent);
|
||||
}
|
||||
|
||||
.ai-avatar.large { width: 80px; height: 80px; font-size: 2.5rem; margin: 0 auto 1.5rem; }
|
||||
|
|
@ -539,7 +665,7 @@ textarea {
|
|||
border-radius: 12px;
|
||||
background: rgba(15, 0, 30, 0.95);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px rgba(138, 43, 226, 0.2);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8), 0 0 10px color-mix(in srgb, var(--accent-purple) 20%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -568,7 +694,507 @@ textarea {
|
|||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Settings Modal ───────────────────────────────────────────────────────── */
|
||||
.settings-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem 0;
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.settings-close:hover { color: var(--accent-magenta); }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
margin: 1rem 2rem 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-pane { display: none; }
|
||||
.settings-pane.active { display: block; }
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.settings-value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-main);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.settings-input:focus { border-color: var(--accent-purple); }
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-range {
|
||||
flex: 1;
|
||||
accent-color: var(--accent-magenta);
|
||||
}
|
||||
|
||||
.settings-toggle-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-toggle-btn.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
border-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
padding: 6px 18px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-toggle.active {
|
||||
background: var(--success-green);
|
||||
color: #000;
|
||||
border-color: var(--success-green);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.settings-msg.success { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
|
||||
.settings-msg.error { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 18px;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 32px;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff3366 0%, #cc0033 100%);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
box-shadow: 0 6px 20px rgba(255, 51, 102, 0.4);
|
||||
}
|
||||
|
||||
/* Premium tab */
|
||||
.premium-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.premium-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.premium-hero h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ffd700, #ff8c00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.premium-tagline {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.premium-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.premium-feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 215, 0, 0.05);
|
||||
border: 1px solid rgba(255, 215, 0, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pf-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: 2px; }
|
||||
|
||||
.premium-feature strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.premium-feature p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.premium-cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.premium-price {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: linear-gradient(135deg, #ffd700, #ff8c00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.premium-price span {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.premium-note {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Mobile Overrides ─────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Admin Panel ──────────────────────────────────────────────────────────── */
|
||||
.admin-card {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem 0;
|
||||
}
|
||||
|
||||
.admin-header h2 { font-size: 1.4rem; font-weight: 700; }
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
margin: 1rem 2rem 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
flex: 1;
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
background: var(--accent-purple);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
padding: 1rem 2rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-pane { display: none; }
|
||||
.admin-pane.active { display: block; }
|
||||
|
||||
.admin-search-wrap { margin-bottom: 0.75rem; }
|
||||
|
||||
.admin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-user-row .au-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.admin-user-row .au-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.au-role.root { background: rgba(255, 215, 0, 0.2); color: #ffd700; }
|
||||
.au-role.admin { background: rgba(255, 0, 255, 0.15); color: var(--accent-magenta); }
|
||||
.au-role.mod { background: rgba(0, 200, 83, 0.15); color: #00c853; }
|
||||
.au-role.user { background: rgba(255, 255, 255, 0.08); color: var(--text-dim); }
|
||||
|
||||
.admin-user-row .au-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.au-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.au-badge.online { background: rgba(0, 255, 170, 0.15); color: var(--success-green); }
|
||||
.au-badge.verified { background: rgba(0, 200, 255, 0.1); color: var(--ai-teal); }
|
||||
.au-badge.unverified { background: rgba(255, 51, 102, 0.15); color: var(--error-red); }
|
||||
.au-badge.ai-on { background: rgba(138, 43, 226, 0.15); color: var(--accent-purple); }
|
||||
|
||||
.admin-user-row .au-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.au-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.au-btn:hover { background: var(--accent-purple); color: white; border-color: var(--accent-purple); }
|
||||
.au-btn.danger:hover { background: var(--error-red); border-color: var(--error-red); }
|
||||
|
||||
.au-select {
|
||||
padding: 3px 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-main);
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-ban-row, .admin-mute-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-ban-row .ab-name, .admin-mute-row .am-name {
|
||||
font-weight: 700;
|
||||
color: var(--error-red);
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
margin: 0 2rem 1rem;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 255, 170, 0.15);
|
||||
color: var(--success-green);
|
||||
text-align: center;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
/* ── Theme Picker ─────────────────────────────────────────────────────────── */
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.theme-swatch:hover { transform: scale(1.08); }
|
||||
.theme-swatch.active {
|
||||
border-color: var(--accent-magenta);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.swatch-fill {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.swatch-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nicklist-sidebar {
|
||||
position: absolute;
|
||||
|
|
|
|||
Loading…
Reference in New Issue