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
@@ -1,60 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
function require(key: string): string {
|
||||
const val = process.env[key];
|
||||
if (!val) throw new Error(`Missing required environment variable: ${key}`);
|
||||
return val;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
env: process.env.NODE_ENV || 'production',
|
||||
port: parseInt(process.env.PORT || '4000', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
|
||||
db: {
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
user: require('DB_USER'),
|
||||
password: require('DB_PASSWORD'),
|
||||
name: require('DB_NAME'),
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: require('JWT_SECRET'),
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
},
|
||||
|
||||
stripe: {
|
||||
secretKey: require('STRIPE_SECRET_KEY'),
|
||||
webhookSecret: require('STRIPE_WEBHOOK_SECRET'),
|
||||
plans: {
|
||||
// Stripe Price IDs — set after creating products in Stripe dashboard
|
||||
solo_monthly: process.env.STRIPE_PRICE_SOLO_MONTHLY || '',
|
||||
solo_annual: process.env.STRIPE_PRICE_SOLO_ANNUAL || '',
|
||||
host_monthly: process.env.STRIPE_PRICE_HOST_MONTHLY || '',
|
||||
host_annual: process.env.STRIPE_PRICE_HOST_ANNUAL || '',
|
||||
agency_monthly: process.env.STRIPE_PRICE_AGENCY_MONTHLY || '',
|
||||
agency_annual: process.env.STRIPE_PRICE_AGENCY_ANNUAL || '',
|
||||
},
|
||||
},
|
||||
|
||||
licensingServer: {
|
||||
url: require('LICENSING_SERVER_URL'),
|
||||
apiKey: require('LICENSING_SERVER_API_KEY'),
|
||||
},
|
||||
|
||||
mail: {
|
||||
host: process.env.MAIL_HOST || 'smtp.mailgun.org',
|
||||
port: parseInt(process.env.MAIL_PORT || '587', 10),
|
||||
user: process.env.MAIL_USER || '',
|
||||
password: process.env.MAIL_PASSWORD || '',
|
||||
from: process.env.MAIL_FROM || '[email protected]',
|
||||
},
|
||||
|
||||
appUrl: process.env.APP_URL || 'https://nixpanel.io',
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'https://nixpanel.io',
|
||||
},
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import { config } from '../config';
|
||||
|
||||
let pool: mysql.Pool;
|
||||
|
||||
export function getPool(): mysql.Pool {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
database: config.db.name,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 20,
|
||||
queueLimit: 0,
|
||||
timezone: 'Z',
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query<T = mysql.RowDataPacket[]>(
|
||||
sql: string,
|
||||
params?: unknown[]
|
||||
): Promise<T> {
|
||||
const [rows] = await getPool().execute(sql, params);
|
||||
return rows as T;
|
||||
}
|
||||
|
||||
export async function testConnection(): Promise<void> {
|
||||
const conn = await getPool().getConnection();
|
||||
await conn.ping();
|
||||
conn.release();
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getPool, testConnection } from './index';
|
||||
|
||||
async function migrate(): Promise<void> {
|
||||
await testConnection();
|
||||
console.log('Connected to database.');
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||
const pool = getPool();
|
||||
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
const statements = sql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--'));
|
||||
|
||||
for (const statement of statements) {
|
||||
await pool.execute(statement);
|
||||
}
|
||||
console.log(`Applied: ${file}`);
|
||||
}
|
||||
|
||||
console.log('Migration complete.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
-- NixPanel Website — Customer Schema
|
||||
-- MariaDB / MySQL compatible
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
full_name VARCHAR(255) NOT NULL,
|
||||
is_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
verify_token VARCHAR(128) NULL,
|
||||
reset_token VARCHAR(128) NULL,
|
||||
reset_token_expires DATETIME NULL,
|
||||
stripe_customer_id VARCHAR(100) NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_email (email),
|
||||
INDEX idx_stripe_customer (stripe_customer_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
customer_id VARCHAR(36) NOT NULL,
|
||||
license_id VARCHAR(36) NOT NULL,
|
||||
stripe_subscription_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
stripe_price_id VARCHAR(100) NOT NULL,
|
||||
plan_key ENUM('free','solo','host','agency') NOT NULL DEFAULT 'free',
|
||||
billing_interval ENUM('monthly','annual') NOT NULL DEFAULT 'monthly',
|
||||
status ENUM('active','past_due','canceled','incomplete','trialing') NOT NULL DEFAULT 'active',
|
||||
server_ip VARCHAR(45) NOT NULL,
|
||||
current_period_start DATETIME NOT NULL,
|
||||
current_period_end DATETIME NOT NULL,
|
||||
cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0,
|
||||
canceled_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||
INDEX idx_customer_id (customer_id),
|
||||
INDEX idx_license_id (license_id),
|
||||
INDEX idx_stripe_sub (stripe_subscription_id),
|
||||
INDEX idx_server_ip (server_ip)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_change_requests (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
customer_id VARCHAR(36) NOT NULL,
|
||||
subscription_id VARCHAR(36) NOT NULL,
|
||||
old_ip VARCHAR(45) NOT NULL,
|
||||
new_ip VARCHAR(45) NOT NULL,
|
||||
status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'pending',
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at DATETIME NULL,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||
INDEX idx_customer_id (customer_id)
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { config } from './config';
|
||||
import { testConnection } from './db';
|
||||
import authRoutes from './routes/auth';
|
||||
import billingRoutes from './routes/billing';
|
||||
import licenseRoutes from './routes/license';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.env === 'production' ? 'warn' : 'info',
|
||||
transport: config.env !== 'production' ? { target: 'pino-pretty' } : undefined,
|
||||
},
|
||||
trustProxy: true,
|
||||
});
|
||||
|
||||
// Add rawBody support for Stripe webhooks
|
||||
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
||||
(req as any).rawBody = body;
|
||||
try {
|
||||
done(null, JSON.parse(body.toString()));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await app.register(helmet, { global: true });
|
||||
|
||||
await app.register(cors, {
|
||||
origin: config.env === 'production' ? config.cors.origin : true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: 120,
|
||||
timeWindow: '1 minute',
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
app.get('/health', async (_req, reply) => {
|
||||
return reply.send({ status: 'ok', ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
await app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
await app.register(billingRoutes, { prefix: '/api/v1/billing' });
|
||||
await app.register(licenseRoutes, { prefix: '/api/v1/license' });
|
||||
|
||||
await testConnection();
|
||||
app.log.info('Database connection verified');
|
||||
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
app.log.info(`NixPanel Website API running on ${config.host}:${config.port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch(err => {
|
||||
console.error('Failed to start API:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { verifyJwt } from '../services/authService';
|
||||
import { getCustomerById } from '../services/authService';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
customer?: { id: string; email: string };
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireCustomerAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = verifyJwt(authHeader.slice(7));
|
||||
const customer = await getCustomerById(payload.sub);
|
||||
if (!customer) {
|
||||
reply.code(401).send({ error: 'Account not found' });
|
||||
return;
|
||||
}
|
||||
request.customer = { id: customer.id, email: customer.email };
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Invalid or expired session' });
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireCustomerAuth } from '../middleware/auth';
|
||||
import {
|
||||
registerCustomer,
|
||||
loginCustomer,
|
||||
verifyEmail,
|
||||
getCustomerById,
|
||||
initiatePasswordReset,
|
||||
resetPassword,
|
||||
} from '../services/authService';
|
||||
import {
|
||||
sendVerificationEmail,
|
||||
sendPasswordResetEmail,
|
||||
} from '../services/mailService';
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /auth/register
|
||||
app.post<{
|
||||
Body: { email: string; password: string; fullName: string };
|
||||
}>('/register', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password', 'fullName'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 8 },
|
||||
fullName: { type: 'string', minLength: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, password, fullName } = request.body;
|
||||
const { customer, verifyToken } = await registerCustomer({ email, password, fullName });
|
||||
await sendVerificationEmail(email, verifyToken);
|
||||
return reply.code(201).send({
|
||||
message: 'Account created. Check your email to verify your account.',
|
||||
id: customer.id,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /auth/login
|
||||
app.post<{
|
||||
Body: { email: string; password: string };
|
||||
}>('/login', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { email, password } = request.body;
|
||||
const token = await loginCustomer(email, password);
|
||||
return reply.send({ token });
|
||||
});
|
||||
|
||||
// GET /auth/me
|
||||
app.get('/me', { preHandler: requireCustomerAuth }, async (request, reply) => {
|
||||
const customer = await getCustomerById(request.customer!.id);
|
||||
return reply.send(customer);
|
||||
});
|
||||
|
||||
// GET /auth/verify-email?token=...
|
||||
app.get<{ Querystring: { token: string } }>('/verify-email', async (request, reply) => {
|
||||
const ok = await verifyEmail(request.query.token);
|
||||
if (!ok) return reply.code(400).send({ error: 'Invalid or expired verification link' });
|
||||
return reply.send({ message: 'Email verified. You can now log in.' });
|
||||
});
|
||||
|
||||
// POST /auth/forgot-password
|
||||
app.post<{ Body: { email: string } }>('/forgot-password', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: { email: { type: 'string', format: 'email' } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const token = await initiatePasswordReset(request.body.email);
|
||||
if (token) await sendPasswordResetEmail(request.body.email, token);
|
||||
// Always return success to prevent email enumeration
|
||||
return reply.send({ message: 'If that email exists, a reset link has been sent.' });
|
||||
});
|
||||
|
||||
// POST /auth/reset-password
|
||||
app.post<{ Body: { token: string; password: string } }>('/reset-password', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const ok = await resetPassword(request.body.token, request.body.password);
|
||||
if (!ok) return reply.code(400).send({ error: 'Invalid or expired reset token' });
|
||||
return reply.send({ message: 'Password updated. You can now log in.' });
|
||||
});
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { requireCustomerAuth } from '../middleware/auth';
|
||||
import { getCustomerById } from '../services/authService';
|
||||
import {
|
||||
getOrCreateStripeCustomer,
|
||||
createCheckoutSession,
|
||||
createBillingPortalSession,
|
||||
cancelSubscription,
|
||||
changeSubscriptionPlan,
|
||||
constructWebhookEvent,
|
||||
PLAN_PRICES,
|
||||
} from '../services/stripeService';
|
||||
import { createLicense, revokeLicense } from '../services/licensingService';
|
||||
import { sendLicenseConfirmationEmail } from '../services/mailService';
|
||||
import { query } from '../db';
|
||||
import { config } from '../config';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Plan key → licensing server plan ID mapping
|
||||
const PLAN_ID_MAP: Record<string, string> = {
|
||||
free: '00000000-0000-0000-0000-000000000001',
|
||||
solo: '00000000-0000-0000-0000-000000000002',
|
||||
host: '00000000-0000-0000-0000-000000000003',
|
||||
agency: '00000000-0000-0000-0000-000000000004',
|
||||
};
|
||||
|
||||
export default async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /billing/plans — public
|
||||
app.get('/plans', async (_request, reply) => {
|
||||
return reply.send(PLAN_PRICES);
|
||||
});
|
||||
|
||||
// POST /billing/checkout — start Stripe checkout
|
||||
app.post<{
|
||||
Body: { planKey: string; interval: 'monthly' | 'annual'; serverIp: string };
|
||||
}>('/checkout', {
|
||||
preHandler: requireCustomerAuth,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['planKey', 'interval', 'serverIp'],
|
||||
properties: {
|
||||
planKey: { type: 'string', enum: ['solo', 'host', 'agency'] },
|
||||
interval: { type: 'string', enum: ['monthly', 'annual'] },
|
||||
serverIp: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { planKey, interval, serverIp } = request.body;
|
||||
const customer = await getCustomerById(request.customer!.id);
|
||||
if (!customer) return reply.code(404).send({ error: 'Account not found' });
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer({
|
||||
customerId: customer.id,
|
||||
email: customer.email,
|
||||
name: customer.full_name,
|
||||
stripeCustomerId: customer.stripe_customer_id,
|
||||
});
|
||||
|
||||
const priceKey = `${planKey}_${interval}` as keyof typeof config.stripe.plans;
|
||||
const priceId = config.stripe.plans[priceKey];
|
||||
if (!priceId) return reply.code(400).send({ error: 'Plan not configured' });
|
||||
|
||||
const url = await createCheckoutSession({
|
||||
stripeCustomerId,
|
||||
priceId,
|
||||
planKey,
|
||||
interval,
|
||||
serverIp,
|
||||
customerId: customer.id,
|
||||
successUrl: `${config.appUrl}/dashboard?success=1`,
|
||||
cancelUrl: `${config.appUrl}/pricing`,
|
||||
});
|
||||
|
||||
return reply.send({ url });
|
||||
});
|
||||
|
||||
// POST /billing/portal — Stripe customer portal (manage payment method, invoices)
|
||||
app.post('/portal', { preHandler: requireCustomerAuth }, async (request, reply) => {
|
||||
const customer = await getCustomerById(request.customer!.id);
|
||||
if (!customer?.stripe_customer_id) {
|
||||
return reply.code(400).send({ error: 'No billing account found' });
|
||||
}
|
||||
const url = await createBillingPortalSession(
|
||||
customer.stripe_customer_id,
|
||||
`${config.appUrl}/dashboard`
|
||||
);
|
||||
return reply.send({ url });
|
||||
});
|
||||
|
||||
// POST /billing/cancel — cancel at period end
|
||||
app.post('/cancel', { preHandler: requireCustomerAuth }, async (request, reply) => {
|
||||
const subs = await query<any[]>(
|
||||
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
|
||||
[request.customer!.id]
|
||||
);
|
||||
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
|
||||
await cancelSubscription(subs[0].stripe_subscription_id);
|
||||
return reply.send({ message: 'Subscription will cancel at end of billing period.' });
|
||||
});
|
||||
|
||||
// POST /billing/change-plan — upgrade or downgrade
|
||||
app.post<{
|
||||
Body: { planKey: string; interval: 'monthly' | 'annual' };
|
||||
}>('/change-plan', {
|
||||
preHandler: requireCustomerAuth,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['planKey', 'interval'],
|
||||
properties: {
|
||||
planKey: { type: 'string', enum: ['solo', 'host', 'agency'] },
|
||||
interval: { type: 'string', enum: ['monthly', 'annual'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { planKey, interval } = request.body;
|
||||
const subs = await query<any[]>(
|
||||
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
|
||||
[request.customer!.id]
|
||||
);
|
||||
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
|
||||
|
||||
const priceKey = `${planKey}_${interval}` as keyof typeof config.stripe.plans;
|
||||
const priceId = config.stripe.plans[priceKey];
|
||||
if (!priceId) return reply.code(400).send({ error: 'Plan not configured' });
|
||||
|
||||
await changeSubscriptionPlan({ stripeSubscriptionId: subs[0].stripe_subscription_id, newPriceId: priceId });
|
||||
await query(
|
||||
'UPDATE subscriptions SET plan_key = ?, billing_interval = ? WHERE id = ?',
|
||||
[planKey, interval, subs[0].id]
|
||||
);
|
||||
return reply.send({ message: 'Plan updated.' });
|
||||
});
|
||||
|
||||
// GET /billing/subscription — current subscription details
|
||||
app.get('/subscription', { preHandler: requireCustomerAuth }, async (request, reply) => {
|
||||
const subs = await query<any[]>(
|
||||
'SELECT * FROM subscriptions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[request.customer!.id]
|
||||
);
|
||||
return reply.send(subs[0] || null);
|
||||
});
|
||||
|
||||
// POST /billing/webhook — Stripe webhook (no auth, validated by signature)
|
||||
app.post('/webhook', {
|
||||
config: { rawBody: true },
|
||||
}, async (request: FastifyRequest & { rawBody?: Buffer }, reply) => {
|
||||
const sig = request.headers['stripe-signature'] as string;
|
||||
if (!sig || !request.rawBody) {
|
||||
return reply.code(400).send({ error: 'Missing signature' });
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = await constructWebhookEvent(request.rawBody, sig);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'Invalid webhook signature' });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as any;
|
||||
const { nixpanel_customer_id, plan_key, billing_interval, server_ip } = session.metadata;
|
||||
|
||||
// Create license on licensing server
|
||||
const cust = await getCustomerById(nixpanel_customer_id);
|
||||
if (!cust) break;
|
||||
|
||||
const license = await createLicense({
|
||||
planId: PLAN_ID_MAP[plan_key],
|
||||
ownerEmail: cust.email,
|
||||
ownerName: cust.full_name,
|
||||
serverIp: server_ip,
|
||||
});
|
||||
|
||||
const stripeSubscription = session.subscription;
|
||||
const subId = crypto.randomUUID();
|
||||
|
||||
await query(
|
||||
`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', ?, NOW(), DATE_ADD(NOW(), INTERVAL 1 MONTH))`,
|
||||
[subId, nixpanel_customer_id, license.id, stripeSubscription,
|
||||
session.metadata?.price_id || '', plan_key, billing_interval, server_ip]
|
||||
);
|
||||
|
||||
await sendLicenseConfirmationEmail(cust.email, {
|
||||
plan: PLAN_PRICES[plan_key]?.label || plan_key,
|
||||
serverIp: server_ip,
|
||||
interval: billing_interval,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const sub = event.data.object as any;
|
||||
await query(
|
||||
`UPDATE subscriptions
|
||||
SET status = ?, current_period_start = ?, current_period_end = ?,
|
||||
cancel_at_period_end = ?
|
||||
WHERE stripe_subscription_id = ?`,
|
||||
[
|
||||
sub.status,
|
||||
new Date(sub.current_period_start * 1000),
|
||||
new Date(sub.current_period_end * 1000),
|
||||
sub.cancel_at_period_end ? 1 : 0,
|
||||
sub.id,
|
||||
]
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = event.data.object as any;
|
||||
const rows = await query<any[]>(
|
||||
'SELECT * FROM subscriptions WHERE stripe_subscription_id = ?',
|
||||
[sub.id]
|
||||
);
|
||||
if (rows.length) {
|
||||
await query(
|
||||
"UPDATE subscriptions SET status = 'canceled', canceled_at = NOW() WHERE stripe_subscription_id = ?",
|
||||
[sub.id]
|
||||
);
|
||||
await revokeLicense(rows[0].license_id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({ received: true });
|
||||
});
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireCustomerAuth } from '../middleware/auth';
|
||||
import { getLicense, updateServerIp } from '../services/licensingService';
|
||||
import { query } from '../db';
|
||||
|
||||
export default async function licenseRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('preHandler', requireCustomerAuth);
|
||||
|
||||
// GET /license — current license details
|
||||
app.get('/', async (request, reply) => {
|
||||
const subs = await query<any[]>(
|
||||
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
|
||||
[request.customer!.id]
|
||||
);
|
||||
if (!subs.length) return reply.send(null);
|
||||
|
||||
const license = await getLicense(subs[0].license_id);
|
||||
return reply.send({ ...license, subscription: subs[0] });
|
||||
});
|
||||
|
||||
// POST /license/change-ip — request IP address change
|
||||
app.post<{
|
||||
Body: { newIp: string };
|
||||
}>('/change-ip', {
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['newIp'],
|
||||
properties: {
|
||||
newIp: {
|
||||
type: 'string',
|
||||
pattern: '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { newIp } = request.body;
|
||||
|
||||
const subs = await query<any[]>(
|
||||
"SELECT * FROM subscriptions WHERE customer_id = ? AND status = 'active' LIMIT 1",
|
||||
[request.customer!.id]
|
||||
);
|
||||
if (!subs.length) return reply.code(404).send({ error: 'No active subscription' });
|
||||
|
||||
const sub = subs[0];
|
||||
if (sub.server_ip === newIp) {
|
||||
return reply.code(400).send({ error: 'New IP is the same as current IP' });
|
||||
}
|
||||
|
||||
// Check if new IP is already in use
|
||||
const conflict = await query<any[]>(
|
||||
"SELECT id FROM subscriptions WHERE server_ip = ? AND status = 'active' AND id != ?",
|
||||
[newIp, sub.id]
|
||||
);
|
||||
if (conflict.length) {
|
||||
return reply.code(409).send({ error: 'That IP address is already in use by another license' });
|
||||
}
|
||||
|
||||
// Log the request and apply immediately
|
||||
const reqId = require('crypto').randomUUID();
|
||||
await query(
|
||||
`INSERT INTO ip_change_requests (id, customer_id, subscription_id, old_ip, new_ip, status, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'approved', NOW())`,
|
||||
[reqId, request.customer!.id, sub.id, sub.server_ip, newIp]
|
||||
);
|
||||
|
||||
await updateServerIp(sub.license_id, newIp);
|
||||
await query(
|
||||
'UPDATE subscriptions SET server_ip = ? WHERE id = ?',
|
||||
[newIp, sub.id]
|
||||
);
|
||||
|
||||
return reply.send({ message: 'IP address updated successfully.', newIp });
|
||||
});
|
||||
|
||||
// GET /license/ip-history — past IP changes
|
||||
app.get('/ip-history', async (request, reply) => {
|
||||
const history = await query<any[]>(
|
||||
`SELECT * FROM ip_change_requests WHERE customer_id = ? ORDER BY requested_at DESC`,
|
||||
[request.customer!.id]
|
||||
);
|
||||
return reply.send(history);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user