v2.0.0
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
# CYBERCHAT v3.0
|
||||||
|
|
||||||
|
A self-contained, embed-ready real-time web chat built with **PHP, JavaScript, HTML5, and SQLite**. No external dependencies beyond PHP 8.0+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Register on the fly** — pick a callsign (username) and passphrase, start chatting instantly
|
||||||
|
- **Session-locked** — one login per user enforced by IP + cookie; no multi-location sessions
|
||||||
|
- **Daily chat rooms** — each day starts fresh; yesterday's messages are automatically archived
|
||||||
|
- **SQLite archives** — stored per `archive/{year}/{month}/{day}.sqlite`; browseable via `archive-viewer.php`
|
||||||
|
- **Neon cyberpunk UI** — dark by default, light mode toggle per user (saved to localStorage)
|
||||||
|
- **Embed-ready** — drop into any `<div>` container; auto-adapts to any size
|
||||||
|
- **Fully configurable** — `config.json` controls all limits, intervals, and theming
|
||||||
|
- **Voice clips** — record, review, send, and queue WAV/WebM clips one at a time using normal PHP polling
|
||||||
|
- **No Node.js or WebSockets** — voice uses browser recording, HTTP uploads, and the existing poll loop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.0+ with **PDO SQLite** extension enabled
|
||||||
|
- Any web server (Apache, Nginx, Caddy, PHP built-in)
|
||||||
|
- Write permissions on `db/`, `archive/`, and `uploads/voice/` directories
|
||||||
|
- PHP/web-server upload limits at or above `voice.max_upload_bytes`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copy files to your web root (or a subdirectory)
|
||||||
|
cp -r cyberchat/ /var/www/html/chat/
|
||||||
|
|
||||||
|
# 2. Make sure database, archive, and voice storage are writable
|
||||||
|
chmod 755 /var/www/html/chat/db/
|
||||||
|
chmod 755 /var/www/html/chat/archive/
|
||||||
|
chmod 755 /var/www/html/chat/uploads/voice/
|
||||||
|
|
||||||
|
# 3. Visit in browser
|
||||||
|
# http://yourdomain.com/chat/
|
||||||
|
```
|
||||||
|
|
||||||
|
The SQLite database is created automatically on first visit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed in Your Own Page
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Stylesheet -->
|
||||||
|
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css">
|
||||||
|
|
||||||
|
<!-- Container — set any size -->
|
||||||
|
<div id="my-chat" style="width:100%; height:600px;"></div>
|
||||||
|
|
||||||
|
<!-- Script -->
|
||||||
|
<script src="/chat/assets/js/cyberchat.js"></script>
|
||||||
|
<script>
|
||||||
|
window.CYBERCHAT_BASE = '/chat'; // path to your cyberchat install
|
||||||
|
CyberChat.init('#my-chat');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
See `embed-example.html` for a live demo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration — `config.json`
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `chat.max_message_length` | 500 | Max characters per message |
|
||||||
|
| `chat.max_username_length` | 12 | Max callsign length |
|
||||||
|
| `chat.poll_interval_ms` | 2000 | How often client polls for new messages (ms) |
|
||||||
|
| `chat.messages_per_page` | 100 | Messages loaded on initial connect |
|
||||||
|
| `chat.session_lock_ip` | true | Enforce single login per IP |
|
||||||
|
| `chat.session_lock_cookie` | true | Enforce single login per cookie |
|
||||||
|
| `voice.enabled` | true | Enable voice clip controls and uploads |
|
||||||
|
| `voice.max_duration_seconds` | 60 | Maximum voice clip duration |
|
||||||
|
| `voice.max_upload_bytes` | 12582912 | Maximum voice upload size |
|
||||||
|
| `voice.auto_play_default` | true | Queue and play new incoming clips one at a time by default |
|
||||||
|
| `voice.upload_dir` | "uploads/voice" | Voice recording storage path |
|
||||||
|
| `security.min_password_length` | 4 | Minimum passphrase length |
|
||||||
|
| `security.session_timeout_hours` | 24 | Session expiry |
|
||||||
|
| `security.bcrypt_cost` | 10 | bcrypt work factor for passwords |
|
||||||
|
| `ui.default_theme` | "dark" | Default theme (`dark` or `light`) |
|
||||||
|
| `ui.app_title` | "CYBERCHAT" | Title shown in header |
|
||||||
|
| `ui.app_subtitle` | "v3.0 // SECURE CHANNEL" | Subtitle shown in header |
|
||||||
|
| `colors.user_palette` | [...] | Array of hex colors assigned randomly to users |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cyberchat/
|
||||||
|
├── index.html ← Main chat page
|
||||||
|
├── embed-example.html ← Embed usage example
|
||||||
|
├── archive-viewer.php ← Browse archived days
|
||||||
|
├── bootstrap.php ← DB init, helpers, session auth
|
||||||
|
├── config.json ← All configuration
|
||||||
|
├── api/
|
||||||
|
│ ├── auth.php ← Register / Login / Logout / Check
|
||||||
|
│ ├── messages.php ← Send / Poll / History
|
||||||
|
│ └── config.php ← Serves safe config to frontend
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/cyberchat.css ← Full cyberpunk stylesheet
|
||||||
|
│ └── js/cyberchat.js ← Chat client (no framework)
|
||||||
|
├── db/
|
||||||
|
│ └── chat.sqlite ← Created automatically; today's messages
|
||||||
|
├── uploads/
|
||||||
|
│ └── voice/ ← WAV/WebM voice recordings
|
||||||
|
└── archive/
|
||||||
|
└── {year}/{month}/ ← Auto-archived per day as .sqlite files
|
||||||
|
└── {day}.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archive Viewer
|
||||||
|
|
||||||
|
Visit `archive-viewer.php` to browse historical chat logs. Select a day from the sidebar to read its messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nginx Config Example
|
||||||
|
|
||||||
|
See `nginx-example.conf` for a ready-to-use virtual host config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Passwords are hashed with **bcrypt** (configurable cost factor)
|
||||||
|
- Session tokens are 64-char cryptographically random hex strings
|
||||||
|
- Session cookies are `HttpOnly`, `SameSite=Strict`, and `Secure` when served over HTTPS
|
||||||
|
- All user input is escaped on output; no raw HTML accepted
|
||||||
|
- Single-login enforcement via IP + cookie prevents session sharing
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
// api/auth.php
|
||||||
|
define('CYBERCHAT_API', true);
|
||||||
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
|
sendCorsHeaders();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'register':
|
||||||
|
handleRegister();
|
||||||
|
break;
|
||||||
|
case 'login':
|
||||||
|
handleLogin();
|
||||||
|
break;
|
||||||
|
case 'logout':
|
||||||
|
handleLogout();
|
||||||
|
break;
|
||||||
|
case 'check':
|
||||||
|
handleCheck();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
jsonResponse(['error' => 'Unknown action'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegister(): never {
|
||||||
|
$db = getDB();
|
||||||
|
$config = getConfig();
|
||||||
|
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
$maxUser = getConfigVal('chat.max_username_length', 12);
|
||||||
|
$minPass = getConfigVal('security.min_password_length', 4);
|
||||||
|
|
||||||
|
if (!$username || !$password) {
|
||||||
|
jsonResponse(['error' => 'Username and password required'], 400);
|
||||||
|
}
|
||||||
|
if (strlen($username) > $maxUser) {
|
||||||
|
jsonResponse(['error' => "Username max {$maxUser} characters"], 400);
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) {
|
||||||
|
jsonResponse(['error' => 'Username: letters, numbers, _ - only'], 400);
|
||||||
|
}
|
||||||
|
if (strlen($password) < $minPass) {
|
||||||
|
jsonResponse(['error' => "Password min {$minPass} characters"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username exists
|
||||||
|
$stmt = $db->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
jsonResponse(['error' => 'Username already taken'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$colors = getConfigVal('colors.user_palette', ['#00ff9f']);
|
||||||
|
$color = $colors[array_rand($colors)];
|
||||||
|
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => getConfigVal('security.bcrypt_cost', 10)]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO users (username, password_hash, color, created_at) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$username, $hash, $color, time()]);
|
||||||
|
$userId = $db->lastInsertId();
|
||||||
|
|
||||||
|
createSession($db, $userId, $username, $color);
|
||||||
|
jsonResponse(['success' => true, 'username' => $username, 'color' => $color]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(): never {
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if (!$username || !$password) {
|
||||||
|
jsonResponse(['error' => 'Username and password required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE");
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
|
jsonResponse(['error' => 'Invalid username or password'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing session (single login enforcement)
|
||||||
|
if (getConfigVal('chat.session_lock_ip') || getConfigVal('chat.session_lock_cookie')) {
|
||||||
|
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
|
||||||
|
$cutoff = time() - $sessionTimeout;
|
||||||
|
|
||||||
|
$stmt2 = $db->prepare("SELECT id, ip FROM sessions WHERE user_id = ? AND last_active > ?");
|
||||||
|
$stmt2->execute([$user['id'], $cutoff]);
|
||||||
|
$existing = $stmt2->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$currentIP = clientIP();
|
||||||
|
// Allow same IP to re-login (refresh), block different IP
|
||||||
|
if (getConfigVal('chat.session_lock_ip') && $existing['ip'] !== $currentIP) {
|
||||||
|
jsonResponse(['error' => 'Already logged in from another location'], 403);
|
||||||
|
}
|
||||||
|
// Kill old session and create new
|
||||||
|
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$user['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession($db, $user['id'], $user['username'], $user['color']);
|
||||||
|
jsonResponse(['success' => true, 'username' => $user['username'], 'color' => $user['color']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(PDO $db, int $userId, string $username, string $color): void {
|
||||||
|
$sid = bin2hex(random_bytes(32));
|
||||||
|
$ip = clientIP();
|
||||||
|
|
||||||
|
// Remove any existing sessions for this user
|
||||||
|
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$userId]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO sessions (id, user_id, ip, created_at, last_active) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$sid, $userId, $ip, time(), time()]);
|
||||||
|
|
||||||
|
// Update last_seen
|
||||||
|
$db->prepare("UPDATE users SET last_seen = ? WHERE id = ?")->execute([time(), $userId]);
|
||||||
|
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
setcookie('cyberchat_sid', $sid, [
|
||||||
|
'expires' => time() + (getConfigVal('security.session_timeout_hours', 24) * 3600),
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Strict',
|
||||||
|
'secure' => $secure,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout(): never {
|
||||||
|
$db = getDB();
|
||||||
|
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
||||||
|
if ($sid) {
|
||||||
|
$db->prepare("DELETE FROM sessions WHERE id = ?")->execute([$sid]);
|
||||||
|
}
|
||||||
|
setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true);
|
||||||
|
jsonResponse(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheck(): never {
|
||||||
|
$db = getDB();
|
||||||
|
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
||||||
|
if (!$sid) jsonResponse(['authenticated' => false]);
|
||||||
|
|
||||||
|
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
|
||||||
|
$cutoff = time() - $sessionTimeout;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.*, u.username, u.color
|
||||||
|
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.id = ? AND s.last_active > ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$sid, $cutoff]);
|
||||||
|
$session = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$session) jsonResponse(['authenticated' => false]);
|
||||||
|
if (getConfigVal('chat.session_lock_ip') && $session['ip'] !== clientIP()) {
|
||||||
|
jsonResponse(['authenticated' => false, 'reason' => 'ip_mismatch']);
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(['authenticated' => true, 'username' => $session['username'], 'color' => $session['color']]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// api/config.php - Serve safe config values to frontend
|
||||||
|
define('CYBERCHAT_API', true);
|
||||||
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
|
sendCorsHeaders();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
$config = getConfig();
|
||||||
|
|
||||||
|
// Only expose safe/needed values to frontend
|
||||||
|
$safe = [
|
||||||
|
'chat' => [
|
||||||
|
'max_message_length' => $config['chat']['max_message_length'] ?? 500,
|
||||||
|
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
||||||
|
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
|
||||||
|
],
|
||||||
|
'ui' => $config['ui'] ?? [],
|
||||||
|
'security' => [
|
||||||
|
'min_password_length' => $config['security']['min_password_length'] ?? 4,
|
||||||
|
],
|
||||||
|
'colors' => $config['colors'] ?? [],
|
||||||
|
'voice' => [
|
||||||
|
'enabled' => $config['voice']['enabled'] ?? true,
|
||||||
|
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
|
||||||
|
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
||||||
|
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode($safe);
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<?php
|
||||||
|
// api/messages.php
|
||||||
|
define('CYBERCHAT_API', true);
|
||||||
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
|
sendCorsHeaders();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
// Run archiving silently
|
||||||
|
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { /* silent */ }
|
||||||
|
|
||||||
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'send':
|
||||||
|
handleSend();
|
||||||
|
break;
|
||||||
|
case 'send_voice':
|
||||||
|
handleSendVoice();
|
||||||
|
break;
|
||||||
|
case 'poll':
|
||||||
|
handlePoll();
|
||||||
|
break;
|
||||||
|
case 'history':
|
||||||
|
handleHistory();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
jsonResponse(['error' => 'Unknown action'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePayload(array $row): array {
|
||||||
|
return [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'username' => $row['username'],
|
||||||
|
'color' => $row['color'],
|
||||||
|
'message' => $row['message'],
|
||||||
|
'message_type' => $row['message_type'] ?? 'text',
|
||||||
|
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
|
||||||
|
'voice_mime' => $row['voice_mime'] ?? null,
|
||||||
|
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null,
|
||||||
|
'created_at' => (int)$row['created_at'],
|
||||||
|
'day_key' => $row['day_key'] ?? dayKey(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSend(): never {
|
||||||
|
$session = authRequired();
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$message = trim($_POST['message'] ?? '');
|
||||||
|
$maxLen = getConfigVal('chat.max_message_length', 500);
|
||||||
|
|
||||||
|
if (!$message) jsonResponse(['error' => 'Empty message'], 400);
|
||||||
|
if (strlen($message) > $maxLen) {
|
||||||
|
jsonResponse(['error' => "Message too long (max {$maxLen} chars)"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayKey = dayKey();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO messages (user_id, username, color, message, created_at, day_key)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$session['uid'],
|
||||||
|
$session['username'],
|
||||||
|
$session['color'],
|
||||||
|
$message,
|
||||||
|
time(),
|
||||||
|
$dayKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
$id = $db->lastInsertId();
|
||||||
|
|
||||||
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
|
'id' => $id, 'username' => $session['username'], 'color' => $session['color'],
|
||||||
|
'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey,
|
||||||
|
])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSendVoice(): never {
|
||||||
|
$session = authRequired();
|
||||||
|
if (!getConfigVal('voice.enabled', true)) {
|
||||||
|
jsonResponse(['error' => 'Voice clips are disabled'], 403);
|
||||||
|
}
|
||||||
|
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
|
||||||
|
jsonResponse(['error' => 'No voice clip uploaded'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['voice'];
|
||||||
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
jsonResponse(['error' => 'Voice upload failed'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
|
||||||
|
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
|
||||||
|
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = (float)($_POST['duration'] ?? 0);
|
||||||
|
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
|
||||||
|
if ($duration <= 0 || $duration > $maxSeconds + 1) {
|
||||||
|
jsonResponse(['error' => "Voice clip must be {$maxSeconds} seconds or less"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = class_exists('finfo')
|
||||||
|
? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name'])
|
||||||
|
: '';
|
||||||
|
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
|
||||||
|
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) {
|
||||||
|
$mime = 'audio/webm';
|
||||||
|
} elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') {
|
||||||
|
$mime = 'audio/wav';
|
||||||
|
}
|
||||||
|
$allowed = [
|
||||||
|
'audio/webm' => 'webm',
|
||||||
|
'video/webm' => 'webm',
|
||||||
|
'audio/wav' => 'wav',
|
||||||
|
'audio/x-wav' => 'wav',
|
||||||
|
'audio/wave' => 'wav',
|
||||||
|
];
|
||||||
|
if (!isset($allowed[$mime])) {
|
||||||
|
jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = voiceUploadDir();
|
||||||
|
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||||
|
jsonResponse(['error' => 'Voice storage is unavailable'], 500);
|
||||||
|
}
|
||||||
|
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
|
||||||
|
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$created = time();
|
||||||
|
$dayKey = dayKey();
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO messages
|
||||||
|
(user_id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key)
|
||||||
|
VALUES (?, ?, ?, ?, 'voice', ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$session['uid'], $session['username'], $session['color'], '[Voice clip]',
|
||||||
|
$filename, $mime === 'video/webm' ? 'audio/webm' : $mime, $duration, $created, $dayKey
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
deleteVoiceFile($filename);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
|
'id' => $db->lastInsertId(), 'username' => $session['username'], 'color' => $session['color'],
|
||||||
|
'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename,
|
||||||
|
'voice_mime' => $mime === 'video/webm' ? 'audio/webm' : $mime,
|
||||||
|
'voice_duration' => $duration, 'created_at' => $created, 'day_key' => $dayKey,
|
||||||
|
])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePoll(): never {
|
||||||
|
$session = authRequired();
|
||||||
|
$db = getDB();
|
||||||
|
|
||||||
|
$since = (int)($_GET['since'] ?? 0);
|
||||||
|
$dayKey = dayKey();
|
||||||
|
$limit = getConfigVal('chat.messages_per_page', 100);
|
||||||
|
|
||||||
|
if ($since === 0) {
|
||||||
|
// Initial load — get last N messages for today
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
|
||||||
|
FROM messages
|
||||||
|
WHERE day_key = ?
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$dayKey, $limit]);
|
||||||
|
$rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
} else {
|
||||||
|
// Poll for new messages since last id
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
|
||||||
|
FROM messages
|
||||||
|
WHERE day_key = ? AND id > ?
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$dayKey, $since, $limit]);
|
||||||
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast types
|
||||||
|
$messages = array_map('messagePayload', $rows);
|
||||||
|
|
||||||
|
// Get online user count (active in last 5 minutes)
|
||||||
|
$cutoff5min = time() - 300;
|
||||||
|
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active > ?");
|
||||||
|
$onlineStmt->execute([$cutoff5min]);
|
||||||
|
$onlineCount = (int)$onlineStmt->fetchColumn();
|
||||||
|
|
||||||
|
jsonResponse([
|
||||||
|
'messages' => $messages,
|
||||||
|
'online' => $onlineCount,
|
||||||
|
'day_key' => $dayKey,
|
||||||
|
'server_time' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistory(): never {
|
||||||
|
// Return list of archived days
|
||||||
|
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
|
||||||
|
$days = [];
|
||||||
|
|
||||||
|
if (is_dir($archiveDir)) {
|
||||||
|
$years = glob($archiveDir . '/*', GLOB_ONLYDIR);
|
||||||
|
foreach ($years as $yearDir) {
|
||||||
|
$year = basename($yearDir);
|
||||||
|
$months = glob($yearDir . '/*', GLOB_ONLYDIR);
|
||||||
|
foreach ($months as $monthDir) {
|
||||||
|
$month = basename($monthDir);
|
||||||
|
$files = glob($monthDir . '/*.sqlite');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$day = basename($file, '.sqlite');
|
||||||
|
$days[] = "$year-$month-$day";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rsort($days);
|
||||||
|
jsonResponse(['days' => $days]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
// api/ping.php — Server diagnostics endpoint
|
||||||
|
// Visit this URL directly in your browser to check if PHP + SQLite are working
|
||||||
|
// e.g. http://yourdomain.com/chat/api/ping.php
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// 1. PHP version
|
||||||
|
$results['php_version'] = PHP_VERSION;
|
||||||
|
$results['php_ok'] = version_compare(PHP_VERSION, '8.0.0', '>=');
|
||||||
|
|
||||||
|
// 2. PDO SQLite
|
||||||
|
$results['pdo_sqlite'] = extension_loaded('pdo_sqlite');
|
||||||
|
|
||||||
|
// 3. ROOT_DIR and config.json
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$configFile = $root . '/config.json';
|
||||||
|
$results['config_exists'] = file_exists($configFile);
|
||||||
|
|
||||||
|
if ($results['config_exists']) {
|
||||||
|
$raw = file_get_contents($configFile);
|
||||||
|
$cfg = json_decode($raw, true);
|
||||||
|
$results['config_valid_json'] = ($cfg !== null);
|
||||||
|
} else {
|
||||||
|
$results['config_valid_json'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. db/ directory writable
|
||||||
|
$dbDir = $root . '/db';
|
||||||
|
if (!is_dir($dbDir)) {
|
||||||
|
@mkdir($dbDir, 0755, true);
|
||||||
|
}
|
||||||
|
$results['db_dir_exists'] = is_dir($dbDir);
|
||||||
|
$results['db_dir_writable'] = is_writable($dbDir);
|
||||||
|
|
||||||
|
// 5. archive/ directory writable
|
||||||
|
$archiveDir = $root . '/archive';
|
||||||
|
if (!is_dir($archiveDir)) {
|
||||||
|
@mkdir($archiveDir, 0755, true);
|
||||||
|
}
|
||||||
|
$results['archive_dir_exists'] = is_dir($archiveDir);
|
||||||
|
$results['archive_dir_writable'] = is_writable($archiveDir);
|
||||||
|
|
||||||
|
// 6. Try creating SQLite DB
|
||||||
|
$results['sqlite_create'] = false;
|
||||||
|
$results['sqlite_error'] = null;
|
||||||
|
if ($results['pdo_sqlite'] && $results['db_dir_writable']) {
|
||||||
|
try {
|
||||||
|
$testDb = new PDO('sqlite:' . $dbDir . '/chat.sqlite');
|
||||||
|
$testDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$testDb->exec('PRAGMA journal_mode=WAL');
|
||||||
|
$testDb->exec('CREATE TABLE IF NOT EXISTS _ping_test (id INTEGER PRIMARY KEY)');
|
||||||
|
$results['sqlite_create'] = true;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$results['sqlite_error'] = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Session support
|
||||||
|
$results['sessions_available'] = function_exists('session_start');
|
||||||
|
|
||||||
|
// 8. Overall status
|
||||||
|
$results['all_ok'] = (
|
||||||
|
$results['php_ok'] &&
|
||||||
|
$results['pdo_sqlite'] &&
|
||||||
|
$results['config_exists'] &&
|
||||||
|
$results['config_valid_json'] &&
|
||||||
|
$results['db_dir_writable'] &&
|
||||||
|
$results['sqlite_create']
|
||||||
|
);
|
||||||
|
|
||||||
|
$results['root_dir'] = $root;
|
||||||
|
$results['server_time'] = date('Y-m-d H:i:s T');
|
||||||
|
|
||||||
|
http_response_code($results['all_ok'] ? 200 : 500);
|
||||||
|
echo json_encode($results, JSON_PRETTY_PRINT);
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
// archive-viewer.php — Browse archived chat logs
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
|
||||||
|
$days = [];
|
||||||
|
if (is_dir($archiveDir)) {
|
||||||
|
foreach (glob($archiveDir . '/*/*/*.sqlite') as $f) {
|
||||||
|
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
|
||||||
|
$days[] = "{$m[1]}-{$m[2]}-{$m[3]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rsort($days);
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedDay = $_GET['day'] ?? null;
|
||||||
|
$archiveMessages = [];
|
||||||
|
$archiveMeta = [];
|
||||||
|
if ($selectedDay && preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDay)) {
|
||||||
|
[$y, $mo, $d] = explode('-', $selectedDay);
|
||||||
|
$dbPath = ROOT_DIR . '/' . str_replace(
|
||||||
|
['{year}', '{month}', '{day}'], [$y, $mo, $d],
|
||||||
|
getConfigVal('database.archive_path')
|
||||||
|
);
|
||||||
|
if (file_exists($dbPath)) {
|
||||||
|
$adb = getArchiveDB($y, $mo, $d);
|
||||||
|
$archiveMessages = $adb->query("SELECT * FROM messages ORDER BY created_at ASC")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$metaRows = $adb->query("SELECT key, value FROM archive_meta")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($metaRows as $mr) $archiveMeta[$mr['key']] = $mr['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?><!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CYBERCHAT // ARCHIVE</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="assets/css/cyberchat.css">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg0);
|
||||||
|
padding: 20px 16px 40px;
|
||||||
|
font-family: var(--ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-wrap { max-width: 920px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.arc-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-back {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--b);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: color 0.14s;
|
||||||
|
}
|
||||||
|
.arc-back:hover { color: var(--g); }
|
||||||
|
|
||||||
|
.arc-title {
|
||||||
|
font-family: var(--disp);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: var(--b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 130px);
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-sidebar, .arc-content {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-sidebar-head, .arc-content-head {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--t1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-days-list { overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
.arc-day-item {
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t1);
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.arc-day-item:hover { color: var(--g); border-left-color: var(--g); background: rgba(0,255,159,0.04); }
|
||||||
|
.arc-day-item.active { color: var(--b); border-left-color: var(--b); background: rgba(0,184,255,0.06); }
|
||||||
|
|
||||||
|
.arc-messages-list { overflow-y: auto; flex: 1; padding: 4px 0; }
|
||||||
|
|
||||||
|
.arc-msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 3px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.arc-msg:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.arc-msg-time { color: var(--t2); font-size: 9px; min-width: 44px; flex-shrink: 0; margin-right: 7px; }
|
||||||
|
.arc-msg-user { font-weight: 600; font-size: 11px; flex-shrink: 0; min-width: 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.arc-msg-sep { color: var(--t2); margin: 0 7px; flex-shrink: 0; }
|
||||||
|
.arc-msg-text { color: var(--t0); flex: 1; word-break: break-word; min-width: 0; }
|
||||||
|
.arc-msg-text audio { width: min(300px, 100%); height: 30px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.arc-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t2);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arc-meta {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--o);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.arc-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; height: auto; }
|
||||||
|
.arc-sidebar { height: 160px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="arc-wrap">
|
||||||
|
<div class="arc-topbar">
|
||||||
|
<a href="index.html" class="arc-back">◂ BACK TO CHAT</a>
|
||||||
|
<span class="arc-title">// ARCHIVE VIEWER</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arc-grid">
|
||||||
|
<div class="arc-sidebar">
|
||||||
|
<div class="arc-sidebar-head">// DAYS</div>
|
||||||
|
<div class="arc-days-list">
|
||||||
|
<?php if (empty($days)): ?>
|
||||||
|
<div class="arc-empty">NO ARCHIVES YET</div>
|
||||||
|
<?php else: foreach ($days as $day): ?>
|
||||||
|
<div class="arc-day-item <?= $day === $selectedDay ? 'active' : '' ?>"
|
||||||
|
onclick="location.href='?day=<?= htmlspecialchars($day) ?>'">
|
||||||
|
<?= htmlspecialchars($day) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="arc-content">
|
||||||
|
<div class="arc-content-head">// MESSAGES</div>
|
||||||
|
<?php if (!$selectedDay): ?>
|
||||||
|
<div class="arc-empty">SELECT A DAY FROM THE LIST</div>
|
||||||
|
<?php elseif (empty($archiveMessages)): ?>
|
||||||
|
<div class="arc-empty">NO MESSAGES FOR <?= htmlspecialchars($selectedDay) ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="arc-meta">
|
||||||
|
// <?= htmlspecialchars($selectedDay) ?>
|
||||||
|
| <?= count($archiveMessages) ?> MESSAGES
|
||||||
|
<?php if (!empty($archiveMeta['archived_at'])): ?>
|
||||||
|
| ARCHIVED <?= date('Y-m-d H:i', (int)$archiveMeta['archived_at']) ?> UTC
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="arc-messages-list">
|
||||||
|
<?php foreach ($archiveMessages as $msg): ?>
|
||||||
|
<div class="arc-msg">
|
||||||
|
<span class="arc-msg-time"><?= date('H:i', (int)$msg['created_at']) ?></span>
|
||||||
|
<span class="arc-msg-user" style="color:<?= htmlspecialchars($msg['color']) ?>"><?= htmlspecialchars($msg['username']) ?></span>
|
||||||
|
<span class="arc-msg-sep">›</span>
|
||||||
|
<span class="arc-msg-text">
|
||||||
|
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
||||||
|
<audio controls preload="metadata"
|
||||||
|
src="<?= htmlspecialchars(trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= htmlspecialchars($msg['message']) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Chat logs are archived here automatically, organized as:
|
||||||
|
archive/{year}/{month}/{day}.sqlite
|
||||||
|
|
||||||
|
Each file is a read-only snapshot of that day's messages.
|
||||||
|
Ensure this directory is writable by your web server (chmod 755).
|
||||||
@@ -0,0 +1,986 @@
|
|||||||
|
/* ─── CyberChat CSS v3.0 — Cyberpunk Dark Theme ─────────────────────────── */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap');
|
||||||
|
|
||||||
|
/* ─── Variables ──────────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--g: #00ff9f; /* neon green */
|
||||||
|
--b: #00b8ff; /* neon blue */
|
||||||
|
--p: #ff2d78; /* neon pink */
|
||||||
|
--o: #ff9f00; /* neon orange */
|
||||||
|
--r: #ff3c3c; /* neon red */
|
||||||
|
--v: #bf5fff; /* neon violet */
|
||||||
|
--c: #00ffff; /* neon cyan */
|
||||||
|
|
||||||
|
--bg0: #05080d;
|
||||||
|
--bg1: #080c12;
|
||||||
|
--bg2: #0c1118;
|
||||||
|
--bg3: #101820;
|
||||||
|
--bgp: #090d14;
|
||||||
|
--bgi: #0b101a;
|
||||||
|
|
||||||
|
--bd: #182030;
|
||||||
|
--bdg: rgba(0,255,159,0.18);
|
||||||
|
|
||||||
|
--t0: #c8e6ff;
|
||||||
|
--t1: #4d6a88;
|
||||||
|
--t2: #1e2e40;
|
||||||
|
|
||||||
|
--mono: 'Share Tech Mono', monospace;
|
||||||
|
--ui: 'Rajdhani', sans-serif;
|
||||||
|
--disp: 'Orbitron', monospace;
|
||||||
|
|
||||||
|
--glowg: 0 0 8px #00ff9f99, 0 0 22px #00ff9f33;
|
||||||
|
--glowb: 0 0 8px #00b8ff99, 0 0 22px #00b8ff33;
|
||||||
|
--glowp: 0 0 8px #ff2d7899, 0 0 22px #ff2d7833;
|
||||||
|
--glowr: 0 0 8px #ff3c3c99, 0 0 22px #ff3c3c33;
|
||||||
|
|
||||||
|
--r1: 3px;
|
||||||
|
--r2: 6px;
|
||||||
|
--ease: 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Light mode overrides ───────────────────────────────────────────────── */
|
||||||
|
body.cc-light {
|
||||||
|
--bg0: #edf1f7;
|
||||||
|
--bg1: #f4f7fc;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--bg3: #e8edf5;
|
||||||
|
--bgp: #f0f4fa;
|
||||||
|
--bgi: #ffffff;
|
||||||
|
--bd: #ccd7e6;
|
||||||
|
--bdg: rgba(0,138,220,0.25);
|
||||||
|
--t0: #1a2a3c;
|
||||||
|
--t1: #4a6888;
|
||||||
|
--t2: #aabcce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reset ──────────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
button { cursor: pointer; font-family: inherit; }
|
||||||
|
a { color: inherit; }
|
||||||
|
input, button { font-family: inherit; }
|
||||||
|
|
||||||
|
/* ─── Root container (embed-ready) ──────────────────────────────────────── */
|
||||||
|
#cyberchat-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
background: var(--bg0);
|
||||||
|
color: var(--t0);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT scanline overlay */
|
||||||
|
#cyberchat-root::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
transparent 3px,
|
||||||
|
rgba(0,0,0,0.035) 3px,
|
||||||
|
rgba(0,0,0,0.035) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ──────────────────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bgp); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 2px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(0,184,255,0.4); }
|
||||||
|
|
||||||
|
/* ─── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
.cc-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 46px;
|
||||||
|
background: var(--bgp);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; left: 0; right: 0; height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, var(--g) 25%, var(--b) 50%, var(--p) 75%, transparent 100%);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-logo-glyph {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--g);
|
||||||
|
text-shadow: var(--glowg);
|
||||||
|
animation: logo-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-pulse {
|
||||||
|
0%,100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-logo-text {
|
||||||
|
font-family: var(--disp);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--g), var(--b));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-logo-sub {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-header-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-online-num {
|
||||||
|
color: var(--g);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-online-label {
|
||||||
|
color: var(--t1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-conn-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--g);
|
||||||
|
box-shadow: var(--glowg);
|
||||||
|
animation: dot-blink 2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-conn-dot.offline { background: var(--r); box-shadow: var(--glowr); animation: none; }
|
||||||
|
.cc-conn-dot.online { background: var(--g); box-shadow: var(--glowg); }
|
||||||
|
|
||||||
|
@keyframes dot-blink {
|
||||||
|
0%,100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-header-right { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.cc-theme-btn {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-theme-btn:hover {
|
||||||
|
border-color: var(--b);
|
||||||
|
color: var(--b);
|
||||||
|
box-shadow: var(--glowb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Body area ──────────────────────────────────────────────────────────── */
|
||||||
|
.cc-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TOAST NOTIFICATIONS ────────────────────────────────────────────────── */
|
||||||
|
.cc-toast-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 54px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 88%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-toast {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
text-align: center;
|
||||||
|
animation: toastIn 0.18s ease;
|
||||||
|
transition: opacity 0.32s, transform 0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-toast.error { border-color: var(--r); color: var(--r); }
|
||||||
|
.cc-toast.success { border-color: var(--g); color: var(--g); }
|
||||||
|
.cc-toast.info { border-color: var(--b); color: var(--b); }
|
||||||
|
.cc-toast-fade { opacity: 0; transform: translateY(-8px); }
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────────
|
||||||
|
AUTH SCREEN
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
.cc-auth-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: radial-gradient(ellipse at 50% 30%, rgba(0,184,255,0.04) 0%, transparent 70%),
|
||||||
|
var(--bg0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 40px rgba(0,0,0,0.5);
|
||||||
|
animation: panelIn 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px 0;
|
||||||
|
font-family: var(--disp);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: var(--b);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-brand-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--b));
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-brand-line:last-child {
|
||||||
|
background: linear-gradient(90deg, var(--b), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.cc-auth-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
margin: 14px 20px 0;
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-tab {
|
||||||
|
padding: 9px 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--ui);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all var(--ease);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-tab:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--bd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-tab .cc-tab-icon {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-tab.active {
|
||||||
|
background: var(--g);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-tab:not(.active):hover {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.cc-auth-form {
|
||||||
|
display: none;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-form.active {
|
||||||
|
display: block;
|
||||||
|
animation: formSlide 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes formSlide {
|
||||||
|
from { opacity: 0; transform: translateX(6px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-form-hint {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field {
|
||||||
|
margin-bottom: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-field-note {
|
||||||
|
color: var(--t2);
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bgi);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
color: var(--t0);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--ease), box-shadow var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input:focus {
|
||||||
|
border-color: var(--b);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,184,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input::placeholder { color: var(--t2); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.cc-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r1);
|
||||||
|
font-family: var(--disp);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all var(--ease);
|
||||||
|
margin-top: 6px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--ease);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn:hover::before { opacity: 1; }
|
||||||
|
.cc-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.cc-btn:disabled::before { display: none; }
|
||||||
|
|
||||||
|
.cc-btn-register {
|
||||||
|
background: var(--g);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 0 15px rgba(0,255,159,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn-register:hover:not(:disabled) {
|
||||||
|
box-shadow: var(--glowg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn-login {
|
||||||
|
background: var(--b);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 0 15px rgba(0,184,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn-login:hover:not(:disabled) {
|
||||||
|
box-shadow: var(--glowb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-btn-inner { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.cc-auth-error {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--r);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 20px 14px;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-auth-error.visible {
|
||||||
|
max-height: 40px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────────
|
||||||
|
CHAT SCREEN
|
||||||
|
────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* User bar */
|
||||||
|
.cc-userbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bgp);
|
||||||
|
border-bottom: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-userbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-you-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-you-name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-day-tag {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-userbar-right { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.cc-logout-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--r);
|
||||||
|
color: var(--r);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: var(--r1);
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-logout-btn:hover {
|
||||||
|
background: var(--r);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: var(--glowr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.cc-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 0 4px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 2px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
.cc-msg-time {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t2);
|
||||||
|
min-width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 7px;
|
||||||
|
padding-top: 2px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg-user {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 86px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg-sep {
|
||||||
|
color: var(--t2);
|
||||||
|
margin: 0 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg-text {
|
||||||
|
color: var(--t0);
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg-own .cc-msg-text { color: #ddeeff; }
|
||||||
|
|
||||||
|
.cc-voice-message {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(100%, 390px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-label {
|
||||||
|
color: var(--b);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-message audio {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
height: 30px;
|
||||||
|
accent-color: var(--g);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-msg-voice-playing {
|
||||||
|
background: rgba(0,255,159,0.06);
|
||||||
|
box-shadow: inset 2px 0 0 var(--g);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New message animation */
|
||||||
|
.cc-msg-new {
|
||||||
|
animation: msgIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes msgIn {
|
||||||
|
from { opacity: 0; transform: translateX(-5px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / empty states */
|
||||||
|
.cc-loading-msg {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-spin {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border: 2px solid var(--bd);
|
||||||
|
border-top-color: var(--g);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.cc-empty-day {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t2);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll-to-bottom */
|
||||||
|
.cc-scroll-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 56px;
|
||||||
|
right: 12px;
|
||||||
|
width: 27px; height: 27px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
color: var(--b);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: var(--glowb);
|
||||||
|
transition: all var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-body.cc-has-voice .cc-scroll-to-bottom { bottom: 100px; }
|
||||||
|
|
||||||
|
.cc-scroll-to-bottom.visible { display: flex; }
|
||||||
|
.cc-scroll-to-bottom:hover { background: var(--b); color: #000; }
|
||||||
|
|
||||||
|
/* Voice compose */
|
||||||
|
.cc-voice-compose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border-top: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-record {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--p);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
color: var(--p);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-record:hover,
|
||||||
|
.cc-voice-record.recording {
|
||||||
|
background: rgba(255,45,120,0.12);
|
||||||
|
box-shadow: var(--glowp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-record:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-record.recording .cc-voice-dot {
|
||||||
|
animation: dot-blink 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-status {
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-queue {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--b);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
color: var(--b);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-queue span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,184,255,0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-queue:hover:not(:disabled),
|
||||||
|
.cc-voice-queue.blocked {
|
||||||
|
background: rgba(0,184,255,0.1);
|
||||||
|
box-shadow: var(--glowb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-queue.playing {
|
||||||
|
border-color: var(--g);
|
||||||
|
color: var(--g);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-queue:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-autoplay {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--t1);
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-autoplay input { accent-color: var(--g); }
|
||||||
|
|
||||||
|
.cc-voice-review {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-review[hidden] { display: none; }
|
||||||
|
.cc-voice-review audio { width: 170px; height: 28px; accent-color: var(--g); }
|
||||||
|
|
||||||
|
.cc-voice-action {
|
||||||
|
height: 27px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--t1);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--t1);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-voice-action.send { border-color: var(--g); color: var(--g); }
|
||||||
|
.cc-voice-action:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Compose bar */
|
||||||
|
.cc-compose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bgp);
|
||||||
|
border-top: 1px solid var(--bd);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0,184,255,0.45), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bgi);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
color: var(--t0);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
outline: none;
|
||||||
|
height: 36px;
|
||||||
|
transition: border-color var(--ease), box-shadow var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-input:focus {
|
||||||
|
border-color: var(--g);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0,255,159,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-input::placeholder { color: var(--t2); }
|
||||||
|
|
||||||
|
.cc-compose-counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t2);
|
||||||
|
min-width: 34px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-counter.warn { color: var(--o); }
|
||||||
|
.cc-compose-counter.danger { color: var(--r); }
|
||||||
|
|
||||||
|
.cc-compose-send {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--g);
|
||||||
|
color: var(--g);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-send:hover {
|
||||||
|
background: var(--g);
|
||||||
|
color: #000;
|
||||||
|
box-shadow: var(--glowg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-compose-send:disabled {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.cc-logo-sub { display: none; }
|
||||||
|
.cc-day-tag { display: none; }
|
||||||
|
.cc-msg-time { min-width: 36px; font-size: 8px; }
|
||||||
|
.cc-msg-user { max-width: 66px; font-size: 11px; }
|
||||||
|
.cc-auth-panel { max-width: 100%; }
|
||||||
|
.cc-auth-wrap { padding: 12px 10px; }
|
||||||
|
.cc-auth-form { padding: 14px 14px 16px; }
|
||||||
|
.cc-voice-compose { flex-wrap: wrap; }
|
||||||
|
.cc-voice-status { flex: 1; }
|
||||||
|
.cc-voice-queue { order: 3; }
|
||||||
|
.cc-voice-autoplay { margin-left: 0; }
|
||||||
|
.cc-voice-review { width: 100%; }
|
||||||
|
.cc-voice-review audio { flex: 1; min-width: 100px; }
|
||||||
|
.cc-voice-message { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 380px) {
|
||||||
|
.cc-header { height: 36px; }
|
||||||
|
.cc-logo-sub { display: none; }
|
||||||
|
.cc-auth-wrap { align-items: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full-page (index.html) embed ───────────────────────────────────────── */
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+300
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
// bootstrap.php — Core helpers, DB init, session auth
|
||||||
|
|
||||||
|
define('ROOT_DIR', __DIR__);
|
||||||
|
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
||||||
|
|
||||||
|
// ─── Error handling: always return JSON, never raw PHP errors ────────────
|
||||||
|
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
|
||||||
|
// Only intercept fatal-ish errors during API requests
|
||||||
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Server error: ' . $errstr . ' in ' . basename($errfile) . ':' . $errline]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function(Throwable $e): void {
|
||||||
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Server exception: ' . $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── CORS headers (needed if embedding cross-origin) ─────────────────────
|
||||||
|
function sendCorsHeaders(): void {
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
// Allow same-origin and any origin configured in config
|
||||||
|
// For strictest security, lock this to your domain
|
||||||
|
if ($origin) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
||||||
|
}
|
||||||
|
// Handle preflight
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config ───────────────────────────────────────────────────────────────
|
||||||
|
function getConfig(): array {
|
||||||
|
static $config = null;
|
||||||
|
if ($config === null) {
|
||||||
|
if (!file_exists(CONFIG_FILE)) {
|
||||||
|
throw new RuntimeException('config.json not found at: ' . CONFIG_FILE);
|
||||||
|
}
|
||||||
|
$raw = file_get_contents(CONFIG_FILE);
|
||||||
|
$config = json_decode($raw, true);
|
||||||
|
if ($config === null) {
|
||||||
|
throw new RuntimeException('config.json is not valid JSON: ' . json_last_error_msg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigVal(string $path, mixed $default = null): mixed {
|
||||||
|
$config = getConfig();
|
||||||
|
$keys = explode('.', $path);
|
||||||
|
$val = $config;
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!is_array($val) || !array_key_exists($key, $val)) return $default;
|
||||||
|
$val = $val[$key];
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Database ─────────────────────────────────────────────────────────────
|
||||||
|
function getDB(): PDO {
|
||||||
|
static $db = null;
|
||||||
|
if ($db === null) {
|
||||||
|
$dbRelPath = getConfigVal('database.main_db', 'db/chat.sqlite');
|
||||||
|
$dbPath = ROOT_DIR . '/' . ltrim($dbRelPath, '/');
|
||||||
|
$dir = dirname($dbPath);
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
if (!mkdir($dir, 0755, true)) {
|
||||||
|
throw new RuntimeException("Cannot create db directory: $dir — check permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_writable($dir)) {
|
||||||
|
throw new RuntimeException("db directory is not writable: $dir — run: chmod 755 $dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = new PDO('sqlite:' . $dbPath);
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$db->exec('PRAGMA journal_mode=WAL');
|
||||||
|
$db->exec('PRAGMA synchronous=NORMAL');
|
||||||
|
$db->exec('PRAGMA foreign_keys=ON');
|
||||||
|
initDB($db);
|
||||||
|
}
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDB(PDO $db): void {
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
last_seen INTEGER
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
last_active INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
voice_file TEXT,
|
||||||
|
voice_mime TEXT,
|
||||||
|
voice_duration REAL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
day_key TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
)");
|
||||||
|
|
||||||
|
ensureMessageColumns($db);
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMessageColumns(PDO $db): void {
|
||||||
|
$columns = [];
|
||||||
|
foreach ($db->query("PRAGMA table_info(messages)") as $column) {
|
||||||
|
$columns[$column['name']] = true;
|
||||||
|
}
|
||||||
|
$additions = [
|
||||||
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
|
'voice_file' => 'TEXT',
|
||||||
|
'voice_mime' => 'TEXT',
|
||||||
|
'voice_duration' => 'REAL',
|
||||||
|
];
|
||||||
|
foreach ($additions as $name => $definition) {
|
||||||
|
if (!isset($columns[$name])) {
|
||||||
|
$db->exec("ALTER TABLE messages ADD COLUMN $name $definition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function voiceUploadDir(): string {
|
||||||
|
$relative = getConfigVal('voice.upload_dir', 'uploads/voice');
|
||||||
|
return ROOT_DIR . '/' . trim((string)$relative, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function voicePublicPath(string $filename): string {
|
||||||
|
$relative = trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/');
|
||||||
|
return $relative . '/' . rawurlencode($filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVoiceFile(?string $filename): void {
|
||||||
|
if (!$filename || basename($filename) !== $filename) return;
|
||||||
|
$path = voiceUploadDir() . '/' . $filename;
|
||||||
|
if (is_file($path)) unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Archive DB ───────────────────────────────────────────────────────────
|
||||||
|
function getArchiveDB(string $year, string $month, string $day): PDO {
|
||||||
|
$tpl = getConfigVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
||||||
|
$relPath = str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl);
|
||||||
|
$fullPath = ROOT_DIR . '/' . ltrim($relPath, '/');
|
||||||
|
$dir = dirname($fullPath);
|
||||||
|
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||||
|
|
||||||
|
$adb = new PDO('sqlite:' . $fullPath);
|
||||||
|
$adb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$adb->exec('PRAGMA journal_mode=WAL');
|
||||||
|
|
||||||
|
$adb->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
voice_file TEXT,
|
||||||
|
voice_mime TEXT,
|
||||||
|
voice_duration REAL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
day_key TEXT NOT NULL
|
||||||
|
)");
|
||||||
|
ensureMessageColumns($adb);
|
||||||
|
$adb->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)");
|
||||||
|
|
||||||
|
return $adb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function dayKey(): string {
|
||||||
|
return date('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIP(): string {
|
||||||
|
foreach (['HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR','HTTP_X_REAL_IP','REMOTE_ADDR'] as $key) {
|
||||||
|
if (!empty($_SERVER[$key])) {
|
||||||
|
$ip = trim(explode(',', $_SERVER[$key])[0]);
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(array $data, int $code = 200): never {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth guard ───────────────────────────────────────────────────────────
|
||||||
|
function authRequired(): array {
|
||||||
|
$db = getDB();
|
||||||
|
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
||||||
|
|
||||||
|
if (!$sid) {
|
||||||
|
jsonResponse(['error' => 'Not authenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timeout = (int)getConfigVal('security.session_timeout_hours', 24) * 3600;
|
||||||
|
$cutoff = time() - $timeout;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT s.id, s.ip, s.last_active,
|
||||||
|
u.id AS uid, u.username, u.color
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.id = ? AND s.last_active > ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$sid, $cutoff]);
|
||||||
|
$session = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
jsonResponse(['error' => 'Session expired'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getConfigVal('chat.session_lock_ip', true) && $session['ip'] !== clientIP()) {
|
||||||
|
jsonResponse(['error' => 'Session IP mismatch — logged in elsewhere'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("UPDATE sessions SET last_active = ? WHERE id = ?")->execute([time(), $sid]);
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Daily archive ────────────────────────────────────────────────────────
|
||||||
|
function archiveYesterdayIfNeeded(): void {
|
||||||
|
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
$flagFile = ROOT_DIR . '/db/.archived_' . $yesterday;
|
||||||
|
if (file_exists($flagFile)) return;
|
||||||
|
|
||||||
|
$db = getDB();
|
||||||
|
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key = ? ORDER BY created_at ASC");
|
||||||
|
$stmt->execute([$yesterday]);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
if (!empty($rows)) {
|
||||||
|
[$y, $m, $d] = explode('-', $yesterday);
|
||||||
|
$adb = getArchiveDB($y, $m, $d);
|
||||||
|
$adb->beginTransaction();
|
||||||
|
$ins = $adb->prepare("INSERT OR IGNORE INTO messages
|
||||||
|
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$ins->execute([
|
||||||
|
$r['id'],$r['user_id'],$r['username'],$r['color'],$r['message'],
|
||||||
|
$r['message_type'] ?? 'text',$r['voice_file'] ?? null,$r['voice_mime'] ?? null,
|
||||||
|
$r['voice_duration'] ?? null,$r['created_at'],$r['day_key']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('archived_at','" . time() . "')");
|
||||||
|
$adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('message_count','" . count($rows) . "')");
|
||||||
|
$adb->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->prepare("DELETE FROM messages WHERE day_key = ?")->execute([$yesterday]);
|
||||||
|
file_put_contents($flagFile, date('c'));
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"chat": {
|
||||||
|
"max_message_length": 500,
|
||||||
|
"max_username_length": 12,
|
||||||
|
"poll_interval_ms": 2000,
|
||||||
|
"messages_per_page": 100,
|
||||||
|
"session_lock_ip": true,
|
||||||
|
"session_lock_cookie": true,
|
||||||
|
"allow_guest": false
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"enabled": true,
|
||||||
|
"archive_dir": "archive",
|
||||||
|
"format": "sqlite"
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_duration_seconds": 60,
|
||||||
|
"max_upload_bytes": 12582912,
|
||||||
|
"auto_play_default": true,
|
||||||
|
"upload_dir": "uploads/voice"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"main_db": "db/chat.sqlite",
|
||||||
|
"archive_path": "archive/{year}/{month}/{day}.sqlite"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"default_theme": "dark",
|
||||||
|
"allow_theme_toggle": true,
|
||||||
|
"app_title": "Chat : )",
|
||||||
|
"app_subtitle": "Be kind."
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"min_password_length": 4,
|
||||||
|
"max_login_attempts": 10,
|
||||||
|
"session_timeout_hours": 24,
|
||||||
|
"bcrypt_cost": 10
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"user_palette": [
|
||||||
|
"#00ff9f",
|
||||||
|
"#00b8ff",
|
||||||
|
"#ff2d78",
|
||||||
|
"#ff9f00",
|
||||||
|
"#ff3c3c",
|
||||||
|
"#bf5fff",
|
||||||
|
"#00ffff",
|
||||||
|
"#ffff00",
|
||||||
|
"#ff6b35",
|
||||||
|
"#39ff14"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CyberChat — Embed Example</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="assets/css/cyberchat.css">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg0);
|
||||||
|
padding: 32px 20px 48px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-page { max-width: 860px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.demo-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-header h1 {
|
||||||
|
font-family: var(--disp);
|
||||||
|
font-size: clamp(18px, 4vw, 32px);
|
||||||
|
background: linear-gradient(90deg, var(--g), var(--b), var(--p));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-header p {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t1);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--g);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── THE EMBED CONTAINER — set any size you want ── */
|
||||||
|
#chat-here {
|
||||||
|
width: 100%;
|
||||||
|
height: 580px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-code {
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bd);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t1);
|
||||||
|
line-height: 1.9;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-code .kw { color: var(--p); }
|
||||||
|
.demo-code .str { color: var(--g); }
|
||||||
|
.demo-code .cm { color: var(--t2); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="demo-page">
|
||||||
|
<div class="demo-header">
|
||||||
|
<h1>CYBERCHAT</h1>
|
||||||
|
<p>// EMBED EXAMPLE — DROP INTO ANY PAGE</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-label">▸ LIVE EMBED (580px height, full width)</div>
|
||||||
|
|
||||||
|
<!-- ── This is all you need in your page ── -->
|
||||||
|
<div id="chat-here"></div>
|
||||||
|
|
||||||
|
<div class="demo-code"><span class="cm"><!-- 1. Google Fonts (optional but recommended) --></span>
|
||||||
|
<span class="kw"><link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">></span>
|
||||||
|
|
||||||
|
<span class="cm"><!-- 2. CyberChat stylesheet --></span>
|
||||||
|
<span class="kw"><link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css"</span><span class="kw">></span>
|
||||||
|
|
||||||
|
<span class="cm"><!-- 3. Container with any dimensions --></span>
|
||||||
|
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||||
|
|
||||||
|
<span class="cm"><!-- 4. Script + init --></span>
|
||||||
|
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat.js"</span><span class="kw">></script></span>
|
||||||
|
<span class="kw"><script></span>
|
||||||
|
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
||||||
|
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||||
|
<span class="kw"></script></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.CYBERCHAT_BASE = '';
|
||||||
|
</script>
|
||||||
|
<script src="assets/js/cyberchat.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
CyberChat.init('#chat-here');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="description" content="CyberChat — Secure Real-Time Chat">
|
||||||
|
<title>Chat @ TyClifford.com</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="assets/css/cyberchat.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
window.CYBERCHAT_BASE = '';
|
||||||
|
</script>
|
||||||
|
<script src="assets/js/cyberchat.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
CyberChat.init('#app');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# nginx-example.conf — Example Nginx virtual host for CyberChat
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name chat.yourdomain.com;
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name chat.yourdomain.com;
|
||||||
|
|
||||||
|
# SSL — update paths to your certs
|
||||||
|
ssl_certificate /etc/letsencrypt/live/chat.yourdomain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/chat.yourdomain.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
root /var/www/html/cyberchat;
|
||||||
|
index index.html index.php;
|
||||||
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
# Serve static files directly
|
||||||
|
location ~* \.(css|js|ico|png|jpg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Never execute files from the voice upload directory
|
||||||
|
location ~ ^/uploads/voice/.*\.(php|phtml|phar|cgi|pl|py|sh)$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP files
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass unix:/run/php/php8.1-fpm.sock; # adjust PHP version
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block direct access to db/ and archive/ directories
|
||||||
|
location ~ ^/(db|archive)/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block .sqlite files from being served directly
|
||||||
|
location ~* \.sqlite$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block dotfiles such as .htaccess and .gitignore
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!.htaccess
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Options -Indexes
|
||||||
|
<FilesMatch "\.(php|phtml|phar|cgi|pl|py|sh)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
Reference in New Issue
Block a user