413 lines
15 KiB
PHP
413 lines
15 KiB
PHP
<?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;
|
||
|
|
}
|
||
|
|
}
|