Files
chat/lib/admin_platform.php
T
Ty Clifford 6217a216a7 - Implemented.
Safari/iPhone recording now uses MP4/AAC when WebM is unavailable.
WebM remains the default when supported and FFmpeg is disabled.
Added optional FFmpeg profiles: recommended M4A/AAC-LC or requested 
MP4/H.264 + AAC.
Added MIME-aware, playsinline audio playback.
Added dashboard controls under Plans & Platform → Voice Transcoding / 
FFmpeg.
Added Apache/Nginx media MIME configuration and documentation.
Core implementation: voice_transcoder.php (line 72), messages.php (line 
86), and cyberchat-app.js (line 635).
2026-06-09 02:21:42 -04:00

273 lines
20 KiB
PHP

<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') {
$voiceProfile = (string)($_POST['voice_transcoding_profile'] ?? 'm4a_aac');
if (!in_array($voiceProfile, ['m4a_aac', 'mp4_h264_aac'], true)) $voiceProfile = 'm4a_aac';
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'] ?? '')),
'voice.transcoding.enabled' => !empty($_POST['voice_transcoding_enabled']),
'voice.transcoding.ffmpeg_path' => trim((string)($_POST['voice_ffmpeg_path'] ?? 'ffmpeg')) ?: 'ffmpeg',
'voice.transcoding.profile' => $voiceProfile,
'voice.transcoding.audio_bitrate_kbps' => max(48, min(320, (int)($_POST['voice_audio_bitrate'] ?? 128))),
'voice.transcoding.timeout_seconds' => max(5, min(300, (int)($_POST['voice_transcoding_timeout'] ?? 45))),
'voice.transcoding.fallback_to_source' => !empty($_POST['voice_transcoding_fallback']),
'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', 'auto_voice_play', '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, '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,
'auto_voice_play' => false, '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,u.manual_plan_id
FROM users u LEFT JOIN plans p ON p.id=COALESCE(u.manual_plan_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(),
];
$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', 'auto_voice_play',
'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>
<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">// VOICE TRANSCODING / FFMPEG</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="voice_transcoding_enabled"
<?= getConfigVal('voice.transcoding.enabled', false) ? 'checked' : '' ?>>
<span>Transcode uploaded voice clips for broader playback compatibility</span></label>
<div class="form-row3">
<div class="field"><label>FFmpeg Binary</label><input name="voice_ffmpeg_path"
value="<?= esc(getConfigVal('voice.transcoding.ffmpeg_path', 'ffmpeg')) ?>" placeholder="/usr/bin/ffmpeg"></div>
<div class="field"><label>Output Profile</label><select name="voice_transcoding_profile">
<?php $voiceProfile = getConfigVal('voice.transcoding.profile', 'm4a_aac'); ?>
<option value="m4a_aac" <?= $voiceProfile === 'm4a_aac' ? 'selected' : '' ?>>M4A / AAC-LC (recommended)</option>
<option value="mp4_h264_aac" <?= $voiceProfile === 'mp4_h264_aac' ? 'selected' : '' ?>>MP4 / H.264 + AAC</option>
</select></div>
<div class="field"><label>Audio Bitrate (kbps)</label><input type="number" min="48" max="320"
name="voice_audio_bitrate" value="<?= (int)getConfigVal('voice.transcoding.audio_bitrate_kbps', 128) ?>"></div>
</div>
<div class="form-row">
<div class="field"><label>Conversion Timeout (seconds)</label><input type="number" min="5" max="300"
name="voice_transcoding_timeout" value="<?= (int)getConfigVal('voice.transcoding.timeout_seconds', 45) ?>"></div>
<label class="cb-row"><input type="checkbox" name="voice_transcoding_fallback"
<?= getConfigVal('voice.transcoding.fallback_to_source', true) ? 'checked' : '' ?>>
<span>Keep the original recording if FFmpeg fails</span></label>
</div>
<p class="dim">AAC-LC in an M4A container is the recommended audio-only profile for iPhone, Safari, Android, and desktop browsers.
The H.264 profile creates a tiny black video track plus AAC audio for systems that require a conventional MP4 file.</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 ($key === 'text_archive_days'): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="30"
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') ?><?= $subscriber['manual_plan_id'] ? ' <span class="badge badge-o">ADMIN</span>' : '' ?></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
}