From 33a176f18e7652721800ee8c18d0eac7bf25b1c0 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 12:26:08 -0400 Subject: [PATCH] v2.0.0 --- README.md | 143 +++ admin.php | 1878 ++++++++++++++++++++++++++++++++++++++ api/auth.php | 168 ++++ api/config.php | 32 + api/messages.php | 234 +++++ api/ping.php | 80 ++ archive-viewer.php | 225 +++++ archive/.gitkeep | 0 archive/README.txt | 5 + assets/css/cyberchat.css | 986 ++++++++++++++++++++ assets/js/cyberchat.js | 1027 +++++++++++++++++++++ bootstrap.php | 300 ++++++ config.json | 53 ++ db/.gitkeep | 0 embed-example.html | 114 +++ index.html | 24 + nginx-example.conf | 66 ++ uploads/voice/.gitignore | 3 + uploads/voice/.htaccess | 4 + 19 files changed, 5342 insertions(+) create mode 100644 README.md create mode 100644 admin.php create mode 100644 api/auth.php create mode 100644 api/config.php create mode 100644 api/messages.php create mode 100644 api/ping.php create mode 100644 archive-viewer.php create mode 100644 archive/.gitkeep create mode 100644 archive/README.txt create mode 100644 assets/css/cyberchat.css create mode 100644 assets/js/cyberchat.js create mode 100644 bootstrap.php create mode 100644 config.json create mode 100644 db/.gitkeep create mode 100644 embed-example.html create mode 100644 index.html create mode 100644 nginx-example.conf create mode 100644 uploads/voice/.gitignore create mode 100644 uploads/voice/.htaccess diff --git a/README.md b/README.md new file mode 100644 index 0000000..367020b --- /dev/null +++ b/README.md @@ -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 `
` 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 + + + + + + + +
+ + + + +``` + +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 diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..506a1ff --- /dev/null +++ b/admin.php @@ -0,0 +1,1878 @@ +Database not found at: ' . esc($path) . '
'); + $d = new PDO('sqlite:' . $path); + $d->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $d->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + ensureAdminMessageColumns($d); + return $d; +} + +function ensureAdminMessageColumns(PDO $d): void { + $columns = []; + foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true; + foreach ([ + 'message_type' => "TEXT NOT NULL DEFAULT 'text'", + 'voice_file' => 'TEXT', + 'voice_mime' => 'TEXT', + 'voice_duration' => 'REAL', + ] as $name => $definition) { + if (!isset($columns[$name])) $d->exec("ALTER TABLE messages ADD COLUMN $name $definition"); + } +} + +function archiveDB(string $year, string $month, string $day): ?PDO { + $tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); + $path = ROOT_DIR . '/' . ltrim(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl), '/'); + if (!file_exists($path)) return null; + $a = new PDO('sqlite:' . $path); + $a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + ensureAdminMessageColumns($a); + return $a; +} + +function createArchiveDB(string $year, string $month, string $day): PDO { + $tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); + $path = ROOT_DIR . '/' . ltrim(str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl), '/'); + $dir = dirname($path); + if (!is_dir($dir) && !mkdir($dir, 0755, true)) { + throw new RuntimeException('Could not create archive directory.'); + } + + $a = new PDO('sqlite:' . $path); + $a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $a->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $a->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 + )"); + ensureAdminMessageColumns($a); + $a->exec("CREATE TABLE IF NOT EXISTS archive_meta ( + key TEXT PRIMARY KEY, + value TEXT + )"); + return $a; +} + +function esc(mixed $v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); } +function fmtTime(int $ts): string { return date('Y-m-d H:i:s', $ts); } +function ago(int $ts): string { + $d = time() - $ts; if ($d < 60) return $d . 's ago'; + if ($d < 3600) return floor($d/60) . 'm ago'; + if ($d < 86400) return floor($d/3600) . 'h ago'; + return floor($d/86400) . 'd ago'; +} + +function flash(string $msg, string $type = 'ok'): void { + $_SESSION['flash'] = ['msg' => $msg, 'type' => $type]; +} +function getFlash(): ?array { + $f = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $f; +} + +function redirect(string $qs = ''): never { + header('Location: admin.php' . ($qs ? '?' . $qs : '')); exit; +} + +function csrfToken(): string { + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); + return $_SESSION['csrf']; +} +function csrfCheck(): void { + if (($_POST['csrf'] ?? '') !== ($_SESSION['csrf'] ?? '')) { + flash('Invalid CSRF token.', 'err'); redirect(); + } +} + +function deleteRecording(?string $filename): void { + if (!$filename || basename($filename) !== $filename) return; + $dir = ROOT_DIR . '/' . trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/'); + $path = $dir . '/' . $filename; + if (is_file($path)) unlink($path); +} + +function deleteRecordingsForRows(array $rows): void { + foreach ($rows as $row) deleteRecording($row['voice_file'] ?? null); +} + +function voiceFileUrl(?string $filename): string { + if (!$filename || basename($filename) !== $filename) return ''; + return trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($filename); +} + +function voiceFileSize(?string $filename): ?int { + if (!$filename || basename($filename) !== $filename) return null; + $path = ROOT_DIR . '/' . trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . $filename; + return is_file($path) ? filesize($path) : null; +} + +function refreshArchiveMeta(PDO $archive): void { + $archive->exec("CREATE TABLE IF NOT EXISTS archive_meta ( + key TEXT PRIMARY KEY, + value TEXT + )"); + $count = (int)$archive->query("SELECT COUNT(*) FROM messages")->fetchColumn(); + $meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES(?,?)"); + $meta->execute(['archived_at', (string)time()]); + $meta->execute(['message_count', (string)$count]); +} + +// ── Auth ────────────────────────────────────────────────────────────────────── +$isLoggedIn = !empty($_SESSION['admin_auth']); + +if (isset($_POST['admin_login'])) { + if ($_POST['admin_pass'] === ADMIN_PASSWORD) { + $_SESSION['admin_auth'] = true; + $_SESSION['csrf'] = bin2hex(random_bytes(16)); + redirect(); + } else { + $loginError = 'Incorrect password.'; + } +} + +if (isset($_POST['admin_logout'])) { + session_destroy(); redirect(); +} + +// ── POST actions (require auth) ─────────────────────────────────────────────── +if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { + csrfCheck(); + $act = $_POST['action'] ?? ''; + + // ── Messages ────────────────────────────────────────────────────────────── + if ($act === 'archive_voice_clip') { + $id = (int)($_POST['msg_id'] ?? 0); + $stmt = db()->prepare("SELECT * FROM messages WHERE id = ? AND message_type = 'voice'"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) { + flash('Voice clip not found.', 'err'); + redirect($_POST['return_qs'] ?? 'tab=voice'); + } + + $day = (string)$row['day_key']; + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) { + flash('Voice clip has an invalid archive date.', 'err'); + redirect($_POST['return_qs'] ?? 'tab=voice'); + } + + [$y, $m, $d] = explode('-', $day); + try { + $adb = createArchiveDB($y, $m, $d); + $adb->beginTransaction(); + try { + $existing = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?"); + $existing->execute([$id]); + $existingFile = $existing->fetchColumn(); + if ($existingFile !== false && $existingFile !== $row['voice_file']) { + throw new RuntimeException('Archive already contains a different message with this ID.'); + } + if ($existingFile === false) { + $insert = $adb->prepare("INSERT INTO messages + (id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + $insert->execute([ + $row['id'], $row['user_id'], $row['username'], $row['color'], $row['message'], + $row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'], + $row['created_at'], $row['day_key'] + ]); + } + refreshArchiveMeta($adb); + $adb->commit(); + } catch (Throwable $e) { + if ($adb->inTransaction()) $adb->rollBack(); + throw $e; + } + $delete = db()->prepare("DELETE FROM messages WHERE id = ? AND message_type = 'voice'"); + $delete->execute([$id]); + if ($delete->rowCount() !== 1) { + throw new RuntimeException('The live clip changed before it could be removed.'); + } + flash("Voice clip #$id archived to $day."); + } catch (Throwable $e) { + flash('Could not archive voice clip: ' . $e->getMessage(), 'err'); + } + redirect($_POST['return_qs'] ?? 'tab=voice'); + } + + if ($act === 'delete_message') { + $id = (int)$_POST['msg_id']; + $src = $_POST['msg_src'] ?? 'live'; // 'live' or 'archive:Y-m-d' + if ($src === 'live') { + $row = db()->prepare("SELECT voice_file FROM messages WHERE id = ?"); + $row->execute([$id]); + deleteRecordingsForRows($row->fetchAll()); + db()->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]); + flash("Message #$id deleted."); + } else { + [$_, $day] = explode(':', $src, 2); + [$y,$m,$d] = explode('-', $day); + $adb = archiveDB($y, $m, $d); + if ($adb) { + $row = $adb->prepare("SELECT voice_file FROM messages WHERE id = ?"); + $row->execute([$id]); + deleteRecordingsForRows($row->fetchAll()); + $adb->prepare("DELETE FROM messages WHERE id = ?")->execute([$id]); + refreshArchiveMeta($adb); + flash("Archive message #$id deleted."); + } + } + redirect($_POST['return_qs'] ?? ''); + } + + if ($act === 'edit_message') { + $id = (int)$_POST['msg_id']; + $txt = trim($_POST['msg_text']); + $username = trim($_POST['msg_username'] ?? ''); + $src = $_POST['msg_src'] ?? 'live'; + if ($txt === '') { flash('Message cannot be empty.', 'err'); redirect($_POST['return_qs'] ?? ''); } + if ($username === '' || !preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) { + flash('A valid displayed sender is required.', 'err'); redirect($_POST['return_qs'] ?? ''); + } + if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) { + flash('Displayed sender is too long.', 'err'); redirect($_POST['return_qs'] ?? ''); + } + if ($src === 'live') { + db()->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]); + flash("Message #$id updated."); + } else { + [$_, $day] = explode(':', $src, 2); + [$y,$m,$d] = explode('-', $day); + $adb = archiveDB($y, $m, $d); + if ($adb) { + $adb->prepare("UPDATE messages SET message = ?, username = ? WHERE id = ?")->execute([$txt, $username, $id]); + flash("Archive message #$id updated."); + } + } + redirect($_POST['return_qs'] ?? ''); + } + + if ($act === 'delete_messages_bulk') { + $ids = array_map('intval', $_POST['msg_ids'] ?? []); + if ($ids) { + $ph = implode(',', array_fill(0, count($ids), '?')); + $rows = db()->prepare("SELECT voice_file FROM messages WHERE id IN ($ph)"); + $rows->execute($ids); + deleteRecordingsForRows($rows->fetchAll()); + db()->prepare("DELETE FROM messages WHERE id IN ($ph)")->execute($ids); + flash(count($ids) . ' message(s) deleted.'); + } + redirect($_POST['return_qs'] ?? ''); + } + + if ($act === 'delete_day_live') { + $day = $_POST['day_key']; + $rows = db()->prepare("SELECT voice_file FROM messages WHERE day_key = ?"); + $rows->execute([$day]); + deleteRecordingsForRows($rows->fetchAll()); + $st = db()->prepare("DELETE FROM messages WHERE day_key = ?"); $st->execute([$day]); + flash("All messages for $day deleted (" . $st->rowCount() . " rows)."); + redirect('tab=messages'); + } + + if ($act === 'delete_archive_day') { + $day = $_POST['day']; + [$y,$m,$d] = explode('-', $day); + $tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); + $path = ROOT_DIR . '/' . ltrim(str_replace(['{year}','{month}','{day}'], [$y,$m,$d], $tpl), '/'); + if (file_exists($path)) { + $adb = archiveDB($y, $m, $d); + if ($adb) deleteRecordingsForRows($adb->query("SELECT voice_file FROM messages")->fetchAll()); + $adb = null; + unlink($path); + flash("Archive for $day deleted."); + } + else flash("Archive file not found.", 'err'); + redirect('tab=archives'); + } + + if ($act === 'clear_all_live') { + deleteRecordingsForRows(db()->query("SELECT voice_file FROM messages")->fetchAll()); + db()->exec("DELETE FROM messages"); + flash("All live messages cleared."); + redirect('tab=messages'); + } + + // ── Users ───────────────────────────────────────────────────────────────── + if ($act === 'edit_user') { + $id = (int)$_POST['user_id']; + $username = trim($_POST['username']); + $color = trim($_POST['color']); + $newpass = trim($_POST['new_password']); + $errors = []; + + if ($username === '') $errors[] = 'Username required.'; + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Username: letters, numbers, _ and - only.'; + if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.'; + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.'; + + if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); } + + // Check username collision + $existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?")->execute([$username, $id]); + if (db()->query("SELECT COUNT(*) FROM users WHERE username = '$username' AND id != $id")->fetchColumn() > 0) { + flash('Username already taken.', 'err'); redirect('tab=users'); + } + + db()->prepare("UPDATE users SET username = ?, color = ? WHERE id = ?")->execute([$username, $color, $id]); + // Also update username in messages (denormalised) + db()->prepare("UPDATE messages SET username = ?, color = ? WHERE user_id = ?")->execute([$username, $color, $id]); + + if ($newpass !== '') { + $hash = password_hash($newpass, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]); + db()->prepare("UPDATE users SET password_hash = ? WHERE id = ?")->execute([$hash, $id]); + } + flash("User #$id updated."); + redirect('tab=users'); + } + + if ($act === 'delete_user') { + $id = (int)$_POST['user_id']; + // Cascade deletes sessions; messages are kept (username preserved in row) + db()->prepare("DELETE FROM users WHERE id = ?")->execute([$id]); + flash("User #$id deleted. Their messages are preserved."); + redirect('tab=users'); + } + + if ($act === 'kick_user') { + $id = (int)$_POST['user_id']; + db()->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$id]); + flash("User #$id sessions terminated (kicked)."); + redirect('tab=users'); + } + + if ($act === 'create_user') { + $username = trim($_POST['username']); + $password = trim($_POST['password']); + $color = trim($_POST['color']); + $palette = cfgVal('colors.user_palette', ['#00ff9f']); + if (!$color) $color = $palette[array_rand($palette)]; + $errors = []; + if ($username === '') $errors[] = 'Username required.'; + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) $errors[] = 'Invalid username characters.'; + if (strlen($username) > (int)cfgVal('chat.max_username_length', 12)) $errors[] = 'Username too long.'; + if (strlen($password) < (int)cfgVal('security.min_password_length', 4)) $errors[] = 'Password too short.'; + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Invalid color hex.'; + if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); } + try { + $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]); + db()->prepare("INSERT INTO users (username,password_hash,color,created_at) VALUES (?,?,?,?)") + ->execute([$username, $hash, $color, time()]); + flash("User '$username' created."); + } catch (Exception $e) { flash('Username already exists.', 'err'); } + redirect('tab=users'); + } + + // ── Sessions ────────────────────────────────────────────────────────────── + if ($act === 'delete_session') { + db()->prepare("DELETE FROM sessions WHERE id = ?")->execute([$_POST['session_id']]); + flash("Session terminated."); + redirect('tab=sessions'); + } + if ($act === 'clear_expired_sessions') { + $timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600; + $st = db()->prepare("DELETE FROM sessions WHERE last_active < ?"); $st->execute([time() - $timeout]); + flash($st->rowCount() . " expired session(s) cleared."); + redirect('tab=sessions'); + } + if ($act === 'clear_all_sessions') { + db()->exec("DELETE FROM sessions"); + flash("All sessions cleared — everyone is logged out."); + redirect('tab=sessions'); + } + + // ── Config ──────────────────────────────────────────────────────────────── + if ($act === 'save_config') { + $raw = $_POST['config_json'] ?? ''; + $parsed = json_decode($raw, true); + if ($parsed === null) { flash('Invalid JSON: ' . json_last_error_msg(), 'err'); redirect('tab=config'); } + file_put_contents(CONFIG_FILE, json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + flash('config.json saved.'); + redirect('tab=config'); + } +} + +// ── Data fetching ───────────────────────────────────────────────────────────── +$tab = $_GET['tab'] ?? 'dashboard'; + +$stats = []; +if ($isLoggedIn) { + try { + $stats['users'] = db()->query("SELECT COUNT(*) FROM users")->fetchColumn(); + $stats['messages'] = db()->query("SELECT COUNT(*) FROM messages")->fetchColumn(); + $stats['voice'] = db()->query("SELECT COUNT(*) FROM messages WHERE message_type = 'voice'")->fetchColumn(); + $stats['sessions'] = db()->query("SELECT COUNT(*) FROM sessions")->fetchColumn(); + $stats['today'] = db()->query("SELECT COUNT(*) FROM messages WHERE day_key = '" . date('Y-m-d') . "'")->fetchColumn(); + $timeout = (int)cfgVal('security.session_timeout_hours', 24) * 3600; + $stats['active'] = db()->query("SELECT COUNT(*) FROM sessions WHERE last_active > " . (time() - $timeout))->fetchColumn(); + } catch (Exception $e) { $stats = ['users'=>'?','messages'=>'?','voice'=>'?','sessions'=>'?','today'=>'?','active'=>'?']; } +} + +// Archive list +$archives = []; +if ($isLoggedIn) { + $archDir = ROOT_DIR . '/' . cfgVal('archive.archive_dir', 'archive'); + if (is_dir($archDir)) { + foreach (glob($archDir . '/*/*/*.sqlite') as $f) { + if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) { + $day = "{$m[1]}-{$m[2]}-{$m[3]}"; + $sz = filesize($f); + $cnt = 0; + try { $x = new PDO('sqlite:'.$f); $cnt = $x->query("SELECT COUNT(*) FROM messages")->fetchColumn(); } catch(Exception $e){} + $archives[] = ['day'=>$day,'path'=>$f,'size'=>$sz,'count'=>$cnt,'year'=>$m[1],'month'=>$m[2],'daynum'=>$m[3]]; + } + } + usort($archives, fn($a,$b) => strcmp($b['day'], $a['day'])); + } +} + +$flash = getFlash(); +?> + + + + + +CyberChat Admin + + + + + + + + +
+ +
+ + + +
+ + + + + +
+
+ // +
+
+ + +
+
+
+ +
+ + +
+ +
+ + + + + +
+

DASHBOARD

+

// system overview

+
+ +
+
+
+
Users
+
+
+
+
Live Messages
+
+
+
+
Live Voice Clips
+
+
+
+
Today
+
+
+
+
Sessions
+
+
+
+
Active Now
+
+
+ +
+ +
+
+ // RECENT MESSAGES + VIEW ALL +
+
+ query("SELECT m.*,u.username as uname FROM messages m LEFT JOIN users u ON u.id=m.user_id ORDER BY m.created_at DESC LIMIT 8")->fetchAll(); + if (empty($recent)): ?> +
NO MESSAGES YET
+ + + + + + + + + + + + +
UserMessageTime
+ +
+
+ + +
+
+ // RECENT USERS + VIEW ALL +
+
+ query("SELECT * FROM users ORDER BY created_at DESC LIMIT 8")->fetchAll(); + if (empty($recUsers)): ?> +
NO USERS YET
+ + + + + + + + + + + + +
UsernameColorJoined
+ +
+
+
+ + + + + + +
+

LIVE MESSAGES

+

// today's active chat —

+
+ + prepare($sql); $msgs->execute($params); $msgs = $msgs->fetchAll(); + + // Days available + $days = db()->query("SELECT DISTINCT day_key FROM messages ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN); + ?> + + + +
+
+ // MESSAGE(S) +
+ +
+ + + +
+ +
+
+
+ +
+ 0 selected + + +
+ +
+ +
NO MESSAGES FOUND
+ +
+ + + + + + + + + + + + + + + + + + +
IDDayUserMessageTimeActions
# + +
VOICE · s
+ + +
+ +
+
+ +
+ + + + + + +
+
+
+
+ +
+
+ + +
+
// DANGER ZONE
+
+ +
+ + + + +
+ +
+ + + +
+
+
+ + + + +
+

VOICE CLIPS

+

// play, archive, and delete walkie-talkie recordings

+
+ + prepare("SELECT * FROM messages WHERE " . implode(' AND ', $vWhere) . " ORDER BY created_at DESC LIMIT 500"); + $vStmt->execute($vParams); + foreach ($vStmt->fetchAll() as $row) { + $row['_source'] = 'live'; + $voiceRows[] = $row; + } + } + + if ($vSource !== 'live') { + foreach ($archives as $arc) { + if ($vDay !== '' && $arc['day'] !== $vDay) continue; + try { + $vAdb = archiveDB($arc['year'], $arc['month'], $arc['daynum']); + if (!$vAdb) continue; + $vSql = "SELECT * FROM messages WHERE message_type = 'voice'"; + $vParams = []; + if ($vUser !== '') { $vSql .= ' AND username LIKE ?'; $vParams[] = '%' . $vUser . '%'; } + $vSql .= ' ORDER BY created_at DESC LIMIT 500'; + $vStmt = $vAdb->prepare($vSql); + $vStmt->execute($vParams); + foreach ($vStmt->fetchAll() as $row) { + $row['_source'] = 'archive'; + $voiceRows[] = $row; + } + } catch (Throwable $e) { + // Keep the rest of the archive list usable if one file is damaged. + } + } + } + + usort($voiceRows, fn($a, $b) => ((int)$b['created_at'] <=> (int)$a['created_at'])); + $voiceRows = array_slice($voiceRows, 0, 500); + $voiceDays = array_unique(array_merge( + db()->query("SELECT DISTINCT day_key FROM messages WHERE message_type = 'voice' ORDER BY day_key DESC")->fetchAll(PDO::FETCH_COLUMN), + array_column($archives, 'day') + )); + rsort($voiceDays); + $voiceReturn = http_build_query(['tab'=>'voice', 'vu'=>$vUser, 'vd'=>$vDay, 'vs'=>$vSource]); + ?> + + + +
+
+ // VOICE CLIP(S) + MAX 500 RESULTS +
+
+ +
NO VOICE CLIPS FOUND
+ +
+ + + + + + + + + + + + + + + + +
IDSourceDayUserRecordingFileTimeActions
# + + + + RECORDING MISSING + + + + · s + · KB + +
+ +
+ + + + + +
+ +
+ + + + + + +
+
+
+
+ +
+
+ + + + +
+

ARCHIVES

+

// daily chat logs — archive(s)

+
+ + prepare($asql); $astmt->execute($aparams); + $archMsgs = $astmt->fetchAll(); + } + } + ?> + + + +
+ ◂ BACK TO LIST +
+
+

+

// archive day view

+
+ + + +
+
+ // MESSAGE(S) +
+
+ +
NO MESSAGES FOUND
+ +
+ + + + + + + + + + + + + +
IDUserMessageTimeActions
# + +
VOICE · s
+ + +
+ +
+
+ +
+ + + + + + +
+
+
+
+ +
+
+ +
+
// DANGER ZONE
+
+ + + + +
+
+ + + +
+
// ARCHIVED DAYS
+
+ +
NO ARCHIVES YET
+ +
+ + + + + + + + + + + + +
DateMessagesFile SizeActions
msgs KB +
+ BROWSE +
+ + + + +
+
+
+
+ +
+
+ + + + + +
+

USERS

+

// manage accounts and credentials

+
+ + +
+
// CREATE USER
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+
+ + query(" + SELECT u.*, + (SELECT COUNT(*) FROM messages m WHERE m.user_id = u.id) as msg_count, + (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) as sess_count + FROM users u ORDER BY u.created_at DESC + ")->fetchAll(); + ?> + +
+
+ // USER(S) +
+
+ +
NO USERS YET
+ +
+ + + + + + + + + + + + + + + + + + + +
IDUsernameColorMessagesSessionsLast SeenJoinedActions
# + + +
+ + +
+
+
+ +
+ + + + +
+
+ + + + +
+
+
+
+ +
+
+ + + + +
+

SESSIONS

+

// active logins and session management

+
+ + query(" + SELECT s.*, u.username, u.color + FROM sessions s JOIN users u ON u.id = s.user_id + ORDER BY s.last_active DESC + ")->fetchAll(); + ?> + +
+
+ // SESSION(S) +
+
+ + + +
+
+ + + +
+
+
+
+ +
NO SESSIONS
+ +
+ + + + + + + $timeout; + ?> + + + + + + + + + + + +
TokenUserIPStatusLast ActiveCreatedActions
+ + + + +
+ + + + +
+
+
+ +
+
+ + + + +
+

CONFIGURATION

+

// edit config.json live — changes take effect immediately

+
+ +
+
+ // config.json + +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+
// QUICK REFERENCE
+ + + + + + + + + + + + + + + + + +
KeyTypeDescription
chat.max_message_lengthintegerMax chars per message
chat.max_username_lengthintegerMax callsign length
chat.poll_interval_msintegerClient poll interval (ms)
chat.session_lock_ipboolLock session to IP
voice.enabledboolShow voice recording controls
voice.max_duration_secondsintegerMaximum voice clip duration
voice.max_upload_bytesintegerMaximum recording upload size
voice.auto_play_defaultboolQueue and play incoming clips sequentially by default
security.min_password_lengthintegerMin password chars
security.session_timeout_hoursintegerSession expiry in hours
security.bcrypt_costintegerbcrypt work factor (10–14)
ui.app_titlestringHeader title text
colors.user_palettearrayHex colors for new users
+
+
+
+ + + +
+
+
+ + + + + + + + + + + + + diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..73ce2c1 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,168 @@ + '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']]); +} diff --git a/api/config.php b/api/config.php new file mode 100644 index 0000000..bfc944a --- /dev/null +++ b/api/config.php @@ -0,0 +1,32 @@ + [ + '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); diff --git a/api/messages.php b/api/messages.php new file mode 100644 index 0000000..506eade --- /dev/null +++ b/api/messages.php @@ -0,0 +1,234 @@ + '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]); +} diff --git a/api/ping.php b/api/ping.php new file mode 100644 index 0000000..fe818a5 --- /dev/null +++ b/api/ping.php @@ -0,0 +1,80 @@ +='); + +// 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); diff --git a/archive-viewer.php b/archive-viewer.php new file mode 100644 index 0000000..a599321 --- /dev/null +++ b/archive-viewer.php @@ -0,0 +1,225 @@ +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']; + } +} +?> + + + + + CYBERCHAT // ARCHIVE + + + + + + +
+
+ ◂ BACK TO CHAT + // ARCHIVE VIEWER +
+ +
+
+
// DAYS
+
+ +
NO ARCHIVES YET
+ +
+ +
+ +
+
+ +
+
// MESSAGES
+ +
SELECT A DAY FROM THE LIST
+ +
NO MESSAGES FOR
+ +
+ // +  |  MESSAGES + +  |  ARCHIVED UTC + +
+
+ +
+ + + + + + + + + + +
+ +
+ +
+
+
+ + diff --git a/archive/.gitkeep b/archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/archive/README.txt b/archive/README.txt new file mode 100644 index 0000000..6fc9d40 --- /dev/null +++ b/archive/README.txt @@ -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). diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css new file mode 100644 index 0000000..ef73519 --- /dev/null +++ b/assets/css/cyberchat.css @@ -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%; +} diff --git a/assets/js/cyberchat.js b/assets/js/cyberchat.js new file mode 100644 index 0000000..a107806 --- /dev/null +++ b/assets/js/cyberchat.js @@ -0,0 +1,1027 @@ +/** + * CyberChat Client v3.0 + * Embed-ready real-time chat with registration & login + */ + +(function(window, document) { + 'use strict'; + + // ─── State ──────────────────────────────────────────────────────────────── + const state = { + config: null, + auth: { authenticated: false, username: null, color: null }, + chat: { lastId: 0, pollTimer: null, polling: false }, + voice: { + recorder: null, stream: null, timer: null, startedAt: 0, + chunks: [], blob: null, duration: 0, fallback: null, + autoPlay: true, starting: false, queue: [], current: null, + playbackBlocked: false + }, + ui: { darkMode: true, atBottom: true, screen: 'loading' }, + online: 0, + }; + + // ─── DOM refs ───────────────────────────────────────────────────────────── + let root, msgList, msgInput, charCount, sendBtn, scrollBtn, + toastArea, onlineBadge, connStatus, voiceRecordBtn, voiceStatus, + voiceReview, voicePreview, voiceSendBtn, voiceDiscardBtn, + voiceQueueBtn, voiceQueueCount; + + // ─── Utils ──────────────────────────────────────────────────────────────── + const $ = (sel, ctx) => (ctx || root).querySelector(sel); + const esc = str => String(str) + .replace(/&/g,'&').replace(//g,'>') + .replace(/"/g,'"').replace(/'/g,'''); + + function apiPath(ep) { + // Auto-detect base path from the script tag if CYBERCHAT_BASE not explicitly set + if (!window._CC_BASE_RESOLVED) { + window._CC_BASE_RESOLVED = true; + if (!window.CYBERCHAT_BASE) { + var scripts = document.querySelectorAll('script[src]'); + for (var i = 0; i < scripts.length; i++) { + var src = scripts[i].src; + if (src.indexOf('cyberchat.js') !== -1) { + // e.g. /chat/assets/js/cyberchat.js -> /chat + window.CYBERCHAT_BASE = src.replace(/\/assets\/js\/cyberchat\.js.*$/, ''); + break; + } + } + } + } + var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, ''); + return base + '/api/' + ep; + } + + function publicPath(path) { + if (/^(https?:)?\/\//.test(path || '') || String(path || '').startsWith('/')) return path; + var base = (window.CYBERCHAT_BASE || '').replace(/\/$/, ''); + return base + '/' + String(path || '').replace(/^\//, ''); + } + + async function apiFetch(ep, opts) { + const res = await fetch(apiPath(ep), { credentials: 'include', ...(opts||{}) }); + if (!res.ok && res.status !== 400 && res.status !== 401 && res.status !== 403 && res.status !== 409) { + const text = await res.text().catch(() => ''); + throw new Error('HTTP ' + res.status + (text ? ': ' + text.slice(0, 120) : '')); + } + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + const text = await res.text().catch(() => ''); + throw new Error('Expected JSON but got: ' + text.slice(0, 160)); + } + return res.json(); + } + + function fmtTime(ts) { + return new Date(ts * 1000).toLocaleTimeString('en-US', + { hour:'2-digit', minute:'2-digit', hour12:false }); + } + + function linkify(str) { + return str.replace(/(https?:\/\/[^\s<>]+)/g, + '$1'); + } + + function saveTheme(dark) { + try { localStorage.setItem('cc_theme', dark ? 'dark' : 'light'); } catch(e){} + } + function loadTheme() { + try { return localStorage.getItem('cc_theme') !== 'light'; } catch(e){ return true; } + } + function loadVoiceAutoplay(defaultEnabled) { + try { + const saved = localStorage.getItem('cc_voice_autoplay'); + return saved === null ? !!defaultEnabled : saved === 'true'; + } catch(e) { + return !!defaultEnabled; + } + } + function saveVoiceAutoplay(enabled) { + try { localStorage.setItem('cc_voice_autoplay', enabled ? 'true' : 'false'); } catch(e){} + } + + function toast(msg, type) { + if (!toastArea) return; + const el = document.createElement('div'); + el.className = 'cc-toast ' + (type || 'error'); + el.textContent = msg; + toastArea.appendChild(el); + setTimeout(() => { el.classList.add('cc-toast-fade'); setTimeout(() => el.remove(), 350); }, 2800); + } + + // ─── Init ───────────────────────────────────────────────────────────────── + async function init(sel) { + root = typeof sel === 'string' ? document.querySelector(sel) : sel; + if (!root) { console.error('[CyberChat] Container not found:', sel); return; } + root.id = 'cyberchat-root'; + + // Load config from server + try { + const r = await fetch(apiPath('config.php'), { credentials: 'include' }); + state.config = await r.json(); + } catch(e) { + state.config = { + chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 }, + ui: { app_title: 'CYBERCHAT', app_subtitle: 'v3.0 // SECURE CHANNEL' }, + security: { min_password_length: 4 }, + colors: { user_palette: ['#00ff9f','#00b8ff','#ff2d78','#ff9f00','#ff3c3c'] }, + voice: { enabled: true, max_duration_seconds: 60, max_upload_bytes: 12582912, auto_play_default: true } + }; + } + + state.ui.darkMode = loadTheme(); + state.voice.autoPlay = loadVoiceAutoplay(state.config?.voice?.auto_play_default !== false); + if (!state.ui.darkMode) document.body.classList.add('cc-light'); + + renderShell(); + cacheRefs(); + bindGlobal(); + + // Check existing session + try { + const check = await apiFetch('auth.php?action=check'); + if (check.authenticated) { + state.auth = { authenticated: true, username: check.username, color: check.color }; + showChat(); + return; + } + } catch(e) {} + + showAuth('register'); // Default to register for new visitors + } + + // ─── Shell render (persistent chrome) ──────────────────────────────────── + function renderShell() { + const cfg = state.config; + const title = esc(cfg?.ui?.app_title || 'CYBERCHAT'); + const sub = esc(cfg?.ui?.app_subtitle || 'v3.0 // SECURE CHANNEL'); + + root.innerHTML = ` +
+ +
+ +
+ + + online +
+
+ +
+
+ +
+ +
+ `; + } + + function cacheRefs() { + toastArea = $('#cc-toasts'); + onlineBadge = $('#cc-online-count'); + connStatus = $('#cc-conn-dot'); + } + + // ─── Auth screen ────────────────────────────────────────────────────────── + function showAuth(initialTab) { + stopPolling(); + state.ui.screen = 'auth'; + + const cfg = state.config; + const maxUser = cfg?.chat?.max_username_length || 12; + const minPass = cfg?.security?.min_password_length || 4; + const maxMsg = cfg?.chat?.max_message_length || 500; + const tab = initialTab || 'register'; + + $('#cc-body').classList.remove('cc-has-voice'); + $('#cc-body').innerHTML = ` +
+
+ +
+
+ IDENTIFY YOURSELF +
+
+ +
+ + +
+ + +
+

New here? Choose your callsign and set a passphrase.

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+

Welcome back. Enter your callsign and passphrase.

+ +
+ + +
+ +
+ + +
+ + +
+ +
+ +
+
+ `; + + bindAuth(); + + // Autofocus first input + setTimeout(() => { + const first = tab === 'register' + ? $('#cc-reg-user') + : $('#cc-login-user'); + first && first.focus(); + }, 80); + } + + function bindAuth() { + // Tab switching + const tabs = root.querySelectorAll('.cc-auth-tab'); + tabs.forEach(t => { + t.addEventListener('click', () => { + const target = t.dataset.target; + const which = t.dataset.tab; + tabs.forEach(x => x.classList.remove('active')); + t.classList.add('active'); + root.querySelectorAll('.cc-auth-form').forEach(f => f.classList.remove('active')); + $('#' + target).classList.add('active'); + clearAuthErr(); + const inp = $('#' + target + ' .cc-input'); + inp && inp.focus(); + }); + }); + + // Register + const regBtn = $('#cc-reg-btn'); + if (regBtn) { + regBtn.addEventListener('click', doRegister); + ['cc-reg-user','cc-reg-pass','cc-reg-pass2'].forEach(id => { + const el = $('#' + id); + if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doRegister(); }); + }); + } + + // Login + const loginBtn = $('#cc-login-btn'); + if (loginBtn) { + loginBtn.addEventListener('click', doLogin); + ['cc-login-user','cc-login-pass'].forEach(id => { + const el = $('#' + id); + if (el) el.addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); }); + }); + } + } + + function showAuthErr(msg) { + const el = $('#cc-auth-err'); + if (el) { el.textContent = '⚠ ' + msg; el.classList.add('visible'); } + } + function clearAuthErr() { + const el = $('#cc-auth-err'); + if (el) { el.textContent = ''; el.classList.remove('visible'); } + } + + async function doRegister() { + const user = ($('#cc-reg-user')?.value || '').trim(); + const pass = $('#cc-reg-pass')?.value || ''; + const pass2 = $('#cc-reg-pass2')?.value || ''; + const btn = $('#cc-reg-btn'); + const cfg = state.config; + const maxUser = cfg?.chat?.max_username_length || 12; + const minPass = cfg?.security?.min_password_length || 4; + + clearAuthErr(); + + if (!user) return showAuthErr('Please enter a callsign'); + if (user.length > maxUser) return showAuthErr(`Callsign max ${maxUser} characters`); + if (!/^[a-zA-Z0-9_\-]+$/.test(user)) return showAuthErr('Letters, numbers, _ and - only'); + if (!pass) return showAuthErr('Please enter a passphrase'); + if (pass.length < minPass) return showAuthErr(`Passphrase min ${minPass} characters`); + if (pass !== pass2) return showAuthErr('Passphrases do not match'); + + btn.disabled = true; + btn.querySelector('.cc-btn-inner').textContent = 'CREATING...'; + + const fd = new FormData(); + fd.append('action', 'register'); + fd.append('username', user); + fd.append('password', pass); + + try { + const res = await apiFetch('auth.php', { method:'POST', body:fd }); + if (res.success) { + state.auth = { authenticated: true, username: res.username, color: res.color }; + toast('Welcome, ' + res.username + '!', 'success'); + showChat(); + } else { + showAuthErr(res.error || 'Registration failed'); + btn.disabled = false; + btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY'; + } + } catch(e) { + showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP')); + btn.disabled = false; + btn.querySelector('.cc-btn-inner').textContent = 'CREATE IDENTITY'; + } + } + + async function doLogin() { + const user = ($('#cc-login-user')?.value || '').trim(); + const pass = $('#cc-login-pass')?.value || ''; + const btn = $('#cc-login-btn'); + + clearAuthErr(); + + if (!user) return showAuthErr('Please enter your callsign'); + if (!pass) return showAuthErr('Please enter your passphrase'); + + btn.disabled = true; + btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATING...'; + + const fd = new FormData(); + fd.append('action', 'login'); + fd.append('username', user); + fd.append('password', pass); + + try { + const res = await apiFetch('auth.php', { method:'POST', body:fd }); + if (res.success) { + state.auth = { authenticated: true, username: res.username, color: res.color }; + toast('Access granted, ' + res.username, 'success'); + showChat(); + } else { + showAuthErr(res.error || 'Authentication failed'); + btn.disabled = false; + btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE'; + } + } catch(e) { + showAuthErr('Server error: ' + (e.message || 'cannot reach /api/auth.php — check CYBERCHAT_BASE and PHP')); + btn.disabled = false; + btn.querySelector('.cc-btn-inner').textContent = 'AUTHENTICATE'; + } + } + + // ─── Chat screen ────────────────────────────────────────────────────────── + function showChat() { + state.ui.screen = 'chat'; + const { username, color } = state.auth; + const maxMsg = state.config?.chat?.max_message_length || 500; + const voiceEnabled = state.config?.voice?.enabled !== false; + const voiceMax = state.config?.voice?.max_duration_seconds || 60; + const today = new Date().toLocaleDateString('en-US', + { weekday:'short', month:'short', day:'numeric', year:'numeric' }); + + $('#cc-body').classList.toggle('cc-has-voice', voiceEnabled); + $('#cc-body').innerHTML = ` +
+
+ YOU: + + ${esc(username)} + + ${esc(today.toUpperCase())} +
+
+ +
+
+ +
+
+
+ LOADING CHANNEL DATA... +
+
+ + + + ${voiceEnabled ? ` +
+ + VOICE CLIP · MAX ${voiceMax}s + + + +
` : ''} + +
+ + ${maxMsg} + +
+ `; + + // Cache + msgList = $('#cc-messages'); + msgInput = $('#cc-msg-input'); + charCount = $('#cc-char-count'); + sendBtn = $('#cc-send-btn'); + scrollBtn = $('#cc-scroll-btn'); + voiceRecordBtn = $('#cc-voice-record'); + voiceStatus = $('#cc-voice-status'); + voiceReview = $('#cc-voice-review'); + voicePreview = $('#cc-voice-preview'); + voiceSendBtn = $('#cc-voice-send'); + voiceDiscardBtn = $('#cc-voice-discard'); + voiceQueueBtn = $('#cc-voice-queue'); + voiceQueueCount = $('#cc-voice-queue-count'); + + bindChat(); + updateVoiceQueueUI(); + state.chat.lastId = 0; + startPolling(); + msgInput && msgInput.focus(); + } + + function bindChat() { + $('#cc-logout-btn')?.addEventListener('click', doLogout); + + msgInput?.addEventListener('input', updateCharCount); + msgInput?.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } + }); + sendBtn?.addEventListener('click', doSend); + voiceRecordBtn?.addEventListener('click', toggleVoiceRecording); + voiceSendBtn?.addEventListener('click', sendVoiceClip); + voiceDiscardBtn?.addEventListener('click', discardVoiceClip); + voiceQueueBtn?.addEventListener('click', () => { + state.voice.playbackBlocked = false; + playNextVoice(); + }); + $('#cc-voice-autoplay')?.addEventListener('change', e => { + state.voice.autoPlay = !!e.target.checked; + saveVoiceAutoplay(state.voice.autoPlay); + if (state.voice.autoPlay) { + state.voice.playbackBlocked = false; + playNextVoice(); + } + updateVoiceQueueUI(); + }); + + msgList?.addEventListener('scroll', () => { + const el = msgList; + state.ui.atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40; + const show = !state.ui.atBottom && el.scrollHeight > el.clientHeight + 120; + scrollBtn?.classList.toggle('visible', show); + }); + + scrollBtn?.addEventListener('click', () => scrollToBottom(false)); + } + + function updateCharCount() { + const max = state.config?.chat?.max_message_length || 500; + const rem = max - (msgInput?.value?.length || 0); + if (!charCount) return; + charCount.textContent = rem; + charCount.className = 'cc-compose-counter' + + (rem < 20 ? ' danger' : rem < 60 ? ' warn' : ''); + } + + async function doLogout() { + discardVoiceClip(); + clearVoiceQueue(); + stopPolling(); + try { + const fd = new FormData(); + fd.append('action','logout'); + await apiFetch('auth.php', { method:'POST', body:fd }); + } catch(e){} + state.auth = { authenticated:false, username:null, color:null }; + state.chat.lastId = 0; + showAuth('login'); + } + + // ─── Voice clips ────────────────────────────────────────────────────────── + function supportedWebmMime() { + if (!window.MediaRecorder) return ''; + const options = ['audio/webm;codecs=opus', 'audio/webm']; + return options.find(type => !MediaRecorder.isTypeSupported || MediaRecorder.isTypeSupported(type)) || ''; + } + + async function toggleVoiceRecording() { + if (state.voice.starting) return; + if (state.voice.recorder || state.voice.fallback) { + stopVoiceCapture(false); + return; + } + discardVoiceClip(); + if (!navigator.mediaDevices?.getUserMedia) { + toast('Voice recording is not supported in this browser', 'error'); + return; + } + state.voice.starting = true; + voiceRecordBtn?.setAttribute('disabled', 'disabled'); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + state.voice.stream = stream; + state.voice.startedAt = Date.now(); + state.voice.chunks = []; + const mime = supportedWebmMime(); + if (mime) { + try { + startMediaRecorder(stream, mime); + } catch (e) { + startWavRecorder(stream); + } + } else { + startWavRecorder(stream); + } + voiceRecordBtn?.classList.add('recording'); + const label = $('#cc-voice-record-label'); + if (label) label.textContent = 'STOP'; + updateVoiceTimer(); + state.voice.timer = setInterval(updateVoiceTimer, 200); + } catch (e) { + cleanupVoiceStream(); + toast(e?.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone', 'error'); + } finally { + state.voice.starting = false; + voiceRecordBtn?.removeAttribute('disabled'); + } + } + + function startMediaRecorder(stream, mime) { + const recorder = new MediaRecorder(stream, { mimeType: mime }); + recorder.addEventListener('dataavailable', e => { + if (e.data?.size) state.voice.chunks.push(e.data); + }); + recorder.addEventListener('stop', () => { + if (recorder._ccCanceled) { + state.voice.recorder = null; + return; + } + const duration = Math.min((Date.now() - state.voice.startedAt) / 1000, + state.config?.voice?.max_duration_seconds || 60); + const blob = new Blob(state.voice.chunks, { type: recorder.mimeType || mime || 'audio/webm' }); + finishVoiceClip(blob, duration); + state.voice.recorder = null; + }, { once: true }); + recorder.start(250); + state.voice.recorder = recorder; + } + + function startWavRecorder(stream) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + if (!AudioCtx) throw new Error('Audio recording unsupported'); + const context = new AudioCtx(); + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(4096, 1, 1); + const samples = []; + processor.onaudioprocess = e => samples.push(new Float32Array(e.inputBuffer.getChannelData(0))); + source.connect(processor); + processor.connect(context.destination); + state.voice.fallback = { context, source, processor, samples, sampleRate: context.sampleRate }; + } + + function updateVoiceTimer() { + const max = state.config?.voice?.max_duration_seconds || 60; + const elapsed = Math.min((Date.now() - state.voice.startedAt) / 1000, max); + if (voiceStatus) voiceStatus.textContent = 'RECORDING ' + formatDuration(elapsed) + ' / ' + formatDuration(max); + if (elapsed >= max) stopVoiceCapture(false); + } + + function stopVoiceCapture(cancel) { + clearInterval(state.voice.timer); + state.voice.timer = null; + const duration = state.voice.startedAt ? (Date.now() - state.voice.startedAt) / 1000 : 0; + if (state.voice.recorder && state.voice.recorder.state !== 'inactive') { + if (cancel) { + state.voice.recorder._ccCanceled = true; + state.voice.chunks = []; + } + state.voice.recorder.stop(); + } else if (state.voice.fallback) { + const wav = state.voice.fallback; + wav.processor.disconnect(); + wav.source.disconnect(); + wav.context.close(); + state.voice.fallback = null; + if (!cancel) finishVoiceClip(encodeWav(wav.samples, wav.sampleRate), duration); + } + cleanupVoiceStream(); + voiceRecordBtn?.classList.remove('recording'); + const label = $('#cc-voice-record-label'); + if (label) label.textContent = 'RECORD'; + if (cancel && voiceStatus) { + voiceStatus.textContent = 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's'; + } + } + + function cleanupVoiceStream() { + state.voice.stream?.getTracks().forEach(track => track.stop()); + state.voice.stream = null; + } + + function finishVoiceClip(blob, duration) { + if (!blob?.size || duration < 0.25) { + discardVoiceClip(); + toast('Voice clip was too short', 'error'); + return; + } + state.voice.blob = blob; + state.voice.duration = duration; + if (voicePreview) { + if (voicePreview.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl); + const url = URL.createObjectURL(blob); + voicePreview.src = url; + voicePreview.dataset.objectUrl = url; + } + if (voiceReview) voiceReview.hidden = false; + if (voiceStatus) voiceStatus.textContent = 'READY · ' + formatDuration(duration); + } + + function discardVoiceClip() { + if (state.voice.recorder || state.voice.fallback) stopVoiceCapture(true); + if (voicePreview?.dataset.objectUrl) URL.revokeObjectURL(voicePreview.dataset.objectUrl); + if (voicePreview) { + voicePreview.removeAttribute('src'); + delete voicePreview.dataset.objectUrl; + voicePreview.load(); + } + state.voice.blob = null; + state.voice.duration = 0; + if (voiceReview) voiceReview.hidden = true; + if (voiceStatus) voiceStatus.textContent = + 'VOICE CLIP · MAX ' + (state.config?.voice?.max_duration_seconds || 60) + 's'; + } + + async function sendVoiceClip() { + if (!state.voice.blob || voiceSendBtn?.disabled) return; + const maxBytes = state.config?.voice?.max_upload_bytes || 12582912; + if (state.voice.blob.size > maxBytes) { + toast('Voice clip exceeds the upload limit', 'error'); + return; + } + voiceSendBtn.disabled = true; + const fd = new FormData(); + fd.append('action', 'send_voice'); + fd.append('duration', state.voice.duration.toFixed(2)); + fd.append('voice', state.voice.blob, state.voice.blob.type.includes('wav') ? 'voice.wav' : 'voice.webm'); + try { + const res = await apiFetch('messages.php', { method: 'POST', body: fd }); + if (res.error) { + toast(res.error, 'error'); + } else if (res.success && res.message) { + if (!root.querySelector('[data-id="' + res.message.id + '"]')) appendMsg(res.message, true); + state.chat.lastId = Math.max(state.chat.lastId, res.message.id); + discardVoiceClip(); + } + } catch (e) { + toast('Voice send failed: ' + (e.message || 'check server'), 'error'); + } + if (voiceSendBtn) voiceSendBtn.disabled = false; + } + + function encodeWav(chunks, sampleRate) { + const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const buffer = new ArrayBuffer(44 + length * 2); + const view = new DataView(buffer); + const write = (offset, text) => { + for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i)); + }; + write(0, 'RIFF'); view.setUint32(4, 36 + length * 2, true); write(8, 'WAVE'); + write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); + view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); + view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, length * 2, true); + let offset = 44; + chunks.forEach(chunk => chunk.forEach(sample => { + const value = Math.max(-1, Math.min(1, sample)); + view.setInt16(offset, value < 0 ? value * 0x8000 : value * 0x7fff, true); + offset += 2; + })); + return new Blob([view], { type: 'audio/wav' }); + } + + function formatDuration(seconds) { + const total = Math.max(0, Math.ceil(Number(seconds) || 0)); + return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); + } + + // Incoming voice clips use one FIFO player so multiple polls never overlap. + function enqueueVoiceClip(audio) { + if (!audio || audio.dataset.voiceQueued === 'true') return; + audio.dataset.voiceQueued = 'true'; + state.voice.queue.push(audio); + audio.addEventListener('play', () => { + if (state.voice.current === audio) return; + const index = state.voice.queue.indexOf(audio); + if (index !== -1) state.voice.queue.splice(index, 1); + delete audio.dataset.voiceQueued; + updateVoiceQueueUI(); + }, { once: true }); + updateVoiceQueueUI(); + if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice(); + } + + async function playNextVoice() { + if (state.voice.current || !state.voice.queue.length) { + updateVoiceQueueUI(); + return; + } + + const audio = state.voice.queue.shift(); + if (!audio?.isConnected) { + delete audio?.dataset?.voiceQueued; + playNextVoice(); + return; + } + + state.voice.current = audio; + audio.closest('.cc-msg')?.classList.add('cc-msg-voice-playing'); + let finished = false; + const finish = () => { + if (finished) return; + finished = true; + audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); + delete audio.dataset.voiceQueued; + if (state.voice.current === audio) state.voice.current = null; + updateVoiceQueueUI(); + if (state.voice.autoPlay && !state.voice.playbackBlocked) playNextVoice(); + }; + audio.addEventListener('ended', finish, { once: true }); + audio.addEventListener('error', finish, { once: true }); + updateVoiceQueueUI(); + + try { + await audio.play(); + } catch (e) { + audio.removeEventListener('ended', finish); + audio.removeEventListener('error', finish); + audio.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); + state.voice.current = null; + state.voice.queue.unshift(audio); + state.voice.playbackBlocked = true; + updateVoiceQueueUI(); + } + } + + function clearVoiceQueue() { + if (state.voice.current) { + state.voice.current.pause(); + state.voice.current.closest('.cc-msg')?.classList.remove('cc-msg-voice-playing'); + delete state.voice.current.dataset.voiceQueued; + } + state.voice.queue.forEach(audio => delete audio.dataset.voiceQueued); + state.voice.queue = []; + state.voice.current = null; + state.voice.playbackBlocked = false; + updateVoiceQueueUI(); + } + + function updateVoiceQueueUI() { + const waiting = state.voice.queue.length; + const total = waiting + (state.voice.current ? 1 : 0); + if (voiceQueueCount) voiceQueueCount.textContent = String(total); + if (!voiceQueueBtn) return; + voiceQueueBtn.disabled = total === 0; + voiceQueueBtn.classList.toggle('playing', !!state.voice.current); + voiceQueueBtn.classList.toggle('blocked', state.voice.playbackBlocked); + const label = state.voice.current + ? 'PLAYING' + : state.voice.playbackBlocked + ? 'TAP TO PLAY' + : 'PLAY NEXT'; + voiceQueueBtn.firstChild.textContent = label + ' '; + } + + // ─── Send ───────────────────────────────────────────────────────────────── + async function doSend() { + const msg = msgInput?.value?.trim(); + if (!msg || sendBtn?.disabled) return; + const max = state.config?.chat?.max_message_length || 500; + if (msg.length > max) { toast('Message too long (max ' + max + ' chars)', 'error'); return; } + + msgInput.value = ''; + updateCharCount(); + sendBtn.disabled = true; + + const fd = new FormData(); + fd.append('action','send'); + fd.append('message', msg); + + try { + const res = await apiFetch('messages.php', { method:'POST', body:fd }); + if (res.error) { + toast(res.error, 'error'); + msgInput.value = msg; + updateCharCount(); + } else if (res.success && res.message) { + if (!root.querySelector('[data-id="' + res.message.id + '"]')) { + appendMsg(res.message, true); + state.chat.lastId = Math.max(state.chat.lastId, res.message.id); + } + } + } catch(e) { + toast('Send failed: ' + (e.message || 'check server'), 'error'); + msgInput.value = msg; + updateCharCount(); + } + + sendBtn.disabled = false; + msgInput?.focus(); + } + + // ─── Polling ───────────────────────────────────────────────────────────── + function startPolling() { + stopPolling(); + doPoll(); + const iv = state.config?.chat?.poll_interval_ms || 2000; + state.chat.pollTimer = setInterval(doPoll, iv); + } + + function stopPolling() { + if (state.chat.pollTimer) { + clearInterval(state.chat.pollTimer); + state.chat.pollTimer = null; + } + state.chat.polling = false; + } + + async function doPoll() { + if (state.chat.polling) return; + state.chat.polling = true; + try { + const since = state.chat.lastId; + const data = await apiFetch('messages.php?action=poll&since=' + since); + + if (data.error === 'Not authenticated' || data.error === 'Session expired') { + stopPolling(); + state.auth.authenticated = false; + toast('Session expired — please login again', 'error'); + showAuth('login'); + state.chat.polling = false; + return; + } + + setConn('online'); + + if (Array.isArray(data.messages)) { + if (since === 0) { + renderMsgList(data.messages); + } else if (data.messages.length > 0) { + data.messages.forEach(m => appendMsg(m, true)); + state.chat.lastId = data.messages[data.messages.length - 1].id; + } + } + + if (data.online !== undefined && onlineBadge) { + onlineBadge.textContent = data.online; + } + + } catch(e) { + setConn('offline'); + } + state.chat.polling = false; + } + + // ─── Message rendering ──────────────────────────────────────────────────── + function renderMsgList(msgs) { + if (!msgList) return; + if (!msgs.length) { + msgList.innerHTML = '
// NO MESSAGES TODAY — BE THE FIRST TO TRANSMIT
'; + return; + } + msgList.innerHTML = ''; + msgs.forEach(m => appendMsg(m, false)); + scrollToBottom(true); + if (msgs.length) state.chat.lastId = msgs[msgs.length - 1].id; + } + + function appendMsg(msg, animate) { + if (!msgList) return; + + // Remove loading placeholder if present + const loader = msgList.querySelector('.cc-loading-msg, .cc-empty-day'); + if (loader) loader.remove(); + + const isOwn = msg.username === state.auth.username; + const div = document.createElement('div'); + div.className = 'cc-msg' + (isOwn ? ' cc-msg-own' : '') + (animate ? ' cc-msg-new' : ''); + div.dataset.id = msg.id; + + const isVoice = msg.message_type === 'voice' && msg.voice_url; + const safeText = isVoice + ? 'VOICE ' + + formatDuration(msg.voice_duration) + '' + : linkify(esc(msg.message)); + + div.innerHTML = + '' + fmtTime(msg.created_at) + '' + + '' + esc(msg.username) + '' + + '' + + '' + safeText + ''; + + msgList.appendChild(div); + if (isVoice && animate && !isOwn) enqueueVoiceClip(div.querySelector('audio')); + if (state.ui.atBottom) scrollToBottom(false); + } + + function scrollToBottom(instant) { + if (!msgList) return; + if (instant) { msgList.scrollTop = msgList.scrollHeight; } + else { msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' }); } + state.ui.atBottom = true; + scrollBtn?.classList.remove('visible'); + } + + // ─── Global events ──────────────────────────────────────────────────────── + function bindGlobal() { + // Theme toggle + $('#cc-theme-btn')?.addEventListener('click', () => { + state.ui.darkMode = !state.ui.darkMode; + document.body.classList.toggle('cc-light', !state.ui.darkMode); + const icon = $('#cc-theme-icon'); + if (icon) icon.textContent = state.ui.darkMode ? '☀' : '☾'; + saveTheme(state.ui.darkMode); + }); + const themeIcon = $('#cc-theme-icon'); + if (themeIcon) themeIcon.textContent = state.ui.darkMode ? '☀' : '☾'; + } + + // ─── Conn status ───────────────────────────────────────────────────────── + function setConn(status) { + if (!connStatus) return; + connStatus.className = 'cc-conn-dot ' + status; + } + + // ─── Public ─────────────────────────────────────────────────────────────── + window.CyberChat = { init }; + +})(window, document); diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..0317d5d --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,300 @@ + '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')); +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..80069ce --- /dev/null +++ b/config.json @@ -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" + ] + } +} diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/embed-example.html b/embed-example.html new file mode 100644 index 0000000..55face2 --- /dev/null +++ b/embed-example.html @@ -0,0 +1,114 @@ + + + + + + CyberChat — Embed Example + + + + + + +
+
+

CYBERCHAT

+

// EMBED EXAMPLE — DROP INTO ANY PAGE

+
+ +
▸ LIVE EMBED (580px height, full width)
+ + +
+ +
<!-- 1. Google Fonts (optional but recommended) --> +<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"> + +<!-- 2. CyberChat stylesheet --> +<link rel="stylesheet" href="assets/css/cyberchat.css"> + +<!-- 3. Container with any dimensions --> +<div id="my-chat" style="width:100%; height:600px"></div> + +<!-- 4. Script + init --> +<script src="assets/js/cyberchat.js"></script> +<script> + window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path + CyberChat.init('#my-chat'); +</script>
+
+ + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..e5d279a --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + + + Chat @ TyClifford.com + + + + + +
+ + + + + diff --git a/nginx-example.conf b/nginx-example.conf new file mode 100644 index 0000000..6a3c769 --- /dev/null +++ b/nginx-example.conf @@ -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; + } +} diff --git a/uploads/voice/.gitignore b/uploads/voice/.gitignore new file mode 100644 index 0000000..e24a60f --- /dev/null +++ b/uploads/voice/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.htaccess diff --git a/uploads/voice/.htaccess b/uploads/voice/.htaccess new file mode 100644 index 0000000..21d67a6 --- /dev/null +++ b/uploads/voice/.htaccess @@ -0,0 +1,4 @@ +Options -Indexes + + Require all denied +