feat: rewrite website in PHP, drop Node.js stack

Replace React/Vite frontend + Fastify API with a single PHP application.

- Front controller router in public/index.php (clean URLs via .htaccess)
- Session-based auth (no JWT) — register, login, verify email,
  forgot/reset password, logout
- Stripe checkout sessions, billing portal, webhook handler
  (checkout.session.completed, subscription.updated/deleted)
- License IP change via internal licensing server API
- PHP classes: DB (PDO), Auth, Mailer (PHPMailer), StripeClient
- Views: home, pricing, install, login, register, dashboard,
  verify-email, forgot-password, reset-password, 404
- Tailwind CSS via CDN — no build step required
- Composer dependencies: stripe/stripe-php, phpmailer/phpmailer,
  vlucas/phpdotenv, ramsey/uuid
- CSRF protection on all POST forms
- PRG pattern with session flash messages

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
NixReaper
2026-04-05 11:29:58 -05:00
co-authored by Claude Sonnet 4.6
parent 72f5cc1060
commit faf0818dde
69 changed files with 1733 additions and 2616 deletions
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace NixPanel;
use Ramsey\Uuid\Uuid;
class Auth
{
public static function isLoggedIn(): bool
{
return isset($_SESSION['customer_id']);
}
public static function customer(): ?array
{
return $_SESSION['customer'] ?? null;
}
public static function requireLogin(): void
{
if (!self::isLoggedIn()) {
redirect('/login');
}
}
public static function requireGuest(): void
{
if (self::isLoggedIn()) {
redirect('/dashboard');
}
}
public static function register(string $email, string $password, string $fullName): array
{
$email = strtolower(trim($email));
if (DB::fetch('SELECT id FROM customers WHERE email = ?', [$email])) {
throw new \RuntimeException('An account with that email already exists.');
}
$id = Uuid::uuid4()->toString();
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$verifyToken = bin2hex(random_bytes(32));
DB::execute(
'INSERT INTO customers (id, email, password_hash, full_name, verify_token) VALUES (?, ?, ?, ?, ?)',
[$id, $email, $hash, $fullName, $verifyToken]
);
return ['id' => $id, 'email' => $email, 'verify_token' => $verifyToken];
}
public static function login(string $email, string $password): array
{
$email = strtolower(trim($email));
$customer = DB::fetch('SELECT * FROM customers WHERE email = ?', [$email]);
if (!$customer || !password_verify($password, $customer['password_hash'])) {
throw new \RuntimeException('Invalid email or password.');
}
if (!$customer['is_verified']) {
throw new \RuntimeException('Please verify your email before logging in.');
}
unset($customer['password_hash'], $customer['verify_token'], $customer['reset_token'], $customer['reset_token_expires']);
$_SESSION['customer_id'] = $customer['id'];
$_SESSION['customer'] = $customer;
return $customer;
}
public static function logout(): void
{
$_SESSION = [];
session_destroy();
}
public static function verifyEmail(string $token): bool
{
$customer = DB::fetch('SELECT id FROM customers WHERE verify_token = ? AND is_verified = 0', [$token]);
if (!$customer) {
return false;
}
DB::execute(
'UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?',
[$customer['id']]
);
return true;
}
public static function initiatePasswordReset(string $email): ?string
{
$customer = DB::fetch('SELECT id FROM customers WHERE email = ?', [strtolower($email)]);
if (!$customer) {
return null;
}
$token = bin2hex(random_bytes(32));
$expires = date('Y-m-d H:i:s', strtotime('+1 hour'));
DB::execute(
'UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
[$token, $expires, $customer['id']]
);
return $token;
}
public static function resetPassword(string $token, string $newPassword): bool
{
$customer = DB::fetch(
'SELECT id FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()',
[$token]
);
if (!$customer) {
return false;
}
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
DB::execute(
'UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
[$hash, $customer['id']]
);
return true;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace NixPanel;
use PDO;
use PDOException;
class DB
{
private static ?PDO $pdo = null;
public static function connect(): PDO
{
if (self::$pdo !== null) {
return self::$pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$_ENV['DB_HOST'],
$_ENV['DB_PORT'] ?? 3306,
$_ENV['DB_NAME']
);
try {
self::$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
error_log('DB connection failed: ' . $e->getMessage());
http_response_code(500);
die('Database connection error.');
}
return self::$pdo;
}
public static function query(string $sql, array $params = []): \PDOStatement
{
$stmt = self::connect()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public static function fetch(string $sql, array $params = []): ?array
{
$row = self::query($sql, $params)->fetch();
return $row ?: null;
}
public static function fetchAll(string $sql, array $params = []): array
{
return self::query($sql, $params)->fetchAll();
}
public static function execute(string $sql, array $params = []): int
{
return self::query($sql, $params)->rowCount();
}
public static function lastInsertId(): string
{
return self::connect()->lastInsertId();
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace NixPanel;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class Mailer
{
private static function make(): PHPMailer
{
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $_ENV['MAIL_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['MAIL_USER'];
$mail->Password = $_ENV['MAIL_PASSWORD'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 587);
$mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME'] ?? 'NixPanel');
$mail->isHTML(true);
return $mail;
}
public static function sendVerificationEmail(string $email, string $token): void
{
$url = rtrim($_ENV['APP_URL'], '/') . '/verify-email?token=' . urlencode($token);
$mail = self::make();
$mail->addAddress($email);
$mail->Subject = 'Verify your NixPanel account';
$mail->Body = self::template('Verify your email', "
<p style='color:#9ca3af;font-size:15px;'>Thanks for signing up! Click the button below to verify your email address.</p>
<p style='margin-top:28px;'>
<a href='{$url}' style='background:#2563eb;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;'>
Verify Email
</a>
</p>
<p style='color:#6b7280;font-size:13px;margin-top:24px;'>Or copy this link:<br><a href='{$url}' style='color:#3b82f6;'>{$url}</a></p>
<p style='color:#6b7280;font-size:13px;margin-top:16px;'>This link expires in 24 hours.</p>
");
$mail->send();
}
public static function sendPasswordResetEmail(string $email, string $token): void
{
$url = rtrim($_ENV['APP_URL'], '/') . '/reset-password?token=' . urlencode($token);
$mail = self::make();
$mail->addAddress($email);
$mail->Subject = 'Reset your NixPanel password';
$mail->Body = self::template('Reset your password', "
<p style='color:#9ca3af;font-size:15px;'>We received a request to reset your password. Click the button below.</p>
<p style='margin-top:28px;'>
<a href='{$url}' style='background:#2563eb;color:#fff;padding:12px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;'>
Reset Password
</a>
</p>
<p style='color:#6b7280;font-size:13px;margin-top:24px;'>This link expires in 1 hour. If you didn't request a reset, ignore this email.</p>
");
$mail->send();
}
private static function template(string $title, string $body): string
{
return "
<!DOCTYPE html>
<html><body style='background:#030712;font-family:system-ui,sans-serif;padding:40px 20px;margin:0;'>
<div style='max-width:520px;margin:0 auto;background:#111827;border:1px solid #1f2937;border-radius:12px;padding:40px;'>
<p style='font-size:22px;font-weight:700;color:#fff;margin:0 0 24px;'>
Nix<span style='color:#3b82f6;'>Panel</span>
</p>
<h1 style='color:#fff;font-size:20px;margin:0 0 16px;'>{$title}</h1>
{$body}
</div>
<p style='text-align:center;color:#374151;font-size:12px;margin-top:24px;'>&copy; " . date('Y') . " NixPanel. All rights reserved.</p>
</body></html>";
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace NixPanel;
use Stripe\Stripe;
use Stripe\Checkout\Session as CheckoutSession;
use Stripe\BillingPortal\Session as PortalSession;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
class StripeClient
{
private static bool $configured = false;
private static function configure(): void
{
if (!self::$configured) {
Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);
self::$configured = true;
}
}
/** Map plan_key + interval to a Stripe price ID from .env */
public static function priceId(string $planKey, string $interval): string
{
$map = [
'solo' => ['monthly' => 'STRIPE_PRICE_SOLO_MONTHLY', 'annual' => 'STRIPE_PRICE_SOLO_ANNUAL'],
'host' => ['monthly' => 'STRIPE_PRICE_HOST_MONTHLY', 'annual' => 'STRIPE_PRICE_HOST_ANNUAL'],
'agency' => ['monthly' => 'STRIPE_PRICE_AGENCY_MONTHLY', 'annual' => 'STRIPE_PRICE_AGENCY_ANNUAL'],
];
$key = $map[$planKey][$interval] ?? null;
if (!$key || empty($_ENV[$key])) {
throw new \InvalidArgumentException("Unknown plan: {$planKey}/{$interval}");
}
return $_ENV[$key];
}
public static function createCheckoutSession(
string $customerId,
string $email,
string $planKey,
string $interval,
string $serverIp
): string {
self::configure();
$priceId = self::priceId($planKey, $interval);
$appUrl = rtrim($_ENV['APP_URL'], '/');
$params = [
'mode' => 'subscription',
'line_items' => [['price' => $priceId, 'quantity' => 1]],
'customer_email' => $email,
'success_url' => $appUrl . '/dashboard?checkout=success',
'cancel_url' => $appUrl . '/pricing',
'metadata' => [
'customer_id' => $customerId,
'plan_key' => $planKey,
'interval' => $interval,
'server_ip' => $serverIp,
],
'subscription_data' => [
'metadata' => [
'customer_id' => $customerId,
'plan_key' => $planKey,
],
],
];
// Attach existing Stripe customer if we have one
$customer = DB::fetch('SELECT stripe_customer_id FROM customers WHERE id = ?', [$customerId]);
if ($customer && $customer['stripe_customer_id']) {
$params['customer'] = $customer['stripe_customer_id'];
unset($params['customer_email']);
}
$session = CheckoutSession::create($params);
return $session->url;
}
public static function createPortalSession(string $stripeCustomerId): string
{
self::configure();
$session = PortalSession::create([
'customer' => $stripeCustomerId,
'return_url' => rtrim($_ENV['APP_URL'], '/') . '/dashboard',
]);
return $session->url;
}
public static function constructWebhookEvent(string $payload, string $sigHeader): \Stripe\Event
{
self::configure();
try {
return Webhook::constructEvent($payload, $sigHeader, $_ENV['STRIPE_WEBHOOK_SECRET']);
} catch (SignatureVerificationException $e) {
http_response_code(400);
exit('Webhook signature verification failed.');
}
}
public static function cancelSubscription(string $stripeSubscriptionId): void
{
self::configure();
\Stripe\Subscription::update($stripeSubscriptionId, [
'cancel_at_period_end' => true,
]);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
function redirect(string $path, int $code = 302): never
{
header('Location: ' . $path, true, $code);
exit;
}
function flash(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array
{
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $flash;
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars(csrf_token()) . '">';
}
function csrf_verify(): void
{
$token = $_POST['_csrf'] ?? '';
if (!hash_equals(csrf_token(), $token)) {
http_response_code(403);
die('Invalid CSRF token.');
}
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function old(string $key, string $default = ''): string
{
return e($_SESSION['old'][$key] ?? $default);
}
function save_old(array $fields): void
{
$_SESSION['old'] = array_map('strval', array_intersect_key($_POST, array_flip($fields)));
}
function error(string $key): string
{
$errors = $_SESSION['errors'] ?? [];
unset($_SESSION['errors']);
return $errors[$key] ?? '';
}
function has_errors(): bool
{
return !empty($_SESSION['errors']);
}
function validation_error(string $field, string $message): void
{
$_SESSION['errors'][$field] = $message;
}
function is_valid_ip(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
}
function view(string $name, array $data = []): void
{
extract($data);
$flash = get_flash();
require __DIR__ . '/../views/layout/head.php';
require __DIR__ . '/../views/layout/header.php';
require __DIR__ . '/../views/' . $name . '.php';
require __DIR__ . '/../views/layout/footer.php';
}