load(); // Stripe webhook — must capture raw body before session start $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $method = $_SERVER['REQUEST_METHOD']; if ($uri === '/webhook/stripe' && $method === 'POST') { $payload = file_get_contents('php://input'); $sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; $event = StripeClient::constructWebhookEvent($payload, $sigHeader); try { handleStripeWebhook($event); http_response_code(200); echo json_encode(['received' => true]); } catch (\Throwable $e) { error_log('Stripe webhook error: ' . $e->getMessage()); http_response_code(500); } exit; } // Start session for all other requests ini_set('session.cookie_httponly', '1'); ini_set('session.cookie_samesite', 'Lax'); if ($_ENV['APP_ENV'] === 'production') { ini_set('session.cookie_secure', '1'); } session_start(); // ── Router ──────────────────────────────────────────────────────────────────── match (true) { // ── Public GET pages ────────────────────────────────────────────────────── $method === 'GET' && $uri === '/' => view('home'), $method === 'GET' && $uri === '/pricing' => view('pricing'), $method === 'GET' && $uri === '/install' => view('install'), // ── Auth GET pages ──────────────────────────────────────────────────────── $method === 'GET' && $uri === '/login' => (function () { Auth::requireGuest(); view('login'); })(), $method === 'GET' && $uri === '/register' => (function () { Auth::requireGuest(); view('register'); })(), $method === 'GET' && $uri === '/verify-email' => (function () { $token = $_GET['token'] ?? ''; $ok = \NixPanel\Auth::verifyEmail($token); view('verify-email', ['verified' => $ok]); })(), $method === 'GET' && $uri === '/forgot-password' => (function () { Auth::requireGuest(); view('forgot-password'); })(), $method === 'GET' && $uri === '/reset-password' => (function () { Auth::requireGuest(); $token = $_GET['token'] ?? ''; view('reset-password', ['token' => $token]); })(), $method === 'GET' && $uri === '/dashboard' => (function () { Auth::requireLogin(); $customer = Auth::customer(); $subscription = DB::fetch( 'SELECT * FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1', [$customer['id']] ); $license = null; if ($subscription) { $license = fetchLicense($subscription['license_id']); } view('dashboard', compact('customer', 'subscription', 'license')); })(), $method === 'GET' && $uri === '/logout' => (function () { Auth::logout(); redirect('/'); })(), // ── Auth POST handlers ──────────────────────────────────────────────────── $method === 'POST' && $uri === '/login' => (function () { csrf_verify(); Auth::requireGuest(); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; try { Auth::login($email, $password); redirect('/dashboard'); } catch (\RuntimeException $e) { flash('error', $e->getMessage()); save_old(['email']); redirect('/login'); } })(), $method === 'POST' && $uri === '/register' => (function () { csrf_verify(); Auth::requireGuest(); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; $fullName = trim($_POST['full_name'] ?? ''); if (strlen($password) < 8) { flash('error', 'Password must be at least 8 characters.'); save_old(['email', 'full_name']); redirect('/register'); } try { $result = \NixPanel\Auth::register($email, $password, $fullName); Mailer::sendVerificationEmail($email, $result['verify_token']); flash('success', 'Account created! Check your email to verify your account.'); redirect('/login'); } catch (\RuntimeException $e) { flash('error', $e->getMessage()); save_old(['email', 'full_name']); redirect('/register'); } })(), $method === 'POST' && $uri === '/forgot-password' => (function () { csrf_verify(); $email = trim($_POST['email'] ?? ''); $token = \NixPanel\Auth::initiatePasswordReset($email); if ($token) { try { Mailer::sendPasswordResetEmail($email, $token); } catch (\Throwable $e) { error_log('Failed to send reset email: ' . $e->getMessage()); } } flash('success', 'If that email exists, a reset link has been sent.'); redirect('/forgot-password'); })(), $method === 'POST' && $uri === '/reset-password' => (function () { csrf_verify(); $token = $_POST['token'] ?? ''; $password = $_POST['password'] ?? ''; $confirm = $_POST['password_confirm'] ?? ''; if (strlen($password) < 8) { flash('error', 'Password must be at least 8 characters.'); redirect('/reset-password?token=' . urlencode($token)); } if ($password !== $confirm) { flash('error', 'Passwords do not match.'); redirect('/reset-password?token=' . urlencode($token)); } $ok = \NixPanel\Auth::resetPassword($token, $password); if (!$ok) { flash('error', 'Invalid or expired reset link. Please request a new one.'); redirect('/forgot-password'); } flash('success', 'Password updated. You can now log in.'); redirect('/login'); })(), // ── Billing ─────────────────────────────────────────────────────────────── $method === 'POST' && $uri === '/checkout' => (function () { csrf_verify(); Auth::requireLogin(); $customer = Auth::customer(); $planKey = $_POST['plan_key'] ?? ''; $interval = $_POST['interval'] === 'annual' ? 'annual' : 'monthly'; $serverIp = trim($_POST['server_ip'] ?? ''); if (!in_array($planKey, ['solo', 'host', 'agency'], true)) { flash('error', 'Invalid plan selected.'); redirect('/pricing'); } if (!is_valid_ip($serverIp)) { flash('error', 'Please enter a valid server IPv4 address.'); redirect('/pricing'); } try { $url = StripeClient::createCheckoutSession( $customer['id'], $customer['email'], $planKey, $interval, $serverIp ); redirect($url); } catch (\Throwable $e) { error_log('Checkout error: ' . $e->getMessage()); flash('error', 'Could not start checkout. Please try again.'); redirect('/pricing'); } })(), $method === 'POST' && $uri === '/billing/portal' => (function () { csrf_verify(); Auth::requireLogin(); $customer = Auth::customer(); $row = DB::fetch('SELECT stripe_customer_id FROM customers WHERE id = ?', [$customer['id']]); if (!$row || !$row['stripe_customer_id']) { flash('error', 'No billing account found.'); redirect('/dashboard'); } try { $url = StripeClient::createPortalSession($row['stripe_customer_id']); redirect($url); } catch (\Throwable $e) { flash('error', 'Could not open billing portal.'); redirect('/dashboard'); } })(), $method === 'POST' && $uri === '/billing/cancel' => (function () { csrf_verify(); Auth::requireLogin(); $customer = Auth::customer(); $subscription = DB::fetch( 'SELECT * FROM subscriptions WHERE customer_id = ? AND status = "active" LIMIT 1', [$customer['id']] ); if (!$subscription) { flash('error', 'No active subscription found.'); redirect('/dashboard'); } try { StripeClient::cancelSubscription($subscription['stripe_subscription_id']); DB::execute( 'UPDATE subscriptions SET cancel_at_period_end = 1 WHERE id = ?', [$subscription['id']] ); flash('success', 'Your subscription will cancel at the end of the billing period.'); } catch (\Throwable $e) { flash('error', 'Could not cancel subscription. Please try again.'); } redirect('/dashboard'); })(), $method === 'POST' && $uri === '/license/change-ip' => (function () { csrf_verify(); Auth::requireLogin(); $customer = Auth::customer(); $newIp = trim($_POST['server_ip'] ?? ''); if (!is_valid_ip($newIp)) { flash('error', 'Invalid IP address.'); redirect('/dashboard'); } $subscription = DB::fetch( 'SELECT * FROM subscriptions WHERE customer_id = ? AND status = "active" LIMIT 1', [$customer['id']] ); if (!$subscription) { flash('error', 'No active subscription found.'); redirect('/dashboard'); } // Call internal licensing server $result = callLicensingServer('PUT', '/licenses/' . $subscription['license_id'] . '/ip', ['server_ip' => $newIp]); if ($result['ok']) { DB::execute( 'UPDATE subscriptions SET server_ip = ? WHERE id = ?', [$newIp, $subscription['id']] ); flash('success', 'Server IP updated to ' . $newIp . '.'); } else { flash('error', $result['error'] ?? 'Failed to update IP. Please try again.'); } redirect('/dashboard'); })(), // ── 404 ─────────────────────────────────────────────────────────────────── default => (function () { http_response_code(404); view('404'); })(), }; // ── Internal helpers ────────────────────────────────────────────────────────── function fetchLicense(string $licenseId): ?array { $result = callLicensingServer('GET', '/licenses/' . $licenseId); return $result['ok'] ? $result['data'] : null; } function callLicensingServer(string $method, string $path, array $body = []): array { $url = rtrim($_ENV['LICENSING_SERVER_URL'], '/') . $path; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Api-Key: ' . $_ENV['LICENSING_SERVER_API_KEY'], ], CURLOPT_CUSTOMREQUEST => $method, ]); if ($body) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($response === false || $httpCode >= 500) { return ['ok' => false, 'error' => 'Licensing server unavailable']; } $data = json_decode($response, true); return ['ok' => $httpCode < 400, 'data' => $data, 'error' => $data['error'] ?? null]; } function handleStripeWebhook(\Stripe\Event $event): void { $data = $event->data->object; switch ($event->type) { case 'checkout.session.completed': if ($data->mode !== 'subscription') break; $meta = $data->metadata; $customerId = $meta->customer_id; $planKey = $meta->plan_key; $interval = $meta->interval; $serverIp = $meta->server_ip; $stripeSubId = $data->subscription; $stripeCustId = $data->customer; // Fetch subscription details from Stripe \Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']); $sub = \Stripe\Subscription::retrieve($stripeSubId); // Create license via licensing server $licenseResult = callLicensingServer('POST', '/licenses', [ 'customer_id' => $customerId, 'plan_key' => $planKey, 'server_ip' => $serverIp, ]); $licenseId = $licenseResult['data']['id'] ?? \Ramsey\Uuid\Uuid::uuid4()->toString(); DB::execute( 'UPDATE customers SET stripe_customer_id = ? WHERE id = ?', [$stripeCustId, $customerId] ); DB::execute( 'INSERT INTO subscriptions (id, customer_id, license_id, stripe_subscription_id, stripe_price_id, plan_key, billing_interval, status, server_ip, current_period_start, current_period_end) VALUES (?, ?, ?, ?, ?, ?, ?, "active", ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?))', [ \Ramsey\Uuid\Uuid::uuid4()->toString(), $customerId, $licenseId, $stripeSubId, $sub->items->data[0]->price->id, $planKey, $interval, $serverIp, $sub->current_period_start, $sub->current_period_end, ] ); break; case 'customer.subscription.updated': $status = $data->status; $cancelAtPeriodEnd = $data->cancel_at_period_end ? 1 : 0; DB::execute( 'UPDATE subscriptions SET status = ?, cancel_at_period_end = ?, current_period_start = FROM_UNIXTIME(?), current_period_end = FROM_UNIXTIME(?) WHERE stripe_subscription_id = ?', [ $status, $cancelAtPeriodEnd, $data->current_period_start, $data->current_period_end, $data->id, ] ); break; case 'customer.subscription.deleted': DB::execute( 'UPDATE subscriptions SET status = "canceled", canceled_at = NOW() WHERE stripe_subscription_id = ?', [$data->id] ); break; } }