- Voice enhancements, user management, payment method
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user