This commit is contained in:
Ty Clifford
2026-06-08 12:26:08 -04:00
commit 33a176f18e
19 changed files with 5342 additions and 0 deletions
+143
View File
@@ -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
+1878
View File
File diff suppressed because it is too large Load Diff
+168
View File
@@ -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']]);
}
+32
View File
@@ -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);
+234
View File
@@ -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]);
}
+80
View File
@@ -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);
+225
View File
@@ -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) ?>
&nbsp;|&nbsp; <?= count($archiveMessages) ?> MESSAGES
<?php if (!empty($archiveMeta['archived_at'])): ?>
&nbsp;|&nbsp; 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>
View File
+5
View File
@@ -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).
+986
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
}
View File
+114
View File
@@ -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">&lt;!-- 1. Google Fonts (optional but recommended) --&gt;</span>
<span class="kw">&lt;link</span> href=<span class="str">"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Rajdhani:wght@400;600;700&amp;family=Orbitron:wght@700;900&amp;display=swap"</span> rel=<span class="str">"stylesheet"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 2. CyberChat stylesheet --&gt;</span>
<span class="kw">&lt;link</span> rel=<span class="str">"stylesheet"</span> href=<span class="str">"assets/css/cyberchat.css"</span><span class="kw">&gt;</span>
<span class="cm">&lt;!-- 3. Container with any dimensions --&gt;</span>
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat.js"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</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">&lt;/script&gt;</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
View File
@@ -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>
+66
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!.htaccess
+4
View File
@@ -0,0 +1,4 @@
Options -Indexes
<FilesMatch "\.(php|phtml|phar|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>