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
-112
View File
@@ -1,112 +0,0 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { query } from '../db';
import { config } from '../config';
export interface Customer {
id: string;
email: string;
full_name: string;
is_verified: boolean;
stripe_customer_id: string | null;
created_at: Date;
}
export async function getCustomerByEmail(email: string): Promise<any | null> {
const rows = await query<any[]>('SELECT * FROM customers WHERE email = ?', [email]);
return rows[0] || null;
}
export async function getCustomerById(id: string): Promise<Customer | null> {
const rows = await query<any[]>(
'SELECT id, email, full_name, is_verified, stripe_customer_id, created_at FROM customers WHERE id = ?',
[id]
);
return rows[0] || null;
}
export async function registerCustomer(data: {
email: string;
password: string;
fullName: string;
}): Promise<{ customer: Customer; verifyToken: string }> {
const existing = await getCustomerByEmail(data.email);
if (existing) throw new Error('Email already registered');
const id = crypto.randomUUID();
const hash = await bcrypt.hash(data.password, 12);
const verifyToken = crypto.randomBytes(32).toString('hex');
await query(
`INSERT INTO customers (id, email, password_hash, full_name, verify_token)
VALUES (?, ?, ?, ?, ?)`,
[id, data.email.toLowerCase().trim(), hash, data.fullName.trim(), verifyToken]
);
const customer = await getCustomerById(id) as Customer;
return { customer, verifyToken };
}
export async function verifyEmail(token: string): Promise<boolean> {
const rows = await query<any[]>(
'SELECT id FROM customers WHERE verify_token = ?',
[token]
);
if (!rows.length) return false;
await query(
'UPDATE customers SET is_verified = 1, verify_token = NULL WHERE id = ?',
[rows[0].id]
);
return true;
}
export async function loginCustomer(email: string, password: string): Promise<string> {
const customer = await getCustomerByEmail(email.toLowerCase().trim());
if (!customer) throw new Error('Invalid credentials');
if (!customer.is_verified) throw new Error('Please verify your email before logging in');
const valid = await bcrypt.compare(password, customer.password_hash);
if (!valid) throw new Error('Invalid credentials');
return issueJwt(customer.id, customer.email);
}
export function issueJwt(id: string, email: string): string {
return jwt.sign({ sub: id, email }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
} as jwt.SignOptions);
}
export function verifyJwt(token: string): { sub: string; email: string } {
return jwt.verify(token, config.jwt.secret) as any;
}
export async function initiatePasswordReset(email: string): Promise<string | null> {
const customer = await getCustomerByEmail(email.toLowerCase().trim());
if (!customer) return null; // don't reveal whether email exists
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 1000 * 60 * 60); // 1 hour
await query(
'UPDATE customers SET reset_token = ?, reset_token_expires = ? WHERE id = ?',
[token, expires, customer.id]
);
return token;
}
export async function resetPassword(token: string, newPassword: string): Promise<boolean> {
const rows = await query<any[]>(
'SELECT * FROM customers WHERE reset_token = ? AND reset_token_expires > NOW()',
[token]
);
if (!rows.length) return false;
const hash = await bcrypt.hash(newPassword, 12);
await query(
'UPDATE customers SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?',
[hash, rows[0].id]
);
return true;
}
-52
View File
@@ -1,52 +0,0 @@
/**
* HTTP bridge to the nixpanel-licensing server.
* The website API calls this internally — never exposes licensing server directly.
*/
import { config } from '../config';
async function licensingRequest<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${config.licensingServer.url}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.licensingServer.apiKey}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error((err as any).error || `Licensing server error: ${res.status}`);
}
return res.json() as T;
}
export async function createLicense(data: {
planId: string;
ownerEmail: string;
ownerName: string;
serverIp: string;
}): Promise<{ id: string; status: string }> {
return licensingRequest('POST', '/api/v1/licenses', data);
}
export async function getLicense(licenseId: string): Promise<any> {
return licensingRequest('GET', `/api/v1/licenses/${licenseId}`);
}
export async function revokeLicense(licenseId: string): Promise<void> {
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/revoke`);
}
export async function updateServerIp(licenseId: string, serverIp: string): Promise<void> {
await licensingRequest('POST', `/api/v1/licenses/${licenseId}/rebind`, { serverIp });
}
export async function getPlans(): Promise<any[]> {
return licensingRequest('GET', '/api/v1/admin/plans');
}
-62
View File
@@ -1,62 +0,0 @@
import nodemailer from 'nodemailer';
import { config } from '../config';
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
auth: { user: config.mail.user, pass: config.mail.password },
});
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
const url = `${config.appUrl}/verify-email?token=${token}`;
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: 'Verify your NixPanel account',
html: `
<h2>Welcome to NixPanel</h2>
<p>Click the link below to verify your email address:</p>
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Verify Email
</a>
<p>This link expires in 24 hours.</p>
`,
});
}
export async function sendPasswordResetEmail(email: string, token: string): Promise<void> {
const url = `${config.appUrl}/reset-password?token=${token}`;
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: 'Reset your NixPanel password',
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${url}" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
});
}
export async function sendLicenseConfirmationEmail(email: string, data: {
plan: string;
serverIp: string;
interval: string;
}): Promise<void> {
await transporter.sendMail({
from: config.mail.from,
to: email,
subject: `Your NixPanel ${data.plan} license is active`,
html: `
<h2>License Activated</h2>
<p>Your <strong>${data.plan}</strong> plan (${data.interval}) is now active for server IP <strong>${data.serverIp}</strong>.</p>
<p>Log in to your dashboard to manage your license:</p>
<a href="${config.appUrl}/dashboard" style="background:#2563eb;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Go to Dashboard
</a>
`,
});
}
-110
View File
@@ -1,110 +0,0 @@
import Stripe from 'stripe';
import { config } from '../config';
import { query } from '../db';
const stripe = new Stripe(config.stripe.secretKey, { apiVersion: '2024-06-20' });
// Annual plans are 20% off (pay 10 months, get 12)
export const PLAN_PRICES: Record<string, { monthly: number; annual: number; label: string }> = {
free: { monthly: 0, annual: 0, label: 'Free' },
solo: { monthly: 9.99, annual: 95.90, label: 'Solo' },
host: { monthly: 29.99, annual: 287.90, label: 'Host' },
agency: { monthly: 79.99, annual: 767.90, label: 'Agency' },
};
export async function getOrCreateStripeCustomer(data: {
customerId: string;
email: string;
name: string;
stripeCustomerId?: string | null;
}): Promise<string> {
if (data.stripeCustomerId) return data.stripeCustomerId;
const stripeCustomer = await stripe.customers.create({
email: data.email,
name: data.name,
metadata: { nixpanel_customer_id: data.customerId },
});
await query(
'UPDATE customers SET stripe_customer_id = ? WHERE id = ?',
[stripeCustomer.id, data.customerId]
);
return stripeCustomer.id;
}
export async function createCheckoutSession(data: {
stripeCustomerId: string;
priceId: string;
planKey: string;
interval: 'monthly' | 'annual';
serverIp: string;
customerId: string;
successUrl: string;
cancelUrl: string;
}): Promise<string> {
const session = await stripe.checkout.sessions.create({
customer: data.stripeCustomerId,
mode: 'subscription',
line_items: [{ price: data.priceId, quantity: 1 }],
success_url: data.successUrl,
cancel_url: data.cancelUrl,
metadata: {
nixpanel_customer_id: data.customerId,
plan_key: data.planKey,
billing_interval: data.interval,
server_ip: data.serverIp,
},
subscription_data: {
metadata: {
nixpanel_customer_id: data.customerId,
plan_key: data.planKey,
server_ip: data.serverIp,
},
},
});
return session.url!;
}
export async function createBillingPortalSession(
stripeCustomerId: string,
returnUrl: string
): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl,
});
return session.url;
}
export async function cancelSubscription(stripeSubscriptionId: string): Promise<void> {
await stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
await query(
'UPDATE subscriptions SET cancel_at_period_end = 1 WHERE stripe_subscription_id = ?',
[stripeSubscriptionId]
);
}
export async function changeSubscriptionPlan(data: {
stripeSubscriptionId: string;
newPriceId: string;
}): Promise<void> {
const sub = await stripe.subscriptions.retrieve(data.stripeSubscriptionId);
await stripe.subscriptions.update(data.stripeSubscriptionId, {
items: [{ id: sub.items.data[0].id, price: data.newPriceId }],
proration_behavior: 'create_prorations',
});
}
export async function constructWebhookEvent(
payload: Buffer,
signature: string
): Promise<Stripe.Event> {
return stripe.webhooks.constructEvent(payload, signature, config.stripe.webhookSecret);
}
export { stripe };