- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+11 -5
View File
@@ -1,16 +1,22 @@
# CyberChat .htaccess — Apache configuration
# Deny direct access to SQLite databases
<FilesMatch "\.sqlite$">
Order Allow,Deny
Deny from all
# Deny direct access to databases and the credential-bearing config file
<FilesMatch "(^config\.json$|\.sqlite($|-wal$|-shm$))">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order Allow,Deny
Deny from all
</IfModule>
</FilesMatch>
# Deny direct access to db/ and archive/ directories
# Deny direct access to storage and internal PHP libraries
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^db/ - [F,L]
RewriteRule ^archive/ - [F,L]
RewriteRule ^lib/ - [F,L]
</IfModule>
# PHP settings (if allowed by host)
+152 -116
View File
@@ -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 `<div>` container; auto-adapts to any size
- **Fully configurable** — `config.json` controls all limits, intervals, and theming
- **Voice clips** — record, review, send, and queue WAV/WebM clips one at a time using normal PHP polling
- **No Node.js or WebSockets** — voice uses browser recording, HTTP uploads, and the existing poll loop
---
- 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: <https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages>
## 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:
- <https://docs.stripe.com/api/checkout/sessions/create>
- <https://docs.stripe.com/webhooks>
## 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
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600;700&family=Orbitron:wght@700;900&display=swap" rel="stylesheet">
<!-- Stylesheet -->
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css">
<!-- Container — set any size -->
<div id="my-chat" style="width:100%; height:600px;"></div>
<!-- Script -->
<script src="/chat/assets/js/cyberchat.js"></script>
<div id="my-chat" style="width:100%;height:600px"></div>
<script>
window.CYBERCHAT_BASE = '/chat';
</script>
<script src="/chat/assets/js/cyberchat-v4.js"></script>
<script>
window.CYBERCHAT_BASE = '/chat'; // path to your cyberchat install
CyberChat.init('#my-chat');
</script>
```
See `embed-example.html` for a live demo.
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.
+53 -29
View File
@@ -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('<div class="alert err">Database not found at: ' . esc($path) . '</div>');
$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}
<span class="ico">⚡</span><span>Sessions</span>
</a>
<a class="sb-link <?= $tab==='platform'?'active':'' ?>" href="?tab=platform">
<span class="ico">◆</span><span>Plans & Platform</span>
</a>
<div class="sb-section">SYSTEM</div>
<a class="sb-link <?= $tab==='config'?'active':'' ?>" href="?tab=config">
<span class="ico">⚙</span><span>Config</span>
@@ -1074,7 +1095,7 @@ td.actions{white-space:nowrap;width:1px}
<td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div>
<audio controls preload="metadata" style="display:block;width:260px;height:30px"
<audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
@@ -1240,7 +1261,7 @@ td.actions{white-space:nowrap;width:1px}
<td style="color:<?= esc($clip['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($clip['username']) ?></td>
<td>
<?php if ($clipUrl !== '' && $clipSize !== null): ?>
<audio controls preload="metadata" style="display:block;width:280px;height:30px" src="<?= esc($clipUrl) ?>"></audio>
<audio class="admin-audio" controls preload="metadata" src="<?= esc($clipUrl) ?>"></audio>
<?php else: ?>
<span class="badge badge-r">RECORDING MISSING</span>
<?php endif; ?>
@@ -1350,7 +1371,7 @@ td.actions{white-space:nowrap;width:1px}
<td>
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<div class="badge badge-b" style="margin-bottom:4px">VOICE · <?= esc(round((float)$msg['voice_duration'], 1)) ?>s</div>
<audio controls preload="metadata" style="display:block;width:260px;height:30px"
<audio class="admin-audio" controls preload="metadata"
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?>
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
@@ -1628,6 +1649,9 @@ td.actions{white-space:nowrap;width:1px}
</div>
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
<?php elseif ($tab === 'platform'): ?>
<?php renderAdminPlatformTab(); ?>
<?php elseif ($tab === 'config'): ?>
<div class="page-head">
+63
View File
@@ -0,0 +1,63 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/mailer.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'profile';
switch ($action) {
case 'profile':
jsonResponse(['user' => 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);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
$search = trim((string)($_GET['search'] ?? $_POST['search'] ?? ''));
$rows = personalArchiveRows($user, $search, min(1000, max(1, (int)($_GET['limit'] ?? 500))));
if ($action === 'export') {
if (!featureValue($user, 'text_export', false)) jsonResponse(['error' => '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),
]);
+102 -145
View File
@@ -1,168 +1,125 @@
<?php
// api/auth.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/mailer.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
match ($action) {
'register' => 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]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/stripe.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'status';
$subscriptionsEnabled = (bool)getConfigVal('subscriptions.enabled', true);
$hasExistingBilling = !empty($user['stripe_customer_id']) || !in_array($user['subscription_status'] ?? 'free', ['free', 'canceled'], true);
switch ($action) {
case 'status':
$availablePlans = [];
if ($subscriptionsEnabled) {
foreach (plans(true) as $plan) {
if ($plan['slug'] === 'free') continue;
$availablePlans[] = [
'id' => $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);
}
+6 -15
View File
@@ -1,32 +1,23 @@
<?php
// api/config.php - Serve safe config values to frontend
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$config = getConfig();
// Only expose safe/needed values to frontend
$safe = [
jsonResponse([
'chat' => [
'max_message_length' => $config['chat']['max_message_length'] ?? 500,
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
'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],
]);
+98
View File
@@ -0,0 +1,98 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
switch ($action) {
case 'list':
jsonResponse(['groups' => 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);
}
+116 -171
View File
@@ -1,37 +1,24 @@
<?php
// api/messages.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
// Run archiving silently
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { /* silent */ }
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->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]);
}
+13 -8
View File
@@ -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);
+10
View File
@@ -0,0 +1,10 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authUser(false);
$type = preg_replace('/[^a-z0-9_.-]/i', '', (string)($_POST['event'] ?? 'visit')) ?: 'visit';
$path = (string)($_POST['path'] ?? '');
trackEvent($type, $path, $user ? (int)$user['id'] : null);
jsonResponse(['success' => true]);
+36
View File
@@ -0,0 +1,36 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/stripe.php';
$payload = (string)file_get_contents('php://input');
$signature = (string)($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');
if (!verifyStripeSignature($payload, $signature)) jsonResponse(['error' => '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]);
+4 -224
View File
@@ -1,225 +1,5 @@
<?php
// archive-viewer.php — Browse archived chat logs
require_once __DIR__ . '/bootstrap.php';
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
$days = [];
if (is_dir($archiveDir)) {
foreach (glob($archiveDir . '/*/*/*.sqlite') as $f) {
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
$days[] = "{$m[1]}-{$m[2]}-{$m[3]}";
}
}
rsort($days);
}
$selectedDay = $_GET['day'] ?? null;
$archiveMessages = [];
$archiveMeta = [];
if ($selectedDay && preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDay)) {
[$y, $mo, $d] = explode('-', $selectedDay);
$dbPath = ROOT_DIR . '/' . str_replace(
['{year}', '{month}', '{day}'], [$y, $mo, $d],
getConfigVal('database.archive_path')
);
if (file_exists($dbPath)) {
$adb = getArchiveDB($y, $mo, $d);
$archiveMessages = $adb->query("SELECT * FROM messages ORDER BY created_at ASC")->fetchAll(PDO::FETCH_ASSOC);
$metaRows = $adb->query("SELECT key, value FROM archive_meta")->fetchAll(PDO::FETCH_ASSOC);
foreach ($metaRows as $mr) $archiveMeta[$mr['key']] = $mr['value'];
}
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CYBERCHAT // ARCHIVE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/cyberchat.css">
<style>
html, body {
min-height: 100vh;
height: auto;
overflow: auto;
background: var(--bg0);
padding: 20px 16px 40px;
font-family: var(--ui);
}
.arc-wrap { max-width: 920px; margin: 0 auto; }
.arc-topbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--bd);
}
.arc-back {
font-family: var(--mono);
font-size: 11px;
color: var(--b);
text-decoration: none;
letter-spacing: 1px;
transition: color 0.14s;
}
.arc-back:hover { color: var(--g); }
.arc-title {
font-family: var(--disp);
font-size: 11px;
letter-spacing: 3px;
color: var(--b);
}
.arc-grid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
height: calc(100vh - 130px);
min-height: 300px;
}
.arc-sidebar, .arc-content {
background: var(--bg1);
border: 1px solid var(--bd);
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.arc-sidebar-head, .arc-content-head {
padding: 8px 12px;
border-bottom: 1px solid var(--bd);
font-family: var(--mono);
font-size: 9px;
letter-spacing: 2px;
color: var(--t1);
text-transform: uppercase;
flex-shrink: 0;
}
.arc-days-list { overflow-y: auto; flex: 1; }
.arc-day-item {
padding: 9px 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--t1);
cursor: pointer;
border-left: 2px solid transparent;
transition: all 0.12s;
}
.arc-day-item:hover { color: var(--g); border-left-color: var(--g); background: rgba(0,255,159,0.04); }
.arc-day-item.active { color: var(--b); border-left-color: var(--b); background: rgba(0,184,255,0.06); }
.arc-messages-list { overflow-y: auto; flex: 1; padding: 4px 0; }
.arc-msg {
display: flex;
align-items: baseline;
padding: 3px 12px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.65;
}
.arc-msg:hover { background: rgba(255,255,255,0.02); }
.arc-msg-time { color: var(--t2); font-size: 9px; min-width: 44px; flex-shrink: 0; margin-right: 7px; }
.arc-msg-user { font-weight: 600; font-size: 11px; flex-shrink: 0; min-width: 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.arc-msg-sep { color: var(--t2); margin: 0 7px; flex-shrink: 0; }
.arc-msg-text { color: var(--t0); flex: 1; word-break: break-word; min-width: 0; }
.arc-msg-text audio { width: min(300px, 100%); height: 30px; vertical-align: middle; }
.arc-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 10px;
color: var(--t2);
letter-spacing: 1.5px;
padding: 20px;
text-align: center;
}
.arc-meta {
font-family: var(--mono);
font-size: 9px;
color: var(--o);
padding: 6px 12px;
border-bottom: 1px solid var(--bd);
letter-spacing: 0.5px;
flex-shrink: 0;
}
@media (max-width: 600px) {
.arc-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; height: auto; }
.arc-sidebar { height: 160px; }
}
</style>
</head>
<body>
<div class="arc-wrap">
<div class="arc-topbar">
<a href="index.html" class="arc-back">◂ BACK TO CHAT</a>
<span class="arc-title">// ARCHIVE VIEWER</span>
</div>
<div class="arc-grid">
<div class="arc-sidebar">
<div class="arc-sidebar-head">// DAYS</div>
<div class="arc-days-list">
<?php if (empty($days)): ?>
<div class="arc-empty">NO ARCHIVES YET</div>
<?php else: foreach ($days as $day): ?>
<div class="arc-day-item <?= $day === $selectedDay ? 'active' : '' ?>"
onclick="location.href='?day=<?= htmlspecialchars($day) ?>'">
<?= htmlspecialchars($day) ?>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<div class="arc-content">
<div class="arc-content-head">// MESSAGES</div>
<?php if (!$selectedDay): ?>
<div class="arc-empty">SELECT A DAY FROM THE LIST</div>
<?php elseif (empty($archiveMessages)): ?>
<div class="arc-empty">NO MESSAGES FOR <?= htmlspecialchars($selectedDay) ?></div>
<?php else: ?>
<div class="arc-meta">
// <?= htmlspecialchars($selectedDay) ?>
&nbsp;|&nbsp; <?= count($archiveMessages) ?> MESSAGES
<?php if (!empty($archiveMeta['archived_at'])): ?>
&nbsp;|&nbsp; ARCHIVED <?= date('Y-m-d H:i', (int)$archiveMeta['archived_at']) ?> UTC
<?php endif; ?>
</div>
<div class="arc-messages-list">
<?php foreach ($archiveMessages as $msg): ?>
<div class="arc-msg">
<span class="arc-msg-time"><?= date('H:i', (int)$msg['created_at']) ?></span>
<span class="arc-msg-user" style="color:<?= htmlspecialchars($msg['color']) ?>"><?= htmlspecialchars($msg['username']) ?></span>
<span class="arc-msg-sep"></span>
<span class="arc-msg-text">
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
<audio controls preload="metadata"
src="<?= htmlspecialchars(trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
<?php else: ?>
<?= htmlspecialchars($msg['message']) ?>
<?php endif; ?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
</body>
</html>
// 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;
+227 -5
View File
@@ -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; }
}
+641
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
}
function audioPlayer(src = '', id = '') {
return `<span class="cc-cyber-audio">
<audio ${id ? `id="${id}"` : ''} preload="metadata" ${src ? `src="${esc(src)}"` : ''}></audio>
<button class="cc-audio-play" type="button" aria-label="Play audio"><span class="cc-audio-play-icon"></span></button>
<span class="cc-audio-signal" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<input class="cc-audio-seek" type="range" min="0" max="1000" value="0" aria-label="Audio position">
<span class="cc-audio-time">0:00 / --:--</span>
<button class="cc-audio-mute" type="button" aria-label="Mute audio">VOL</button>
</span>`;
}
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 = `
<div class="cc-toast-area" id="cc-toasts"></div>
<header class="cc-header">
<div class="cc-logo"><span class="cc-logo-glyph">+</span><div>
<div class="cc-logo-text">${esc(state.config.ui?.app_title || 'CYBERCHAT')}</div>
<div class="cc-logo-sub">${esc(state.config.ui?.app_subtitle || 'SECURE CHANNEL')}</div>
</div></div>
<div class="cc-header-center"><span class="cc-conn-dot" id="cc-conn-dot"></span>
<span class="cc-online-num" id="cc-online-count">--</span><span class="cc-online-label">online</span></div>
<div class="cc-header-right"><button class="cc-theme-btn cc-theme-text" id="cc-theme">THEME</button></div>
</header><div class="cc-body" id="cc-body"></div>`;
$('#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 = `<div class="cc-auth-wrap"><div class="cc-auth-panel">
<div class="cc-auth-brand"><div class="cc-auth-brand-line"></div><span>IDENTIFY YOURSELF</span><div class="cc-auth-brand-line"></div></div>
${state.verifyToken ? '<div class="cc-auth-notice">Log in to finish email verification.</div>' : ''}
${state.loginChallenge ? '<div class="cc-auth-notice">Enter the code from your login email.</div>' : ''}
<div class="cc-auth-tabs"><button class="cc-auth-tab ${tab === 'register' ? 'active' : ''}" data-tab="register">REGISTER</button>
<button class="cc-auth-tab ${tab === 'login' ? 'active' : ''}" data-tab="login">LOGIN</button></div>
<div class="cc-auth-form ${tab === 'register' ? 'active' : ''}" data-form="register">
<p class="cc-form-hint">Email is optional for free registration.</p>
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="reg-user" maxlength="${maxUser}"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE (MIN ${minPass})</label><input class="cc-input" id="reg-pass" type="password"></div>
<div class="cc-field"><label class="cc-field-label">CONFIRM</label><input class="cc-input" id="reg-pass2" type="password"></div>
<button class="cc-btn cc-btn-register" id="register">CREATE IDENTITY</button></div>
<div class="cc-auth-form ${tab === 'login' ? 'active' : ''}" data-form="login">
<p class="cc-form-hint">Welcome back.</p>
<div class="cc-field"><label class="cc-field-label">CALLSIGN</label><input class="cc-input" id="login-user"></div>
<div class="cc-field"><label class="cc-field-label">PASSPHRASE</label><input class="cc-input" id="login-pass" type="password"></div>
<button class="cc-btn cc-btn-login" id="login">AUTHENTICATE</button></div>
<div class="cc-auth-form" data-form="2fa"><p class="cc-form-hint">Enter the code sent to your verified email.</p>
<div class="cc-field"><label class="cc-field-label">SIX-DIGIT CODE</label><input class="cc-input" id="two-code" maxlength="6" inputmode="numeric"></div>
<button class="cc-btn cc-btn-login" id="two-submit">VERIFY CODE</button></div>
<div class="cc-auth-error" id="auth-error"></div></div></div>`;
$$('[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 = `<nav class="cc-app-nav">
<button data-view="chat">CHAT</button><button data-view="groups">GROUPS</button>
<button data-view="archive">MY ARCHIVE</button><button data-view="account">ACCOUNT</button>
</nav><main class="cc-view" id="cc-view"></main>`;
$$('[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 '<option value="">PUBLIC LOBBY</option>' + state.groups.map(group =>
`<option value="${group.id}" ${state.groupId === group.id ? 'selected' : ''}>${esc(group.name)} (${group.member_count})</option>`
).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 = `<div class="cc-userbar"><div class="cc-userbar-left">
<span class="cc-you-label">YOU:</span><span class="cc-you-name" style="color:${esc(state.user.color)};border-color:${esc(state.user.color)}">${esc(state.user.username)}</span>
<span class="cc-plan-badge">${esc(state.user.plan.name)}</span></div>
<select class="cc-compact-select" id="group-select">${groupOptions()}</select></div>
<div class="cc-recording-indicator" id="recording" hidden></div>
<div class="cc-messages" id="messages"><div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING CHANNEL</span></div></div>
${voice ? `<div class="cc-voice-compose"><button class="cc-voice-record" id="record"><span class="cc-voice-dot"></span><span id="record-label">RECORD</span></button>
<span class="cc-voice-status" id="voice-status">VOICE MAX ${state.config.voice?.max_duration_seconds || 60}s</span>
${feature('auto_voice_send') ? `<label class="cc-voice-autoplay"><input id="auto-send" type="checkbox" ${state.voice.autoSend ? 'checked' : ''}> AUTO SEND</label>` : ''}
<div class="cc-voice-review" id="voice-review" hidden>${audioPlayer('', 'voice-preview')}
<button class="cc-voice-action send" id="voice-send">SEND</button><button class="cc-voice-action" id="voice-discard">DISCARD</button></div></div>` : ''}
<div class="cc-compose"><input class="cc-compose-input" id="message" maxlength="${max}" placeholder="transmit message...">
<span class="cc-compose-counter" id="count">${max}</span><button class="cc-compose-send" id="send">SEND</button></div>`;
$('#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 ? '' : '<div class="cc-empty-day">NO MESSAGES IN THIS CHANNEL TODAY</div>';
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
? `<span class="cc-voice-message"><span class="cc-voice-label">VOICE ${duration(message.voice_duration)}</span>${audioPlayer(publicUrl(message.voice_url))}</span>`
: linkify(esc(message.message));
item.innerHTML = `<span class="cc-msg-time">${clock(message.created_at)}</span>
<span class="cc-msg-user" style="color:${esc(message.color)}">${esc(message.username)}</span>
<span class="cc-msg-sep">&gt;</span><span class="cc-msg-text">${content}</span>`;
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 = `<div class="cc-page"><div class="cc-page-head"><div><h2>GROUPS</h2>
<p>Up to ${limit} people per group, including you.</p></div></div>
<section class="cc-card"><h3>CREATE GROUP</h3><div class="cc-form-grid">
<input class="cc-input" id="group-name" maxlength="40" placeholder="Group name">
<input class="cc-input" id="group-members" placeholder="Member callsigns, comma separated">
<button class="cc-inline-btn primary" id="group-create">CREATE</button></div></section>
<div class="cc-card-grid">${state.groups.length ? state.groups.map(groupCard).join('') : '<div class="cc-empty-card">No private groups yet.</div>'}</div></div>`;
$('#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 `<section class="cc-card"><div class="cc-card-title"><h3>${esc(group.name)}</h3>
<button class="cc-inline-btn" data-open="${group.id}">OPEN CHAT</button></div>
<div class="cc-member-list">${group.members.map(member => `<span style="color:${esc(member.color)}">${esc(member.username)}
${member.role === 'owner' ? '(owner)' : ''}${owner && member.role !== 'owner' ? ` <button data-remove="${group.id}:${member.id}">x</button>` : ''}</span>`).join('')}</div>
${owner ? `<div class="cc-form-grid compact"><input class="cc-input" id="add-${group.id}" placeholder="Callsign">
<button class="cc-inline-btn" data-add="${group.id}">ADD</button><button class="cc-inline-btn danger" data-delete="${group.id}">DELETE</button></div>` : ''}</section>`;
}
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 = '<div class="cc-loading-msg"><div class="cc-spin"></div><span>LOADING YOUR ARCHIVE</span></div>';
const result = await api('archive.php', null, new URLSearchParams({ action: 'list', search }).toString());
if (result.error) return $('#cc-view').innerHTML = `<div class="cc-empty-card">${esc(result.error)}</div>`;
$('#cc-view').innerHTML = `<div class="cc-page"><div class="cc-page-head"><div><h2>MY ARCHIVE</h2>
<p>Your own messages for ${result.retention_days} days. ${result.voice_included ? 'Voice included.' : 'Text only on this plan.'}</p></div>
${result.can_export ? `<div class="cc-actions"><a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=json')}">JSON</a>
<a class="cc-inline-btn" href="${apiUrl('archive.php', 'action=export&format=csv')}">CSV</a></div>` : ''}</div>
<div class="cc-search-row"><input class="cc-input" id="archive-search" value="${esc(search)}" placeholder="Search your messages">
<button class="cc-inline-btn primary" id="archive-submit">SEARCH</button></div>
<div class="cc-archive-list">${result.messages.length ? result.messages.map(archiveRow).join('') : '<div class="cc-empty-card">No matching messages.</div>'}</div></div>`;
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 `<div class="cc-archive-row"><time>${esc(message.day_key)} ${clock(message.created_at)}</time>
<span class="cc-type-tag">${esc(message.message_type)}</span><div>${content}</div></div>`;
}
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 = `<div class="cc-page"><div class="cc-page-head"><div><h2>ACCOUNT</h2>
<p>${esc(state.user.username)} / ${esc(state.user.plan.name)}</p></div><button class="cc-inline-btn danger" id="logout">LOG OUT</button></div>
<div class="cc-card-grid"><section class="cc-card"><h3>EMAIL VERIFICATION</h3>
<p>${state.user.email_verified ? `Verified: ${esc(state.user.email)}` : 'Email is only required before premium activation.'}</p>
<div class="cc-form-grid"><input class="cc-input" id="email" type="email" value="${esc(state.user.email || '')}" placeholder="you@example.com">
<button class="cc-inline-btn primary" id="email-send">SEND VERIFICATION</button></div>
<div class="cc-form-grid compact"><input class="cc-input" id="email-code" maxlength="6" placeholder="Six-digit code">
<button class="cc-inline-btn" id="email-verify">VERIFY</button></div></section>
<section class="cc-card"><h3>IDENTITY & SECURITY</h3>
${feature('username_color') ? `<div class="cc-form-grid compact"><input type="color" id="color" value="${esc(state.user.color)}">
<button class="cc-inline-btn" id="color-save">SAVE COLOR</button></div>` : '<p>Custom username color is not included in this plan.</p>'}
${feature('email_2fa') ? `<label class="cc-switch"><input type="checkbox" id="two-toggle" ${state.user.two_factor_enabled ? 'checked' : ''}> EMAIL TWO-FACTOR AUTHENTICATION</label>` : '<p>Email two-factor authentication is not included in this plan.</p>'}
</section></div>
${showBilling ? billingHtml(billing) : ''}
<section class="cc-card cc-feature-card"><h3>CURRENT FEATURES</h3><div class="cc-feature-list">
${Object.entries(state.user.features).map(([key, value]) => `<span><b>${esc(key.replace(/_/g, ' '))}</b> ${esc(String(value))}</span>`).join('')}</div></section>
</div>`;
$('#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 `<section class="cc-card cc-billing-card"><div class="cc-card-title"><div><h3>SUBSCRIPTION</h3>
<p>Status: ${esc(state.user.subscription.status)}${state.user.subscription.cancel_at_period_end ? ' / cancels at period end' : ''}</p></div>
<div class="cc-actions">${billing.can_manage ? '<button class="cc-inline-btn" id="billing-manage">MANAGE IN STRIPE</button>' : ''}
${state.user.subscription.manageable && !state.user.subscription.cancel_at_period_end ? '<button class="cc-inline-btn danger" id="billing-cancel">CANCEL</button>' : ''}</div></div>
${plans.length ? `<div class="cc-plan-grid">${plans.map(plan => `<article class="cc-plan"><h4>${esc(plan.name)}</h4>
<div class="cc-price">${money(plan.price_cents, plan.currency)}<small>/${esc(plan.interval)}</small></div>
<p>${esc(plan.description)}</p><button class="cc-inline-btn primary" data-plan="${plan.id}" ${plan.checkout_ready ? '' : 'disabled'}>SELECT PLAN</button></article>`).join('')}</div>` : ''}
${billing.subscriptions_enabled && alreadySubscribed ? '<p>Use the Stripe portal to change plans or payment details.</p>' : ''}
${!billing.subscriptions_enabled && billing.can_manage ? '<p>New subscriptions are hidden. Your existing subscription remains manageable.</p>' : ''}</section>`;
}
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);
+590 -200
View File
@@ -1,221 +1,460 @@
<?php
// bootstrap.php — Core helpers, DB init, session auth
// Core configuration, persistence, authentication, entitlements, and archives.
define('ROOT_DIR', __DIR__);
define('CONFIG_FILE', ROOT_DIR . '/config.json');
if (!defined('ROOT_DIR')) define('ROOT_DIR', __DIR__);
if (!defined('CONFIG_FILE')) define('CONFIG_FILE', ROOT_DIR . '/config.json');
// ─── Error handling: always return JSON, never raw PHP errors ────────────
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
// Only intercept fatal-ish errors during API requests
if (!(error_reporting() & $errno)) return false;
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'Server error: ' . $errstr . ' in ' . basename($errfile) . ':' . $errline]);
exit;
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'));
}
+37 -2
View File
@@ -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": [
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!.gitkeep
+2 -2
View File
@@ -94,7 +94,7 @@
<span class="kw">&lt;div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">&gt;&lt;/div&gt;</span>
<span class="cm">&lt;!-- 4. Script + init --&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat.js"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script</span> src=<span class="str">"assets/js/cyberchat-v4.js"</span><span class="kw">&gt;&lt;/script&gt;</span>
<span class="kw">&lt;script&gt;</span>
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
CyberChat.init(<span class="str">'#my-chat'</span>);
@@ -104,7 +104,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat.js"></script>
<script src="assets/js/cyberchat-v4.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#chat-here');
+1 -1
View File
@@ -14,7 +14,7 @@
<script>
window.CYBERCHAT_BASE = '';
</script>
<script src="assets/js/cyberchat.js"></script>
<script src="assets/js/cyberchat-v4.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
CyberChat.init('#app');
+242
View File
@@ -0,0 +1,242 @@
<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') {
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
'mailgun.api_base' => trim((string)($_POST['mailgun_api_base'] ?? 'https://api.mailgun.net/v3')),
'stripe.secret_key' => trim((string)($_POST['stripe_secret_key'] ?? '')),
'stripe.webhook_secret' => trim((string)($_POST['stripe_webhook_secret'] ?? '')),
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
'database.mysql.username' => trim((string)($_POST['mysql_username'] ?? '')),
'database.mysql.password' => (string)($_POST['mysql_password'] ?? ''),
'database.mysql.sync_interval_seconds' => max(60, (int)($_POST['mysql_sync_interval'] ?? 900)),
]);
return 'Platform service settings saved.';
}
if ($action === 'save_plans') {
$db = getDB();
$plans = $_POST['plans'] ?? [];
$boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
];
$db->beginTransaction();
try {
$update = $db->prepare("UPDATE plans SET name=?,description=?,price_cents=?,currency=?,
billing_interval=?,stripe_price_id=?,active=?,sort_order=?,updated_at=? WHERE id=?");
$upsertFeature = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)
ON CONFLICT(plan_id,feature_key) DO UPDATE SET value_json=excluded.value_json");
foreach ($plans as $id => $plan) {
$id = (int)$id;
$name = trim((string)($plan['name'] ?? ''));
if ($id < 1 || $name === '') continue;
$update->execute([
$name,
trim((string)($plan['description'] ?? '')),
max(0, (int)($plan['price_cents'] ?? 0)),
strtolower(substr(trim((string)($plan['currency'] ?? 'usd')), 0, 3)),
in_array($plan['billing_interval'] ?? '', ['day', 'week', 'month', 'year'], true)
? $plan['billing_interval'] : 'month',
trim((string)($plan['stripe_price_id'] ?? '')) ?: null,
!empty($plan['active']) ? 1 : 0,
(int)($plan['sort_order'] ?? 0),
time(),
$id,
]);
foreach ($boolFeatures as $key) {
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
}
$upsertFeature->execute([$id, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]);
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
return 'Plans, prices, limits, and feature assignments saved.';
}
if ($action === 'create_plan') {
$slug = strtolower(trim((string)($_POST['slug'] ?? '')));
$name = trim((string)($_POST['name'] ?? ''));
if (!preg_match('/^[a-z0-9_-]{2,30}$/', $slug) || $name === '') {
throw new RuntimeException('Plan slug or name is invalid.');
}
$db = getDB();
$db->prepare("INSERT INTO plans
(slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at)
VALUES (?,?,?,0,'usd','month',1,50,?,?)")
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
$id = (int)$db->lastInsertId();
$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,
];
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
foreach ($features as $key => $value) $insert->execute([$id, $key, json_encode($value)]);
return "Plan '$name' created.";
}
if ($action === 'run_mysql_mirror') {
require_once ROOT_DIR . '/lib/mysql_mirror.php';
$result = mirrorAllSQLiteDatabases();
return "MySQL mirror completed: {$result['databases']} database(s), {$result['rows']} row(s).";
}
return null;
}
function adminFeatureLabel(string $key): string {
return ucwords(str_replace('_', ' ', $key));
}
function renderAdminPlatformTab(): void {
$plans = plans(false);
$db = getDB();
$subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status,
u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id
FROM users u LEFT JOIN plans p ON p.id=u.plan_id
ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll();
$stats = [
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->fetchColumn(),
];
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [
'voice_messages', 'recording_status', 'auto_voice_send', 'group_member_limit',
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa',
];
?>
<div class="page-head"><h1>PLANS & PLATFORM</h1>
<p>// pricing, entitlements, subscriptions, email, Stripe, database mirroring, and usage</p></div>
<div class="stat-grid">
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
<div class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</div></div>
</div>
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_platform_services">
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIPTION VISIBILITY</span></div>
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3">
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
<div class="field"><label>Domain</label><input name="mailgun_domain" value="<?= esc(getConfigVal('mailgun.domain', '')) ?>"></div>
<div class="field"><label>From</label><input name="mailgun_from" value="<?= esc(getConfigVal('mailgun.from', '')) ?>"></div>
</div>
<div class="field"><label>API Base</label><input name="mailgun_api_base" value="<?= esc(getConfigVal('mailgun.api_base', 'https://api.mailgun.net/v3')) ?>"></div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// STRIPE</span></div><div class="panel-body">
<div class="form-row">
<div class="field"><label>Secret Key</label><input type="password" name="stripe_secret_key" value="<?= esc(getConfigVal('stripe.secret_key', '')) ?>"></div>
<div class="field"><label>Webhook Secret</label><input type="password" name="stripe_webhook_secret" value="<?= esc(getConfigVal('stripe.webhook_secret', '')) ?>"></div>
</div>
<div class="form-row3">
<div class="field"><label>Success URL</label><input name="stripe_success_url" value="<?= esc(getConfigVal('stripe.success_url', '')) ?>"></div>
<div class="field"><label>Cancel URL</label><input name="stripe_cancel_url" value="<?= esc(getConfigVal('stripe.cancel_url', '')) ?>"></div>
<div class="field"><label>Portal Return URL</label><input name="stripe_portal_return_url" value="<?= esc(getConfigVal('stripe.portal_return_url', '')) ?>"></div>
</div>
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
<div class="form-row3">
<div class="field"><label>PDO DSN</label><input name="mysql_dsn" value="<?= esc(getConfigVal('database.mysql.dsn', '')) ?>" placeholder="mysql:host=localhost;dbname=cyberchat;charset=utf8mb4"></div>
<div class="field"><label>Username</label><input name="mysql_username" value="<?= esc(getConfigVal('database.mysql.username', '')) ?>"></div>
<div class="field"><label>Password</label><input type="password" name="mysql_password" value="<?= esc(getConfigVal('database.mysql.password', '')) ?>"></div>
</div>
<div class="field"><label>Sync Interval Seconds</label><input type="number" min="60" name="mysql_sync_interval" value="<?= (int)getConfigVal('database.mysql.sync_interval_seconds', 900) ?>"></div>
</div></div>
<button class="btn btn-g" type="submit">SAVE PLATFORM SERVICES</button>
</form>
<div class="panel" style="margin-top:20px"><div class="panel-head"><span class="panel-title">// PLANS, PRICES & ENTITLEMENTS</span></div>
<div class="panel-body"><form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="save_plans">
<?php foreach ($plans as $plan): ?>
<div class="danger-zone" style="border-color:var(--bd2);margin:0 0 14px">
<div class="form-row3">
<div class="field"><label>Name / <?= esc($plan['slug']) ?></label><input name="plans[<?= $plan['id'] ?>][name]" value="<?= esc($plan['name']) ?>"></div>
<div class="field"><label>Price Cents</label><input type="number" min="0" name="plans[<?= $plan['id'] ?>][price_cents]" value="<?= $plan['price_cents'] ?>"></div>
<div class="field"><label>Stripe Price ID</label><input name="plans[<?= $plan['id'] ?>][stripe_price_id]" value="<?= esc($plan['stripe_price_id']) ?>" placeholder="price_..."></div>
</div>
<div class="field"><label>Description</label><input name="plans[<?= $plan['id'] ?>][description]" value="<?= esc($plan['description']) ?>"></div>
<div class="form-row3">
<div class="field"><label>Currency</label><input maxlength="3" name="plans[<?= $plan['id'] ?>][currency]" value="<?= esc($plan['currency']) ?>"></div>
<div class="field"><label>Interval</label><select name="plans[<?= $plan['id'] ?>][billing_interval]">
<?php foreach (['day','week','month','year'] as $interval): ?><option <?= $plan['billing_interval']===$interval?'selected':'' ?>><?= $interval ?></option><?php endforeach; ?>
</select></div>
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
<div class="form-row3">
<?php foreach ($featureKeys as $key): ?>
<?php if (in_array($key, ['group_member_limit','text_archive_days'], true)): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="<?= $key==='text_archive_days' ? 30 : 2 ?>"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
<?php else: ?>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<button class="btn btn-g" type="submit">SAVE ALL PLANS</button>
</form></div>
</div>
<div class="panel"><div class="panel-head"><span class="panel-title">// CREATE TIER</span></div><div class="panel-body">
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="create_plan">
<div class="form-row3"><div class="field"><label>Slug</label><input name="slug" placeholder="business"></div>
<div class="field"><label>Name</label><input name="name" placeholder="Business"></div>
<div class="field"><label>Description</label><input name="description"></div></div>
<button class="btn btn-b" type="submit">CREATE TIER</button>
</form></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIBER TRACKING</span></div><div class="panel-body np">
<div class="tbl-wrap"><table><thead><tr><th>User</th><th>Email</th><th>Plan</th><th>Status</th><th>Period End</th><th>Stripe</th></tr></thead><tbody>
<?php foreach ($subscribers as $subscriber): ?><tr>
<td><?= esc($subscriber['username']) ?></td>
<td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td>
<td><?= esc($subscriber['plan_name'] ?: 'Free') ?></td>
<td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td>
<td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td>
<td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td>
</tr><?php endforeach; ?></tbody></table></div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// 30-DAY EVENT TOTALS</span></div><div class="panel-body">
<div class="btn-group"><?php foreach ($events as $event): ?><span class="badge badge-b"><?= esc($event['event_type']) ?>: <?= (int)$event['total'] ?></span><?php endforeach; ?></div>
</div></div>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="run_mysql_mirror">
<button class="btn btn-v" type="submit">RUN MYSQL MIRROR NOW</button></form>
<?php
}
+93
View File
@@ -0,0 +1,93 @@
<?php
function sendMailgunMessage(string $to, string $subject, string $text, string $html = ''): array {
$key = trim((string)getConfigVal('mailgun.api_key', ''));
$domain = trim((string)getConfigVal('mailgun.domain', ''));
$from = trim((string)getConfigVal('mailgun.from', ''));
$base = rtrim(trim((string)getConfigVal('mailgun.api_base', '')) ?: 'https://api.mailgun.net/v3', '/');
if ($key === '' || $domain === '' || $from === '') {
throw new RuntimeException('Mailgun is not configured');
}
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid recipient email');
if (!function_exists('curl_init')) throw new RuntimeException('PHP cURL extension is required');
$fields = ['from' => $from, 'to' => $to, 'subject' => $subject, 'text' => $text];
if ($html !== '') $fields['html'] = $html;
$curl = curl_init($base . '/' . rawurlencode($domain) . '/messages');
curl_setopt_array($curl, [
CURLOPT_USERPWD => 'api:' . $key,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false || $status < 200 || $status >= 300) {
throw new RuntimeException('Mailgun request failed: ' . ($error ?: 'HTTP ' . $status));
}
return json_decode((string)$body, true) ?: ['success' => true];
}
function issueEmailToken(array $user, string $email, string $purpose, int $ttl = 900): array {
$token = bin2hex(random_bytes(24));
$code = (string)random_int(100000, 999999);
$db = getDB();
$db->prepare("UPDATE email_tokens SET used_at=? WHERE user_id=? AND purpose=? AND used_at IS NULL")
->execute([time(), $user['id'], $purpose]);
$db->prepare("INSERT INTO email_tokens
(user_id,email,purpose,token_hash,code_hash,expires_at,created_at) VALUES (?,?,?,?,?,?,?)")
->execute([
$user['id'], strtolower($email), $purpose, hash('sha256', $token),
password_hash($code, PASSWORD_DEFAULT), time() + $ttl, time(),
]);
return ['token' => $token, 'code' => $code, 'expires_at' => time() + $ttl];
}
function sendVerificationEmail(array $user, string $email): void {
$issued = issueEmailToken($user, $email, 'verify_email', 1800);
$url = appBaseUrl() . '/index.html?verify=' . rawurlencode($issued['token']);
$title = (string)getConfigVal('ui.app_title', 'CyberChat');
$text = "Verify your $title email.\n\nCode: {$issued['code']}\nVerification link: $url\n\nThis expires in 30 minutes.";
$html = '<p>Verify your ' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . ' email.</p>'
. '<p>Your code is <strong style="font-size:20px">' . $issued['code'] . '</strong></p>'
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Open verification screen</a></p>'
. '<p>This expires in 30 minutes.</p>';
sendMailgunMessage($email, 'Verify your ' . $title . ' email', $text, $html);
}
function consumeEmailVerification(array $user, string $token = '', string $code = ''): bool {
$stmt = getDB()->prepare("SELECT * FROM email_tokens
WHERE user_id=? AND purpose='verify_email' AND used_at IS NULL AND expires_at>? ORDER BY id DESC LIMIT 1");
$stmt->execute([$user['id'], time()]);
$row = $stmt->fetch();
if (!$row || (int)($row['attempts'] ?? 0) >= 5) return false;
$valid = ($token !== '' && hash_equals($row['token_hash'], hash('sha256', $token)))
|| ($code !== '' && password_verify($code, $row['code_hash']));
if (!$valid) {
getDB()->prepare("UPDATE email_tokens SET attempts=attempts+1 WHERE id=?")->execute([$row['id']]);
return false;
}
$db = getDB();
$db->beginTransaction();
$db->prepare("UPDATE email_tokens SET used_at=? WHERE id=?")->execute([time(), $row['id']]);
$db->prepare("UPDATE users SET email=?,email_verified_at=? WHERE id=?")
->execute([$row['email'], time(), $user['id']]);
$db->commit();
return true;
}
function sendTwoFactorCode(array $user, string $challengeId, string $code): void {
$title = (string)getConfigVal('ui.app_title', 'CyberChat');
$url = appBaseUrl() . '/index.html?login_challenge=' . rawurlencode($challengeId);
sendMailgunMessage(
(string)$user['email'],
$title . ' login code',
"Your login code is $code.\n\nOpen the authentication screen: $url\n\nIt expires in 10 minutes.",
'<p>Your login code is <strong style="font-size:22px">' . $code . '</strong>.</p>'
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Open authentication screen</a></p>'
. '<p>It expires in 10 minutes.</p>'
);
}
+55
View File
@@ -0,0 +1,55 @@
<?php
function mysqlMirrorConnection(): PDO {
$dsn = trim((string)getConfigVal('database.mysql.dsn', ''));
if ($dsn === '') throw new RuntimeException('MySQL DSN is missing');
$pdo = new PDO(
$dsn,
(string)getConfigVal('database.mysql.username', ''),
(string)getConfigVal('database.mysql.password', ''),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->exec("CREATE TABLE IF NOT EXISTS sqlite_mirror_rows (
source_db VARCHAR(255) NOT NULL,
table_name VARCHAR(128) NOT NULL,
row_key VARCHAR(255) NOT NULL,
row_json LONGTEXT NOT NULL,
synced_at BIGINT NOT NULL,
PRIMARY KEY(source_db,table_name,row_key)
)");
return $pdo;
}
function mirrorAllSQLiteDatabases(): array {
$main = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite'));
$files = is_file($main) ? [$main] : [];
$archiveRoot = storagePath((string)getConfigVal('archive.archive_dir', 'archive'));
foreach (glob($archiveRoot . '/*/*/*.sqlite') ?: [] as $file) $files[] = $file;
$mysql = mysqlMirrorConnection();
$upsert = $mysql->prepare("INSERT INTO sqlite_mirror_rows
(source_db,table_name,row_key,row_json,synced_at) VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE row_json=VALUES(row_json),synced_at=VALUES(synced_at)");
$count = 0;
foreach ($files as $file) {
$sqlite = new PDO('sqlite:' . $file);
$sqlite->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$tables = $sqlite->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$quoted = '"' . str_replace('"', '""', $table) . '"';
foreach ($sqlite->query("SELECT rowid AS _mirror_rowid,* FROM $quoted") as $row) {
$key = (string)($row['id'] ?? $row['_mirror_rowid']);
unset($row['_mirror_rowid']);
$source = str_starts_with($file, ROOT_DIR . '/')
? substr($file, strlen(ROOT_DIR) + 1)
: $file;
$upsert->execute([
$source, $table, $key,
json_encode($row, JSON_UNESCAPED_SLASHES), time(),
]);
$count++;
}
}
}
return ['databases' => count($files), 'rows' => $count];
}
+140
View File
@@ -0,0 +1,140 @@
<?php
function stripeRequest(string $method, string $path, array $fields = []): array {
$secret = trim((string)getConfigVal('stripe.secret_key', ''));
if ($secret === '') throw new RuntimeException('Stripe is not configured');
if (!function_exists('curl_init')) throw new RuntimeException('PHP cURL extension is required');
$url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
if ($method === 'GET' && $fields) $url .= '?' . http_build_query($fields);
$curl = curl_init($url);
$options = [
CURLOPT_USERPWD => $secret . ':',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
];
if ($method !== 'GET' && $fields) $options[CURLOPT_POSTFIELDS] = http_build_query($fields);
curl_setopt_array($curl, $options);
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
$decoded = json_decode((string)$body, true);
if ($body === false || $status < 200 || $status >= 300) {
$message = $decoded['error']['message'] ?? $error ?: ('Stripe HTTP ' . $status);
throw new RuntimeException($message);
}
return is_array($decoded) ? $decoded : [];
}
function stripeReturnUrl(string $configKey, string $fallback): string {
$configured = trim((string)getConfigVal($configKey, ''));
return $configured !== '' ? $configured : $fallback;
}
function createStripeCheckout(array $user, array $plan): string {
if (empty($user['email_verified_at']) || empty($user['email'])) {
throw new RuntimeException('A verified email is required before checkout');
}
if (empty($plan['stripe_price_id'])) throw new RuntimeException('This plan does not have a Stripe price ID');
$base = appBaseUrl();
$fields = [
'mode' => 'subscription',
'line_items[0][price]' => $plan['stripe_price_id'],
'line_items[0][quantity]' => 1,
'success_url' => stripeReturnUrl('stripe.success_url', $base . '/index.html?billing=success'),
'cancel_url' => stripeReturnUrl('stripe.cancel_url', $base . '/index.html?billing=cancelled'),
'client_reference_id' => (string)$user['id'],
'metadata[user_id]' => (string)$user['id'],
'metadata[plan_id]' => (string)$plan['id'],
'subscription_data[metadata][user_id]' => (string)$user['id'],
'subscription_data[metadata][plan_id]' => (string)$plan['id'],
'allow_promotion_codes' => 'true',
];
if (!empty($user['stripe_customer_id'])) $fields['customer'] = $user['stripe_customer_id'];
else $fields['customer_email'] = $user['email'];
$session = stripeRequest('POST', 'checkout/sessions', $fields);
if (empty($session['url'])) throw new RuntimeException('Stripe did not return a checkout URL');
return $session['url'];
}
function createStripePortal(array $user): string {
if (empty($user['stripe_customer_id'])) throw new RuntimeException('No Stripe customer is connected');
$session = stripeRequest('POST', 'billing_portal/sessions', [
'customer' => $user['stripe_customer_id'],
'return_url' => stripeReturnUrl('stripe.portal_return_url', appBaseUrl() . '/index.html?view=account'),
]);
if (empty($session['url'])) throw new RuntimeException('Stripe did not return a portal URL');
return $session['url'];
}
function verifyStripeSignature(string $payload, string $header): bool {
$secret = trim((string)getConfigVal('stripe.webhook_secret', ''));
if ($secret === '' || $header === '') return false;
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $timestamp = $value;
if ($key === 'v1') $signatures[] = $value;
}
if (!$timestamp || abs(time() - (int)$timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) if (hash_equals($expected, $signature)) return true;
return false;
}
function planIdFromStripeObject(array $object): ?int {
$metadataPlan = (int)($object['metadata']['plan_id'] ?? 0);
if ($metadataPlan > 0 && planById($metadataPlan)) return $metadataPlan;
$priceId = $object['items']['data'][0]['price']['id'] ?? '';
if ($priceId !== '') {
$stmt = getDB()->prepare("SELECT id FROM plans WHERE stripe_price_id=?");
$stmt->execute([$priceId]);
$id = (int)$stmt->fetchColumn();
if ($id > 0) return $id;
}
return null;
}
function syncSubscriptionFromStripe(array $object): void {
$db = getDB();
$subscriptionId = (string)($object['id'] ?? '');
$customerId = (string)($object['customer'] ?? '');
$userId = (int)($object['metadata']['user_id'] ?? 0);
if ($userId < 1 && $subscriptionId !== '') {
$stmt = $db->prepare("SELECT user_id FROM subscriptions WHERE stripe_subscription_id=?");
$stmt->execute([$subscriptionId]);
$userId = (int)$stmt->fetchColumn();
}
if ($userId < 1 && $customerId !== '') {
$stmt = $db->prepare("SELECT id FROM users WHERE stripe_customer_id=?");
$stmt->execute([$customerId]);
$userId = (int)$stmt->fetchColumn();
}
if ($userId < 1) return;
$status = (string)($object['status'] ?? 'active');
$paidStatuses = ['active', 'trialing', 'past_due'];
$planId = planIdFromStripeObject($object);
if (!in_array($status, $paidStatuses, true)) {
$planId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
}
if (!$planId) return;
$periodEnd = (int)($object['current_period_end'] ?? 0) ?: null;
$cancel = !empty($object['cancel_at_period_end']) ? 1 : 0;
$db->beginTransaction();
$db->prepare("INSERT INTO subscriptions
(user_id,plan_id,stripe_customer_id,stripe_subscription_id,status,current_period_end,cancel_at_period_end,updated_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id,stripe_customer_id=excluded.stripe_customer_id,
stripe_subscription_id=excluded.stripe_subscription_id,status=excluded.status,
current_period_end=excluded.current_period_end,cancel_at_period_end=excluded.cancel_at_period_end,updated_at=excluded.updated_at")
->execute([$userId, $planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, time()]);
$db->prepare("UPDATE users SET plan_id=?,stripe_customer_id=?,stripe_subscription_id=?,
subscription_status=?,subscription_period_end=?,cancel_at_period_end=? WHERE id=?")
->execute([$planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, $userId]);
$db->commit();
}
+9 -4
View File
@@ -41,14 +41,19 @@ server {
include fastcgi_params;
}
# Block direct access to db/ and archive/ directories
location ~ ^/(db|archive)/ {
# Block direct access to storage and internal libraries
location ~ ^/(db|archive|lib)/ {
deny all;
return 404;
}
# Block .sqlite files from being served directly
location ~* \.sqlite$ {
# Block credentials and SQLite sidecar files
location = /config.json {
deny all;
return 404;
}
location ~* \.sqlite($|-wal$|-shm$) {
deny all;
return 404;
}