- Voice enhancements, user management, payment method
This commit is contained in:
@@ -1,16 +1,22 @@
|
|||||||
# CyberChat .htaccess — Apache configuration
|
# CyberChat .htaccess — Apache configuration
|
||||||
|
|
||||||
# Deny direct access to SQLite databases
|
# Deny direct access to databases and the credential-bearing config file
|
||||||
<FilesMatch "\.sqlite$">
|
<FilesMatch "(^config\.json$|\.sqlite($|-wal$|-shm$))">
|
||||||
Order Allow,Deny
|
<IfModule mod_authz_core.c>
|
||||||
Deny from all
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order Allow,Deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Deny direct access to db/ and archive/ directories
|
# Deny direct access to storage and internal PHP libraries
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteRule ^db/ - [F,L]
|
RewriteRule ^db/ - [F,L]
|
||||||
RewriteRule ^archive/ - [F,L]
|
RewriteRule ^archive/ - [F,L]
|
||||||
|
RewriteRule ^lib/ - [F,L]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# PHP settings (if allowed by host)
|
# PHP settings (if allowed by host)
|
||||||
|
|||||||
@@ -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
|
- Registration without an email address
|
||||||
|
- Verified email required before premium checkout
|
||||||
- **Register on the fly** — pick a callsign (username) and passphrase, start chatting instantly
|
- Mailgun email verification and email two-factor login codes
|
||||||
- **Session-locked** — one login per user enforced by IP + cookie; no multi-location sessions
|
- Stripe Checkout, Billing Portal, webhook synchronization, and cancellation
|
||||||
- **Daily chat rooms** — each day starts fresh; yesterday's messages are automatically archived
|
- Configurable plans, prices, Stripe Price IDs, limits, and feature assignments
|
||||||
- **SQLite archives** — stored per `archive/{year}/{month}/{day}.sqlite`; browseable via `archive-viewer.php`
|
- Subscription display switch that hides new sales while preserving billing management for existing customers
|
||||||
- **Neon cyberpunk UI** — dark by default, light mode toggle per user (saved to localStorage)
|
- Private groups with plan-based member limits, including the owner
|
||||||
- **Embed-ready** — drop into any `<div>` container; auto-adapts to any size
|
- Premium-configurable recording presence for both sending and viewing
|
||||||
- **Fully configurable** — `config.json` controls all limits, intervals, and theming
|
- Premium-configurable automatic voice sending
|
||||||
- **Voice clips** — record, review, send, and queue WAV/WebM clips one at a time using normal PHP polling
|
- Plan-based custom username colors
|
||||||
- **No Node.js or WebSockets** — voice uses browser recording, HTTP uploads, and the existing poll loop
|
- 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
|
## Requirements
|
||||||
|
|
||||||
- PHP 8.0+ with **PDO SQLite** extension enabled
|
- PHP 8.1 or newer
|
||||||
- Any web server (Apache, Nginx, Caddy, PHP built-in)
|
- PHP extensions: `pdo_sqlite`, `curl`, and `fileinfo`
|
||||||
- Write permissions on `db/`, `archive/`, and `uploads/voice/` directories
|
- A web server with HTTPS for production
|
||||||
- PHP/web-server upload limits at or above `voice.max_upload_bytes`
|
- 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
|
```bash
|
||||||
# 1. Copy files to your web root (or a subdirectory)
|
chmod 0777 db archive uploads/voice
|
||||||
cp -r cyberchat/ /var/www/html/chat/
|
|
||||||
|
|
||||||
# 2. Make sure database, archive, and voice storage are writable
|
|
||||||
chmod 755 /var/www/html/chat/db/
|
|
||||||
chmod 755 /var/www/html/chat/archive/
|
|
||||||
chmod 755 /var/www/html/chat/uploads/voice/
|
|
||||||
|
|
||||||
# 3. Visit in browser
|
|
||||||
# http://yourdomain.com/chat/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The SQLite database is created automatically on first visit.
|
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
|
```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">
|
<link rel="stylesheet" href="/chat/assets/css/cyberchat.css">
|
||||||
|
<div id="my-chat" style="width:100%;height:600px"></div>
|
||||||
<!-- Container — set any size -->
|
<script>
|
||||||
<div id="my-chat" style="width:100%; height:600px;"></div>
|
window.CYBERCHAT_BASE = '/chat';
|
||||||
|
</script>
|
||||||
<!-- Script -->
|
<script src="/chat/assets/js/cyberchat-v4.js"></script>
|
||||||
<script src="/chat/assets/js/cyberchat.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '/chat'; // path to your cyberchat install
|
|
||||||
CyberChat.init('#my-chat');
|
CyberChat.init('#my-chat');
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
See `embed-example.html` for a live demo.
|
See `embed-example.html` for a complete example.
|
||||||
|
|
||||||
---
|
## Main Files
|
||||||
|
|
||||||
## Configuration — `config.json`
|
```text
|
||||||
|
admin.php Administrator dashboard
|
||||||
| Key | Default | Description |
|
bootstrap.php Configuration, schema, auth, plans, archives, stats
|
||||||
|-----|---------|-------------|
|
config.json Runtime settings and service credentials
|
||||||
| `chat.max_message_length` | 500 | Max characters per message |
|
api/account.php Email, color, and 2FA settings
|
||||||
| `chat.max_username_length` | 12 | Max callsign length |
|
api/archive.php Private personal archive and export
|
||||||
| `chat.poll_interval_ms` | 2000 | How often client polls for new messages (ms) |
|
api/auth.php Registration, login, 2FA, sessions
|
||||||
| `chat.messages_per_page` | 100 | Messages loaded on initial connect |
|
api/billing.php Stripe checkout, portal, and cancellation
|
||||||
| `chat.session_lock_ip` | true | Enforce single login per IP |
|
api/groups.php Private group management
|
||||||
| `chat.session_lock_cookie` | true | Enforce single login per cookie |
|
api/messages.php Text, voice, polling, and recording presence
|
||||||
| `voice.enabled` | true | Enable voice clip controls and uploads |
|
api/stripe-webhook.php Stripe event synchronization
|
||||||
| `voice.max_duration_seconds` | 60 | Maximum voice clip duration |
|
assets/js/cyberchat-v4.js Browser application
|
||||||
| `voice.max_upload_bytes` | 12582912 | Maximum voice upload size |
|
lib/mailer.php Reusable Mailgun sender
|
||||||
| `voice.auto_play_default` | true | Queue and play new incoming clips one at a time by default |
|
lib/stripe.php Stripe REST client and subscription sync
|
||||||
| `voice.upload_dir` | "uploads/voice" | Voice recording storage path |
|
lib/mysql_mirror.php Optional SQLite-to-MySQL mirror
|
||||||
| `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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Production Security
|
||||||
|
|
||||||
## Archive Viewer
|
- Change the default administrator password and statistics salt.
|
||||||
|
- Serve the application only over HTTPS.
|
||||||
Visit `archive-viewer.php` to browse historical chat logs. Select a day from the sidebar to read its messages.
|
- 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.
|
||||||
## Nginx Config Example
|
- Back up SQLite databases and voice files together.
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
define('ROOT_DIR', __DIR__);
|
define('ROOT_DIR', __DIR__);
|
||||||
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
||||||
define('ADMIN_VERSION', '1.0.0');
|
define('ADMIN_VERSION', '1.0.0');
|
||||||
|
require_once ROOT_DIR . '/bootstrap.php';
|
||||||
|
require_once ROOT_DIR . '/lib/admin_platform.php';
|
||||||
|
|
||||||
// ─── CHANGE THIS PASSWORD ────────────────────────────────────────────────────
|
// ─── CHANGE THIS PASSWORD ────────────────────────────────────────────────────
|
||||||
define('ADMIN_PASSWORD', 'changeme123');
|
define('ADMIN_PASSWORD', (string)getConfigVal('admin.password', 'changeme123'));
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
session_name('cyberchat_admin');
|
session_name('cyberchat_admin');
|
||||||
@@ -34,21 +36,14 @@ function cfgVal(string $path, mixed $default = null): mixed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function db(): PDO {
|
function db(): PDO {
|
||||||
static $d = null;
|
return getDB();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAdminMessageColumns(PDO $d): void {
|
function ensureAdminMessageColumns(PDO $d): void {
|
||||||
$columns = [];
|
$columns = [];
|
||||||
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
|
foreach ($d->query("PRAGMA table_info(messages)") as $column) $columns[$column['name']] = true;
|
||||||
foreach ([
|
foreach ([
|
||||||
|
'group_id' => 'INTEGER',
|
||||||
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
'voice_file' => 'TEXT',
|
'voice_file' => 'TEXT',
|
||||||
'voice_mime' => 'TEXT',
|
'voice_mime' => 'TEXT',
|
||||||
@@ -60,7 +55,7 @@ function ensureAdminMessageColumns(PDO $d): void {
|
|||||||
|
|
||||||
function archiveDB(string $year, string $month, string $day): ?PDO {
|
function archiveDB(string $year, string $month, string $day): ?PDO {
|
||||||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
$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;
|
if (!file_exists($path)) return null;
|
||||||
$a = new PDO('sqlite:' . $path);
|
$a = new PDO('sqlite:' . $path);
|
||||||
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$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 {
|
function createArchiveDB(string $year, string $month, string $day): PDO {
|
||||||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
$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);
|
$dir = dirname($path);
|
||||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
ensureWritableDirectory($dir, 'Archive');
|
||||||
throw new RuntimeException('Could not create archive directory.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$a = new PDO('sqlite:' . $path);
|
$a = new PDO('sqlite:' . $path);
|
||||||
$a->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$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 (
|
$a->exec("CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
@@ -133,7 +127,7 @@ function csrfCheck(): void {
|
|||||||
|
|
||||||
function deleteRecording(?string $filename): void {
|
function deleteRecording(?string $filename): void {
|
||||||
if (!$filename || basename($filename) !== $filename) return;
|
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;
|
$path = $dir . '/' . $filename;
|
||||||
if (is_file($path)) unlink($path);
|
if (is_file($path)) unlink($path);
|
||||||
}
|
}
|
||||||
@@ -149,7 +143,7 @@ function voiceFileUrl(?string $filename): string {
|
|||||||
|
|
||||||
function voiceFileSize(?string $filename): ?int {
|
function voiceFileSize(?string $filename): ?int {
|
||||||
if (!$filename || basename($filename) !== $filename) return null;
|
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;
|
return is_file($path) ? filesize($path) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +180,16 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
csrfCheck();
|
csrfCheck();
|
||||||
$act = $_POST['action'] ?? '';
|
$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 ──────────────────────────────────────────────────────────────
|
// ── Messages ──────────────────────────────────────────────────────────────
|
||||||
if ($act === 'archive_voice_clip') {
|
if ($act === 'archive_voice_clip') {
|
||||||
$id = (int)($_POST['msg_id'] ?? 0);
|
$id = (int)($_POST['msg_id'] ?? 0);
|
||||||
@@ -216,10 +220,10 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
if ($existingFile === false) {
|
if ($existingFile === false) {
|
||||||
$insert = $adb->prepare("INSERT INTO messages
|
$insert = $adb->prepare("INSERT INTO messages
|
||||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
(id,user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
$insert->execute([
|
$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['message_type'], $row['voice_file'], $row['voice_mime'], $row['voice_duration'],
|
||||||
$row['created_at'], $row['day_key']
|
$row['created_at'], $row['day_key']
|
||||||
]);
|
]);
|
||||||
@@ -321,7 +325,7 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$day = $_POST['day'];
|
$day = $_POST['day'];
|
||||||
[$y,$m,$d] = explode('-', $day);
|
[$y,$m,$d] = explode('-', $day);
|
||||||
$tpl = cfgVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
$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)) {
|
if (file_exists($path)) {
|
||||||
$adb = archiveDB($y, $m, $d);
|
$adb = archiveDB($y, $m, $d);
|
||||||
if ($adb) deleteRecordingsForRows($adb->query("SELECT voice_file FROM messages")->fetchAll());
|
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'); }
|
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
|
||||||
|
|
||||||
// Check username collision
|
// Check username collision
|
||||||
$existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?")->execute([$username, $id]);
|
$existing = db()->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?");
|
||||||
if (db()->query("SELECT COUNT(*) FROM users WHERE username = '$username' AND id != $id")->fetchColumn() > 0) {
|
$existing->execute([$username, $id]);
|
||||||
|
if ($existing->fetchColumn()) {
|
||||||
flash('Username already taken.', 'err'); redirect('tab=users');
|
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'); }
|
if ($errors) { flash(implode(' ', $errors), 'err'); redirect('tab=users'); }
|
||||||
try {
|
try {
|
||||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)cfgVal('security.bcrypt_cost', 10)]);
|
$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 (?,?,?,?)")
|
$freeId = (int)db()->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
|
||||||
->execute([$username, $hash, $color, time()]);
|
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.");
|
flash("User '$username' created.");
|
||||||
} catch (Exception $e) { flash('Username already exists.', 'err'); }
|
} catch (Exception $e) { flash('Username already exists.', 'err'); }
|
||||||
redirect('tab=users');
|
redirect('tab=users');
|
||||||
@@ -458,7 +464,7 @@ if ($isLoggedIn) {
|
|||||||
// Archive list
|
// Archive list
|
||||||
$archives = [];
|
$archives = [];
|
||||||
if ($isLoggedIn) {
|
if ($isLoggedIn) {
|
||||||
$archDir = ROOT_DIR . '/' . cfgVal('archive.archive_dir', 'archive');
|
$archDir = storagePath((string)cfgVal('archive.archive_dir', 'archive'));
|
||||||
if (is_dir($archDir)) {
|
if (is_dir($archDir)) {
|
||||||
foreach (glob($archDir . '/*/*/*.sqlite') as $f) {
|
foreach (glob($archDir . '/*/*/*.sqlite') as $f) {
|
||||||
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
|
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;
|
max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||||
font-family:var(--mono);font-size:11px;color:var(--t0);
|
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 ──────────────────────────────────────────────────────────── */
|
||||||
.modal-bg{
|
.modal-bg{
|
||||||
@@ -845,6 +862,10 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
<span class="ico">⚡</span><span>Sessions</span>
|
<span class="ico">⚡</span><span>Sessions</span>
|
||||||
</a>
|
</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>
|
<div class="sb-section">SYSTEM</div>
|
||||||
<a class="sb-link <?= $tab==='config'?'active':'' ?>" href="?tab=config">
|
<a class="sb-link <?= $tab==='config'?'active':'' ?>" href="?tab=config">
|
||||||
<span class="ico">⚙</span><span>Config</span>
|
<span class="ico">⚙</span><span>Config</span>
|
||||||
@@ -1074,7 +1095,7 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
<td>
|
<td>
|
||||||
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
<?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>
|
<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>
|
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
<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 style="color:<?= esc($clip['color']) ?>;font-family:var(--mono);font-size:11px;white-space:nowrap"><?= esc($clip['username']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($clipUrl !== '' && $clipSize !== null): ?>
|
<?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: ?>
|
<?php else: ?>
|
||||||
<span class="badge badge-r">RECORDING MISSING</span>
|
<span class="badge badge-r">RECORDING MISSING</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -1350,7 +1371,7 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
<td>
|
<td>
|
||||||
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
<?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>
|
<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>
|
src="<?= esc(trim((string)cfgVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
<div class="msg-preview" title="<?= esc($msg['message']) ?>"><?= esc($msg['message']) ?></div>
|
||||||
@@ -1628,6 +1649,9 @@ td.actions{white-space:nowrap;width:1px}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
|
<?php /* ════════════════════════════════ CONFIG ══════════════════════ */ ?>
|
||||||
|
<?php elseif ($tab === 'platform'): ?>
|
||||||
|
<?php renderAdminPlatformTab(); ?>
|
||||||
|
|
||||||
<?php elseif ($tab === 'config'): ?>
|
<?php elseif ($tab === 'config'): ?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,168 +1,125 @@
|
|||||||
<?php
|
<?php
|
||||||
// api/auth.php
|
|
||||||
define('CYBERCHAT_API', true);
|
define('CYBERCHAT_API', true);
|
||||||
require_once __DIR__ . '/../bootstrap.php';
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
|
require_once ROOT_DIR . '/lib/mailer.php';
|
||||||
sendCorsHeaders();
|
sendCorsHeaders();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
header('Cache-Control: no-store');
|
|
||||||
|
|
||||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
$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) {
|
function registerUser(): never {
|
||||||
case 'register':
|
$db = getDB();
|
||||||
handleRegister();
|
$username = trim((string)($_POST['username'] ?? ''));
|
||||||
break;
|
$password = (string)($_POST['password'] ?? '');
|
||||||
case 'login':
|
$maxUser = (int)getConfigVal('chat.max_username_length', 12);
|
||||||
handleLogin();
|
$minPass = (int)getConfigVal('security.min_password_length', 4);
|
||||||
break;
|
if ($username === '' || $password === '') jsonResponse(['error' => 'Username and password required'], 400);
|
||||||
case 'logout':
|
if (strlen($username) > $maxUser) jsonResponse(['error' => "Username max $maxUser characters"], 400);
|
||||||
handleLogout();
|
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $username)) {
|
||||||
break;
|
jsonResponse(['error' => 'Username: letters, numbers, _ and - only'], 400);
|
||||||
case 'check':
|
}
|
||||||
handleCheck();
|
if (strlen($password) < $minPass) jsonResponse(['error' => "Password min $minPass characters"], 400);
|
||||||
break;
|
$check = $db->prepare("SELECT 1 FROM users WHERE username=? COLLATE NOCASE");
|
||||||
default:
|
$check->execute([$username]);
|
||||||
jsonResponse(['error' => 'Unknown action'], 400);
|
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();
|
$db = getDB();
|
||||||
$config = getConfig();
|
$username = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
$username = trim($_POST['username'] ?? '');
|
if ($username === '' || $password === '') jsonResponse(['error' => 'Username and password required'], 400);
|
||||||
$password = $_POST['password'] ?? '';
|
$stmt = $db->prepare("SELECT * FROM users WHERE username=? COLLATE NOCASE");
|
||||||
|
|
||||||
$maxUser = getConfigVal('chat.max_username_length', 12);
|
|
||||||
$minPass = getConfigVal('security.min_password_length', 4);
|
|
||||||
|
|
||||||
if (!$username || !$password) {
|
|
||||||
jsonResponse(['error' => 'Username and password required'], 400);
|
|
||||||
}
|
|
||||||
if (strlen($username) > $maxUser) {
|
|
||||||
jsonResponse(['error' => "Username max {$maxUser} characters"], 400);
|
|
||||||
}
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) {
|
|
||||||
jsonResponse(['error' => 'Username: letters, numbers, _ - only'], 400);
|
|
||||||
}
|
|
||||||
if (strlen($password) < $minPass) {
|
|
||||||
jsonResponse(['error' => "Password min {$minPass} characters"], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username exists
|
|
||||||
$stmt = $db->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE");
|
|
||||||
$stmt->execute([$username]);
|
$stmt->execute([$username]);
|
||||||
if ($stmt->fetch()) {
|
$user = $stmt->fetch();
|
||||||
jsonResponse(['error' => 'Username already taken'], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$colors = getConfigVal('colors.user_palette', ['#00ff9f']);
|
|
||||||
$color = $colors[array_rand($colors)];
|
|
||||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => getConfigVal('security.bcrypt_cost', 10)]);
|
|
||||||
|
|
||||||
$stmt = $db->prepare("INSERT INTO users (username, password_hash, color, created_at) VALUES (?, ?, ?, ?)");
|
|
||||||
$stmt->execute([$username, $hash, $color, time()]);
|
|
||||||
$userId = $db->lastInsertId();
|
|
||||||
|
|
||||||
createSession($db, $userId, $username, $color);
|
|
||||||
jsonResponse(['success' => true, 'username' => $username, 'color' => $color]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogin(): never {
|
|
||||||
$db = getDB();
|
|
||||||
|
|
||||||
$username = trim($_POST['username'] ?? '');
|
|
||||||
$password = $_POST['password'] ?? '';
|
|
||||||
|
|
||||||
if (!$username || !$password) {
|
|
||||||
jsonResponse(['error' => 'Username and password required'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE");
|
|
||||||
$stmt->execute([$username]);
|
|
||||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
|
trackEvent('login_failed', '/api/auth.php');
|
||||||
jsonResponse(['error' => 'Invalid username or password'], 401);
|
jsonResponse(['error' => 'Invalid username or password'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing session (single login enforcement)
|
if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) {
|
||||||
if (getConfigVal('chat.session_lock_ip') || getConfigVal('chat.session_lock_cookie')) {
|
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
|
||||||
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
|
$existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?");
|
||||||
$cutoff = time() - $sessionTimeout;
|
$existingStmt->execute([$user['id'], $cutoff]);
|
||||||
|
$existing = $existingStmt->fetch();
|
||||||
$stmt2 = $db->prepare("SELECT id, ip FROM sessions WHERE user_id = ? AND last_active > ?");
|
if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) {
|
||||||
$stmt2->execute([$user['id'], $cutoff]);
|
jsonResponse(['error' => 'Already logged in from another location'], 403);
|
||||||
$existing = $stmt2->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$currentIP = clientIP();
|
|
||||||
// Allow same IP to re-login (refresh), block different IP
|
|
||||||
if (getConfigVal('chat.session_lock_ip') && $existing['ip'] !== $currentIP) {
|
|
||||||
jsonResponse(['error' => 'Already logged in from another location'], 403);
|
|
||||||
}
|
|
||||||
// Kill old session and create new
|
|
||||||
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$user['id']]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createSession($db, $user['id'], $user['username'], $user['color']);
|
if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) {
|
||||||
jsonResponse(['success' => true, 'username' => $user['username'], 'color' => $user['color']]);
|
if (empty($user['email_verified_at']) || empty($user['email'])) {
|
||||||
}
|
jsonResponse(['error' => 'Two-factor authentication requires a verified email'], 403);
|
||||||
|
}
|
||||||
function createSession(PDO $db, int $userId, string $username, string $color): void {
|
$challengeId = bin2hex(random_bytes(24));
|
||||||
$sid = bin2hex(random_bytes(32));
|
$code = (string)random_int(100000, 999999);
|
||||||
$ip = clientIP();
|
$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 (?,?,?,?,?)")
|
||||||
// Remove any existing sessions for this user
|
->execute([$challengeId, $user['id'], password_hash($code, PASSWORD_DEFAULT), time() + 600, time()]);
|
||||||
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$userId]);
|
try {
|
||||||
|
sendTwoFactorCode($user, $challengeId, $code);
|
||||||
$stmt = $db->prepare("INSERT INTO sessions (id, user_id, ip, created_at, last_active) VALUES (?, ?, ?, ?, ?)");
|
} catch (Throwable $e) {
|
||||||
$stmt->execute([$sid, $userId, $ip, time(), time()]);
|
$db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challengeId]);
|
||||||
|
jsonResponse(['error' => 'Could not send the login code: ' . $e->getMessage()], 503);
|
||||||
// Update last_seen
|
}
|
||||||
$db->prepare("UPDATE users SET last_seen = ? WHERE id = ?")->execute([time(), $userId]);
|
jsonResponse(['success' => true, 'requires_2fa' => true, 'challenge' => $challengeId]);
|
||||||
|
|
||||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
|
||||||
setcookie('cyberchat_sid', $sid, [
|
|
||||||
'expires' => time() + (getConfigVal('security.session_timeout_hours', 24) * 3600),
|
|
||||||
'path' => '/',
|
|
||||||
'httponly' => true,
|
|
||||||
'samesite' => 'Strict',
|
|
||||||
'secure' => $secure,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout(): never {
|
|
||||||
$db = getDB();
|
|
||||||
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
|
||||||
if ($sid) {
|
|
||||||
$db->prepare("DELETE FROM sessions WHERE id = ?")->execute([$sid]);
|
|
||||||
}
|
}
|
||||||
setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true);
|
|
||||||
|
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]);
|
jsonResponse(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCheck(): never {
|
function checkSession(): never {
|
||||||
$db = getDB();
|
$user = authUser(false);
|
||||||
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
jsonResponse($user
|
||||||
if (!$sid) jsonResponse(['authenticated' => false]);
|
? ['authenticated' => true, 'user' => userPublicPayload($user)]
|
||||||
|
: ['authenticated' => false]);
|
||||||
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
|
|
||||||
$cutoff = time() - $sessionTimeout;
|
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
SELECT s.*, u.username, u.color
|
|
||||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
||||||
WHERE s.id = ? AND s.last_active > ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$sid, $cutoff]);
|
|
||||||
$session = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if (!$session) jsonResponse(['authenticated' => false]);
|
|
||||||
if (getConfigVal('chat.session_lock_ip') && $session['ip'] !== clientIP()) {
|
|
||||||
jsonResponse(['authenticated' => false, 'reason' => 'ip_mismatch']);
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonResponse(['authenticated' => true, 'username' => $session['username'], 'color' => $session['color']]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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
@@ -1,32 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
// api/config.php - Serve safe config values to frontend
|
|
||||||
define('CYBERCHAT_API', true);
|
define('CYBERCHAT_API', true);
|
||||||
require_once __DIR__ . '/../bootstrap.php';
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
sendCorsHeaders();
|
sendCorsHeaders();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
header('Cache-Control: no-store');
|
|
||||||
|
|
||||||
$config = getConfig();
|
$config = getConfig();
|
||||||
|
jsonResponse([
|
||||||
// Only expose safe/needed values to frontend
|
|
||||||
$safe = [
|
|
||||||
'chat' => [
|
'chat' => [
|
||||||
'max_message_length' => $config['chat']['max_message_length'] ?? 500,
|
'max_message_length' => $config['chat']['max_message_length'] ?? 500,
|
||||||
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
|
'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'] ?? [],
|
'ui' => $config['ui'] ?? [],
|
||||||
'security' => [
|
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
|
||||||
'min_password_length' => $config['security']['min_password_length'] ?? 4,
|
'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
|
||||||
],
|
|
||||||
'colors' => $config['colors'] ?? [],
|
|
||||||
'voice' => [
|
'voice' => [
|
||||||
'enabled' => $config['voice']['enabled'] ?? true,
|
'enabled' => $config['voice']['enabled'] ?? true,
|
||||||
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
|
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
|
||||||
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
|
||||||
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
|
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
|
||||||
],
|
],
|
||||||
];
|
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
|
||||||
|
]);
|
||||||
echo json_encode($safe);
|
|
||||||
|
|||||||
@@ -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
@@ -1,37 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
// api/messages.php
|
|
||||||
define('CYBERCHAT_API', true);
|
define('CYBERCHAT_API', true);
|
||||||
require_once __DIR__ . '/../bootstrap.php';
|
require_once __DIR__ . '/../bootstrap.php';
|
||||||
sendCorsHeaders();
|
sendCorsHeaders();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
|
||||||
header('Cache-Control: no-store');
|
|
||||||
|
|
||||||
// Run archiving silently
|
|
||||||
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { /* silent */ }
|
|
||||||
|
|
||||||
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
$action = $_POST['action'] ?? $_GET['action'] ?? '';
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
case 'send':
|
case 'send': sendTextMessage();
|
||||||
handleSend();
|
case 'send_voice': sendVoiceMessage();
|
||||||
break;
|
case 'recording': setRecordingStatus();
|
||||||
case 'send_voice':
|
case 'poll': pollMessages();
|
||||||
handleSendVoice();
|
case 'history': archiveDays();
|
||||||
break;
|
default: jsonResponse(['error' => 'Unknown action'], 400);
|
||||||
case 'poll':
|
|
||||||
handlePoll();
|
|
||||||
break;
|
|
||||||
case 'history':
|
|
||||||
handleHistory();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
jsonResponse(['error' => 'Unknown action'], 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function messagePayload(array $row): array {
|
function messagePayload(array $row): array {
|
||||||
return [
|
return [
|
||||||
'id' => (int)$row['id'],
|
'id' => (int)$row['id'],
|
||||||
|
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null,
|
||||||
'username' => $row['username'],
|
'username' => $row['username'],
|
||||||
'color' => $row['color'],
|
'color' => $row['color'],
|
||||||
'message' => $row['message'],
|
'message' => $row['message'],
|
||||||
@@ -44,191 +31,149 @@ function messagePayload(array $row): array {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSend(): never {
|
function requestedGroup(array $user): ?int {
|
||||||
$session = authRequired();
|
$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();
|
$db = getDB();
|
||||||
|
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key)
|
||||||
$message = trim($_POST['message'] ?? '');
|
VALUES (?,?,?,?,?,?,?)")->execute([
|
||||||
$maxLen = getConfigVal('chat.max_message_length', 500);
|
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(),
|
||||||
|
]);
|
||||||
if (!$message) jsonResponse(['error' => 'Empty message'], 400);
|
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
|
||||||
if (strlen($message) > $maxLen) {
|
|
||||||
jsonResponse(['error' => "Message too long (max {$maxLen} chars)"], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dayKey = dayKey();
|
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
INSERT INTO messages (user_id, username, color, message, created_at, day_key)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
$session['uid'],
|
|
||||||
$session['username'],
|
|
||||||
$session['color'],
|
|
||||||
$message,
|
|
||||||
time(),
|
|
||||||
$dayKey
|
|
||||||
]);
|
|
||||||
|
|
||||||
$id = $db->lastInsertId();
|
|
||||||
|
|
||||||
jsonResponse(['success' => true, 'message' => messagePayload([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $id, 'username' => $session['username'], 'color' => $session['color'],
|
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
|
||||||
'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey,
|
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
|
||||||
|
'created_at' => $created, 'day_key' => dayKey(),
|
||||||
])]);
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSendVoice(): never {
|
function sendVoiceMessage(): never {
|
||||||
$session = authRequired();
|
$user = authRequired();
|
||||||
if (!getConfigVal('voice.enabled', true)) {
|
$groupId = requestedGroup($user);
|
||||||
jsonResponse(['error' => 'Voice clips are disabled'], 403);
|
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'])) {
|
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) jsonResponse(['error' => 'No voice clip uploaded'], 400);
|
||||||
jsonResponse(['error' => 'No voice clip uploaded'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $_FILES['voice'];
|
$file = $_FILES['voice'];
|
||||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) jsonResponse(['error' => 'Voice upload failed'], 400);
|
||||||
jsonResponse(['error' => 'Voice upload failed'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
|
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
|
||||||
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
|
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
|
||||||
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$duration = (float)($_POST['duration'] ?? 0);
|
$duration = (float)($_POST['duration'] ?? 0);
|
||||||
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
|
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
|
||||||
if ($duration <= 0 || $duration > $maxSeconds + 1) {
|
if ($duration <= 0 || $duration > $maxSeconds + 1) jsonResponse(['error' => "Voice clip must be $maxSeconds seconds or less"], 400);
|
||||||
jsonResponse(['error' => "Voice clip must be {$maxSeconds} seconds or less"], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mime = class_exists('finfo')
|
$mime = class_exists('finfo') ? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']) : '';
|
||||||
? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name'])
|
|
||||||
: '';
|
|
||||||
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
|
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
|
||||||
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) {
|
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) $mime = 'audio/webm';
|
||||||
$mime = 'audio/webm';
|
elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') $mime = 'audio/wav';
|
||||||
} elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') {
|
$allowed = ['audio/webm' => 'webm', 'video/webm' => 'webm', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/wave' => 'wav'];
|
||||||
$mime = 'audio/wav';
|
if (!isset($allowed[$mime])) jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
|
||||||
}
|
|
||||||
$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();
|
$dir = voiceUploadDir();
|
||||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
ensureWritableDirectory($dir, 'Voice storage');
|
||||||
jsonResponse(['error' => 'Voice storage is unavailable'], 500);
|
|
||||||
}
|
|
||||||
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
|
||||||
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
|
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500);
|
||||||
jsonResponse(['error' => 'Could not store voice clip'], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = getDB();
|
|
||||||
$created = time();
|
$created = time();
|
||||||
$dayKey = dayKey();
|
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
|
||||||
|
$db = getDB();
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare("
|
$db->prepare("INSERT INTO messages
|
||||||
INSERT INTO messages
|
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
||||||
(user_id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key)
|
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([
|
||||||
VALUES (?, ?, ?, ?, 'voice', ?, ?, ?, ?, ?)
|
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
|
||||||
");
|
$filename, $storedMime, $duration, $created, dayKey(),
|
||||||
$stmt->execute([
|
]);
|
||||||
$session['uid'], $session['username'], $session['color'], '[Voice clip]',
|
|
||||||
$filename, $mime === 'video/webm' ? 'audio/webm' : $mime, $duration, $created, $dayKey
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
deleteVoiceFile($filename);
|
deleteVoiceFile($filename);
|
||||||
throw $e;
|
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([
|
jsonResponse(['success' => true, 'message' => messagePayload([
|
||||||
'id' => $db->lastInsertId(), 'username' => $session['username'], 'color' => $session['color'],
|
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
|
||||||
'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename,
|
'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice',
|
||||||
'voice_mime' => $mime === 'video/webm' ? 'audio/webm' : $mime,
|
'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration,
|
||||||
'voice_duration' => $duration, 'created_at' => $created, 'day_key' => $dayKey,
|
'created_at' => $created, 'day_key' => dayKey(),
|
||||||
])]);
|
])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePoll(): never {
|
function setRecordingStatus(): never {
|
||||||
$session = authRequired();
|
$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();
|
$db = getDB();
|
||||||
|
if ($active) {
|
||||||
$since = (int)($_GET['since'] ?? 0);
|
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?)
|
||||||
$dayKey = dayKey();
|
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
|
||||||
$limit = getConfigVal('chat.messages_per_page', 100);
|
->execute([groupKey($groupId), $user['id'], time() + 10]);
|
||||||
|
|
||||||
if ($since === 0) {
|
|
||||||
// Initial load — get last N messages for today
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
|
|
||||||
FROM messages
|
|
||||||
WHERE day_key = ?
|
|
||||||
ORDER BY created_at DESC, id DESC
|
|
||||||
LIMIT ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$dayKey, $limit]);
|
|
||||||
$rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC));
|
|
||||||
} else {
|
} else {
|
||||||
// Poll for new messages since last id
|
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
|
||||||
$stmt = $db->prepare("
|
->execute([groupKey($groupId), $user['id']]);
|
||||||
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
|
}
|
||||||
FROM messages
|
jsonResponse(['success' => true]);
|
||||||
WHERE day_key = ? AND id > ?
|
}
|
||||||
ORDER BY created_at ASC, id ASC
|
|
||||||
LIMIT ?
|
function pollMessages(): never {
|
||||||
");
|
$user = authRequired();
|
||||||
$stmt->execute([$dayKey, $since, $limit]);
|
$groupId = requestedGroup($user);
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$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
|
$cutoff = time() - 300;
|
||||||
$messages = array_map('messagePayload', $rows);
|
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
|
||||||
|
$onlineStmt->execute([$cutoff]);
|
||||||
// Get online user count (active in last 5 minutes)
|
$recording = [];
|
||||||
$cutoff5min = time() - 300;
|
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
|
||||||
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active > ?");
|
if (featureValue($user, 'recording_status', false)) {
|
||||||
$onlineStmt->execute([$cutoff5min]);
|
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
|
||||||
$onlineCount = (int)$onlineStmt->fetchColumn();
|
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([
|
jsonResponse([
|
||||||
'messages' => $messages,
|
'messages' => array_map('messagePayload', $rows),
|
||||||
'online' => $onlineCount,
|
'online' => (int)$onlineStmt->fetchColumn(),
|
||||||
'day_key' => $dayKey,
|
'recording' => $recording,
|
||||||
|
'group_id' => $groupId,
|
||||||
'server_time' => time(),
|
'server_time' => time(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHistory(): never {
|
function archiveDays(): never {
|
||||||
// Return list of archived days
|
$user = authRequired();
|
||||||
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
|
$rows = personalArchiveRows($user, '', 1000);
|
||||||
$days = [];
|
$days = array_values(array_unique(array_column($rows, 'day_key')));
|
||||||
|
|
||||||
if (is_dir($archiveDir)) {
|
|
||||||
$years = glob($archiveDir . '/*', GLOB_ONLYDIR);
|
|
||||||
foreach ($years as $yearDir) {
|
|
||||||
$year = basename($yearDir);
|
|
||||||
$months = glob($yearDir . '/*', GLOB_ONLYDIR);
|
|
||||||
foreach ($months as $monthDir) {
|
|
||||||
$month = basename($monthDir);
|
|
||||||
$files = glob($monthDir . '/*.sqlite');
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$day = basename($file, '.sqlite');
|
|
||||||
$days[] = "$year-$month-$day";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rsort($days);
|
rsort($days);
|
||||||
jsonResponse(['days' => $days]);
|
jsonResponse(['days' => $days]);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-8
@@ -7,19 +7,22 @@ header('Content-Type: application/json');
|
|||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
// 1. PHP version
|
// 1. PHP version
|
||||||
$results['php_version'] = 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
|
// 2. PDO SQLite
|
||||||
$results['pdo_sqlite'] = extension_loaded('pdo_sqlite');
|
$results['pdo_sqlite'] = extension_loaded('pdo_sqlite');
|
||||||
|
|
||||||
// 3. ROOT_DIR and config.json
|
// 3. ROOT_DIR and config.json
|
||||||
$root = dirname(__DIR__);
|
$root = ROOT_DIR;
|
||||||
$configFile = $root . '/config.json';
|
$configFile = $root . '/config.json';
|
||||||
$results['config_exists'] = file_exists($configFile);
|
$results['config_exists'] = file_exists($configFile);
|
||||||
|
$results['config_writable'] = is_writable($configFile);
|
||||||
|
|
||||||
if ($results['config_exists']) {
|
if ($results['config_exists']) {
|
||||||
$raw = file_get_contents($configFile);
|
$raw = file_get_contents($configFile);
|
||||||
@@ -30,17 +33,19 @@ if ($results['config_exists']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. db/ directory writable
|
// 4. db/ directory writable
|
||||||
$dbDir = $root . '/db';
|
$dbPath = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite'));
|
||||||
|
$dbDir = dirname($dbPath);
|
||||||
if (!is_dir($dbDir)) {
|
if (!is_dir($dbDir)) {
|
||||||
@mkdir($dbDir, 0755, true);
|
@mkdir($dbDir, 0775, true);
|
||||||
}
|
}
|
||||||
$results['db_dir_exists'] = is_dir($dbDir);
|
$results['db_dir_exists'] = is_dir($dbDir);
|
||||||
$results['db_dir_writable'] = is_writable($dbDir);
|
$results['db_dir_writable'] = is_writable($dbDir);
|
||||||
|
$results['db_file_writable'] = !is_file($dbPath) || is_writable($dbPath);
|
||||||
|
|
||||||
// 5. archive/ directory writable
|
// 5. archive/ directory writable
|
||||||
$archiveDir = $root . '/archive';
|
$archiveDir = storagePath((string)getConfigVal('archive.archive_dir', 'archive'));
|
||||||
if (!is_dir($archiveDir)) {
|
if (!is_dir($archiveDir)) {
|
||||||
@mkdir($archiveDir, 0755, true);
|
@mkdir($archiveDir, 0775, true);
|
||||||
}
|
}
|
||||||
$results['archive_dir_exists'] = is_dir($archiveDir);
|
$results['archive_dir_exists'] = is_dir($archiveDir);
|
||||||
$results['archive_dir_writable'] = is_writable($archiveDir);
|
$results['archive_dir_writable'] = is_writable($archiveDir);
|
||||||
@@ -50,7 +55,7 @@ $results['sqlite_create'] = false;
|
|||||||
$results['sqlite_error'] = null;
|
$results['sqlite_error'] = null;
|
||||||
if ($results['pdo_sqlite'] && $results['db_dir_writable']) {
|
if ($results['pdo_sqlite'] && $results['db_dir_writable']) {
|
||||||
try {
|
try {
|
||||||
$testDb = new PDO('sqlite:' . $dbDir . '/chat.sqlite');
|
$testDb = new PDO('sqlite:' . $dbPath);
|
||||||
$testDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$testDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
$testDb->exec('PRAGMA journal_mode=WAL');
|
$testDb->exec('PRAGMA journal_mode=WAL');
|
||||||
$testDb->exec('CREATE TABLE IF NOT EXISTS _ping_test (id INTEGER PRIMARY KEY)');
|
$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_exists'] &&
|
||||||
$results['config_valid_json'] &&
|
$results['config_valid_json'] &&
|
||||||
$results['db_dir_writable'] &&
|
$results['db_dir_writable'] &&
|
||||||
|
$results['db_file_writable'] &&
|
||||||
$results['sqlite_create']
|
$results['sqlite_create']
|
||||||
);
|
);
|
||||||
|
|
||||||
$results['root_dir'] = $root;
|
|
||||||
$results['server_time'] = date('Y-m-d H:i:s T');
|
$results['server_time'] = date('Y-m-d H:i:s T');
|
||||||
|
|
||||||
http_response_code($results['all_ok'] ? 200 : 500);
|
http_response_code($results['all_ok'] ? 200 : 500);
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -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
@@ -1,225 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
// archive-viewer.php — Browse archived chat logs
|
// Historical logs are private. The authenticated application renders only the
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
// current user's entitled archive through api/archive.php.
|
||||||
|
header('Location: index.html?view=archive', true, 302);
|
||||||
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
|
exit;
|
||||||
$days = [];
|
|
||||||
if (is_dir($archiveDir)) {
|
|
||||||
foreach (glob($archiveDir . '/*/*/*.sqlite') as $f) {
|
|
||||||
if (preg_match('/(\d{4})\/(\d{2})\/(\d{2})\.sqlite$/', $f, $m)) {
|
|
||||||
$days[] = "{$m[1]}-{$m[2]}-{$m[3]}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rsort($days);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectedDay = $_GET['day'] ?? null;
|
|
||||||
$archiveMessages = [];
|
|
||||||
$archiveMeta = [];
|
|
||||||
if ($selectedDay && preg_match('/^\d{4}-\d{2}-\d{2}$/', $selectedDay)) {
|
|
||||||
[$y, $mo, $d] = explode('-', $selectedDay);
|
|
||||||
$dbPath = ROOT_DIR . '/' . str_replace(
|
|
||||||
['{year}', '{month}', '{day}'], [$y, $mo, $d],
|
|
||||||
getConfigVal('database.archive_path')
|
|
||||||
);
|
|
||||||
if (file_exists($dbPath)) {
|
|
||||||
$adb = getArchiveDB($y, $mo, $d);
|
|
||||||
$archiveMessages = $adb->query("SELECT * FROM messages ORDER BY created_at ASC")->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$metaRows = $adb->query("SELECT key, value FROM archive_meta")->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
foreach ($metaRows as $mr) $archiveMeta[$mr['key']] = $mr['value'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?><!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>CYBERCHAT // ARCHIVE</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;600&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="assets/css/cyberchat.css">
|
|
||||||
<style>
|
|
||||||
html, body {
|
|
||||||
min-height: 100vh;
|
|
||||||
height: auto;
|
|
||||||
overflow: auto;
|
|
||||||
background: var(--bg0);
|
|
||||||
padding: 20px 16px 40px;
|
|
||||||
font-family: var(--ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-wrap { max-width: 920px; margin: 0 auto; }
|
|
||||||
|
|
||||||
.arc-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid var(--bd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-back {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--b);
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
transition: color 0.14s;
|
|
||||||
}
|
|
||||||
.arc-back:hover { color: var(--g); }
|
|
||||||
|
|
||||||
.arc-title {
|
|
||||||
font-family: var(--disp);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 3px;
|
|
||||||
color: var(--b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 180px 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
height: calc(100vh - 130px);
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-sidebar, .arc-content {
|
|
||||||
background: var(--bg1);
|
|
||||||
border: 1px solid var(--bd);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-sidebar-head, .arc-content-head {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--bd);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
color: var(--t1);
|
|
||||||
text-transform: uppercase;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-days-list { overflow-y: auto; flex: 1; }
|
|
||||||
|
|
||||||
.arc-day-item {
|
|
||||||
padding: 9px 14px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--t1);
|
|
||||||
cursor: pointer;
|
|
||||||
border-left: 2px solid transparent;
|
|
||||||
transition: all 0.12s;
|
|
||||||
}
|
|
||||||
.arc-day-item:hover { color: var(--g); border-left-color: var(--g); background: rgba(0,255,159,0.04); }
|
|
||||||
.arc-day-item.active { color: var(--b); border-left-color: var(--b); background: rgba(0,184,255,0.06); }
|
|
||||||
|
|
||||||
.arc-messages-list { overflow-y: auto; flex: 1; padding: 4px 0; }
|
|
||||||
|
|
||||||
.arc-msg {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 3px 12px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
.arc-msg:hover { background: rgba(255,255,255,0.02); }
|
|
||||||
.arc-msg-time { color: var(--t2); font-size: 9px; min-width: 44px; flex-shrink: 0; margin-right: 7px; }
|
|
||||||
.arc-msg-user { font-weight: 600; font-size: 11px; flex-shrink: 0; min-width: 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.arc-msg-sep { color: var(--t2); margin: 0 7px; flex-shrink: 0; }
|
|
||||||
.arc-msg-text { color: var(--t0); flex: 1; word-break: break-word; min-width: 0; }
|
|
||||||
.arc-msg-text audio { width: min(300px, 100%); height: 30px; vertical-align: middle; }
|
|
||||||
|
|
||||||
.arc-empty {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--t2);
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arc-meta {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--o);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-bottom: 1px solid var(--bd);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.arc-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; height: auto; }
|
|
||||||
.arc-sidebar { height: 160px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="arc-wrap">
|
|
||||||
<div class="arc-topbar">
|
|
||||||
<a href="index.html" class="arc-back">◂ BACK TO CHAT</a>
|
|
||||||
<span class="arc-title">// ARCHIVE VIEWER</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="arc-grid">
|
|
||||||
<div class="arc-sidebar">
|
|
||||||
<div class="arc-sidebar-head">// DAYS</div>
|
|
||||||
<div class="arc-days-list">
|
|
||||||
<?php if (empty($days)): ?>
|
|
||||||
<div class="arc-empty">NO ARCHIVES YET</div>
|
|
||||||
<?php else: foreach ($days as $day): ?>
|
|
||||||
<div class="arc-day-item <?= $day === $selectedDay ? 'active' : '' ?>"
|
|
||||||
onclick="location.href='?day=<?= htmlspecialchars($day) ?>'">
|
|
||||||
<?= htmlspecialchars($day) ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="arc-content">
|
|
||||||
<div class="arc-content-head">// MESSAGES</div>
|
|
||||||
<?php if (!$selectedDay): ?>
|
|
||||||
<div class="arc-empty">SELECT A DAY FROM THE LIST</div>
|
|
||||||
<?php elseif (empty($archiveMessages)): ?>
|
|
||||||
<div class="arc-empty">NO MESSAGES FOR <?= htmlspecialchars($selectedDay) ?></div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="arc-meta">
|
|
||||||
// <?= htmlspecialchars($selectedDay) ?>
|
|
||||||
| <?= count($archiveMessages) ?> MESSAGES
|
|
||||||
<?php if (!empty($archiveMeta['archived_at'])): ?>
|
|
||||||
| ARCHIVED <?= date('Y-m-d H:i', (int)$archiveMeta['archived_at']) ?> UTC
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="arc-messages-list">
|
|
||||||
<?php foreach ($archiveMessages as $msg): ?>
|
|
||||||
<div class="arc-msg">
|
|
||||||
<span class="arc-msg-time"><?= date('H:i', (int)$msg['created_at']) ?></span>
|
|
||||||
<span class="arc-msg-user" style="color:<?= htmlspecialchars($msg['color']) ?>"><?= htmlspecialchars($msg['username']) ?></span>
|
|
||||||
<span class="arc-msg-sep">›</span>
|
|
||||||
<span class="arc-msg-text">
|
|
||||||
<?php if (($msg['message_type'] ?? 'text') === 'voice' && !empty($msg['voice_file'])): ?>
|
|
||||||
<audio controls preload="metadata"
|
|
||||||
src="<?= htmlspecialchars(trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/') . '/' . rawurlencode($msg['voice_file'])) ?>"></audio>
|
|
||||||
<?php else: ?>
|
|
||||||
<?= htmlspecialchars($msg['message']) ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
+227
-5
@@ -656,9 +656,7 @@ input, button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cc-voice-message audio {
|
.cc-voice-message audio {
|
||||||
width: min(280px, 100%);
|
display: none;
|
||||||
height: 30px;
|
|
||||||
accent-color: var(--g);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-msg-voice-playing {
|
.cc-msg-voice-playing {
|
||||||
@@ -858,7 +856,174 @@ input, button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cc-voice-review[hidden] { display: none; }
|
.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 {
|
.cc-voice-action {
|
||||||
height: 27px;
|
height: 27px;
|
||||||
@@ -964,7 +1129,7 @@ input, button { font-family: inherit; }
|
|||||||
.cc-voice-queue { order: 3; }
|
.cc-voice-queue { order: 3; }
|
||||||
.cc-voice-autoplay { margin-left: 0; }
|
.cc-voice-autoplay { margin-left: 0; }
|
||||||
.cc-voice-review { width: 100%; }
|
.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; }
|
.cc-voice-message { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,3 +1149,60 @@ html, body {
|
|||||||
#app {
|
#app {
|
||||||
width: 100%; height: 100%;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,641 @@
|
|||||||
|
(function(window, document) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const initialView = new URLSearchParams(location.search).get('view');
|
||||||
|
const initialChallenge = new URLSearchParams(location.search).get('login_challenge') || '';
|
||||||
|
const state = {
|
||||||
|
config: {}, user: null, groups: [], billing: null,
|
||||||
|
view: ['chat', 'groups', 'archive', 'account'].includes(initialView) ? initialView : 'chat',
|
||||||
|
groupId: null, lastId: 0, pollTimer: null, polling: false,
|
||||||
|
loginChallenge: initialChallenge,
|
||||||
|
verifyToken: new URLSearchParams(location.search).get('verify') || '',
|
||||||
|
voice: { recorder: null, stream: null, timer: null, heartbeat: null, started: 0,
|
||||||
|
chunks: [], blob: null, duration: 0, url: '', autoSend: false, sending: false }
|
||||||
|
};
|
||||||
|
let root;
|
||||||
|
const $ = (s, c) => (c || root).querySelector(s);
|
||||||
|
const $$ = (s, c) => Array.from((c || root).querySelectorAll(s));
|
||||||
|
const esc = value => String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
|
||||||
|
function base() {
|
||||||
|
if (window.CYBERCHAT_BASE != null) return String(window.CYBERCHAT_BASE).replace(/\/$/, '');
|
||||||
|
const script = Array.from(document.scripts).find(item => item.src.includes('cyberchat-v4.js'));
|
||||||
|
return script ? script.src.replace(/\/assets\/js\/cyberchat-v4\.js.*$/, '') : '';
|
||||||
|
}
|
||||||
|
function apiUrl(file, query = '') { return base() + '/api/' + file + (query ? '?' + query : ''); }
|
||||||
|
function publicUrl(path) {
|
||||||
|
if (!path) return '';
|
||||||
|
return /^(https?:)?\/\//.test(path) || path.startsWith('/') ? path : base() + '/' + path;
|
||||||
|
}
|
||||||
|
function form(data) {
|
||||||
|
const body = new FormData();
|
||||||
|
Object.entries(data).forEach(([key, value]) => body.append(key, value));
|
||||||
|
return { method: 'POST', body };
|
||||||
|
}
|
||||||
|
async function api(file, options, query = '') {
|
||||||
|
const response = await fetch(apiUrl(file, query), { credentials: 'include', ...(options || {}) });
|
||||||
|
const data = await response.json().catch(() => ({ error: 'The server returned an invalid response' }));
|
||||||
|
if (!response.ok && !data.error) data.error = 'Request failed';
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function feature(key, fallback = false) {
|
||||||
|
return state.user?.features && Object.prototype.hasOwnProperty.call(state.user.features, key)
|
||||||
|
? state.user.features[key] : fallback;
|
||||||
|
}
|
||||||
|
function toast(message, type = 'error') {
|
||||||
|
const area = $('#cc-toasts');
|
||||||
|
if (!area) return;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'cc-toast ' + type;
|
||||||
|
item.textContent = message;
|
||||||
|
area.appendChild(item);
|
||||||
|
setTimeout(() => { item.classList.add('cc-toast-fade'); setTimeout(() => item.remove(), 350); }, 2800);
|
||||||
|
}
|
||||||
|
function duration(seconds) {
|
||||||
|
const total = Math.max(0, Math.ceil(Number(seconds) || 0));
|
||||||
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function audioClock(seconds) {
|
||||||
|
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||||
|
return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function clock(timestamp) {
|
||||||
|
return new Date(Number(timestamp) * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
function money(cents, currency) {
|
||||||
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(cents / 100);
|
||||||
|
}
|
||||||
|
function linkify(text) {
|
||||||
|
return text.replace(/(https?:\/\/[^\s<>]+)/g, '<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">></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
@@ -1,221 +1,460 @@
|
|||||||
<?php
|
<?php
|
||||||
// bootstrap.php — Core helpers, DB init, session auth
|
// Core configuration, persistence, authentication, entitlements, and archives.
|
||||||
|
|
||||||
define('ROOT_DIR', __DIR__);
|
if (!defined('ROOT_DIR')) define('ROOT_DIR', __DIR__);
|
||||||
define('CONFIG_FILE', ROOT_DIR . '/config.json');
|
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 {
|
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) {
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
||||||
http_response_code(500);
|
jsonResponse(['error' => 'Server error', 'detail' => basename($errfile) . ':' . $errline], 500);
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['error' => 'Server error: ' . $errstr . ' in ' . basename($errfile) . ':' . $errline]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
set_exception_handler(function(Throwable $e): void {
|
set_exception_handler(function(Throwable $e): void {
|
||||||
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
if (defined('CYBERCHAT_API') && CYBERCHAT_API) {
|
||||||
http_response_code(500);
|
jsonResponse(['error' => 'Server exception', 'detail' => $e->getMessage()], 500);
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['error' => 'Server exception: ' . $e->getMessage()]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── CORS headers (needed if embedding cross-origin) ─────────────────────
|
|
||||||
function sendCorsHeaders(): void {
|
function sendCorsHeaders(): void {
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
// Allow same-origin and any origin configured in config
|
$allowed = (array)getConfigVal('security.allowed_origins', []);
|
||||||
// For strictest security, lock this to your domain
|
$hostOrigin = requestOrigin();
|
||||||
if ($origin) {
|
if ($origin && ($origin === $hostOrigin || in_array($origin, $allowed, true))) {
|
||||||
header('Access-Control-Allow-Origin: ' . $origin);
|
header('Access-Control-Allow-Origin: ' . $origin);
|
||||||
|
header('Vary: Origin');
|
||||||
header('Access-Control-Allow-Credentials: true');
|
header('Access-Control-Allow-Credentials: true');
|
||||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
header('Access-Control-Allow-Headers: Content-Type, X-Requested-With');
|
||||||
}
|
}
|
||||||
// Handle preflight
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Config ───────────────────────────────────────────────────────────────
|
function requestOrigin(): string {
|
||||||
function getConfig(): array {
|
$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;
|
static $config = null;
|
||||||
|
if ($reload) $config = null;
|
||||||
if ($config === null) {
|
if ($config === null) {
|
||||||
if (!file_exists(CONFIG_FILE)) {
|
if (!file_exists(CONFIG_FILE)) throw new RuntimeException('config.json not found');
|
||||||
throw new RuntimeException('config.json not found at: ' . CONFIG_FILE);
|
$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());
|
||||||
$raw = file_get_contents(CONFIG_FILE);
|
|
||||||
$config = json_decode($raw, true);
|
|
||||||
if ($config === null) {
|
|
||||||
throw new RuntimeException('config.json is not valid JSON: ' . json_last_error_msg());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return $config;
|
return $config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigVal(string $path, mixed $default = null): mixed {
|
function getConfigVal(string $path, mixed $default = null): mixed {
|
||||||
$config = getConfig();
|
$value = getConfig();
|
||||||
$keys = explode('.', $path);
|
foreach (explode('.', $path) as $key) {
|
||||||
$val = $config;
|
if (!is_array($value) || !array_key_exists($key, $value)) return $default;
|
||||||
foreach ($keys as $key) {
|
$value = $value[$key];
|
||||||
if (!is_array($val) || !array_key_exists($key, $val)) return $default;
|
|
||||||
$val = $val[$key];
|
|
||||||
}
|
}
|
||||||
return $val;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Database ─────────────────────────────────────────────────────────────
|
function setConfigValues(array $changes): void {
|
||||||
function getDB(): PDO {
|
$config = getConfig();
|
||||||
static $db = null;
|
foreach ($changes as $path => $value) {
|
||||||
if ($db === null) {
|
$keys = explode('.', $path);
|
||||||
$dbRelPath = getConfigVal('database.main_db', 'db/chat.sqlite');
|
$cursor =& $config;
|
||||||
$dbPath = ROOT_DIR . '/' . ltrim($dbRelPath, '/');
|
foreach ($keys as $index => $key) {
|
||||||
$dir = dirname($dbPath);
|
if ($index === count($keys) - 1) {
|
||||||
|
$cursor[$key] = $value;
|
||||||
if (!is_dir($dir)) {
|
} else {
|
||||||
if (!mkdir($dir, 0755, true)) {
|
if (!isset($cursor[$key]) || !is_array($cursor[$key])) $cursor[$key] = [];
|
||||||
throw new RuntimeException("Cannot create db directory: $dir — check permissions");
|
$cursor =& $cursor[$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!is_writable($dir)) {
|
unset($cursor);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
$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;
|
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 {
|
function initDB(PDO $db): void {
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
$db->exec("CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
email TEXT,
|
||||||
last_seen INTEGER
|
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 (
|
$db->exec("CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
last_active 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
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)");
|
)");
|
||||||
|
|
||||||
$db->exec("CREATE TABLE IF NOT EXISTS messages (
|
$db->exec("CREATE TABLE IF NOT EXISTS chat_groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
owner_id INTEGER NOT NULL,
|
||||||
username TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
message TEXT NOT NULL,
|
updated_at INTEGER NOT NULL,
|
||||||
message_type TEXT NOT NULL DEFAULT 'text',
|
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
voice_file TEXT,
|
)");
|
||||||
voice_mime TEXT,
|
$db->exec("CREATE TABLE IF NOT EXISTS group_members (
|
||||||
voice_duration REAL,
|
group_id INTEGER NOT NULL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
user_id INTEGER NOT NULL,
|
||||||
day_key TEXT NOT NULL,
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
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 TABLE IF NOT EXISTS messages (
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_day ON messages(day_key)");
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at)");
|
user_id INTEGER NOT NULL,
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id)");
|
group_id INTEGER,
|
||||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_sess_active ON sessions(last_active)");
|
username TEXT NOT NULL,
|
||||||
}
|
color TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
function ensureMessageColumns(PDO $db): void {
|
message_type TEXT NOT NULL DEFAULT 'text',
|
||||||
$columns = [];
|
voice_file TEXT,
|
||||||
foreach ($db->query("PRAGMA table_info(messages)") as $column) {
|
voice_mime TEXT,
|
||||||
$columns[$column['name']] = true;
|
voice_duration REAL,
|
||||||
}
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
$additions = [
|
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'",
|
'message_type' => "TEXT NOT NULL DEFAULT 'text'",
|
||||||
'voice_file' => 'TEXT',
|
'voice_file' => 'TEXT',
|
||||||
'voice_mime' => 'TEXT',
|
'voice_mime' => 'TEXT',
|
||||||
'voice_duration' => 'REAL',
|
'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])) {
|
$insertPlan = $db->prepare("INSERT OR IGNORE INTO plans
|
||||||
$db->exec("ALTER TABLE messages ADD COLUMN $name $definition");
|
(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 {
|
function plans(bool $activeOnly = false): array {
|
||||||
$relative = getConfigVal('voice.upload_dir', 'uploads/voice');
|
$db = getDB();
|
||||||
return ROOT_DIR . '/' . trim((string)$relative, '/');
|
$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 {
|
function planById(int $planId): ?array {
|
||||||
$relative = trim((string)getConfigVal('voice.upload_dir', 'uploads/voice'), '/');
|
foreach (plans(false) as $plan) if ($plan['id'] === $planId) return $plan;
|
||||||
return $relative . '/' . rawurlencode($filename);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteVoiceFile(?string $filename): void {
|
function userPlan(array $user): array {
|
||||||
if (!$filename || basename($filename) !== $filename) return;
|
$free = null;
|
||||||
$path = voiceUploadDir() . '/' . $filename;
|
foreach (plans(false) as $plan) {
|
||||||
if (is_file($path)) unlink($path);
|
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 featureValue(array $user, string $key, mixed $default = false): mixed {
|
||||||
function getArchiveDB(string $year, string $month, string $day): PDO {
|
$plan = userPlan($user);
|
||||||
$tpl = getConfigVal('database.archive_path', 'archive/{year}/{month}/{day}.sqlite');
|
return array_key_exists($key, $plan['features']) ? $plan['features'][$key] : $default;
|
||||||
$relPath = str_replace(['{year}','{month}','{day}'], [$year,$month,$day], $tpl);
|
|
||||||
$fullPath = ROOT_DIR . '/' . ltrim($relPath, '/');
|
|
||||||
$dir = dirname($fullPath);
|
|
||||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
|
||||||
|
|
||||||
$adb = new PDO('sqlite:' . $fullPath);
|
|
||||||
$adb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
||||||
$adb->exec('PRAGMA journal_mode=WAL');
|
|
||||||
|
|
||||||
$adb->exec("CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
color TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
message_type TEXT NOT NULL DEFAULT 'text',
|
|
||||||
voice_file TEXT,
|
|
||||||
voice_mime TEXT,
|
|
||||||
voice_duration REAL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
day_key TEXT NOT NULL
|
|
||||||
)");
|
|
||||||
ensureMessageColumns($adb);
|
|
||||||
$adb->exec("CREATE TABLE IF NOT EXISTS archive_meta (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT
|
|
||||||
)");
|
|
||||||
|
|
||||||
return $adb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
function userPublicPayload(array $user): array {
|
||||||
function dayKey(): string {
|
$plan = userPlan($user);
|
||||||
return date('Y-m-d');
|
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 {
|
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])) {
|
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;
|
if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,77 +463,228 @@ function clientIP(): string {
|
|||||||
|
|
||||||
function jsonResponse(array $data, int $code = 200): never {
|
function jsonResponse(array $data, int $code = 200): never {
|
||||||
http_response_code($code);
|
http_response_code($code);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Cache-Control: no-store');
|
header('Cache-Control: no-store');
|
||||||
echo json_encode($data);
|
echo json_encode($data, JSON_UNESCAPED_SLASHES);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Auth guard ───────────────────────────────────────────────────────────
|
function authUser(bool $required = true): ?array {
|
||||||
function authRequired(): array {
|
|
||||||
$db = getDB();
|
|
||||||
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
$sid = $_COOKIE['cyberchat_sid'] ?? '';
|
||||||
|
if ($sid === '') {
|
||||||
if (!$sid) {
|
if ($required) jsonResponse(['error' => 'Not authenticated'], 401);
|
||||||
jsonResponse(['error' => 'Not authenticated'], 401);
|
return null;
|
||||||
}
|
}
|
||||||
|
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
|
||||||
$timeout = (int)getConfigVal('security.session_timeout_hours', 24) * 3600;
|
$stmt = getDB()->prepare("SELECT s.id AS session_id,s.ip,s.last_active,u.*
|
||||||
$cutoff = time() - $timeout;
|
FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.id=? AND s.last_active>?");
|
||||||
|
|
||||||
$stmt = $db->prepare("
|
|
||||||
SELECT s.id, s.ip, s.last_active,
|
|
||||||
u.id AS uid, u.username, u.color
|
|
||||||
FROM sessions s
|
|
||||||
JOIN users u ON u.id = s.user_id
|
|
||||||
WHERE s.id = ? AND s.last_active > ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$sid, $cutoff]);
|
$stmt->execute([$sid, $cutoff]);
|
||||||
$session = $stmt->fetch();
|
$user = $stmt->fetch();
|
||||||
|
if (!$user) {
|
||||||
if (!$session) {
|
if ($required) jsonResponse(['error' => 'Session expired'], 401);
|
||||||
jsonResponse(['error' => 'Session expired'], 401);
|
return null;
|
||||||
}
|
}
|
||||||
|
if (getConfigVal('chat.session_lock_ip', true) && $user['ip'] !== clientIP()) {
|
||||||
if (getConfigVal('chat.session_lock_ip', true) && $session['ip'] !== clientIP()) {
|
if ($required) jsonResponse(['error' => 'Session IP mismatch'], 401);
|
||||||
jsonResponse(['error' => 'Session IP mismatch — logged in elsewhere'], 401);
|
return null;
|
||||||
}
|
}
|
||||||
|
getDB()->prepare("UPDATE sessions SET last_active=? WHERE id=?")->execute([time(), $sid]);
|
||||||
$db->prepare("UPDATE sessions SET last_active = ? WHERE id = ?")->execute([time(), $sid]);
|
return $user;
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Daily archive ────────────────────────────────────────────────────────
|
function authRequired(): array {
|
||||||
function archiveYesterdayIfNeeded(): void {
|
$user = authUser(true);
|
||||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
$user['uid'] = $user['id'];
|
||||||
$flagFile = ROOT_DIR . '/db/.archived_' . $yesterday;
|
return $user;
|
||||||
if (file_exists($flagFile)) return;
|
}
|
||||||
|
|
||||||
$db = getDB();
|
function createUserSession(PDO $db, int $userId): void {
|
||||||
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key = ? ORDER BY created_at ASC");
|
$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]);
|
$stmt->execute([$yesterday]);
|
||||||
$rows = $stmt->fetchAll();
|
$rows = $stmt->fetchAll();
|
||||||
|
if ($rows) {
|
||||||
if (!empty($rows)) {
|
[$year, $month, $day] = explode('-', $yesterday);
|
||||||
[$y, $m, $d] = explode('-', $yesterday);
|
$archive = getArchiveDB($year, $month, $day);
|
||||||
$adb = getArchiveDB($y, $m, $d);
|
$archive->beginTransaction();
|
||||||
$adb->beginTransaction();
|
$insert = $archive->prepare("INSERT OR IGNORE INTO messages
|
||||||
$ins = $adb->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)
|
||||||
(id,user_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)");
|
foreach ($rows as $row) {
|
||||||
foreach ($rows as $r) {
|
$insert->execute([
|
||||||
$ins->execute([
|
$row['id'], $row['user_id'], $row['group_id'] ?? null, $row['username'], $row['color'],
|
||||||
$r['id'],$r['user_id'],$r['username'],$r['color'],$r['message'],
|
$row['message'], $row['message_type'], $row['voice_file'], $row['voice_mime'],
|
||||||
$r['message_type'] ?? 'text',$r['voice_file'] ?? null,$r['voice_mime'] ?? null,
|
$row['voice_duration'], $row['created_at'], $row['day_key'],
|
||||||
$r['voice_duration'] ?? null,$r['created_at'],$r['day_key']
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('archived_at','" . time() . "')");
|
$meta = $archive->prepare("INSERT OR REPLACE INTO archive_meta(key,value) VALUES (?,?)");
|
||||||
$adb->exec("INSERT OR REPLACE INTO archive_meta(key,value) VALUES('message_count','" . count($rows) . "')");
|
$meta->execute(['archived_at', (string)time()]);
|
||||||
$adb->commit();
|
$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
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"app": {
|
||||||
|
"base_url": ""
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"max_message_length": 500,
|
"max_message_length": 500,
|
||||||
"max_username_length": 12,
|
"max_username_length": 12,
|
||||||
@@ -22,7 +25,15 @@
|
|||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"main_db": "db/chat.sqlite",
|
"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": {
|
"ui": {
|
||||||
"default_theme": "dark",
|
"default_theme": "dark",
|
||||||
@@ -34,7 +45,31 @@
|
|||||||
"min_password_length": 4,
|
"min_password_length": 4,
|
||||||
"max_login_attempts": 10,
|
"max_login_attempts": 10,
|
||||||
"session_timeout_hours": 24,
|
"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": {
|
"colors": {
|
||||||
"user_palette": [
|
"user_palette": [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!.gitkeep
|
||||||
+2
-2
@@ -94,7 +94,7 @@
|
|||||||
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
<span class="kw"><div</span> id=<span class="str">"my-chat"</span> style=<span class="str">"width:100%; height:600px"</span><span class="kw">></div></span>
|
||||||
|
|
||||||
<span class="cm"><!-- 4. Script + init --></span>
|
<span class="cm"><!-- 4. Script + init --></span>
|
||||||
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat.js"</span><span class="kw">></script></span>
|
<span class="kw"><script</span> src=<span class="str">"assets/js/cyberchat-v4.js"</span><span class="kw">></script></span>
|
||||||
<span class="kw"><script></span>
|
<span class="kw"><script></span>
|
||||||
window.CYBERCHAT_BASE = <span class="str">'/path/to/cyberchat'</span>; <span class="cm">// adjust to your install path</span>
|
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>);
|
CyberChat.init(<span class="str">'#my-chat'</span>);
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat.js"></script>
|
<script src="assets/js/cyberchat-v4.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#chat-here');
|
CyberChat.init('#chat-here');
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.CYBERCHAT_BASE = '';
|
window.CYBERCHAT_BASE = '';
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/js/cyberchat.js"></script>
|
<script src="assets/js/cyberchat-v4.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
CyberChat.init('#app');
|
CyberChat.init('#app');
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -41,14 +41,19 @@ server {
|
|||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Block direct access to db/ and archive/ directories
|
# Block direct access to storage and internal libraries
|
||||||
location ~ ^/(db|archive)/ {
|
location ~ ^/(db|archive|lib)/ {
|
||||||
deny all;
|
deny all;
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Block .sqlite files from being served directly
|
# Block credentials and SQLite sidecar files
|
||||||
location ~* \.sqlite$ {
|
location = /config.json {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.sqlite($|-wal$|-shm$) {
|
||||||
deny all;
|
deny all;
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user