From 063b8dc3e8d2bf988640822a519d4c7e8d80e058 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Mon, 8 Jun 2026 15:44:15 -0400 Subject: [PATCH] - Voice enhancements, user management, payment method --- .htaccess | 16 +- README.md | 268 +++++++------ admin.php | 82 ++-- api/account.php | 63 +++ api/archive.php | 59 +++ api/auth.php | 247 +++++------- api/billing.php | 69 ++++ api/config.php | 21 +- api/groups.php | 98 +++++ api/messages.php | 287 ++++++-------- api/ping.php | 21 +- api/stats.php | 10 + api/stripe-webhook.php | 36 ++ archive-viewer.php | 228 +---------- assets/css/cyberchat.css | 232 ++++++++++- assets/js/cyberchat-v4.js | 641 +++++++++++++++++++++++++++++++ bootstrap.php | 790 ++++++++++++++++++++++++++++---------- config.json | 39 +- db/.gitignore | 3 + embed-example.html | 4 +- index.html | 2 +- lib/admin_platform.php | 242 ++++++++++++ lib/mailer.php | 93 +++++ lib/mysql_mirror.php | 55 +++ lib/stripe.php | 140 +++++++ nginx-example.conf | 13 +- 26 files changed, 2832 insertions(+), 927 deletions(-) create mode 100644 api/account.php create mode 100644 api/archive.php create mode 100644 api/billing.php create mode 100644 api/groups.php create mode 100644 api/stats.php create mode 100644 api/stripe-webhook.php create mode 100644 assets/js/cyberchat-v4.js create mode 100644 db/.gitignore create mode 100644 lib/admin_platform.php create mode 100644 lib/mailer.php create mode 100644 lib/mysql_mirror.php create mode 100644 lib/stripe.php diff --git a/.htaccess b/.htaccess index 7c6a3e9..945f93f 100644 --- a/.htaccess +++ b/.htaccess @@ -1,16 +1,22 @@ # CyberChat .htaccess — Apache configuration -# Deny direct access to SQLite databases - - Order Allow,Deny - Deny from all +# Deny direct access to databases and the credential-bearing config file + + + Require all denied + + + Order Allow,Deny + Deny from all + -# Deny direct access to db/ and archive/ directories +# Deny direct access to storage and internal PHP libraries RewriteEngine On RewriteRule ^db/ - [F,L] RewriteRule ^archive/ - [F,L] + RewriteRule ^lib/ - [F,L] # PHP settings (if allowed by host) diff --git a/README.md b/README.md index 86d6abe..fa31ca1 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,179 @@ -# CYBERCHAT v2.0 +# CyberChat -A self-contained, embed-ready real-time web chat built with **PHP, JavaScript, HTML5, and SQLite**. No external dependencies beyond PHP 8.0+. +CyberChat is a framework-free PHP, SQLite, JavaScript, and HTML5 chat application with configurable paid tiers, Stripe subscriptions, Mailgun email verification, private groups, voice messages, personal archives, an administrator dashboard, and an optional MySQL/MariaDB mirror. ---- +## Included Features -## 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 - ---- +- Registration without an email address +- Verified email required before premium checkout +- Mailgun email verification and email two-factor login codes +- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation +- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments +- Subscription display switch that hides new sales while preserving billing management for existing customers +- Private groups with plan-based member limits, including the owner +- Premium-configurable recording presence for both sending and viewing +- Premium-configurable automatic voice sending +- Plan-based custom username colors +- Personal text archives retained for at least 30 days +- Plan-based voice archive access and JSON/CSV exports +- Visitor and feature usage statistics +- SQLite by default, with optional automatic MySQL/MariaDB mirroring +- Dark neon interface with a saved light-mode toggle ## 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` +- PHP 8.1 or newer +- PHP extensions: `pdo_sqlite`, `curl`, and `fileinfo` +- A web server with HTTPS for production +- Write access to `db/`, `archive/`, `uploads/voice/`, and `config.json` +- A modern browser with `MediaRecorder` for voice recording +- Optional: PDO MySQL for the MySQL/MariaDB mirror ---- +## Installation -## Quick Start +1. Place the application in the web root. +2. Make the runtime directories and configuration writable by the web-server user. +3. Configure Apache with the included `.htaccess`, or adapt `nginx-example.conf`. +4. Open `admin.php` and sign in with the initial password `changeme123`. +5. Immediately change `admin.password` in the Configuration tab. +6. Open **Plans & Platform** to configure tiers, Mailgun, Stripe, and optional MySQL mirroring. + +The SQLite schema and default Free, Plus, and Pro tiers are created automatically on first use. + +For a simple shared-hosting deployment: ```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/ +chmod 0777 db archive uploads/voice ``` -The SQLite database is created automatically on first visit. +For a managed server, ownership is preferable: ---- +```bash +chown -R www-data:www-data db archive uploads/voice +chmod -R 0770 db archive uploads/voice +``` -## Embed in Your Own Page +Replace `www-data` with the PHP-FPM or web-server account. SQLite must be able to create the database plus `-wal` and `-shm` sidecar files. When the application directory itself is restricted, `database.main_db` may be an absolute path to a writable persistent directory. + +## Default Tiers + +The defaults are starting points and are fully editable in the dashboard. + +| Feature | Free | Plus | Pro | +|---|---:|---:|---:| +| Group members, including owner | 2 | 4 | 8 | +| Recording status send/view | No | Yes | Yes | +| Automatic voice send | No | Yes | Yes | +| Custom username color | No | Yes | Yes | +| Text archive | 30 days | 90 days | 365 days | +| Text export | Yes | Yes | Yes | +| Voice archive/export | No | Yes | Yes | +| Email 2FA | No | Yes | Yes | + +Each tier can independently configure its price, currency, billing interval, Stripe Price ID, active status, sort order, archive retention, group limit, and every feature entitlement. + +## Mailgun + +Configure these fields under **Admin > Plans & Platform**: + +- API key +- Sending domain +- From address +- API base, normally `https://api.mailgun.net/v3` +- EU API base, when applicable: `https://api.eu.mailgun.net/v3` + +The reusable mailer is in `lib/mailer.php`. It sends verification links and codes, limits verification attempts, and sends 2FA codes with a direct link to the authentication screen. + +Mailgun API reference: + +## Stripe + +1. Create recurring Stripe Prices for the paid tiers. +2. Enter each `price_...` ID in the matching dashboard plan. +3. Enter the Stripe secret key and webhook signing secret. +4. Add this webhook endpoint: + + `https://YOUR-DOMAIN/YOUR-PATH/api/stripe-webhook.php` + +5. Subscribe the endpoint to: + + - `checkout.session.completed` + - `customer.subscription.created` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.paid` + - `invoice.payment_failed` + +Checkout is rejected until the account has a verified email. Stripe customer and subscription IDs, status, current period end, cancellation state, and selected plan are synchronized into SQLite. + +When `subscriptions.enabled` is off, plans and checkout are hidden from non-subscribers. Existing Stripe customers retain Billing Portal and cancellation access. + +Stripe references: + +- +- + +## Archives + +The application archives daily messages into: + +`archive/{year}/{month}/{day}.sqlite` + +The public legacy archive browser now redirects to the authenticated **My Archive** view. Users can only query their own sent messages within their plan retention window. Free users receive text history only; voice history and voice export require the corresponding plan entitlements. + +## MySQL/MariaDB Mirror + +SQLite remains the required primary database. The optional mirror imports the main database and all archive databases into `sqlite_mirror_rows` using: + +- source database path +- source table +- row key +- JSON row data +- synchronization time + +Set a PDO MySQL DSN in the dashboard, enable the mirror, and optionally enable automatic import. A manual **Run MySQL Mirror Now** action is also available. + +## Embedding ```html - - - - - - -
- - - +
+ + ``` -See `embed-example.html` for a live demo. +See `embed-example.html` for a complete example. ---- +## Main Files -## 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 +```text +admin.php Administrator dashboard +bootstrap.php Configuration, schema, auth, plans, archives, stats +config.json Runtime settings and service credentials +api/account.php Email, color, and 2FA settings +api/archive.php Private personal archive and export +api/auth.php Registration, login, 2FA, sessions +api/billing.php Stripe checkout, portal, and cancellation +api/groups.php Private group management +api/messages.php Text, voice, polling, and recording presence +api/stripe-webhook.php Stripe event synchronization +assets/js/cyberchat-v4.js Browser application +lib/mailer.php Reusable Mailgun sender +lib/stripe.php Stripe REST client and subscription sync +lib/mysql_mirror.php Optional SQLite-to-MySQL mirror ``` ---- +## Production Security -## 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 +- Change the default administrator password and statistics salt. +- Serve the application only over HTTPS. +- Keep `.htaccess` rules enabled or reproduce all deny rules in the web-server configuration. +- Never expose `config.json`, `db/`, `archive/`, or `lib/`. +- Use separate Stripe test and live webhook secrets. +- Restrict `admin.php` by IP or additional HTTP authentication where practical. +- Back up SQLite databases and voice files together. diff --git a/admin.php b/admin.php index 506a1ff..df327b2 100644 --- a/admin.php +++ b/admin.php @@ -11,9 +11,11 @@ define('ROOT_DIR', __DIR__); define('CONFIG_FILE', ROOT_DIR . '/config.json'); define('ADMIN_VERSION', '1.0.0'); +require_once ROOT_DIR . '/bootstrap.php'; +require_once ROOT_DIR . '/lib/admin_platform.php'; // ─── CHANGE THIS PASSWORD ──────────────────────────────────────────────────── -define('ADMIN_PASSWORD', 'changeme123'); +define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123')); // ───────────────────────────────────────────────────────────────────────────── session_name('cyberchat_admin'); @@ -34,21 +36,14 @@ function cfgVal(string $path, mixed $default = null): mixed { } function db(): PDO { - static $d = null; - if ($d !== null) return $d; - $path = ROOT_DIR . '/' . ltrim(cfgVal('database.main_db', 'db/chat.sqlite'), '/'); - if (!file_exists($path)) die('
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; + return getDB(); } function ensureAdminMessageColumns(PDO $d): void { $columns = []; foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true; foreach ([ + 'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', @@ -60,7 +55,7 @@ function ensureAdminMessageColumns(PDO $d): void { 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), '/'); + $path = storagePath(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); @@ -71,11 +66,9 @@ function archiveDB(string $year, string $month, string $day): ?PDO { 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), '/'); + $path = storagePath(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.'); - } + ensureWritableDirectory($dir, 'Archive'); $a = new PDO('sqlite:' . $path); $a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); @@ -83,6 +76,7 @@ function createArchiveDB(string $year, string $month, string $day): PDO { $a->exec("CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + group_id INTEGER, username TEXT NOT NULL, color TEXT NOT NULL, message TEXT NOT NULL, @@ -133,7 +127,7 @@ function csrfCheck(): void { function deleteRecording(?string $filename): void { if (!$filename || basename($filename) !== $filename) return; - $dir = ROOT_DIR . '/' . trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/'); + $dir = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice')); $path = $dir . '/' . $filename; if (is_file($path)) unlink($path); } @@ -149,7 +143,7 @@ function voiceFileUrl(?string $filename): string { function voiceFileSize(?string $filename): ?int { if (!$filename || basename($filename) !== $filename) return null; - $path = ROOT_DIR . '/' . trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . $filename; + $path = storagePath((string)cfgVal('voice.upload_dir', 'uploads/voice')) . '/' . $filename; return is_file($path) ? filesize($path) : null; } @@ -186,6 +180,16 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { csrfCheck(); $act = $_POST['action'] ?? ''; + if (in_array($act, ['save_platform_services','save_plans','create_plan','run_mysql_mirror'], true)) { + try { + $message = handleAdminPlatformAction($act); + flash($message ?: 'Platform action completed.'); + } catch (Throwable $e) { + flash($e->getMessage(), 'err'); + } + redirect('tab=platform'); + } + // ── Messages ────────────────────────────────────────────────────────────── if ($act === 'archive_voice_clip') { $id = (int)($_POST['msg_id'] ?? 0); @@ -216,10 +220,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { } 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 (?,?,?,?,?,?,?,?,?,?,?)"); + (id,user_id,group_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['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'], $row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'], $row['created_at'], $row['day_key'] ]); @@ -321,7 +325,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { $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), '/'); + $path = storagePath(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()); @@ -356,8 +360,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { 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) { + $existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?"); + $existing->execute([$username, $id]); + if ($existing->fetchColumn()) { flash('Username already taken.', 'err'); redirect('tab=users'); } @@ -403,8 +408,9 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') { 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()]); + $freeId = (int)db()->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn(); + db()->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)") + ->execute([$username, $hash, $color, $freeId ?: null, time()]); flash("User '$username' created."); } catch (Exception $e) { flash('Username already exists.', 'err'); } redirect('tab=users'); @@ -458,7 +464,7 @@ if ($isLoggedIn) { // Archive list $archives = []; if ($isLoggedIn) { - $archDir = ROOT_DIR . '/' . cfgVal('archive.archive_dir', 'archive'); + $archDir = storagePath((string)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)) { @@ -684,6 +690,17 @@ td.actions{white-space:nowrap;width:1px} max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-family:var(--mono);font-size:11px;color:var(--t0); } +.admin-audio{ + display:block;width:280px;max-width:100%;height:34px; + color-scheme:dark;accent-color:var(--g); + border:1px solid rgba(0,255,159,.38);border-radius:2px; + background:linear-gradient(90deg,rgba(0,255,159,.08),rgba(0,184,255,.04)),var(--bg2); + box-shadow:inset 0 0 14px rgba(0,0,0,.55),0 0 8px rgba(0,255,159,.12); +} +.admin-audio::-webkit-media-controls-enclosure{border-radius:0;background:transparent} +.admin-audio::-webkit-media-controls-panel{background:linear-gradient(90deg,#07120f,#08111a)} +.admin-audio::-webkit-media-controls-current-time-display, +.admin-audio::-webkit-media-controls-time-remaining-display{color:var(--g);font-family:var(--mono);font-size:9px} /* ─── Modal ──────────────────────────────────────────────────────────── */ .modal-bg{ @@ -845,6 +862,10 @@ td.actions{white-space:nowrap;width:1px} Sessions + + Plans & Platform + +
SYSTEM
Config @@ -1074,7 +1095,7 @@ td.actions{white-space:nowrap;width:1px}
VOICE · s
-
@@ -1240,7 +1261,7 @@ td.actions{white-space:nowrap;width:1px} - + RECORDING MISSING @@ -1350,7 +1371,7 @@ td.actions{white-space:nowrap;width:1px}
VOICE · s
-
@@ -1628,6 +1649,9 @@ td.actions{white-space:nowrap;width:1px}
+ + +
diff --git a/api/account.php b/api/account.php new file mode 100644 index 0000000..9ef8238 --- /dev/null +++ b/api/account.php @@ -0,0 +1,63 @@ + userPublicPayload($user)]); + + case 'request_email_verification': + $email = strtolower(trim((string)($_POST['email'] ?? ''))); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) jsonResponse(['error' => 'Enter a valid email'], 400); + $stmt = getDB()->prepare("SELECT id FROM users WHERE email=? AND id!=?"); + $stmt->execute([$email, $user['id']]); + if ($stmt->fetchColumn()) jsonResponse(['error' => 'That email is already in use'], 409); + try { + sendVerificationEmail($user, $email); + } catch (Throwable $e) { + jsonResponse(['error' => 'Verification email could not be sent: ' . $e->getMessage()], 503); + } + jsonResponse(['success' => true, 'message' => 'Verification email sent']); + + case 'verify_email': + $token = trim((string)($_POST['token'] ?? $_GET['token'] ?? '')); + $code = trim((string)($_POST['code'] ?? '')); + if (!consumeEmailVerification($user, $token, $code)) { + jsonResponse(['error' => 'Invalid or expired verification code'], 400); + } + $fresh = userById((int)$user['id']); + if (!empty($fresh['stripe_customer_id'])) { + try { + require_once ROOT_DIR . '/lib/stripe.php'; + stripeRequest('POST', 'customers/' . rawurlencode($fresh['stripe_customer_id']), ['email' => $fresh['email']]); + } catch (Throwable $e) { + error_log('Stripe email update failed: ' . $e->getMessage()); + } + } + jsonResponse(['success' => true, 'user' => userPublicPayload($fresh)]); + + case 'set_color': + if (!featureValue($user, 'username_color', false)) jsonResponse(['error' => 'Your plan does not include custom colors'], 403); + $color = trim((string)($_POST['color'] ?? '')); + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) jsonResponse(['error' => 'Invalid color'], 400); + getDB()->beginTransaction(); + getDB()->prepare("UPDATE users SET color=? WHERE id=?")->execute([$color, $user['id']]); + getDB()->prepare("UPDATE messages SET color=? WHERE user_id=?")->execute([$color, $user['id']]); + getDB()->commit(); + jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]); + + case 'set_2fa': + if (!featureValue($user, 'email_2fa', false)) jsonResponse(['error' => 'Your plan does not include email 2FA'], 403); + if (empty($user['email_verified_at'])) jsonResponse(['error' => 'Verify an email first'], 400); + $enabled = filter_var($_POST['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + getDB()->prepare("UPDATE users SET two_factor_enabled=? WHERE id=?")->execute([$enabled, $user['id']]); + jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]); + + default: + jsonResponse(['error' => 'Unknown action'], 400); +} diff --git a/api/archive.php b/api/archive.php new file mode 100644 index 0000000..09bec4d --- /dev/null +++ b/api/archive.php @@ -0,0 +1,59 @@ + 'Your plan does not include exports'], 403); + $canVoiceExport = (bool)featureValue($user, 'voice_export', false); + $format = strtolower((string)($_GET['format'] ?? 'json')); + $export = array_map(function(array $row) use ($canVoiceExport): array { + $item = [ + 'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null, + 'username' => $row['username'], 'message' => $row['message'], + 'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']), + ]; + if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) { + $item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']); + } + return $item; + }, $rows); + $filename = 'cyberchat-archive-' . date('Y-m-d'); + if ($format === 'csv') { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '.csv"'); + $out = fopen('php://output', 'w'); + fputcsv($out, ['id', 'group_id', 'username', 'message', 'type', 'created_at', 'voice_url']); + foreach ($export as $row) fputcsv($out, [ + $row['id'], $row['group_id'], $row['username'], $row['message'], $row['type'], + $row['created_at'], $row['voice_url'] ?? '', + ]); + fclose($out); + exit; + } + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '.json"'); + echo json_encode(['exported_at' => date(DATE_ATOM), 'messages' => $export], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + exit; +} + +$payload = array_map(function(array $row): array { + return [ + 'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null, + 'message' => $row['message'], 'message_type' => $row['message_type'], + 'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null, + 'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null, + 'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'], + ]; +}, $rows); +jsonResponse([ + 'messages' => $payload, + 'retention_days' => max(30, (int)featureValue($user, 'text_archive_days', 30)), + 'can_export' => (bool)featureValue($user, 'text_export', false), + 'voice_included' => (bool)featureValue($user, 'voice_archive', false), +]); diff --git a/api/auth.php b/api/auth.php index 73ce2c1..34cb563 100644 --- a/api/auth.php +++ b/api/auth.php @@ -1,168 +1,125 @@ registerUser(), + 'login' => loginUser(), + 'verify_2fa' => verifyTwoFactor(), + 'logout' => logoutUser(), + 'check' => checkSession(), + default => jsonResponse(['error' => 'Unknown action'], 400), +}; -switch ($action) { - case 'register': - handleRegister(); - break; - case 'login': - handleLogin(); - break; - case 'logout': - handleLogout(); - break; - case 'check': - handleCheck(); - break; - default: - jsonResponse(['error' => 'Unknown action'], 400); +function registerUser(): never { + $db = getDB(); + $username = trim((string)($_POST['username'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + $maxUser = (int)getConfigVal('chat.max_username_length', 12); + $minPass = (int)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, _ and - only'], 400); + } + if (strlen($password) < $minPass) jsonResponse(['error' => "Password min $minPass characters"], 400); + $check = $db->prepare("SELECT 1 FROM users WHERE username=? COLLATE NOCASE"); + $check->execute([$username]); + if ($check->fetchColumn()) jsonResponse(['error' => 'Username already taken'], 409); + + $palette = (array)getConfigVal('colors.user_palette', ['#00ff9f']); + $color = $palette[array_rand($palette)]; + $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)getConfigVal('security.bcrypt_cost', 10)]); + $freeId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn(); + $db->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)") + ->execute([$username, $hash, $color, $freeId, time()]); + $userId = (int)$db->lastInsertId(); + createUserSession($db, $userId); + $user = userById($userId); + trackEvent('registration', '/api/auth.php', $userId); + jsonResponse(['success' => true, 'user' => userPublicPayload($user)]); } -function handleRegister(): never { +function loginUser(): 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"); + $username = trim((string)($_POST['username'] ?? '')); + $password = (string)($_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]); - 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); - + $user = $stmt->fetch(); if (!$user || !password_verify($password, $user['password_hash'])) { + trackEvent('login_failed', '/api/auth.php'); 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']]); + if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) { + $cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600); + $existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?"); + $existingStmt->execute([$user['id'], $cutoff]); + $existing = $existingStmt->fetch(); + if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) { + jsonResponse(['error' => 'Already logged in from another location'], 403); } } - 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]); + if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) { + if (empty($user['email_verified_at']) || empty($user['email'])) { + jsonResponse(['error' => 'Two-factor authentication requires a verified email'], 403); + } + $challengeId = bin2hex(random_bytes(24)); + $code = (string)random_int(100000, 999999); + $db->prepare("DELETE FROM login_challenges WHERE user_id=?")->execute([$user['id']]); + $db->prepare("INSERT INTO login_challenges (id,user_id,code_hash,expires_at,created_at) VALUES (?,?,?,?,?)") + ->execute([$challengeId, $user['id'], password_hash($code, PASSWORD_DEFAULT), time() + 600, time()]); + try { + sendTwoFactorCode($user, $challengeId, $code); + } catch (Throwable $e) { + $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challengeId]); + jsonResponse(['error' => 'Could not send the login code: ' . $e->getMessage()], 503); + } + jsonResponse(['success' => true, 'requires_2fa' => true, 'challenge' => $challengeId]); } - setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true); + + createUserSession($db, (int)$user['id']); + trackEvent('login', '/api/auth.php', (int)$user['id']); + jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]); +} + +function verifyTwoFactor(): never { + $challenge = trim((string)($_POST['challenge'] ?? '')); + $code = trim((string)($_POST['code'] ?? '')); + if ($challenge === '' || !preg_match('/^\d{6}$/', $code)) jsonResponse(['error' => 'Enter the six-digit code'], 400); + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM login_challenges WHERE id=? AND expires_at>?"); + $stmt->execute([$challenge, time()]); + $row = $stmt->fetch(); + if (!$row || (int)$row['attempts'] >= 5 || !password_verify($code, $row['code_hash'])) { + if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]); + jsonResponse(['error' => 'Invalid or expired login code'], 401); + } + $db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]); + createUserSession($db, (int)$row['user_id']); + trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']); + jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$row['user_id']))]); +} + +function logoutUser(): never { + $sid = $_COOKIE['cyberchat_sid'] ?? ''; + if ($sid !== '') getDB()->prepare("DELETE FROM sessions WHERE id=?")->execute([$sid]); + setcookie('cyberchat_sid', '', [ + 'expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + ]); 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']]); +function checkSession(): never { + $user = authUser(false); + jsonResponse($user + ? ['authenticated' => true, 'user' => userPublicPayload($user)] + : ['authenticated' => false]); } diff --git a/api/billing.php b/api/billing.php new file mode 100644 index 0000000..dc5692c --- /dev/null +++ b/api/billing.php @@ -0,0 +1,69 @@ + $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name'], + 'description' => $plan['description'], 'price_cents' => $plan['price_cents'], + 'currency' => $plan['currency'], 'interval' => $plan['billing_interval'], + 'features' => $plan['features'], 'checkout_ready' => !empty($plan['stripe_price_id']), + ]; + } + } + jsonResponse([ + 'subscriptions_enabled' => $subscriptionsEnabled, + 'can_manage' => $hasExistingBilling, + 'plans' => $availablePlans, + 'user' => userPublicPayload($user), + ]); + + case 'checkout': + if (!$subscriptionsEnabled) jsonResponse(['error' => 'New subscriptions are not currently available'], 403); + if (in_array($user['subscription_status'] ?? 'free', ['active', 'trialing', 'past_due'], true)) { + jsonResponse(['error' => 'Use Manage in Stripe to change an existing subscription'], 409); + } + $planId = (int)($_POST['plan_id'] ?? 0); + $plan = planById($planId); + if (!$plan || !$plan['active'] || $plan['slug'] === 'free') jsonResponse(['error' => 'Plan not available'], 404); + try { + jsonResponse(['success' => true, 'url' => createStripeCheckout($user, $plan)]); + } catch (Throwable $e) { + jsonResponse(['error' => $e->getMessage()], 400); + } + + case 'portal': + if (!$hasExistingBilling) jsonResponse(['error' => 'No subscription is connected'], 404); + try { + jsonResponse(['success' => true, 'url' => createStripePortal($user)]); + } catch (Throwable $e) { + jsonResponse(['error' => $e->getMessage()], 400); + } + + case 'cancel': + if (empty($user['stripe_subscription_id'])) jsonResponse(['error' => 'No active subscription'], 404); + try { + $subscription = stripeRequest('POST', 'subscriptions/' . rawurlencode($user['stripe_subscription_id']), [ + 'cancel_at_period_end' => 'true', + ]); + syncSubscriptionFromStripe($subscription); + jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]); + } catch (Throwable $e) { + jsonResponse(['error' => $e->getMessage()], 400); + } + + default: + jsonResponse(['error' => 'Unknown action'], 400); +} diff --git a/api/config.php b/api/config.php index bfc944a..cd665ef 100644 --- a/api/config.php +++ b/api/config.php @@ -1,32 +1,23 @@ [ '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, + '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'] ?? [], + 'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4], + 'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []], '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); + 'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true], +]); diff --git a/api/groups.php b/api/groups.php new file mode 100644 index 0000000..8e89c77 --- /dev/null +++ b/api/groups.php @@ -0,0 +1,98 @@ + userGroups((int)$user['id']), 'member_limit' => (int)featureValue($user, 'group_member_limit', 2)]); + + case 'create': + $name = trim((string)($_POST['name'] ?? '')); + $names = array_values(array_unique(array_filter(array_map('trim', explode(',', (string)($_POST['members'] ?? '')))))); + if ($name === '' || strlen($name) > 40) jsonResponse(['error' => 'Group name must be 1-40 characters'], 400); + $limit = max(2, (int)featureValue($user, 'group_member_limit', 2)); + if (count($names) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403); + $memberIds = []; + $lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE"); + foreach ($names as $username) { + if (strcasecmp($username, $user['username']) === 0) continue; + $lookup->execute([$username]); + $id = (int)$lookup->fetchColumn(); + if (!$id) jsonResponse(['error' => "User '$username' was not found"], 404); + $memberIds[$id] = $id; + } + if (!$memberIds) jsonResponse(['error' => 'Add at least one other user to create a group'], 400); + if (count($memberIds) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403); + $db = getDB(); + $db->beginTransaction(); + $db->prepare("INSERT INTO chat_groups (owner_id,name,created_at,updated_at) VALUES (?,?,?,?)") + ->execute([$user['id'], $name, time(), time()]); + $groupId = (int)$db->lastInsertId(); + $insert = $db->prepare("INSERT INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,?,?)"); + $insert->execute([$groupId, $user['id'], 'owner', time()]); + foreach ($memberIds as $id) $insert->execute([$groupId, $id, 'member', time()]); + $db->commit(); + jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]); + + case 'add_member': + $groupId = normalizeGroupId($_POST['group_id'] ?? null); + $username = trim((string)($_POST['username'] ?? '')); + $groupStmt = getDB()->prepare("SELECT * FROM chat_groups WHERE id=? AND owner_id=?"); + $groupStmt->execute([$groupId, $user['id']]); + $group = $groupStmt->fetch(); + if (!$group) jsonResponse(['error' => 'Only the group owner can add members'], 403); + $limit = max(2, (int)featureValue($user, 'group_member_limit', 2)); + $countStmt = getDB()->prepare("SELECT COUNT(*) FROM group_members WHERE group_id=?"); + $countStmt->execute([$groupId]); + if ((int)$countStmt->fetchColumn() >= $limit) jsonResponse(['error' => "This group has reached your $limit-member limit"], 403); + $lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE"); + $lookup->execute([$username]); + $memberId = (int)$lookup->fetchColumn(); + if (!$memberId) jsonResponse(['error' => 'User not found'], 404); + getDB()->prepare("INSERT OR IGNORE INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,'member',?)") + ->execute([$groupId, $memberId, time()]); + getDB()->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([time(), $groupId]); + jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]); + + case 'remove_member': + $groupId = normalizeGroupId($_POST['group_id'] ?? null); + $memberId = (int)($_POST['user_id'] ?? 0); + $stmt = getDB()->prepare("SELECT owner_id FROM chat_groups WHERE id=?"); + $stmt->execute([$groupId]); + $ownerId = (int)$stmt->fetchColumn(); + if (!$ownerId) jsonResponse(['error' => 'Group not found'], 404); + if ($ownerId !== (int)$user['id'] && $memberId !== (int)$user['id']) jsonResponse(['error' => 'Not allowed'], 403); + if ($memberId === $ownerId) jsonResponse(['error' => 'The owner must delete the group instead'], 400); + getDB()->prepare("DELETE FROM group_members WHERE group_id=? AND user_id=?")->execute([$groupId, $memberId]); + jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]); + + case 'delete': + $groupId = normalizeGroupId($_POST['group_id'] ?? null); + $db = getDB(); + $owner = $db->prepare("SELECT 1 FROM chat_groups WHERE id=? AND owner_id=?"); + $owner->execute([$groupId, $user['id']]); + if (!$owner->fetchColumn()) jsonResponse(['error' => 'Only the group owner can delete this group'], 403); + $voice = $db->prepare("SELECT voice_file FROM messages WHERE group_id=? AND voice_file IS NOT NULL"); + $voice->execute([$groupId]); + $voiceFiles = $voice->fetchAll(); + $db->beginTransaction(); + try { + $db->prepare("DELETE FROM recording_status WHERE group_key=?")->execute([groupKey($groupId)]); + $db->prepare("DELETE FROM messages WHERE group_id=?")->execute([$groupId]); + $db->prepare("DELETE FROM group_members WHERE group_id=?")->execute([$groupId]); + $db->prepare("DELETE FROM chat_groups WHERE id=?")->execute([$groupId]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + throw $e; + } + foreach ($voiceFiles as $row) deleteVoiceFile($row['voice_file'] ?? null); + jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]); + + default: + jsonResponse(['error' => 'Unknown action'], 400); +} diff --git a/api/messages.php b/api/messages.php index 506eade..74c2771 100644 --- a/api/messages.php +++ b/api/messages.php @@ -1,37 +1,24 @@ getMessage()); } $action = $_POST['action'] ?? $_GET['action'] ?? ''; switch ($action) { - case 'send': - handleSend(); - break; - case 'send_voice': - handleSendVoice(); - break; - case 'poll': - handlePoll(); - break; - case 'history': - handleHistory(); - break; - default: - jsonResponse(['error' => 'Unknown action'], 400); + case 'send': sendTextMessage(); + case 'send_voice': sendVoiceMessage(); + case 'recording': setRecordingStatus(); + case 'poll': pollMessages(); + case 'history': archiveDays(); + default: jsonResponse(['error' => 'Unknown action'], 400); } function messagePayload(array $row): array { return [ 'id' => (int)$row['id'], + 'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null, 'username' => $row['username'], 'color' => $row['color'], 'message' => $row['message'], @@ -44,191 +31,149 @@ function messagePayload(array $row): array { ]; } -function handleSend(): never { - $session = authRequired(); +function requestedGroup(array $user): ?int { + $groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null); + requireGroupAccess($user, $groupId); + return $groupId; +} + +function sendTextMessage(): never { + $user = authRequired(); + $groupId = requestedGroup($user); + $message = trim((string)($_POST['message'] ?? '')); + $max = (int)getConfigVal('chat.max_message_length', 500); + if ($message === '') jsonResponse(['error' => 'Empty message'], 400); + if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400); + $created = time(); $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(); - + $db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key) + VALUES (?,?,?,?,?,?,?)")->execute([ + $user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(), + ]); + trackEvent('message_text', '/api/messages.php', (int)$user['id']); jsonResponse(['success' => true, 'message' => messagePayload([ - 'id' => $id, 'username' => $session['username'], 'color' => $session['color'], - 'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey, + 'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'], + 'color' => $user['color'], 'message' => $message, 'message_type' => 'text', + 'created_at' => $created, 'day_key' => dayKey(), ])]); } -function handleSendVoice(): never { - $session = authRequired(); - if (!getConfigVal('voice.enabled', true)) { - jsonResponse(['error' => 'Voice clips are disabled'], 403); +function sendVoiceMessage(): never { + $user = authRequired(); + $groupId = requestedGroup($user); + if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) { + jsonResponse(['error' => 'Your plan does not include voice messages'], 403); } - if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) { - jsonResponse(['error' => 'No voice clip uploaded'], 400); - } - + 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); - } - + 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); - } - + 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); - } + 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']) - : ''; + $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); - } + 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); - } + ensureWritableDirectory($dir, 'Voice storage'); $filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime]; - if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) { - jsonResponse(['error' => 'Could not store voice clip'], 500); - } + if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500); - $db = getDB(); $created = time(); - $dayKey = dayKey(); + $storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime; + $db = getDB(); 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 - ]); + $db->prepare("INSERT INTO messages + (user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) + VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([ + $user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]', + $filename, $storedMime, $duration, $created, dayKey(), + ]); } catch (Throwable $e) { deleteVoiceFile($filename); throw $e; } - + getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") + ->execute([groupKey($groupId), $user['id']]); + trackEvent('message_voice', '/api/messages.php', (int)$user['id']); 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, + 'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'], + 'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice', + 'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration, + 'created_at' => $created, 'day_key' => dayKey(), ])]); } -function handlePoll(): never { - $session = authRequired(); +function setRecordingStatus(): never { + $user = authRequired(); + if (!featureValue($user, 'recording_status', false)) { + jsonResponse(['error' => 'Your plan does not include recording indicators'], 403); + } + $groupId = requestedGroup($user); + $active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN); $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)); + if ($active) { + $db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?) + ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at") + ->execute([groupKey($groupId), $user['id'], time() + 10]); } 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); + $db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?") + ->execute([groupKey($groupId), $user['id']]); + } + jsonResponse(['success' => true]); +} + +function pollMessages(): never { + $user = authRequired(); + $groupId = requestedGroup($user); + $since = max(0, (int)($_GET['since'] ?? 0)); + $limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100))); + $db = getDB(); + $whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?'; + $params = $groupId === null ? [dayKey()] : [dayKey(), $groupId]; + if ($since === 0) { + $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?"); + $params[] = $limit; + $stmt->execute($params); + $rows = array_reverse($stmt->fetchAll()); + } else { + $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?"); + $params[] = $since; + $params[] = $limit; + $stmt->execute($params); + $rows = $stmt->fetchAll(); } - // 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(); - + $cutoff = time() - 300; + $onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?"); + $onlineStmt->execute([$cutoff]); + $recording = []; + $db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]); + if (featureValue($user, 'recording_status', false)) { + $recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r + JOIN users u ON u.id=r.user_id WHERE r.group_key=? AND r.expires_at>? AND r.user_id!=?"); + $recordStmt->execute([groupKey($groupId), time(), $user['id']]); + $recording = $recordStmt->fetchAll(); + } jsonResponse([ - 'messages' => $messages, - 'online' => $onlineCount, - 'day_key' => $dayKey, + 'messages' => array_map('messagePayload', $rows), + 'online' => (int)$onlineStmt->fetchColumn(), + 'recording' => $recording, + 'group_id' => $groupId, '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"; - } - } - } - } - +function archiveDays(): never { + $user = authRequired(); + $rows = personalArchiveRows($user, '', 1000); + $days = array_values(array_unique(array_column($rows, 'day_key'))); rsort($days); jsonResponse(['days' => $days]); } diff --git a/api/ping.php b/api/ping.php index fe818a5..9ccf465 100644 --- a/api/ping.php +++ b/api/ping.php @@ -7,19 +7,22 @@ header('Content-Type: application/json'); header('Cache-Control: no-store'); header('Access-Control-Allow-Origin: *'); +require_once dirname(__DIR__) . '/bootstrap.php'; + $results = []; // 1. PHP version $results['php_version'] = PHP_VERSION; -$results['php_ok'] = version_compare(PHP_VERSION, '8.0.0', '>='); +$results['php_ok'] = version_compare(PHP_VERSION, '8.1.0', '>='); // 2. PDO SQLite $results['pdo_sqlite'] = extension_loaded('pdo_sqlite'); // 3. ROOT_DIR and config.json -$root = dirname(__DIR__); +$root = ROOT_DIR; $configFile = $root . '/config.json'; $results['config_exists'] = file_exists($configFile); +$results['config_writable'] = is_writable($configFile); if ($results['config_exists']) { $raw = file_get_contents($configFile); @@ -30,17 +33,19 @@ if ($results['config_exists']) { } // 4. db/ directory writable -$dbDir = $root . '/db'; +$dbPath = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite')); +$dbDir = dirname($dbPath); if (!is_dir($dbDir)) { - @mkdir($dbDir, 0755, true); + @mkdir($dbDir, 0775, true); } $results['db_dir_exists'] = is_dir($dbDir); $results['db_dir_writable'] = is_writable($dbDir); +$results['db_file_writable'] = !is_file($dbPath) || is_writable($dbPath); // 5. archive/ directory writable -$archiveDir = $root . '/archive'; +$archiveDir = storagePath((string)getConfigVal('archive.archive_dir', 'archive')); if (!is_dir($archiveDir)) { - @mkdir($archiveDir, 0755, true); + @mkdir($archiveDir, 0775, true); } $results['archive_dir_exists'] = is_dir($archiveDir); $results['archive_dir_writable'] = is_writable($archiveDir); @@ -50,7 +55,7 @@ $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 = new PDO('sqlite:' . $dbPath); $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)'); @@ -70,10 +75,10 @@ $results['all_ok'] = ( $results['config_exists'] && $results['config_valid_json'] && $results['db_dir_writable'] && + $results['db_file_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); diff --git a/api/stats.php b/api/stats.php new file mode 100644 index 0000000..d164325 --- /dev/null +++ b/api/stats.php @@ -0,0 +1,10 @@ + true]); diff --git a/api/stripe-webhook.php b/api/stripe-webhook.php new file mode 100644 index 0000000..62812f3 --- /dev/null +++ b/api/stripe-webhook.php @@ -0,0 +1,36 @@ + 'Invalid signature'], 400); +$event = json_decode($payload, true); +if (!is_array($event) || empty($event['id']) || empty($event['type'])) jsonResponse(['error' => 'Invalid event'], 400); + +$db = getDB(); +$exists = $db->prepare("SELECT 1 FROM stripe_events WHERE event_id=?"); +$exists->execute([$event['id']]); +if ($exists->fetchColumn()) jsonResponse(['received' => true, 'duplicate' => true]); +$object = $event['data']['object'] ?? []; +if ($event['type'] === 'checkout.session.completed') { + $userId = (int)($object['metadata']['user_id'] ?? $object['client_reference_id'] ?? 0); + if ($userId > 0) { + $db->prepare("UPDATE users SET stripe_customer_id=?,stripe_subscription_id=?,subscription_status='active' WHERE id=?") + ->execute([$object['customer'] ?? null, $object['subscription'] ?? null, $userId]); + } +} elseif (str_starts_with($event['type'], 'customer.subscription.')) { + syncSubscriptionFromStripe($object); +} elseif (in_array($event['type'], ['invoice.payment_failed', 'invoice.paid'], true)) { + $customer = $object['customer'] ?? ''; + if ($customer !== '') { + $status = $event['type'] === 'invoice.paid' ? 'active' : 'past_due'; + $db->prepare("UPDATE users SET subscription_status=? WHERE stripe_customer_id=?")->execute([$status, $customer]); + $db->prepare("UPDATE subscriptions SET status=?,updated_at=? WHERE stripe_customer_id=?") + ->execute([$status, time(), $customer]); + } +} +$db->prepare("INSERT OR IGNORE INTO stripe_events (event_id,event_type,received_at) VALUES (?,?,?)") + ->execute([$event['id'], $event['type'], time()]); +jsonResponse(['received' => true]); diff --git a/archive-viewer.php b/archive-viewer.php index a599321..8c098f4 100644 --- a/archive-viewer.php +++ b/archive-viewer.php @@ -1,225 +1,5 @@ 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 - -
-
- -
- - - - - - - - - - -
- -
- -
-
-
- - +// Historical logs are private. The authenticated application renders only the +// current user's entitled archive through api/archive.php. +header('Location: index.html?view=archive', true, 302); +exit; diff --git a/assets/css/cyberchat.css b/assets/css/cyberchat.css index ef73519..e4a1b00 100644 --- a/assets/css/cyberchat.css +++ b/assets/css/cyberchat.css @@ -656,9 +656,7 @@ input, button { font-family: inherit; } } .cc-voice-message audio { - width: min(280px, 100%); - height: 30px; - accent-color: var(--g); + display: none; } .cc-msg-voice-playing { @@ -858,7 +856,174 @@ input, button { font-family: inherit; } } .cc-voice-review[hidden] { display: none; } -.cc-voice-review audio { width: 170px; height: 28px; accent-color: var(--g); } +.cc-voice-review audio { display: none; } +.cc-voice-review .cc-cyber-audio { width: 230px; min-width: 200px; } + +/* Cyberpunk audio console */ +.cc-cyber-audio { + --cc-audio-progress: 0%; + display: inline-grid; + grid-template-columns: 26px 20px minmax(78px, 1fr) auto 34px; + align-items: center; + gap: 6px; + width: min(100%, 310px); + min-width: 230px; + height: 34px; + padding: 3px 6px 3px 4px; + color: var(--g); + background: + linear-gradient(90deg, rgba(0,255,159,.07), transparent 38%), + var(--bg2); + border: 1px solid rgba(0,255,159,.32); + border-radius: 2px; + box-shadow: inset 0 0 12px rgba(0,0,0,.55), 0 0 8px rgba(0,255,159,.08); + position: relative; + overflow: hidden; + vertical-align: middle; +} + +.cc-cyber-audio::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient(0deg, transparent 0 3px, rgba(0,255,159,.025) 3px 4px); + pointer-events: none; +} + +.cc-cyber-audio.playing { + border-color: var(--g); + box-shadow: inset 0 0 14px rgba(0,255,159,.08), 0 0 10px rgba(0,255,159,.22); +} + +.cc-audio-play, +.cc-audio-mute { + position: relative; + z-index: 1; + height: 24px; + color: var(--g); + background: rgba(0,255,159,.04); + border: 1px solid rgba(0,255,159,.42); + border-radius: 1px; + font: 7px var(--mono); + letter-spacing: .5px; +} + +.cc-audio-play { width: 26px; } +.cc-audio-play:hover, +.cc-audio-mute:hover, +.cc-audio-mute.active { + color: #020806; + background: var(--g); + box-shadow: var(--glowg); +} +.cc-audio-play:focus-visible, +.cc-audio-mute:focus-visible, +.cc-audio-seek:focus-visible { + outline: 1px solid var(--b); + outline-offset: 2px; +} + +.cc-audio-play-icon { + display: block; + width: 0; + height: 0; + margin: auto; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid currentColor; +} + +.cc-cyber-audio.playing .cc-audio-play-icon { + width: 7px; + height: 10px; + border: 0; + border-left: 3px solid currentColor; + border-right: 3px solid currentColor; +} + +.cc-audio-signal { + position: relative; + z-index: 1; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} + +.cc-audio-signal i { + display: block; + width: 2px; + height: 4px; + background: var(--b); + box-shadow: 0 0 4px rgba(0,184,255,.65); +} + +.cc-cyber-audio.playing .cc-audio-signal i { + animation: ccAudioSignal .7s ease-in-out infinite alternate; +} +.cc-cyber-audio.playing .cc-audio-signal i:nth-child(2) { animation-delay: -.4s; } +.cc-cyber-audio.playing .cc-audio-signal i:nth-child(3) { animation-delay: -.2s; } +.cc-cyber-audio.playing .cc-audio-signal i:nth-child(4) { animation-delay: -.55s; } + +@keyframes ccAudioSignal { + from { height: 4px; opacity: .45; } + to { height: 15px; opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .cc-cyber-audio.playing .cc-audio-signal i { animation: none; height: 10px; } +} + +.cc-audio-seek { + position: relative; + z-index: 1; + width: 100%; + height: 12px; + margin: 0; + appearance: none; + -webkit-appearance: none; + background: transparent; + cursor: pointer; +} + +.cc-audio-seek::-webkit-slider-runnable-track { + height: 3px; + background: linear-gradient(90deg, var(--g) var(--cc-audio-progress), var(--bd) var(--cc-audio-progress)); + box-shadow: 0 0 5px rgba(0,255,159,.2); +} +.cc-audio-seek::-moz-range-track { height: 3px; background: var(--bd); } +.cc-audio-seek::-moz-range-progress { height: 3px; background: var(--g); } +.cc-audio-seek::-webkit-slider-thumb { + width: 7px; + height: 13px; + margin-top: -5px; + appearance: none; + -webkit-appearance: none; + border: 0; + border-radius: 0; + background: var(--g); + box-shadow: 0 0 7px rgba(0,255,159,.8); +} +.cc-audio-seek::-moz-range-thumb { + width: 7px; + height: 13px; + border: 0; + border-radius: 0; + background: var(--g); +} + +.cc-audio-time { + position: relative; + z-index: 1; + min-width: 70px; + color: var(--t1); + font: 8px var(--mono); + text-align: center; + white-space: nowrap; +} + +.cc-audio-mute { width: 34px; } .cc-voice-action { height: 27px; @@ -964,7 +1129,7 @@ input, button { font-family: inherit; } .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-review .cc-cyber-audio { flex: 1; min-width: 220px; } .cc-voice-message { align-items: flex-start; flex-direction: column; gap: 3px; } } @@ -984,3 +1149,60 @@ html, body { #app { width: 100%; height: 100%; } + +/* v4 application shell */ +.cc-theme-text { width: auto; padding: 0 8px; font-family: var(--mono); font-size: 8px; } +.cc-auth-notice { margin: 12px 20px 0; padding: 8px; border: 1px solid var(--o); color: var(--o); font-family: var(--mono); font-size: 10px; } +.cc-app-nav { display: grid; grid-template-columns: repeat(4, 1fr); background: var(--bgp); border-bottom: 1px solid var(--bd); flex-shrink: 0; } +.cc-app-nav button { padding: 8px 4px; border: 0; border-right: 1px solid var(--bd); background: transparent; color: var(--t1); font: 9px var(--mono); letter-spacing: 1px; } +.cc-app-nav button:last-child { border-right: 0; } +.cc-app-nav button.active { color: #000; background: var(--g); } +.cc-view { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; } +.cc-plan-badge { padding: 2px 6px; border: 1px solid var(--v); color: var(--v); border-radius: 2px; font: 8px var(--mono); text-transform: uppercase; } +.cc-compact-select { max-width: 220px; padding: 4px 7px; background: var(--bgi); color: var(--t0); border: 1px solid var(--bd); font: 10px var(--mono); } +.cc-recording-indicator { padding: 4px 12px; color: var(--p); background: rgba(255,45,120,.07); border-bottom: 1px solid rgba(255,45,120,.2); font: 10px var(--mono); } +.cc-page { flex: 1; overflow-y: auto; padding: 18px; } +.cc-page-head, .cc-card-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.cc-page-head { margin-bottom: 16px; } +.cc-page h2 { color: var(--b); font: 700 13px var(--disp); letter-spacing: 2px; } +.cc-page h3 { color: var(--g); font: 10px var(--mono); letter-spacing: 1.5px; margin-bottom: 10px; } +.cc-page h4 { color: var(--v); font: 700 12px var(--disp); letter-spacing: 1px; } +.cc-page p { color: var(--t1); font: 10px/1.5 var(--mono); margin-top: 4px; } +.cc-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 12px; } +.cc-card { padding: 14px; margin-bottom: 12px; background: var(--bg1); border: 1px solid var(--bd); border-radius: var(--r2); } +.cc-form-grid { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto; gap: 8px; align-items: center; } +.cc-form-grid.compact { grid-template-columns: minmax(120px, 1fr) auto auto; margin-top: 10px; } +.cc-inline-btn { display: inline-flex; justify-content: center; align-items: center; min-height: 31px; padding: 5px 10px; color: var(--b); border: 1px solid var(--b); border-radius: var(--r1); background: transparent; font: 9px var(--mono); text-decoration: none; } +.cc-inline-btn.primary { color: var(--g); border-color: var(--g); } +.cc-inline-btn.danger { color: var(--r); border-color: var(--r); } +.cc-inline-btn:disabled { opacity: .35; cursor: not-allowed; } +.cc-actions { display: flex; gap: 6px; flex-wrap: wrap; } +.cc-member-list { display: flex; gap: 6px; flex-wrap: wrap; } +.cc-member-list span { padding: 4px 7px; border: 1px solid var(--bd); border-radius: 2px; font: 10px var(--mono); } +.cc-member-list button { color: var(--r); background: none; border: 0; margin-left: 4px; } +.cc-empty-card { padding: 30px; color: var(--t1); border: 1px dashed var(--bd); text-align: center; font: 10px var(--mono); } +.cc-search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-bottom: 12px; } +.cc-archive-list { display: flex; flex-direction: column; gap: 6px; } +.cc-archive-row { display: grid; grid-template-columns: 145px 55px 1fr; gap: 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--bd); background: var(--bg1); font: 11px var(--mono); } +.cc-archive-row time { color: var(--t1); font-size: 9px; } +.cc-archive-row audio { display: none; } +.cc-type-tag { color: var(--o); font-size: 8px; text-transform: uppercase; } +.cc-switch { display: flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--t0); font: 10px var(--mono); } +.cc-switch input { accent-color: var(--g); } +.cc-feature-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 7px; } +.cc-feature-list span { padding: 7px; border: 1px solid var(--bd); color: var(--t1); font: 9px var(--mono); text-transform: uppercase; } +.cc-feature-list b { display: block; color: var(--t0); margin-bottom: 3px; } +.cc-plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 12px; } +.cc-plan { padding: 12px; border: 1px solid var(--bd); background: var(--bg2); } +.cc-price { margin: 8px 0; color: var(--g); font: 700 18px var(--disp); } +.cc-price small { color: var(--t1); font: 9px var(--mono); } +.cc-billing-card, .cc-feature-card { margin-top: 12px; } + +@media (max-width: 620px) { + .cc-app-nav button { font-size: 8px; letter-spacing: 0; } + .cc-form-grid, .cc-form-grid.compact { grid-template-columns: 1fr; } + .cc-archive-row { grid-template-columns: 1fr; } + .cc-cyber-audio { min-width: 0; width: 100%; grid-template-columns: 26px 18px minmax(60px, 1fr) 64px 32px; } + .cc-compact-select { max-width: 145px; } + .cc-page { padding: 12px; } +} diff --git a/assets/js/cyberchat-v4.js b/assets/js/cyberchat-v4.js new file mode 100644 index 0000000..3972e31 --- /dev/null +++ b/assets/js/cyberchat-v4.js @@ -0,0 +1,641 @@ +(function(window, document) { + 'use strict'; + + const initialView = new URLSearchParams(location.search).get('view'); + const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || ''; + const state = { + config: {}, user: null, groups: [], billing: null, + view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat', + groupId: null, lastId: 0, pollTimer: null, polling: false, + loginChallenge: initialChallenge, + verifyToken: new URLSearchParams(location.search).get('verify') || '', + voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0, + chunks: [], blob: null, duration: 0, url: '', autoSend: false, sending: false } + }; + let root; + const $ = (s, c) => (c || root).querySelector(s); + const $$ = (s, c) => Array.from((c || root).querySelectorAll(s)); + const esc = value => String(value == null ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + + function base() { + if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, ''); + const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js')); + return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : ''; + } + function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); } + function publicUrl(path) { + if (!path) return ''; + return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path; + } + function form(data) { + const body = new FormData(); + Object.entries(data).forEach(([key, value]) => body.append(key, value)); + return { method: 'POST', body }; + } + async function api(file, options, query = '') { + const response = await fetch(apiUrl(file, query), { credentials: 'include', ...(options || {}) }); + const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' })); + if (!response.ok && !data.error) data.error = 'Request failed'; + return data; + } + function feature(key, fallback = false) { + return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key) + ? state.user.features[key] : fallback; + } + function toast(message, type = 'error') { + const area = $('#cc-toasts'); + if (!area) return; + const item = document.createElement('div'); + item.className = 'cc-toast ' + type; + item.textContent = message; + area.appendChild(item); + setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800); + } + function duration(seconds) { + const total = Math.max(0, Math.ceil(Number(seconds) || 0)); + return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); + } + function audioClock(seconds) { + const total = Math.max(0, Math.floor(Number(seconds) || 0)); + return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0'); + } + function clock(timestamp) { + return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + function money(cents, currency) { + return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100); + } + function linkify(text) { + return text.replace(/(https?:\/\/[^\s<>]+)/g, '$1'); + } + function audioPlayer(src = '', id = '') { + return ` + + + + + 0:00 / --:-- + + `; + } + function bindAudioPlayers(scope = root) { + $$('.cc-cyber-audio', scope).forEach(player => { + if (player.dataset.bound) return; + player.dataset.bound = '1'; + const audio = $('audio', player); + const play = $('.cc-audio-play', player); + const seek = $('.cc-audio-seek', player); + const time = $('.cc-audio-time', player); + const mute = $('.cc-audio-mute', player); + const update = () => { + const current = Number.isFinite(audio.currentTime) ? audio.currentTime : 0; + const total = Number.isFinite(audio.duration) ? audio.duration : 0; + seek.value = total ? Math.round((current / total) * 1000) : 0; + seek.style.setProperty('--cc-audio-progress', `${seek.value / 10}%`); + time.textContent = `${audioClock(current)} / ${total ? duration(total) : '--:--'}`; + }; + play.addEventListener('click', () => audio.paused ? audio.play().catch(() => toast('Audio playback failed')) : audio.pause()); + seek.addEventListener('input', () => { + if (Number.isFinite(audio.duration)) audio.currentTime = (Number(seek.value) / 1000) * audio.duration; + update(); + }); + mute.addEventListener('click', () => { + audio.muted = !audio.muted; + mute.textContent = audio.muted ? 'MUTE' : 'VOL'; + mute.classList.toggle('active', audio.muted); + }); + audio.addEventListener('play', () => { + $$('.cc-cyber-audio audio').forEach(other => { if (other !== audio) other.pause(); }); + player.classList.add('playing'); + play.setAttribute('aria-label', 'Pause audio'); + }); + audio.addEventListener('pause', () => { + player.classList.remove('playing'); + play.setAttribute('aria-label', 'Play audio'); + }); + audio.addEventListener('ended', () => { player.classList.remove('playing'); update(); }); + audio.addEventListener('loadedmetadata', update); + audio.addEventListener('durationchange', update); + audio.addEventListener('timeupdate', update); + update(); + }); + } + + async function init(selector) { + root = typeof selector === 'string' ? document.querySelector(selector) : selector; + if (!root) return; + root.id = 'cyberchat-root'; + state.config = await api('config.php').catch(() => ({ + chat: { max_message_length: 500, max_username_length: 12, poll_interval_ms: 2000 }, + security: { min_password_length: 4 }, voice: { enabled: true, max_duration_seconds: 60 }, + ui: { app_title: 'CYBERCHAT', app_subtitle: 'SECURE CHANNEL' } + })); + try { document.body.classList.toggle('cc-light', localStorage.getItem('cc_theme') === 'light'); } catch (e) {} + shell(); + api('stats.php', form({ event: 'visit', path: location.pathname })).catch(() => {}); + const check = await api('auth.php', null, 'action=check').catch(() => ({ authenticated: false })); + if (check.authenticated) { + state.user = check.user; + await enter(); + } else auth(state.loginChallenge ? '2fa' : 'register'); + } + + function shell() { + root.innerHTML = ` +
+
+ +
+ --online
+
+
`; + $('#cc-theme').addEventListener('click', () => { + const light = !document.body.classList.contains('cc-light'); + document.body.classList.toggle('cc-light', light); + try { localStorage.setItem('cc_theme', light ? 'light' : 'dark'); } catch (e) {} + }); + } + + function auth(tab) { + stopPoll(); + const maxUser = state.config.chat?.max_username_length || 12; + const minPass = state.config.security?.min_password_length || 4; + $('#cc-body').innerHTML = `
+
IDENTIFY YOURSELF
+ ${state.verifyToken ? '
Log in to finish email verification.
' : ''} + ${state.loginChallenge ? '
Enter the code from your login email.
' : ''} +
+
+
+

Email is optional for free registration.

+
+
+
+
+
+

Welcome back.

+
+
+
+

Enter the code sent to your verified email.

+
+
+
`; + $$('[data-tab]').forEach(button => button.addEventListener('click', () => switchAuth(button.dataset.tab))); + $('#register').addEventListener('click', register); + $('#login').addEventListener('click', login); + if (state.loginChallenge) $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); + } + function switchAuth(name) { + $$('[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab === name)); + $$('[data-form]').forEach(panel => panel.classList.toggle('active', panel.dataset.form === name)); + authError(''); + } + function authError(message) { + const item = $('#auth-error'); + item.textContent = message; + item.classList.toggle('visible', !!message); + } + async function register() { + const username = $('#reg-user').value.trim(); + const password = $('#reg-pass').value; + if (!username || !password) return authError('Username and password are required'); + if (password !== $('#reg-pass2').value) return authError('Passphrases do not match'); + const result = await api('auth.php', form({ action: 'register', username, password })); + if (result.error) return authError(result.error); + state.user = result.user; + await enter(); + } + async function login() { + const result = await api('auth.php', form({ + action: 'login', username: $('#login-user').value.trim(), password: $('#login-pass').value + })); + if (result.error) return authError(result.error); + if (result.requires_2fa) { + state.loginChallenge = result.challenge; + switchAuth('2fa'); + $('#two-submit').addEventListener('click', () => verify2fa(state.loginChallenge)); + return; + } + state.user = result.user; + await enter(); + } + async function verify2fa(challenge) { + const result = await api('auth.php', form({ action: 'verify_2fa', challenge, code: $('#two-code').value.trim() })); + if (result.error) return authError(result.error); + state.loginChallenge = ''; + history.replaceState({}, '', location.pathname); + state.user = result.user; + await enter(); + } + + async function enter() { + const [groups, billing] = await Promise.all([ + api('groups.php', null, 'action=list').catch(() => ({ groups: [] })), + api('billing.php', null, 'action=status').catch(() => null) + ]); + state.groups = groups.groups || []; + state.billing = billing; + app(); + if (state.verifyToken) { + state.view = 'account'; + renderView(); + await verifyEmail(state.verifyToken, ''); + state.verifyToken = ''; + history.replaceState({}, '', location.pathname); + } + } + function app() { + $('#cc-body').innerHTML = `
`; + $$('[data-view]').forEach(button => button.addEventListener('click', () => { + state.view = button.dataset.view; + renderView(); + })); + renderView(); + } + function renderView() { + stopPoll(); + stopVoice(true); + $$('[data-view]').forEach(button => button.classList.toggle('active', button.dataset.view === state.view)); + if (state.view === 'chat') chat(); + else if (state.view === 'groups') groupsView(); + else if (state.view === 'archive') archiveView(); + else accountView(); + } + + function groupOptions() { + return '' + state.groups.map(group => + `` + ).join(''); + } + function chat() { + const max = state.config.chat?.max_message_length || 500; + const voice = state.config.voice?.enabled !== false && feature('voice_messages', true); + $('#cc-view').innerHTML = `
+ YOU:${esc(state.user.username)} + ${esc(state.user.plan.name)}
+
+ +
LOADING CHANNEL
+ ${voice ? `
+ VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s + ${feature('auto_voice_send') ? `` : ''} +
` : ''} +
+ ${max}
`; + $('#group-select').addEventListener('change', event => { + state.groupId = event.target.value ? Number(event.target.value) : null; + state.lastId = 0; + chat(); + }); + $('#message').addEventListener('input', event => $('#count').textContent = max - event.target.value.length); + $('#message').addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendText(); } + }); + $('#send').addEventListener('click', sendText); + $('#record')?.addEventListener('click', toggleVoice); + $('#voice-send')?.addEventListener('click', sendVoice); + $('#voice-discard')?.addEventListener('click', discardVoice); + $('#auto-send')?.addEventListener('change', event => { state.voice.autoSend = event.target.checked; }); + bindAudioPlayers($('#cc-view')); + state.lastId = 0; + startPoll(); + } + async function sendText() { + const input = $('#message'); + const message = input.value.trim(); + if (!message) return; + input.value = ''; + const result = await api('messages.php', form({ action: 'send', message, group_id: state.groupId || '' })); + if (result.error) { input.value = message; return toast(result.error); } + appendMessage(result.message); + state.lastId = Math.max(state.lastId, result.message.id); + } + function startPoll() { + stopPoll(); + poll(); + state.pollTimer = setInterval(poll, state.config.chat?.poll_interval_ms || 2000); + } + function stopPoll() { + if (state.pollTimer) clearInterval(state.pollTimer); + state.pollTimer = null; + state.polling = false; + } + async function poll() { + if (state.polling || state.view !== 'chat') return; + state.polling = true; + const query = new URLSearchParams({ action: 'poll', since: state.lastId, group_id: state.groupId || '' }); + const result = await api('messages.php', null, query.toString()).catch(() => null); + if (result && !result.error) { + $('#cc-conn-dot').className = 'cc-conn-dot online'; + $('#cc-online-count').textContent = result.online; + if (state.lastId === 0) renderMessages(result.messages || []); + else (result.messages || []).forEach(appendMessage); + if (result.messages?.length) state.lastId = result.messages[result.messages.length - 1].id; + const recording = $('#recording'); + recording.hidden = !result.recording?.length; + recording.textContent = result.recording?.length + ? result.recording.map(user => user.username).join(', ') + ' is recording...' : ''; + } else $('#cc-conn-dot').className = 'cc-conn-dot offline'; + state.polling = false; + } + function renderMessages(messages) { + const list = $('#messages'); + list.innerHTML = messages.length ? '' : '
NO MESSAGES IN THIS CHANNEL TODAY
'; + messages.forEach(appendMessage); + if (messages.length) state.lastId = messages[messages.length - 1].id; + list.scrollTop = list.scrollHeight; + } + function appendMessage(message) { + const list = $('#messages'); + if (!list || list.querySelector(`[data-id="${message.id}"]`)) return; + list.querySelector('.cc-loading-msg, .cc-empty-day')?.remove(); + const item = document.createElement('div'); + item.className = 'cc-msg' + (message.username === state.user.username ? ' cc-msg-own' : '') + ' cc-msg-new'; + item.dataset.id = message.id; + const content = message.message_type === 'voice' && message.voice_url + ? `VOICE ${duration(message.voice_duration)}${audioPlayer(publicUrl(message.voice_url))}` + : linkify(esc(message.message)); + item.innerHTML = `${clock(message.created_at)} + ${esc(message.username)} + >${content}`; + list.appendChild(item); + bindAudioPlayers(item); + list.scrollTop = list.scrollHeight; + } + + async function recording(active) { + if (!feature('recording_status')) return; + await api('messages.php', form({ action: 'recording', active: active ? '1' : '0', group_id: state.groupId || '' })).catch(() => {}); + } + async function toggleVoice() { + if (state.voice.recorder) return stopVoice(false); + discardVoice(); + if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Voice recording is not supported'); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mime = ['audio/webm;codecs=opus', 'audio/webm'].find(type => MediaRecorder.isTypeSupported(type)); + if (!mime) { stream.getTracks().forEach(track => track.stop()); return toast('WebM recording is not supported'); } + state.voice.stream = stream; + state.voice.chunks = []; + state.voice.started = Date.now(); + const recorder = new MediaRecorder(stream, { mimeType: mime }); + recorder.addEventListener('dataavailable', event => { if (event.data.size) state.voice.chunks.push(event.data); }); + recorder.addEventListener('stop', () => { + if (!recorder._discard) finishVoice(new Blob(state.voice.chunks, { type: mime }), (Date.now() - state.voice.started) / 1000); + }, { once: true }); + recorder.start(250); + state.voice.recorder = recorder; + $('#record').classList.add('recording'); + $('#record-label').textContent = 'STOP'; + recording(true); + state.voice.heartbeat = setInterval(() => recording(true), 4000); + state.voice.timer = setInterval(voiceTimer, 200); + voiceTimer(); + } catch (e) { toast(e.name === 'NotAllowedError' ? 'Microphone permission was denied' : 'Could not access microphone'); } + } + function voiceTimer() { + const max = state.config.voice?.max_duration_seconds || 60; + const elapsed = Math.min((Date.now() - state.voice.started) / 1000, max); + if ($('#voice-status')) $('#voice-status').textContent = `RECORDING ${duration(elapsed)} / ${duration(max)}`; + if (elapsed >= max) stopVoice(false); + } + function stopVoice(discard) { + clearInterval(state.voice.timer); + clearInterval(state.voice.heartbeat); + state.voice.timer = state.voice.heartbeat = null; + recording(false); + if (state.voice.recorder && state.voice.recorder.state !== 'inactive') { + state.voice.recorder._discard = discard; + state.voice.recorder.stop(); + } + state.voice.recorder = null; + state.voice.stream?.getTracks().forEach(track => track.stop()); + state.voice.stream = null; + $('#record')?.classList.remove('recording'); + if ($('#record-label')) $('#record-label').textContent = 'RECORD'; + } + function finishVoice(blob, length) { + if (!blob.size || length < 0.25) return toast('Voice clip was too short'); + state.voice.blob = blob; + state.voice.duration = length; + if (state.voice.autoSend && feature('auto_voice_send')) return sendVoice(); + if (state.voice.url) URL.revokeObjectURL(state.voice.url); + state.voice.url = URL.createObjectURL(blob); + $('#voice-preview').src = state.voice.url; + $('#voice-preview').load(); + $('#voice-review').hidden = false; + $('#voice-status').textContent = 'READY ' + duration(length); + } + function discardVoice() { + if (state.voice.recorder) stopVoice(true); + if (state.voice.url) URL.revokeObjectURL(state.voice.url); + state.voice.url = ''; + state.voice.blob = null; + state.voice.duration = 0; + if ($('#voice-review')) $('#voice-review').hidden = true; + } + async function sendVoice() { + if (!state.voice.blob || state.voice.sending) return; + state.voice.sending = true; + const body = new FormData(); + body.append('action', 'send_voice'); + body.append('group_id', state.groupId || ''); + body.append('duration', state.voice.duration.toFixed(2)); + body.append('voice', state.voice.blob, 'voice.webm'); + const result = await api('messages.php', { method: 'POST', body }); + state.voice.sending = false; + if (result.error) return toast(result.error); + appendMessage(result.message); + state.lastId = Math.max(state.lastId, result.message.id); + discardVoice(); + } + + function groupsView() { + const limit = Number(feature('group_member_limit', 2)); + $('#cc-view').innerHTML = `

GROUPS

+

Up to ${limit} people per group, including you.

+

CREATE GROUP

+ + +
+
${state.groups.length ? state.groups.map(groupCard).join('') : '
No private groups yet.
'}
`; + $('#group-create').addEventListener('click', createGroup); + $$('[data-open]').forEach(button => button.addEventListener('click', () => { + state.groupId = Number(button.dataset.open); + state.view = 'chat'; + renderView(); + })); + $$('[data-delete]').forEach(button => button.addEventListener('click', () => deleteGroup(Number(button.dataset.delete)))); + $$('[data-add]').forEach(button => button.addEventListener('click', () => addMember(Number(button.dataset.add)))); + $$('[data-remove]').forEach(button => button.addEventListener('click', () => { + const [groupId, userId] = button.dataset.remove.split(':').map(Number); + removeMember(groupId, userId); + })); + } + function groupCard(group) { + const owner = group.owner_id === Number(state.user.id); + return `

${esc(group.name)}

+
+
${group.members.map(member => `${esc(member.username)} + ${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` ` : ''}`).join('')}
+ ${owner ? `
+
` : ''}
`; + } + async function createGroup() { + const result = await api('groups.php', form({ action: 'create', name: $('#group-name').value.trim(), members: $('#group-members').value.trim() })); + if (result.error) return toast(result.error); + state.groups = result.groups; + groupsView(); + } + async function addMember(groupId) { + const result = await api('groups.php', form({ action: 'add_member', group_id: groupId, username: $('#add-' + groupId).value.trim() })); + if (result.error) return toast(result.error); + state.groups = result.groups; + groupsView(); + } + async function removeMember(groupId, userId) { + const result = await api('groups.php', form({ action: 'remove_member', group_id: groupId, user_id: userId })); + if (result.error) return toast(result.error); + state.groups = result.groups; + groupsView(); + } + async function deleteGroup(groupId) { + if (!confirm('Delete this group and its messages?')) return; + const result = await api('groups.php', form({ action: 'delete', group_id: groupId })); + if (result.error) return toast(result.error); + state.groups = result.groups; + if (state.groupId === groupId) state.groupId = null; + groupsView(); + } + + async function archiveView(search = '') { + $('#cc-view').innerHTML = '
LOADING YOUR ARCHIVE
'; + const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString()); + if (result.error) return $('#cc-view').innerHTML = `
${esc(result.error)}
`; + $('#cc-view').innerHTML = `

MY ARCHIVE

+

Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}

+ ${result.can_export ? `` : ''}
+
+
+
${result.messages.length ? result.messages.map(archiveRow).join('') : '
No matching messages.
'}
`; + bindAudioPlayers($('#cc-view')); + $('#archive-submit').addEventListener('click', () => archiveView($('#archive-search').value.trim())); + } + function archiveRow(message) { + const content = message.message_type === 'voice' && message.voice_url + ? audioPlayer(publicUrl(message.voice_url)) : esc(message.message); + return `
+ ${esc(message.message_type)}
${content}
`; + } + + async function accountView() { + if (!state.billing) state.billing = await api('billing.php', null, 'action=status').catch(() => null); + const billing = state.billing; + const showBilling = billing && (billing.subscriptions_enabled || billing.can_manage); + $('#cc-view').innerHTML = `

ACCOUNT

+

${esc(state.user.username)} / ${esc(state.user.plan.name)}

+

EMAIL VERIFICATION

+

${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}

+
+
+
+
+

IDENTITY & SECURITY

+ ${feature('username_color') ? `
+
` : '

Custom username color is not included in this plan.

'} + ${feature('email_2fa') ? `` : '

Email two-factor authentication is not included in this plan.

'} +
+ ${showBilling ? billingHtml(billing) : ''} +

CURRENT FEATURES

+ ${Object.entries(state.user.features).map(([key, value]) => `${esc(key.replace(/_/g, ' '))} ${esc(String(value))}`).join('')}
+
`; + $('#logout').addEventListener('click', () => logout(true)); + $('#email-send').addEventListener('click', requestEmail); + $('#email-verify').addEventListener('click', () => verifyEmail('', $('#email-code').value.trim())); + $('#color-save')?.addEventListener('click', saveColor); + $('#two-toggle')?.addEventListener('change', saveTwoFactor); + $$('[data-plan]').forEach(button => button.addEventListener('click', () => checkout(Number(button.dataset.plan)))); + $('#billing-manage')?.addEventListener('click', portal); + $('#billing-cancel')?.addEventListener('click', cancelSubscription); + } + function billingHtml(billing) { + const alreadySubscribed = ['active', 'trialing', 'past_due'].includes(state.user.subscription.status); + const plans = billing.subscriptions_enabled && !alreadySubscribed ? billing.plans : []; + return `

SUBSCRIPTION

+

Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}

+
${billing.can_manage ? '' : ''} + ${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '' : ''}
+ ${plans.length ? `
${plans.map(plan => `

${esc(plan.name)}

+
${money(plan.price_cents, plan.currency)}/${esc(plan.interval)}
+

${esc(plan.description)}

`).join('')}
` : ''} + ${billing.subscriptions_enabled && alreadySubscribed ? '

Use the Stripe portal to change plans or payment details.

' : ''} + ${!billing.subscriptions_enabled && billing.can_manage ? '

New subscriptions are hidden. Your existing subscription remains manageable.

' : ''}
`; + } + async function requestEmail() { + const result = await api('account.php', form({ action: 'request_email_verification', email: $('#email').value.trim() })); + toast(result.error || result.message, result.error ? 'error' : 'success'); + } + async function verifyEmail(token, code) { + const result = await api('account.php', form({ action: 'verify_email', token, code })); + if (result.error) return toast(result.error); + state.user = result.user; + state.billing = await api('billing.php', null, 'action=status').catch(() => state.billing); + toast('Email verified', 'success'); + accountView(); + } + async function saveColor() { + const result = await api('account.php', form({ action: 'set_color', color: $('#color').value })); + if (result.error) return toast(result.error); + state.user = result.user; + toast('Color updated', 'success'); + accountView(); + } + async function saveTwoFactor(event) { + const result = await api('account.php', form({ action: 'set_2fa', enabled: event.target.checked ? '1' : '0' })); + if (result.error) { event.target.checked = !event.target.checked; return toast(result.error); } + state.user = result.user; + toast('Two-factor setting updated', 'success'); + } + async function checkout(planId) { + if (!state.user.email_verified) return toast('Verify an email before premium checkout'); + const result = await api('billing.php', form({ action: 'checkout', plan_id: planId })); + if (result.error) return toast(result.error); + location.href = result.url; + } + async function portal() { + const result = await api('billing.php', form({ action: 'portal' })); + if (result.error) return toast(result.error); + location.href = result.url; + } + async function cancelSubscription() { + if (!confirm('Cancel this subscription at the end of its billing period?')) return; + const result = await api('billing.php', form({ action: 'cancel' })); + if (result.error) return toast(result.error); + state.user = result.user; + state.billing = await api('billing.php', null, 'action=status'); + accountView(); + } + async function logout(request = true) { + stopPoll(); + stopVoice(true); + if (request) await api('auth.php', form({ action: 'logout' })).catch(() => {}); + state.user = null; + state.groups = []; + state.billing = null; + state.groupId = null; + auth('login'); + } + + window.CyberChat = { init }; +})(window, document); diff --git a/bootstrap.php b/bootstrap.php index 0317d5d..eb72cee 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -1,221 +1,460 @@ 'Server error: ' . $errstr . ' in ' . basename($errfile) . ':' . $errline]); - exit; + jsonResponse(['error' => 'Server error', 'detail' => basename($errfile) . ':' . $errline], 500); } 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; + jsonResponse(['error' => 'Server exception', 'detail' => $e->getMessage()], 500); } }); -// ─── 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) { + $allowed = (array)getConfigVal('security.allowed_origins', []); + $hostOrigin = requestOrigin(); + if ($origin && ($origin === $hostOrigin || in_array($origin, $allowed, true))) { header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: 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') { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); exit; } } -// ─── Config ─────────────────────────────────────────────────────────────── -function getConfig(): array { +function requestOrigin(): string { + $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + $scheme = $https ? 'https' : 'http'; + return $scheme . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); +} + +function appBaseUrl(): string { + $configured = trim((string)getConfigVal('app.base_url', '')); + if ($configured !== '') return rtrim($configured, '/'); + $script = str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')); + if (str_ends_with($script, '/api')) $script = substr($script, 0, -4); + return requestOrigin() . rtrim($script, '/'); +} + +function getConfig(bool $reload = false): array { static $config = null; + if ($reload) $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()); - } + if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found'); + $config = json_decode((string)file_get_contents(CONFIG_FILE), true); + if (!is_array($config)) throw new RuntimeException('config.json is invalid: ' . 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]; + $value = getConfig(); + foreach (explode('.', $path) as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) return $default; + $value = $value[$key]; } - return $val; + return $value; } -// ─── 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"); +function setConfigValues(array $changes): void { + $config = getConfig(); + foreach ($changes as $path => $value) { + $keys = explode('.', $path); + $cursor =& $config; + foreach ($keys as $index => $key) { + if ($index === count($keys) - 1) { + $cursor[$key] = $value; + } else { + if (!isset($cursor[$key]) || !is_array($cursor[$key])) $cursor[$key] = []; + $cursor =& $cursor[$key]; } } - 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); + unset($cursor); } + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents(CONFIG_FILE, $json . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Could not save config.json'); + } + getConfig(true); +} + +function storagePath(string $configured): string { + $configured = trim($configured); + if ($configured === '') throw new RuntimeException('Storage path is empty'); + if (str_starts_with($configured, '/') || preg_match('/^[A-Za-z]:[\\\\\/]/', $configured)) { + return $configured; + } + return ROOT_DIR . '/' . ltrim($configured, '/\\'); +} + +function ensureWritableDirectory(string $dir, string $label): void { + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new RuntimeException("Cannot create $label directory: $dir"); + } + if (!is_writable($dir)) { + throw new RuntimeException( + "$label directory is not writable by PHP: $dir. " + . "Grant the web-server user write access to this directory." + ); + } + $probe = @tempnam($dir, '.cyberchat-write-'); + if ($probe === false) { + throw new RuntimeException( + "$label directory cannot create files: $dir. " + . "Grant the web-server user ownership or write access." + ); + } + @unlink($probe); +} + +function getDB(): PDO { + static $db = null; + if ($db !== null) return $db; + + $path = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite')); + $dir = dirname($path); + ensureWritableDirectory($dir, 'Database'); + if (is_file($path) && !is_writable($path)) { + throw new RuntimeException( + "SQLite database is not writable by PHP: $path. " + . "Grant the web-server user write access to the file and its directory." + ); + } + try { + $db = new PDO('sqlite:' . $path); + } catch (PDOException $e) { + throw new RuntimeException( + "SQLite cannot open $path. Confirm that PHP can write to $dir.", + 0, + $e + ); + } + $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); + maybeMirrorToMySQL($db); return $db; } +function tableColumns(PDO $db, string $table): array { + $columns = []; + foreach ($db->query("PRAGMA table_info($table)") as $column) $columns[$column['name']] = true; + return $columns; +} + +function ensureColumns(PDO $db, string $table, array $additions): void { + $columns = tableColumns($db, $table); + foreach ($additions as $name => $definition) { + if (!isset($columns[$name])) $db->exec("ALTER TABLE $table ADD COLUMN $name $definition"); + } +} + 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 + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL COLLATE NOCASE, + password_hash TEXT NOT NULL, + color TEXT NOT NULL, + email TEXT, + email_verified_at INTEGER, + plan_id INTEGER, + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + subscription_status TEXT NOT NULL DEFAULT 'free', + subscription_period_end INTEGER, + cancel_at_period_end INTEGER NOT NULL DEFAULT 0, + two_factor_enabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + last_seen INTEGER )"); + ensureColumns($db, 'users', [ + 'email' => 'TEXT', + 'email_verified_at' => 'INTEGER', + 'plan_id' => 'INTEGER', + 'stripe_customer_id' => 'TEXT', + 'stripe_subscription_id' => 'TEXT', + 'subscription_status' => "TEXT NOT NULL DEFAULT 'free'", + 'subscription_period_end' => 'INTEGER', + 'cancel_at_period_end' => 'INTEGER NOT NULL DEFAULT 0', + 'two_factor_enabled' => 'INTEGER NOT NULL DEFAULT 0', + ]); $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')), + 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) + $db->exec("CREATE TABLE IF NOT EXISTS chat_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE + )"); + $db->exec("CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at INTEGER NOT NULL, + PRIMARY KEY(group_id, user_id), + FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )"); - 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 = [ + $db->exec("CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + group_id INTEGER, + 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), + FOREIGN KEY(group_id) REFERENCES chat_groups(id) ON DELETE CASCADE + )"); + ensureColumns($db, 'messages', [ + 'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'", 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', + ]); + + $db->exec("CREATE TABLE IF NOT EXISTS plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'usd', + billing_interval TEXT NOT NULL DEFAULT 'month', + stripe_price_id TEXT, + active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"); + $db->exec("CREATE TABLE IF NOT EXISTS plan_features ( + plan_id INTEGER NOT NULL, + feature_key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY(plan_id, feature_key), + FOREIGN KEY(plan_id) REFERENCES plans(id) ON DELETE CASCADE + )"); + $db->exec("CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER UNIQUE NOT NULL, + plan_id INTEGER, + stripe_customer_id TEXT, + stripe_subscription_id TEXT UNIQUE, + status TEXT NOT NULL DEFAULT 'free', + current_period_end INTEGER, + cancel_at_period_end INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(plan_id) REFERENCES plans(id) + )"); + $db->exec("CREATE TABLE IF NOT EXISTS email_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email TEXT NOT NULL, + purpose TEXT NOT NULL, + token_hash TEXT NOT NULL, + code_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + used_at INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )"); + ensureColumns($db, 'email_tokens', [ + 'attempts' => 'INTEGER NOT NULL DEFAULT 0', + ]); + $db->exec("CREATE TABLE IF NOT EXISTS login_challenges ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + code_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )"); + $db->exec("CREATE TABLE IF NOT EXISTS recording_status ( + group_key TEXT NOT NULL, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY(group_key, user_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )"); + $db->exec("CREATE TABLE IF NOT EXISTS visitor_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + event_type TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + ip_hash TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL + )"); + $db->exec("CREATE TABLE IF NOT EXISTS stripe_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + received_at INTEGER NOT NULL + )"); + + $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_group ON messages(group_id, id)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_msg_user_created ON messages(user_id, 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)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_event_created ON visitor_events(created_at)"); + $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"); + + seedPlans($db); +} + +function seedPlans(PDO $db): void { + $now = time(); + $defaults = [ + 'free' => [ + 'name' => 'Free', 'description' => 'Core chat and 30-day personal text history.', + 'price' => 0, 'sort' => 0, + 'features' => [ + 'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false, + 'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30, + 'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false, + ], + ], + 'plus' => [ + 'name' => 'Plus', 'description' => 'Premium voice, identity, security, and groups for four.', + 'price' => 499, 'sort' => 10, + 'features' => [ + 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'group_member_limit' => 4, 'username_color' => true, 'text_archive_days' => 90, + 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, + ], + ], + 'pro' => [ + 'name' => 'Pro', 'description' => 'Expanded groups and one year of personal archives.', + 'price' => 999, 'sort' => 20, + 'features' => [ + 'voice_messages' => true, 'recording_status' => true, 'auto_voice_send' => true, + 'group_member_limit' => 8, 'username_color' => true, 'text_archive_days' => 365, + 'text_export' => true, 'voice_archive' => true, 'voice_export' => true, 'email_2fa' => true, + ], + ], ]; - foreach ($additions as $name => $definition) { - if (!isset($columns[$name])) { - $db->exec("ALTER TABLE messages ADD COLUMN $name $definition"); + + $insertPlan = $db->prepare("INSERT OR IGNORE INTO plans + (slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)"); + $insertFeature = $db->prepare("INSERT OR IGNORE INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)"); + foreach ($defaults as $slug => $plan) { + $insertPlan->execute([$slug, $plan['name'], $plan['description'], $plan['price'], 'usd', 'month', 1, $plan['sort'], $now, $now]); + $stmt = $db->prepare("SELECT id FROM plans WHERE slug = ?"); + $stmt->execute([$slug]); + $planId = (int)$stmt->fetchColumn(); + foreach ($plan['features'] as $key => $value) { + $insertFeature->execute([$planId, $key, json_encode($value)]); } } + $freeId = (int)$db->query("SELECT id FROM plans WHERE slug = 'free'")->fetchColumn(); + $db->prepare("UPDATE users SET plan_id = ? WHERE plan_id IS NULL")->execute([$freeId]); } -function voiceUploadDir(): string { - $relative = getConfigVal('voice.upload_dir', 'uploads/voice'); - return ROOT_DIR . '/' . trim((string)$relative, '/'); +function plans(bool $activeOnly = false): array { + $db = getDB(); + $sql = "SELECT * FROM plans" . ($activeOnly ? " WHERE active = 1" : "") . " ORDER BY sort_order, price_cents, id"; + $rows = $db->query($sql)->fetchAll(); + $featureStmt = $db->prepare("SELECT feature_key,value_json FROM plan_features WHERE plan_id = ?"); + foreach ($rows as &$row) { + $featureStmt->execute([$row['id']]); + $row['features'] = []; + foreach ($featureStmt->fetchAll() as $feature) { + $row['features'][$feature['feature_key']] = json_decode($feature['value_json'], true); + } + $row['id'] = (int)$row['id']; + $row['price_cents'] = (int)$row['price_cents']; + $row['active'] = (bool)$row['active']; + } + return $rows; } -function voicePublicPath(string $filename): string { - $relative = trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/'); - return $relative . '/' . rawurlencode($filename); +function planById(int $planId): ?array { + foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan; + return null; } -function deleteVoiceFile(?string $filename): void { - if (!$filename || basename($filename) !== $filename) return; - $path = voiceUploadDir() . '/' . $filename; - if (is_file($path)) unlink($path); +function userPlan(array $user): array { + $free = null; + foreach (plans(false) as $plan) { + if ($plan['slug'] === 'free') $free = $plan; + if ((int)($user['plan_id'] ?? 0) === $plan['id']) return $plan; + } + return $free ?? ['id' => 0, 'slug' => 'free', 'name' => 'Free', 'features' => []]; } -// ─── 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; +function featureValue(array $user, string $key, mixed $default = false): mixed { + $plan = userPlan($user); + return array_key_exists($key, $plan['features']) ? $plan['features'][$key] : $default; } -// ─── Helpers ────────────────────────────────────────────────────────────── -function dayKey(): string { - return date('Y-m-d'); +function userPublicPayload(array $user): array { + $plan = userPlan($user); + return [ + 'id' => (int)$user['id'], + 'username' => $user['username'], + 'color' => $user['color'], + 'email' => $user['email'] ?? null, + 'email_verified' => !empty($user['email_verified_at']), + 'plan' => ['id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name']], + 'features' => $plan['features'], + 'subscription' => [ + 'status' => $user['subscription_status'] ?? 'free', + 'period_end' => isset($user['subscription_period_end']) ? (int)$user['subscription_period_end'] : null, + 'cancel_at_period_end' => !empty($user['cancel_at_period_end']), + 'manageable' => !empty($user['stripe_customer_id']), + ], + 'two_factor_enabled' => !empty($user['two_factor_enabled']), + ]; +} + +function userById(int $id): ?array { + $stmt = getDB()->prepare("SELECT * FROM users WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; } function clientIP(): string { - foreach (['HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR','HTTP_X_REAL_IP','REMOTE_ADDR'] as $key) { + 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]); + $ip = trim(explode(',', (string)$_SERVER[$key])[0]); if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip; } } @@ -224,77 +463,228 @@ function clientIP(): string { function jsonResponse(array $data, int $code = 200): never { http_response_code($code); - header('Content-Type: application/json'); + header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); - echo json_encode($data); + echo json_encode($data, JSON_UNESCAPED_SLASHES); exit; } -// ─── Auth guard ─────────────────────────────────────────────────────────── -function authRequired(): array { - $db = getDB(); +function authUser(bool $required = true): ?array { $sid = $_COOKIE['cyberchat_sid'] ?? ''; - - if (!$sid) { - jsonResponse(['error' => 'Not authenticated'], 401); + if ($sid === '') { + if ($required) jsonResponse(['error' => 'Not authenticated'], 401); + return null; } - - $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 > ? - "); + $cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600); + $stmt = getDB()->prepare("SELECT s.id AS session_id,s.ip,s.last_active,u.* + 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); + $user = $stmt->fetch(); + if (!$user) { + if ($required) jsonResponse(['error' => 'Session expired'], 401); + return null; } - - if (getConfigVal('chat.session_lock_ip', true) && $session['ip'] !== clientIP()) { - jsonResponse(['error' => 'Session IP mismatch — logged in elsewhere'], 401); + if (getConfigVal('chat.session_lock_ip', true) && $user['ip'] !== clientIP()) { + if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401); + return null; } - - $db->prepare("UPDATE sessions SET last_active = ? WHERE id = ?")->execute([time(), $sid]); - - return $session; + getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]); + return $user; } -// ─── Daily archive ──────────────────────────────────────────────────────── -function archiveYesterdayIfNeeded(): void { - $yesterday = date('Y-m-d', strtotime('-1 day')); - $flagFile = ROOT_DIR . '/db/.archived_' . $yesterday; - if (file_exists($flagFile)) return; +function authRequired(): array { + $user = authUser(true); + $user['uid'] = $user['id']; + return $user; +} - $db = getDB(); - $stmt = $db->prepare("SELECT * FROM messages WHERE day_key = ? ORDER BY created_at ASC"); +function createUserSession(PDO $db, int $userId): void { + $sid = bin2hex(random_bytes(32)); + $db->prepare("DELETE FROM sessions WHERE user_id=?")->execute([$userId]); + $db->prepare("INSERT INTO sessions (id,user_id,ip,created_at,last_active) VALUES (?,?,?,?,?)") + ->execute([$sid, $userId, clientIP(), time(), time()]); + $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() + ((int)getConfigVal('security.session_timeout_hours', 24) * 3600), + 'path' => '/', 'httponly' => true, 'samesite' => 'Strict', 'secure' => $secure, + ]); +} + +function dayKey(): string { + return date('Y-m-d'); +} + +function normalizeGroupId(mixed $value): ?int { + $id = (int)$value; + return $id > 0 ? $id : null; +} + +function groupKey(?int $groupId): string { + return $groupId ? 'group:' . $groupId : 'lobby'; +} + +function requireGroupAccess(array $user, ?int $groupId): void { + if ($groupId === null) return; + $stmt = getDB()->prepare("SELECT 1 FROM group_members WHERE group_id=? AND user_id=?"); + $stmt->execute([$groupId, $user['id']]); + if (!$stmt->fetchColumn()) jsonResponse(['error' => 'You are not a member of this group'], 403); +} + +function userGroups(int $userId): array { + $stmt = getDB()->prepare("SELECT g.id,g.name,g.owner_id,g.created_at,gm.role, + (SELECT COUNT(*) FROM group_members x WHERE x.group_id=g.id) AS member_count + FROM chat_groups g JOIN group_members gm ON gm.group_id=g.id + WHERE gm.user_id=? ORDER BY g.updated_at DESC,g.id DESC"); + $stmt->execute([$userId]); + $groups = $stmt->fetchAll(); + $members = getDB()->prepare("SELECT u.id,u.username,u.color,gm.role FROM group_members gm + JOIN users u ON u.id=gm.user_id WHERE gm.group_id=? ORDER BY gm.role='owner' DESC,u.username"); + foreach ($groups as &$group) { + $group['id'] = (int)$group['id']; + $group['owner_id'] = (int)$group['owner_id']; + $group['member_count'] = (int)$group['member_count']; + $members->execute([$group['id']]); + $group['members'] = $members->fetchAll(); + } + return $groups; +} + +function voiceUploadDir(): string { + return storagePath((string)getConfigVal('voice.upload_dir', 'uploads/voice')); +} + +function voicePublicPath(string $filename): string { + return trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($filename); +} + +function deleteVoiceFile(?string $filename): void { + if (!$filename || basename($filename) !== $filename) return; + $path = voiceUploadDir() . '/' . $filename; + if (is_file($path)) unlink($path); +} + +function getArchiveDB(string $year, string $month, string $day): PDO { + $template = (string)getConfigVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite'); + $relative = str_replace(['{year}', '{month}', '{day}'], [$year, $month, $day], $template); + $path = storagePath($relative); + ensureWritableDirectory(dirname($path), 'Archive'); + $db = new PDO('sqlite:' . $path); + $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("CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER, + 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 + )"); + ensureColumns($db, 'messages', [ + 'group_id' => 'INTEGER', 'message_type' => "TEXT NOT NULL DEFAULT 'text'", + 'voice_file' => 'TEXT', 'voice_mime' => 'TEXT', 'voice_duration' => 'REAL', + ]); + $db->exec("CREATE TABLE IF NOT EXISTS archive_meta (key TEXT PRIMARY KEY,value TEXT)"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_archive_user ON messages(user_id,created_at)"); + return $db; +} + +function archiveFilesSince(int $cutoff): array { + $root = storagePath((string)getConfigVal('archive.archive_dir', 'archive')); + $files = []; + if (!is_dir($root)) return $files; + foreach (glob($root . '/*/*/*.sqlite') ?: [] as $file) { + if (!preg_match('~/(\d{4})/(\d{2})/(\d{2})\.sqlite$~', $file, $m)) continue; + $dayTs = strtotime($m[1] . '-' . $m[2] . '-' . $m[3] . ' 23:59:59'); + if ($dayTs !== false && $dayTs >= $cutoff) $files[] = [$file, $m[1], $m[2], $m[3]]; + } + return $files; +} + +function archiveYesterdayIfNeeded(): void { + if (!getConfigVal('archive.enabled', true)) return; + $yesterday = date('Y-m-d', strtotime('-1 day')); + $flag = ROOT_DIR . '/db/.archived_' . $yesterday; + if (file_exists($flag)) return; + $db = getDB(); + $stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? ORDER BY id"); $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'] + if ($rows) { + [$year, $month, $day] = explode('-', $yesterday); + $archive = getArchiveDB($year, $month, $day); + $archive->beginTransaction(); + $insert = $archive->prepare("INSERT OR IGNORE INTO messages + (id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); + foreach ($rows as $row) { + $insert->execute([ + $row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'], + $row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'], + $row['voice_duration'], $row['created_at'], $row['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(); + $meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)"); + $meta->execute(['archived_at', (string)time()]); + $meta->execute(['message_count', (string)count($rows)]); + $archive->commit(); + } + $db->prepare("DELETE FROM messages WHERE day_key=?")->execute([$yesterday]); + file_put_contents($flag, date('c'), LOCK_EX); +} + +function personalArchiveRows(array $user, string $search = '', int $limit = 500): array { + $days = max(30, (int)featureValue($user, 'text_archive_days', 30)); + $cutoff = time() - ($days * 86400); + $includeVoice = (bool)featureValue($user, 'voice_archive', false); + $rows = []; + $sql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; + $params = [$user['id'], $cutoff]; + if (!$includeVoice) $sql .= " AND message_type='text'"; + if ($search !== '') { $sql .= " AND message LIKE ?"; $params[] = '%' . $search . '%'; } + $stmt = getDB()->prepare($sql . " ORDER BY created_at DESC LIMIT ?"); + $params[] = $limit; + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + foreach (archiveFilesSince($cutoff) as [, $year, $month, $day]) { + $archive = getArchiveDB($year, $month, $day); + $archiveSql = "SELECT * FROM messages WHERE user_id=? AND created_at>=?"; + $archiveParams = [$user['id'], $cutoff]; + if (!$includeVoice) $archiveSql .= " AND message_type='text'"; + if ($search !== '') { $archiveSql .= " AND message LIKE ?"; $archiveParams[] = '%' . $search . '%'; } + $archiveStmt = $archive->prepare($archiveSql . " ORDER BY created_at DESC LIMIT ?"); + $archiveParams[] = $limit; + $archiveStmt->execute($archiveParams); + array_push($rows, ...$archiveStmt->fetchAll()); + } + usort($rows, fn(array $a, array $b): int => ((int)$b['created_at'] <=> (int)$a['created_at'])); + return array_slice($rows, 0, $limit); +} + +function trackEvent(string $type, string $path = '', ?int $userId = null): void { + if (!getConfigVal('stats.enabled', true)) return; + $salt = (string)getConfigVal('stats.ip_hash_salt', 'change-this-stat-salt'); + $ipHash = hash('sha256', $salt . '|' . clientIP()); + $agent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); + getDB()->prepare("INSERT INTO visitor_events (user_id,event_type,path,ip_hash,user_agent,created_at) + VALUES (?,?,?,?,?,?)")->execute([$userId, $type, substr($path, 0, 255), $ipHash, $agent, time()]); +} + +function maybeMirrorToMySQL(PDO $sqlite): void { + if (!getConfigVal('database.mysql.enabled', false) || !getConfigVal('database.mysql.auto_import', false)) return; + static $running = false; + if ($running) return; + $flag = ROOT_DIR . '/db/.mysql_mirror'; + $interval = max(60, (int)getConfigVal('database.mysql.sync_interval_seconds', 900)); + if (is_file($flag) && filemtime($flag) > time() - $interval) return; + $running = true; + try { + require_once ROOT_DIR . '/lib/mysql_mirror.php'; + mirrorAllSQLiteDatabases(); + file_put_contents($flag, (string)time(), LOCK_EX); + } catch (Throwable $e) { + error_log('CyberChat MySQL mirror failed: ' . $e->getMessage()); + } finally { + $running = false; } - - $db->prepare("DELETE FROM messages WHERE day_key = ?")->execute([$yesterday]); - file_put_contents($flagFile, date('c')); } diff --git a/config.json b/config.json index 80069ce..cd509ac 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,7 @@ { + "app": { + "base_url": "" + }, "chat": { "max_message_length": 500, "max_username_length": 12, @@ -22,7 +25,15 @@ }, "database": { "main_db": "db/chat.sqlite", - "archive_path": "archive/{year}/{month}/{day}.sqlite" + "archive_path": "archive/{year}/{month}/{day}.sqlite", + "mysql": { + "enabled": false, + "auto_import": false, + "dsn": "", + "username": "", + "password": "", + "sync_interval_seconds": 900 + } }, "ui": { "default_theme": "dark", @@ -34,7 +45,31 @@ "min_password_length": 4, "max_login_attempts": 10, "session_timeout_hours": 24, - "bcrypt_cost": 10 + "bcrypt_cost": 10, + "allowed_origins": [] + }, + "subscriptions": { + "enabled": true + }, + "mailgun": { + "api_key": "", + "domain": "", + "from": "", + "api_base": "https://api.mailgun.net/v3" + }, + "stripe": { + "secret_key": "", + "webhook_secret": "", + "success_url": "", + "cancel_url": "", + "portal_return_url": "" + }, + "stats": { + "enabled": true, + "ip_hash_salt": "change-this-to-a-long-random-value" + }, + "admin": { + "password": "changeme123" }, "colors": { "user_palette": [ diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..bf27f31 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/embed-example.html b/embed-example.html index 55face2..f1169a4 100644 --- a/embed-example.html +++ b/embed-example.html @@ -94,7 +94,7 @@ <div id="my-chat" style="width:100%; height:600px"></div> <!-- 4. Script + init --> -<script src="assets/js/cyberchat.js"></script> +<script src="assets/js/cyberchat-v4.js"></script> <script> window.CYBERCHAT_BASE = '/path/to/cyberchat'; // adjust to your install path CyberChat.init('#my-chat'); @@ -104,7 +104,7 @@ - + - +