mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(licenses): improve development setup and refactor email handling
- Updated development script in package.json to load environment variables from dotenv. - Refactored email imports to use named exports for better clarity. - Removed unused database expiration logic and adjusted license validation to rely on Stripe subscription status. - Enhanced error logging in webhook processing and improved console output for server status. - Updated SMTP configuration to use more descriptive environment variable names. - Removed the expiresAt field from the database schema to streamline license management.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4002 tsx watch src/index.ts",
|
"dev": "PORT=4002 tsx watch -r dotenv/config src/index.ts",
|
||||||
"build": "tsc --project tsconfig.json",
|
"build": "tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
// import { drizzle } from "drizzle-orm/node-postgres";
|
|
||||||
// import { Pool } from "pg";
|
|
||||||
// import * as schema from "./schema";
|
|
||||||
|
|
||||||
// const pool = new Pool({
|
|
||||||
// connectionString: process.env.DATABASE_URL,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// export const db = drizzle(pool, { schema });
|
|
||||||
|
|
||||||
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
|
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
|
|
||||||
export const transporter = createTransport({
|
export const transporter = createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_SERVER,
|
||||||
port: Number(process.env.SMTP_PORT),
|
port: Number(process.env.SMTP_PORT),
|
||||||
secure: true,
|
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.SMTP_USER,
|
user: process.env.SMTP_USERNAME,
|
||||||
pass: process.env.SMTP_PASS,
|
pass: process.env.SMTP_PASSWORD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
|||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { render } from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
import LicenseEmail from "../templates/emails/license-email";
|
import { LicenseEmail } from "../templates/emails/license-email";
|
||||||
import ResendLicenseEmail from "../templates/emails/resend-license-email";
|
import { ResendLicenseEmail } from "../templates/emails/resend-license-email";
|
||||||
import {
|
import {
|
||||||
createLicense,
|
createLicense,
|
||||||
validateLicense,
|
validateLicense,
|
||||||
@@ -34,6 +34,8 @@ router.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(process.env.DATABASE_URL);
|
||||||
|
|
||||||
const validateSchema = z.object({
|
const validateSchema = z.object({
|
||||||
licenseKey: z.string(),
|
licenseKey: z.string(),
|
||||||
serverIp: z.string(),
|
serverIp: z.string(),
|
||||||
@@ -117,14 +119,20 @@ router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
|||||||
ResendLicenseEmail({
|
ResendLicenseEmail({
|
||||||
licenseKey: license.licenseKey,
|
licenseKey: license.licenseKey,
|
||||||
productName: `Dokploy Self Hosted ${license.type}`,
|
productName: `Dokploy Self Hosted ${license.type}`,
|
||||||
expirationDate: new Date(license.expiresAt),
|
// TODO: Add expiration date
|
||||||
|
expirationDate: new Date(),
|
||||||
requestDate: new Date(),
|
requestDate: new Date(),
|
||||||
customerName: license.email,
|
customerName: license.email,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// await transporter.sendMail({
|
||||||
|
// from: fromAddress,
|
||||||
|
// to: toAddresses.join(", "),
|
||||||
|
// subject,
|
||||||
|
// html: htmlContent,
|
||||||
|
// });
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
to: license.email,
|
to: license.email,
|
||||||
subject: "Your Dokploy License Key",
|
subject: "Your Dokploy License Key",
|
||||||
html: emailHtml,
|
html: emailHtml,
|
||||||
@@ -136,16 +144,15 @@ router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
|
|||||||
return c.json({ success: false, error: "Error resending license" }, 500);
|
return c.json({ success: false, error: "Error resending license" }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/stripe/webhook", async (c) => {
|
router.post("/stripe/webhook", async (c) => {
|
||||||
|
const rawBody = await c.req.raw.text();
|
||||||
const sig = c.req.header("stripe-signature");
|
const sig = c.req.header("stripe-signature");
|
||||||
const body = await c.req.json();
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(
|
event = stripe.webhooks.constructEvent(
|
||||||
JSON.stringify(body),
|
rawBody,
|
||||||
sig!,
|
sig!,
|
||||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||||
);
|
);
|
||||||
@@ -154,6 +161,18 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
return c.json({ error: "Webhook signature verification failed" }, 400);
|
return c.json({ error: "Webhook signature verification failed" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedEvents = [
|
||||||
|
"checkout.session.completed",
|
||||||
|
"customer.subscription.updated",
|
||||||
|
"invoice.payment_succeeded",
|
||||||
|
"invoice.payment_failed",
|
||||||
|
"customer.subscription.deleted",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedEvents.includes(event.type)) {
|
||||||
|
return c.json({ error: "Event not allowed" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed": {
|
case "checkout.session.completed": {
|
||||||
@@ -183,19 +202,23 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
stripeSubscriptionId: session.id,
|
stripeSubscriptionId: session.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("License created", license);
|
||||||
|
|
||||||
const features = getLicenseFeatures(type);
|
const features = getLicenseFeatures(type);
|
||||||
|
console.log("Features", features);
|
||||||
const emailHtml = await render(
|
const emailHtml = await render(
|
||||||
LicenseEmail({
|
LicenseEmail({
|
||||||
customerName: customerResponse.name || "Customer",
|
customerName: customerResponse.name || "Customer",
|
||||||
licenseKey: license.licenseKey,
|
licenseKey: license.licenseKey,
|
||||||
productName: `Dokploy Self Hosted ${type}`,
|
productName: `Dokploy Self Hosted ${type}`,
|
||||||
expirationDate: new Date(license.expiresAt),
|
// TODO: Add expiration date
|
||||||
|
expirationDate: new Date(),
|
||||||
features: features,
|
features: features,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.SMTP_FROM,
|
from: process.env.SMTP_FROM_ADDRESS,
|
||||||
to: license.email,
|
to: license.email,
|
||||||
subject: "Your Dokploy License Key",
|
subject: "Your Dokploy License Key",
|
||||||
html: emailHtml,
|
html: emailHtml,
|
||||||
@@ -230,7 +253,10 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
const customerResponse = await stripe.customers.retrieve(
|
const customerResponse = await stripe.customers.retrieve(
|
||||||
invoice.customer as string,
|
invoice.customer as string,
|
||||||
);
|
);
|
||||||
if (suscription.status !== "active" || customerResponse.deleted) break;
|
if (suscription.status !== "active" || customerResponse.deleted) {
|
||||||
|
await deactivateLicense(invoice.subscription as string);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const existingLicense = await db.query.licenses.findFirst({
|
const existingLicense = await db.query.licenses.findFirst({
|
||||||
where: eq(licenses.stripeCustomerId, invoice.customer as string),
|
where: eq(licenses.stripeCustomerId, invoice.customer as string),
|
||||||
@@ -238,38 +264,13 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
|
|
||||||
if (!existingLicense) break;
|
if (!existingLicense) break;
|
||||||
|
|
||||||
const newExpirationDate = new Date();
|
|
||||||
newExpirationDate.setMonth(
|
|
||||||
newExpirationDate.getMonth() +
|
|
||||||
(existingLicense.billingType === "annual" ? 12 : 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(licenses)
|
.update(licenses)
|
||||||
.set({
|
.set({
|
||||||
expiresAt: newExpirationDate,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
})
|
})
|
||||||
.where(eq(licenses.id, existingLicense.id));
|
.where(eq(licenses.id, existingLicense.id));
|
||||||
|
|
||||||
const features = getLicenseFeatures(existingLicense.type);
|
|
||||||
const emailHtml = await render(
|
|
||||||
LicenseEmail({
|
|
||||||
customerName: customerResponse.name || "Customer",
|
|
||||||
licenseKey: existingLicense.licenseKey,
|
|
||||||
productName: `Dokploy Self Hosted ${existingLicense.type}`,
|
|
||||||
expirationDate: new Date(newExpirationDate),
|
|
||||||
features: features,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM,
|
|
||||||
to: existingLicense.email,
|
|
||||||
subject: "Your Dokploy License Has Been Renewed",
|
|
||||||
html: emailHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +312,7 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
|
|
||||||
return c.json({ received: true });
|
return c.json({ received: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error processing webhook:", error);
|
console.error("Error processing webhook:", error);
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return c.json({ error: error.message }, 500);
|
return c.json({ error: error.message }, 500);
|
||||||
}
|
}
|
||||||
@@ -321,7 +322,7 @@ router.post("/stripe/webhook", async (c) => {
|
|||||||
|
|
||||||
app.route("/api", router);
|
app.route("/api", router);
|
||||||
const port = process.env.PORT || 4002;
|
const port = process.env.PORT || 4002;
|
||||||
console.log(`Server is running on port ${port}`);
|
console.log(`Server is running on port http://localhost:${port}`);
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const licenses = pgTable("licenses", {
|
|||||||
serverIp: text("server_ip"),
|
serverIp: text("server_ip"),
|
||||||
activatedAt: timestamp("activated_at"),
|
activatedAt: timestamp("activated_at"),
|
||||||
lastVerifiedAt: timestamp("last_verified_at"),
|
lastVerifiedAt: timestamp("last_verified_at"),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
|
||||||
stripeCustomerId: text("stripeCustomerId").notNull(),
|
stripeCustomerId: text("stripeCustomerId").notNull(),
|
||||||
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
|
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
|
||||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { type License, licenses } from "../schema";
|
import { licenses } from "../schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { stripe } from "../stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
export const generateLicenseKey = () => {
|
export const generateLicenseKey = () => {
|
||||||
return randomBytes(32).toString("hex");
|
return randomBytes(32).toString("hex");
|
||||||
@@ -25,10 +27,6 @@ export const createLicense = async ({
|
|||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
}: CreateLicenseProps) => {
|
}: CreateLicenseProps) => {
|
||||||
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
const licenseKey = `dokploy-${generateLicenseKey()}`;
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setMonth(
|
|
||||||
expiresAt.getMonth() + (billingType === "annual" ? 12 : 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const license = await db
|
const license = await db
|
||||||
.insert(licenses)
|
.insert(licenses)
|
||||||
@@ -37,7 +35,6 @@ export const createLicense = async ({
|
|||||||
licenseKey,
|
licenseKey,
|
||||||
type,
|
type,
|
||||||
billingType,
|
billingType,
|
||||||
expiresAt,
|
|
||||||
email,
|
email,
|
||||||
stripeCustomerId,
|
stripeCustomerId,
|
||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
@@ -59,26 +56,21 @@ export const validateLicense = async (
|
|||||||
return { isValid: false, error: "License not found" };
|
return { isValid: false, error: "License not found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.status !== "active") {
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
|
license.stripeSubscriptionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (suscription.status !== "active") {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
error: `License is ${getLicenseStatus(license)}`,
|
error: `License is ${getLicenseStatus(suscription)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date() > license.expiresAt) {
|
|
||||||
await db
|
|
||||||
.update(licenses)
|
|
||||||
.set({ status: "expired" })
|
|
||||||
.where(eq(licenses.id, license.id));
|
|
||||||
return { isValid: false, error: "License has expired" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
|
if (license.serverIp && serverIp && license.serverIp !== serverIp) {
|
||||||
return { isValid: false, error: "Invalid server IP" };
|
return { isValid: false, error: "Invalid server IP" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last verified timestamp
|
|
||||||
await db
|
await db
|
||||||
.update(licenses)
|
.update(licenses)
|
||||||
.set({ lastVerifiedAt: new Date() })
|
.set({ lastVerifiedAt: new Date() })
|
||||||
@@ -96,16 +88,12 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
|||||||
throw new Error("License not found");
|
throw new Error("License not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.status !== "active") {
|
const suscription = await stripe.subscriptions.retrieve(
|
||||||
throw new Error("License is not active");
|
license.stripeSubscriptionId,
|
||||||
}
|
);
|
||||||
|
|
||||||
if (new Date() > license.expiresAt) {
|
if (suscription.status !== "active") {
|
||||||
await db
|
throw new Error(`License is ${getLicenseStatus(suscription)}`);
|
||||||
.update(licenses)
|
|
||||||
.set({ status: "expired" })
|
|
||||||
.where(eq(licenses.id, license.id));
|
|
||||||
throw new Error("License has expired");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.serverIp && license.serverIp !== serverIp) {
|
if (license.serverIp && license.serverIp !== serverIp) {
|
||||||
@@ -141,22 +129,40 @@ export const deactivateLicense = async (stripeSubscriptionId: string) => {
|
|||||||
.where(eq(licenses.id, license.id));
|
.where(eq(licenses.id, license.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLicenseStatus = (license: License) => {
|
export const getLicenseStatus = async (license: Stripe.Subscription) => {
|
||||||
if (license.status === "active") {
|
if (license.status === "active") {
|
||||||
return "active";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.status === "expired") {
|
if (license.status === "canceled") {
|
||||||
return "expired";
|
return "canceled";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.status === "cancelled") {
|
if (license.status === "incomplete") {
|
||||||
return "cancelled";
|
return "incomplete";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (license.status === "payment_pending") {
|
if (license.status === "incomplete_expired") {
|
||||||
return "pending payment";
|
return "incomplete expired";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (license.status === "past_due") {
|
||||||
|
return "past due";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "paused") {
|
||||||
|
return "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "trialing") {
|
||||||
|
return "trialing";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (license.status === "unpaid") {
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStripeItems = (
|
export const getStripeItems = (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "react-email-starter",
|
"name": "react-email-starter",
|
||||||
"version": "0.1.10",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "email build",
|
"build": "email build",
|
||||||
"dev": "email dev",
|
"dev": "email dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user