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:
co-authored by
Claude Sonnet 4.6
parent
72f5cc1060
commit
faf0818dde
+134
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;'>© " . date('Y') . " NixPanel. All rights reserved.</p>
|
||||
</body></html>";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user