- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+242
View File
@@ -0,0 +1,242 @@
<?php
function handleAdminPlatformAction(string $action): ?string {
if ($action === 'save_platform_services') {
setConfigValues([
'subscriptions.enabled' => !empty($_POST['subscriptions_enabled']),
'mailgun.api_key' => trim((string)($_POST['mailgun_api_key'] ?? '')),
'mailgun.domain' => trim((string)($_POST['mailgun_domain'] ?? '')),
'mailgun.from' => trim((string)($_POST['mailgun_from'] ?? '')),
'mailgun.api_base' => trim((string)($_POST['mailgun_api_base'] ?? 'https://api.mailgun.net/v3')),
'stripe.secret_key' => trim((string)($_POST['stripe_secret_key'] ?? '')),
'stripe.webhook_secret' => trim((string)($_POST['stripe_webhook_secret'] ?? '')),
'stripe.success_url' => trim((string)($_POST['stripe_success_url'] ?? '')),
'stripe.cancel_url' => trim((string)($_POST['stripe_cancel_url'] ?? '')),
'stripe.portal_return_url' => trim((string)($_POST['stripe_portal_return_url'] ?? '')),
'database.mysql.enabled' => !empty($_POST['mysql_enabled']),
'database.mysql.auto_import' => !empty($_POST['mysql_auto_import']),
'database.mysql.dsn' => trim((string)($_POST['mysql_dsn'] ?? '')),
'database.mysql.username' => trim((string)($_POST['mysql_username'] ?? '')),
'database.mysql.password' => (string)($_POST['mysql_password'] ?? ''),
'database.mysql.sync_interval_seconds' => max(60, (int)($_POST['mysql_sync_interval'] ?? 900)),
]);
return 'Platform service settings saved.';
}
if ($action === 'save_plans') {
$db = getDB();
$plans = $_POST['plans'] ?? [];
$boolFeatures = [
'voice_messages', 'recording_status', 'auto_voice_send', 'username_color',
'text_export', 'voice_archive', 'voice_export', 'email_2fa',
];
$db->beginTransaction();
try {
$update = $db->prepare("UPDATE plans SET name=?,description=?,price_cents=?,currency=?,
billing_interval=?,stripe_price_id=?,active=?,sort_order=?,updated_at=? WHERE id=?");
$upsertFeature = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)
ON CONFLICT(plan_id,feature_key) DO UPDATE SET value_json=excluded.value_json");
foreach ($plans as $id => $plan) {
$id = (int)$id;
$name = trim((string)($plan['name'] ?? ''));
if ($id < 1 || $name === '') continue;
$update->execute([
$name,
trim((string)($plan['description'] ?? '')),
max(0, (int)($plan['price_cents'] ?? 0)),
strtolower(substr(trim((string)($plan['currency'] ?? 'usd')), 0, 3)),
in_array($plan['billing_interval'] ?? '', ['day', 'week', 'month', 'year'], true)
? $plan['billing_interval'] : 'month',
trim((string)($plan['stripe_price_id'] ?? '')) ?: null,
!empty($plan['active']) ? 1 : 0,
(int)($plan['sort_order'] ?? 0),
time(),
$id,
]);
foreach ($boolFeatures as $key) {
$upsertFeature->execute([$id, $key, json_encode(!empty($plan['features'][$key]))]);
}
$upsertFeature->execute([$id, 'group_member_limit', json_encode(max(2, min(100, (int)($plan['features']['group_member_limit'] ?? 2))))]);
$upsertFeature->execute([$id, 'text_archive_days', json_encode(max(30, min(3650, (int)($plan['features']['text_archive_days'] ?? 30))))]);
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
return 'Plans, prices, limits, and feature assignments saved.';
}
if ($action === 'create_plan') {
$slug = strtolower(trim((string)($_POST['slug'] ?? '')));
$name = trim((string)($_POST['name'] ?? ''));
if (!preg_match('/^[a-z0-9_-]{2,30}$/', $slug) || $name === '') {
throw new RuntimeException('Plan slug or name is invalid.');
}
$db = getDB();
$db->prepare("INSERT INTO plans
(slug,name,description,price_cents,currency,billing_interval,active,sort_order,created_at,updated_at)
VALUES (?,?,?,0,'usd','month',1,50,?,?)")
->execute([$slug, $name, trim((string)($_POST['description'] ?? '')), time(), time()]);
$id = (int)$db->lastInsertId();
$features = [
'voice_messages' => true, 'recording_status' => false, 'auto_voice_send' => false,
'group_member_limit' => 2, 'username_color' => false, 'text_archive_days' => 30,
'text_export' => true, 'voice_archive' => false, 'voice_export' => false, 'email_2fa' => false,
];
$insert = $db->prepare("INSERT INTO plan_features (plan_id,feature_key,value_json) VALUES (?,?,?)");
foreach ($features as $key => $value) $insert->execute([$id, $key, json_encode($value)]);
return "Plan '$name' created.";
}
if ($action === 'run_mysql_mirror') {
require_once ROOT_DIR . '/lib/mysql_mirror.php';
$result = mirrorAllSQLiteDatabases();
return "MySQL mirror completed: {$result['databases']} database(s), {$result['rows']} row(s).";
}
return null;
}
function adminFeatureLabel(string $key): string {
return ucwords(str_replace('_', ' ', $key));
}
function renderAdminPlatformTab(): void {
$plans = plans(false);
$db = getDB();
$subscribers = $db->query("SELECT u.id,u.username,u.email,u.email_verified_at,u.subscription_status,
u.subscription_period_end,u.cancel_at_period_end,p.name AS plan_name,u.stripe_customer_id
FROM users u LEFT JOIN plans p ON p.id=u.plan_id
ORDER BY (u.subscription_status IN ('active','trialing','past_due')) DESC,u.created_at DESC LIMIT 500")->fetchAll();
$stats = [
'visits_24h' => (int)$db->query("SELECT COUNT(*) FROM visitor_events WHERE event_type='visit' AND created_at>" . (time() - 86400))->fetchColumn(),
'unique_24h' => (int)$db->query("SELECT COUNT(DISTINCT ip_hash) FROM visitor_events WHERE created_at>" . (time() - 86400))->fetchColumn(),
'paid' => (int)$db->query("SELECT COUNT(*) FROM users WHERE subscription_status IN ('active','trialing','past_due')")->fetchColumn(),
'groups' => (int)$db->query("SELECT COUNT(*) FROM chat_groups")->fetchColumn(),
];
$events = $db->query("SELECT event_type,COUNT(*) AS total FROM visitor_events
WHERE created_at>" . (time() - 2592000) . " GROUP BY event_type ORDER BY total DESC")->fetchAll();
$featureKeys = [
'voice_messages', 'recording_status', 'auto_voice_send', 'group_member_limit',
'username_color', 'text_archive_days', 'text_export', 'voice_archive',
'voice_export', 'email_2fa',
];
?>
<div class="page-head"><h1>PLANS & PLATFORM</h1>
<p>// pricing, entitlements, subscriptions, email, Stripe, database mirroring, and usage</p></div>
<div class="stat-grid">
<div class="stat-card green"><div class="stat-num"><?= $stats['visits_24h'] ?></div><div class="stat-label">Visits / 24h</div></div>
<div class="stat-card blue"><div class="stat-num"><?= $stats['unique_24h'] ?></div><div class="stat-label">Unique / 24h</div></div>
<div class="stat-card violet"><div class="stat-num"><?= $stats['paid'] ?></div><div class="stat-label">Paid Subscribers</div></div>
<div class="stat-card orange"><div class="stat-num"><?= $stats['groups'] ?></div><div class="stat-label">Private Groups</div></div>
</div>
<form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>">
<input type="hidden" name="action" value="save_platform_services">
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIPTION VISIBILITY</span></div>
<div class="panel-body"><label class="cb-row"><input type="checkbox" name="subscriptions_enabled" <?= getConfigVal('subscriptions.enabled', true) ? 'checked' : '' ?>>
<span>Allow and display new subscriptions. When off, existing subscribers can still manage billing.</span></label></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// MAILGUN</span></div><div class="panel-body">
<div class="form-row3">
<div class="field"><label>API Key</label><input type="password" name="mailgun_api_key" value="<?= esc(getConfigVal('mailgun.api_key', '')) ?>"></div>
<div class="field"><label>Domain</label><input name="mailgun_domain" value="<?= esc(getConfigVal('mailgun.domain', '')) ?>"></div>
<div class="field"><label>From</label><input name="mailgun_from" value="<?= esc(getConfigVal('mailgun.from', '')) ?>"></div>
</div>
<div class="field"><label>API Base</label><input name="mailgun_api_base" value="<?= esc(getConfigVal('mailgun.api_base', 'https://api.mailgun.net/v3')) ?>"></div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// STRIPE</span></div><div class="panel-body">
<div class="form-row">
<div class="field"><label>Secret Key</label><input type="password" name="stripe_secret_key" value="<?= esc(getConfigVal('stripe.secret_key', '')) ?>"></div>
<div class="field"><label>Webhook Secret</label><input type="password" name="stripe_webhook_secret" value="<?= esc(getConfigVal('stripe.webhook_secret', '')) ?>"></div>
</div>
<div class="form-row3">
<div class="field"><label>Success URL</label><input name="stripe_success_url" value="<?= esc(getConfigVal('stripe.success_url', '')) ?>"></div>
<div class="field"><label>Cancel URL</label><input name="stripe_cancel_url" value="<?= esc(getConfigVal('stripe.cancel_url', '')) ?>"></div>
<div class="field"><label>Portal Return URL</label><input name="stripe_portal_return_url" value="<?= esc(getConfigVal('stripe.portal_return_url', '')) ?>"></div>
</div>
<p class="dim">Webhook endpoint: <span class="mono"><?= esc(appBaseUrl() . '/api/stripe-webhook.php') ?></span></p>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// OPTIONAL MYSQL / MARIADB MIRROR</span></div><div class="panel-body">
<label class="cb-row"><input type="checkbox" name="mysql_enabled" <?= getConfigVal('database.mysql.enabled', false) ? 'checked' : '' ?>><span>Enable MySQL mirror</span></label>
<label class="cb-row"><input type="checkbox" name="mysql_auto_import" <?= getConfigVal('database.mysql.auto_import', false) ? 'checked' : '' ?>><span>Automatically import all SQLite databases on the configured interval</span></label>
<div class="form-row3">
<div class="field"><label>PDO DSN</label><input name="mysql_dsn" value="<?= esc(getConfigVal('database.mysql.dsn', '')) ?>" placeholder="mysql:host=localhost;dbname=cyberchat;charset=utf8mb4"></div>
<div class="field"><label>Username</label><input name="mysql_username" value="<?= esc(getConfigVal('database.mysql.username', '')) ?>"></div>
<div class="field"><label>Password</label><input type="password" name="mysql_password" value="<?= esc(getConfigVal('database.mysql.password', '')) ?>"></div>
</div>
<div class="field"><label>Sync Interval Seconds</label><input type="number" min="60" name="mysql_sync_interval" value="<?= (int)getConfigVal('database.mysql.sync_interval_seconds', 900) ?>"></div>
</div></div>
<button class="btn btn-g" type="submit">SAVE PLATFORM SERVICES</button>
</form>
<div class="panel" style="margin-top:20px"><div class="panel-head"><span class="panel-title">// PLANS, PRICES & ENTITLEMENTS</span></div>
<div class="panel-body"><form method="post">
<input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="save_plans">
<?php foreach ($plans as $plan): ?>
<div class="danger-zone" style="border-color:var(--bd2);margin:0 0 14px">
<div class="form-row3">
<div class="field"><label>Name / <?= esc($plan['slug']) ?></label><input name="plans[<?= $plan['id'] ?>][name]" value="<?= esc($plan['name']) ?>"></div>
<div class="field"><label>Price Cents</label><input type="number" min="0" name="plans[<?= $plan['id'] ?>][price_cents]" value="<?= $plan['price_cents'] ?>"></div>
<div class="field"><label>Stripe Price ID</label><input name="plans[<?= $plan['id'] ?>][stripe_price_id]" value="<?= esc($plan['stripe_price_id']) ?>" placeholder="price_..."></div>
</div>
<div class="field"><label>Description</label><input name="plans[<?= $plan['id'] ?>][description]" value="<?= esc($plan['description']) ?>"></div>
<div class="form-row3">
<div class="field"><label>Currency</label><input maxlength="3" name="plans[<?= $plan['id'] ?>][currency]" value="<?= esc($plan['currency']) ?>"></div>
<div class="field"><label>Interval</label><select name="plans[<?= $plan['id'] ?>][billing_interval]">
<?php foreach (['day','week','month','year'] as $interval): ?><option <?= $plan['billing_interval']===$interval?'selected':'' ?>><?= $interval ?></option><?php endforeach; ?>
</select></div>
<div class="field"><label>Sort Order</label><input type="number" name="plans[<?= $plan['id'] ?>][sort_order]" value="<?= (int)$plan['sort_order'] ?>"></div>
</div>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][active]" <?= $plan['active'] ? 'checked' : '' ?>><span>Plan active</span></label>
<div class="form-row3">
<?php foreach ($featureKeys as $key): ?>
<?php if (in_array($key, ['group_member_limit','text_archive_days'], true)): ?>
<div class="field"><label><?= esc(adminFeatureLabel($key)) ?></label><input type="number"
min="<?= $key==='text_archive_days' ? 30 : 2 ?>"
name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]" value="<?= (int)($plan['features'][$key] ?? 0) ?>"></div>
<?php else: ?>
<label class="cb-row"><input type="checkbox" name="plans[<?= $plan['id'] ?>][features][<?= $key ?>]"
<?= !empty($plan['features'][$key]) ? 'checked' : '' ?>><span><?= esc(adminFeatureLabel($key)) ?></span></label>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<button class="btn btn-g" type="submit">SAVE ALL PLANS</button>
</form></div>
</div>
<div class="panel"><div class="panel-head"><span class="panel-title">// CREATE TIER</span></div><div class="panel-body">
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="create_plan">
<div class="form-row3"><div class="field"><label>Slug</label><input name="slug" placeholder="business"></div>
<div class="field"><label>Name</label><input name="name" placeholder="Business"></div>
<div class="field"><label>Description</label><input name="description"></div></div>
<button class="btn btn-b" type="submit">CREATE TIER</button>
</form></div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// SUBSCRIBER TRACKING</span></div><div class="panel-body np">
<div class="tbl-wrap"><table><thead><tr><th>User</th><th>Email</th><th>Plan</th><th>Status</th><th>Period End</th><th>Stripe</th></tr></thead><tbody>
<?php foreach ($subscribers as $subscriber): ?><tr>
<td><?= esc($subscriber['username']) ?></td>
<td class="dim"><?= esc($subscriber['email'] ?: 'not set') ?> <?= $subscriber['email_verified_at'] ? '<span class="badge badge-g">VERIFIED</span>' : '' ?></td>
<td><?= esc($subscriber['plan_name'] ?: 'Free') ?></td>
<td><span class="badge <?= in_array($subscriber['subscription_status'], ['active','trialing'], true) ? 'badge-g' : 'badge-o' ?>"><?= esc(strtoupper($subscriber['subscription_status'])) ?></span></td>
<td class="dim"><?= $subscriber['subscription_period_end'] ? esc(fmtTime((int)$subscriber['subscription_period_end'])) : '-' ?><?= $subscriber['cancel_at_period_end'] ? ' / CANCELING' : '' ?></td>
<td class="dim"><?= esc($subscriber['stripe_customer_id'] ?: '-') ?></td>
</tr><?php endforeach; ?></tbody></table></div>
</div></div>
<div class="panel"><div class="panel-head"><span class="panel-title">// 30-DAY EVENT TOTALS</span></div><div class="panel-body">
<div class="btn-group"><?php foreach ($events as $event): ?><span class="badge badge-b"><?= esc($event['event_type']) ?>: <?= (int)$event['total'] ?></span><?php endforeach; ?></div>
</div></div>
<form method="post"><input type="hidden" name="csrf" value="<?= csrfToken() ?>"><input type="hidden" name="action" value="run_mysql_mirror">
<button class="btn btn-v" type="submit">RUN MYSQL MIRROR NOW</button></form>
<?php
}
+93
View File
@@ -0,0 +1,93 @@
<?php
function sendMailgunMessage(string $to, string $subject, string $text, string $html = ''): array {
$key = trim((string)getConfigVal('mailgun.api_key', ''));
$domain = trim((string)getConfigVal('mailgun.domain', ''));
$from = trim((string)getConfigVal('mailgun.from', ''));
$base = rtrim(trim((string)getConfigVal('mailgun.api_base', '')) ?: 'https://api.mailgun.net/v3', '/');
if ($key === '' || $domain === '' || $from === '') {
throw new RuntimeException('Mailgun is not configured');
}
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) throw new InvalidArgumentException('Invalid recipient email');
if (!function_exists('curl_init')) throw new RuntimeException('PHP cURL extension is required');
$fields = ['from' => $from, 'to' => $to, 'subject' => $subject, 'text' => $text];
if ($html !== '') $fields['html'] = $html;
$curl = curl_init($base . '/' . rawurlencode($domain) . '/messages');
curl_setopt_array($curl, [
CURLOPT_USERPWD => 'api:' . $key,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($body === false || $status < 200 || $status >= 300) {
throw new RuntimeException('Mailgun request failed: ' . ($error ?: 'HTTP ' . $status));
}
return json_decode((string)$body, true) ?: ['success' => true];
}
function issueEmailToken(array $user, string $email, string $purpose, int $ttl = 900): array {
$token = bin2hex(random_bytes(24));
$code = (string)random_int(100000, 999999);
$db = getDB();
$db->prepare("UPDATE email_tokens SET used_at=? WHERE user_id=? AND purpose=? AND used_at IS NULL")
->execute([time(), $user['id'], $purpose]);
$db->prepare("INSERT INTO email_tokens
(user_id,email,purpose,token_hash,code_hash,expires_at,created_at) VALUES (?,?,?,?,?,?,?)")
->execute([
$user['id'], strtolower($email), $purpose, hash('sha256', $token),
password_hash($code, PASSWORD_DEFAULT), time() + $ttl, time(),
]);
return ['token' => $token, 'code' => $code, 'expires_at' => time() + $ttl];
}
function sendVerificationEmail(array $user, string $email): void {
$issued = issueEmailToken($user, $email, 'verify_email', 1800);
$url = appBaseUrl() . '/index.html?verify=' . rawurlencode($issued['token']);
$title = (string)getConfigVal('ui.app_title', 'CyberChat');
$text = "Verify your $title email.\n\nCode: {$issued['code']}\nVerification link: $url\n\nThis expires in 30 minutes.";
$html = '<p>Verify your ' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . ' email.</p>'
. '<p>Your code is <strong style="font-size:20px">' . $issued['code'] . '</strong></p>'
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Open verification screen</a></p>'
. '<p>This expires in 30 minutes.</p>';
sendMailgunMessage($email, 'Verify your ' . $title . ' email', $text, $html);
}
function consumeEmailVerification(array $user, string $token = '', string $code = ''): bool {
$stmt = getDB()->prepare("SELECT * FROM email_tokens
WHERE user_id=? AND purpose='verify_email' AND used_at IS NULL AND expires_at>? ORDER BY id DESC LIMIT 1");
$stmt->execute([$user['id'], time()]);
$row = $stmt->fetch();
if (!$row || (int)($row['attempts'] ?? 0) >= 5) return false;
$valid = ($token !== '' && hash_equals($row['token_hash'], hash('sha256', $token)))
|| ($code !== '' && password_verify($code, $row['code_hash']));
if (!$valid) {
getDB()->prepare("UPDATE email_tokens SET attempts=attempts+1 WHERE id=?")->execute([$row['id']]);
return false;
}
$db = getDB();
$db->beginTransaction();
$db->prepare("UPDATE email_tokens SET used_at=? WHERE id=?")->execute([time(), $row['id']]);
$db->prepare("UPDATE users SET email=?,email_verified_at=? WHERE id=?")
->execute([$row['email'], time(), $user['id']]);
$db->commit();
return true;
}
function sendTwoFactorCode(array $user, string $challengeId, string $code): void {
$title = (string)getConfigVal('ui.app_title', 'CyberChat');
$url = appBaseUrl() . '/index.html?login_challenge=' . rawurlencode($challengeId);
sendMailgunMessage(
(string)$user['email'],
$title . ' login code',
"Your login code is $code.\n\nOpen the authentication screen: $url\n\nIt expires in 10 minutes.",
'<p>Your login code is <strong style="font-size:22px">' . $code . '</strong>.</p>'
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">Open authentication screen</a></p>'
. '<p>It expires in 10 minutes.</p>'
);
}
+55
View File
@@ -0,0 +1,55 @@
<?php
function mysqlMirrorConnection(): PDO {
$dsn = trim((string)getConfigVal('database.mysql.dsn', ''));
if ($dsn === '') throw new RuntimeException('MySQL DSN is missing');
$pdo = new PDO(
$dsn,
(string)getConfigVal('database.mysql.username', ''),
(string)getConfigVal('database.mysql.password', ''),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->exec("CREATE TABLE IF NOT EXISTS sqlite_mirror_rows (
source_db VARCHAR(255) NOT NULL,
table_name VARCHAR(128) NOT NULL,
row_key VARCHAR(255) NOT NULL,
row_json LONGTEXT NOT NULL,
synced_at BIGINT NOT NULL,
PRIMARY KEY(source_db,table_name,row_key)
)");
return $pdo;
}
function mirrorAllSQLiteDatabases(): array {
$main = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite'));
$files = is_file($main) ? [$main] : [];
$archiveRoot = storagePath((string)getConfigVal('archive.archive_dir', 'archive'));
foreach (glob($archiveRoot . '/*/*/*.sqlite') ?: [] as $file) $files[] = $file;
$mysql = mysqlMirrorConnection();
$upsert = $mysql->prepare("INSERT INTO sqlite_mirror_rows
(source_db,table_name,row_key,row_json,synced_at) VALUES (?,?,?,?,?)
ON DUPLICATE KEY UPDATE row_json=VALUES(row_json),synced_at=VALUES(synced_at)");
$count = 0;
foreach ($files as $file) {
$sqlite = new PDO('sqlite:' . $file);
$sqlite->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$tables = $sqlite->query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$quoted = '"' . str_replace('"', '""', $table) . '"';
foreach ($sqlite->query("SELECT rowid AS _mirror_rowid,* FROM $quoted") as $row) {
$key = (string)($row['id'] ?? $row['_mirror_rowid']);
unset($row['_mirror_rowid']);
$source = str_starts_with($file, ROOT_DIR . '/')
? substr($file, strlen(ROOT_DIR) + 1)
: $file;
$upsert->execute([
$source, $table, $key,
json_encode($row, JSON_UNESCAPED_SLASHES), time(),
]);
$count++;
}
}
}
return ['databases' => count($files), 'rows' => $count];
}
+140
View File
@@ -0,0 +1,140 @@
<?php
function stripeRequest(string $method, string $path, array $fields = []): array {
$secret = trim((string)getConfigVal('stripe.secret_key', ''));
if ($secret === '') throw new RuntimeException('Stripe is not configured');
if (!function_exists('curl_init')) throw new RuntimeException('PHP cURL extension is required');
$url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
if ($method === 'GET' && $fields) $url .= '?' . http_build_query($fields);
$curl = curl_init($url);
$options = [
CURLOPT_USERPWD => $secret . ':',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
];
if ($method !== 'GET' && $fields) $options[CURLOPT_POSTFIELDS] = http_build_query($fields);
curl_setopt_array($curl, $options);
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
$decoded = json_decode((string)$body, true);
if ($body === false || $status < 200 || $status >= 300) {
$message = $decoded['error']['message'] ?? $error ?: ('Stripe HTTP ' . $status);
throw new RuntimeException($message);
}
return is_array($decoded) ? $decoded : [];
}
function stripeReturnUrl(string $configKey, string $fallback): string {
$configured = trim((string)getConfigVal($configKey, ''));
return $configured !== '' ? $configured : $fallback;
}
function createStripeCheckout(array $user, array $plan): string {
if (empty($user['email_verified_at']) || empty($user['email'])) {
throw new RuntimeException('A verified email is required before checkout');
}
if (empty($plan['stripe_price_id'])) throw new RuntimeException('This plan does not have a Stripe price ID');
$base = appBaseUrl();
$fields = [
'mode' => 'subscription',
'line_items[0][price]' => $plan['stripe_price_id'],
'line_items[0][quantity]' => 1,
'success_url' => stripeReturnUrl('stripe.success_url', $base . '/index.html?billing=success'),
'cancel_url' => stripeReturnUrl('stripe.cancel_url', $base . '/index.html?billing=cancelled'),
'client_reference_id' => (string)$user['id'],
'metadata[user_id]' => (string)$user['id'],
'metadata[plan_id]' => (string)$plan['id'],
'subscription_data[metadata][user_id]' => (string)$user['id'],
'subscription_data[metadata][plan_id]' => (string)$plan['id'],
'allow_promotion_codes' => 'true',
];
if (!empty($user['stripe_customer_id'])) $fields['customer'] = $user['stripe_customer_id'];
else $fields['customer_email'] = $user['email'];
$session = stripeRequest('POST', 'checkout/sessions', $fields);
if (empty($session['url'])) throw new RuntimeException('Stripe did not return a checkout URL');
return $session['url'];
}
function createStripePortal(array $user): string {
if (empty($user['stripe_customer_id'])) throw new RuntimeException('No Stripe customer is connected');
$session = stripeRequest('POST', 'billing_portal/sessions', [
'customer' => $user['stripe_customer_id'],
'return_url' => stripeReturnUrl('stripe.portal_return_url', appBaseUrl() . '/index.html?view=account'),
]);
if (empty($session['url'])) throw new RuntimeException('Stripe did not return a portal URL');
return $session['url'];
}
function verifyStripeSignature(string $payload, string $header): bool {
$secret = trim((string)getConfigVal('stripe.webhook_secret', ''));
if ($secret === '' || $header === '') return false;
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $timestamp = $value;
if ($key === 'v1') $signatures[] = $value;
}
if (!$timestamp || abs(time() - (int)$timestamp) > 300) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) if (hash_equals($expected, $signature)) return true;
return false;
}
function planIdFromStripeObject(array $object): ?int {
$metadataPlan = (int)($object['metadata']['plan_id'] ?? 0);
if ($metadataPlan > 0 && planById($metadataPlan)) return $metadataPlan;
$priceId = $object['items']['data'][0]['price']['id'] ?? '';
if ($priceId !== '') {
$stmt = getDB()->prepare("SELECT id FROM plans WHERE stripe_price_id=?");
$stmt->execute([$priceId]);
$id = (int)$stmt->fetchColumn();
if ($id > 0) return $id;
}
return null;
}
function syncSubscriptionFromStripe(array $object): void {
$db = getDB();
$subscriptionId = (string)($object['id'] ?? '');
$customerId = (string)($object['customer'] ?? '');
$userId = (int)($object['metadata']['user_id'] ?? 0);
if ($userId < 1 && $subscriptionId !== '') {
$stmt = $db->prepare("SELECT user_id FROM subscriptions WHERE stripe_subscription_id=?");
$stmt->execute([$subscriptionId]);
$userId = (int)$stmt->fetchColumn();
}
if ($userId < 1 && $customerId !== '') {
$stmt = $db->prepare("SELECT id FROM users WHERE stripe_customer_id=?");
$stmt->execute([$customerId]);
$userId = (int)$stmt->fetchColumn();
}
if ($userId < 1) return;
$status = (string)($object['status'] ?? 'active');
$paidStatuses = ['active', 'trialing', 'past_due'];
$planId = planIdFromStripeObject($object);
if (!in_array($status, $paidStatuses, true)) {
$planId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
}
if (!$planId) return;
$periodEnd = (int)($object['current_period_end'] ?? 0) ?: null;
$cancel = !empty($object['cancel_at_period_end']) ? 1 : 0;
$db->beginTransaction();
$db->prepare("INSERT INTO subscriptions
(user_id,plan_id,stripe_customer_id,stripe_subscription_id,status,current_period_end,cancel_at_period_end,updated_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id,stripe_customer_id=excluded.stripe_customer_id,
stripe_subscription_id=excluded.stripe_subscription_id,status=excluded.status,
current_period_end=excluded.current_period_end,cancel_at_period_end=excluded.cancel_at_period_end,updated_at=excluded.updated_at")
->execute([$userId, $planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, time()]);
$db->prepare("UPDATE users SET plan_id=?,stripe_customer_id=?,stripe_subscription_id=?,
subscription_status=?,subscription_period_end=?,cancel_at_period_end=? WHERE id=?")
->execute([$planId, $customerId, $subscriptionId, $status, $periodEnd, $cancel, $userId]);
$db->commit();
}