Files
nixpanel-website/public/index.php
T

413 lines
15 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
// ── Bootstrap ─────────────────────────────────────────────────────────────────
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/helpers.php';
use NixPanel\Auth;
use NixPanel\DB;
use NixPanel\Mailer;
use NixPanel\StripeClient;
// Load .env
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->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;
}
}