From 39e6a981790dacb2e393f6571a20db0d530055da Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 23 Mar 2025 18:32:50 -0600
Subject: [PATCH] feat(licenses): add license management and user
authentication features
- Introduced a new SQL schema for managing licenses and users, including foreign key relationships.
- Implemented API routes for license validation, activation, and OTP-based user authentication.
- Updated the license creation process to associate licenses with users and handle server IPs.
- Added support for the nanoid package to generate unique license keys.
- Refactored existing code to improve modularity and maintainability, including the separation of license and stripe-related logic into dedicated API routes.
- Enhanced error handling and logging for better debugging and user feedback.
---
apps/licenses/drizzle/0000_famous_vermin.sql | 28 ++
apps/licenses/drizzle/meta/0000_snapshot.json | 196 ++++++++++++
apps/licenses/drizzle/meta/_journal.json | 10 +-
apps/licenses/package.json | 1 +
apps/licenses/src/api/license.ts | 217 +++++++++++++
apps/licenses/src/api/stripe.ts | 180 +++++++++++
apps/licenses/src/index.ts | 291 +-----------------
apps/licenses/src/schema.ts | 45 +--
apps/licenses/src/utils/license.ts | 93 +++---
.../templates/emails/resend-license-email.tsx | 14 -
pnpm-lock.yaml | 14 +-
11 files changed, 724 insertions(+), 365 deletions(-)
create mode 100644 apps/licenses/drizzle/0000_famous_vermin.sql
create mode 100644 apps/licenses/drizzle/meta/0000_snapshot.json
create mode 100644 apps/licenses/src/api/license.ts
create mode 100644 apps/licenses/src/api/stripe.ts
diff --git a/apps/licenses/drizzle/0000_famous_vermin.sql b/apps/licenses/drizzle/0000_famous_vermin.sql
new file mode 100644
index 000000000..99e65021a
--- /dev/null
+++ b/apps/licenses/drizzle/0000_famous_vermin.sql
@@ -0,0 +1,28 @@
+CREATE TABLE "licenses" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "product_id" text NOT NULL,
+ "license_key" text NOT NULL,
+ "server_ips" text[],
+ "activated_at" timestamp,
+ "last_verified_at" timestamp,
+ "stripeCustomerId" text NOT NULL,
+ "stripeSubscriptionId" text NOT NULL,
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
+ "metadata" text,
+ "user_id" uuid,
+ CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
+);
+--> statement-breakpoint
+CREATE TABLE "user" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "email" text NOT NULL,
+ "created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
+ "otp_code" text,
+ "otp_code_expires_at" timestamp,
+ "temporal_id" uuid DEFAULT gen_random_uuid(),
+ CONSTRAINT "user_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+ALTER TABLE "licenses" ADD CONSTRAINT "licenses_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
\ No newline at end of file
diff --git a/apps/licenses/drizzle/meta/0000_snapshot.json b/apps/licenses/drizzle/meta/0000_snapshot.json
new file mode 100644
index 000000000..743fc412b
--- /dev/null
+++ b/apps/licenses/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,196 @@
+{
+ "id": "553c7c08-f9c6-4090-8372-8d27a389eaa7",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.licenses": {
+ "name": "licenses",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "product_id": {
+ "name": "product_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "license_key": {
+ "name": "license_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_ips": {
+ "name": "server_ips",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "activated_at": {
+ "name": "activated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_verified_at": {
+ "name": "last_verified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "licenses_user_id_user_id_fk": {
+ "name": "licenses_user_id_user_id_fk",
+ "tableFrom": "licenses",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "licenses_license_key_unique": {
+ "name": "licenses_license_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "license_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "CURRENT_TIMESTAMP"
+ },
+ "otp_code": {
+ "name": "otp_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "otp_code_expires_at": {
+ "name": "otp_code_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "temporal_id": {
+ "name": "temporal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "gen_random_uuid()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/licenses/drizzle/meta/_journal.json b/apps/licenses/drizzle/meta/_journal.json
index eaa8fcf3b..fada2e646 100644
--- a/apps/licenses/drizzle/meta/_journal.json
+++ b/apps/licenses/drizzle/meta/_journal.json
@@ -1,5 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
- "entries": []
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1742773711509,
+ "tag": "0000_famous_vermin",
+ "breakpoints": true
+ }
+ ]
}
\ No newline at end of file
diff --git a/apps/licenses/package.json b/apps/licenses/package.json
index ce2083ba6..c3715e3a2 100644
--- a/apps/licenses/package.json
+++ b/apps/licenses/package.json
@@ -16,6 +16,7 @@
"studio": "drizzle-kit studio"
},
"dependencies": {
+ "nanoid": "5.1.5",
"@react-email/components": "^0.0.21",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
diff --git a/apps/licenses/src/api/license.ts b/apps/licenses/src/api/license.ts
new file mode 100644
index 000000000..548a5d56b
--- /dev/null
+++ b/apps/licenses/src/api/license.ts
@@ -0,0 +1,217 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+import { activateLicense, validateLicense } from "../utils/license";
+import { logger } from "../logger";
+import { eq } from "drizzle-orm";
+import { users } from "../schema";
+import { db } from "../db";
+import { transporter } from "../email";
+import { nanoid } from "nanoid";
+import { stripe } from "../stripe";
+import type Stripe from "stripe";
+const validateSchema = z.object({
+ licenseKey: z.string(),
+ serverIp: z.string(),
+});
+
+export const licenseRouter = new Hono();
+
+licenseRouter.post(
+ "/validate",
+ zValidator("json", validateSchema),
+ async (c) => {
+ const { licenseKey, serverIp } = c.req.valid("json");
+
+ try {
+ const result = await validateLicense(licenseKey, serverIp);
+ console.log("Result", result);
+ return c.json(result);
+ } catch (error) {
+ logger.error("Error validating license:", { error });
+ return c.json({ isValid: false, error: "Error validating license" }, 500);
+ }
+ },
+);
+
+licenseRouter.post(
+ "/activate",
+ zValidator("json", validateSchema),
+ async (c) => {
+ const { licenseKey, serverIp } = c.req.valid("json");
+
+ try {
+ const license = await activateLicense(licenseKey, serverIp);
+ return c.json({ success: true, license });
+ } catch (error) {
+ logger.error("Error activating license:", error);
+ if (error instanceof Error) {
+ return c.json({ success: false, error: error.message }, 400);
+ }
+ return c.json({ success: false, error: "Unknown error occurred" }, 400);
+ }
+ },
+);
+
+// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
+// const { licenseKey } = c.req.valid("json");
+
+// try {
+// const license = await db.query.licenses.findFirst({
+// where: eq(licenses.licenseKey, licenseKey),
+// });
+
+// if (!license) {
+// return c.json({ success: false, error: "License not found" }, 404);
+// }
+
+// const suscription = await stripe.subscriptions.retrieve(
+// license.stripeSubscriptionId,
+// );
+
+// const priceId = suscription.items.data[0].price.id;
+// const { type } = getLicenseTypeFromPriceId(priceId);
+
+// const emailHtml = await render(
+// ResendLicenseEmail({
+// licenseKey: license.licenseKey,
+// productName: `Dokploy Self Hosted ${type}`,
+// requestDate: new Date(),
+// customerName: license.email,
+// }),
+// );
+
+// await transporter.sendMail({
+// from: process.env.SMTP_FROM_ADDRESS,
+// to: license.email,
+// subject: "Your Dokploy License Key",
+// html: emailHtml,
+// });
+
+// return c.json({ success: true });
+// } catch (error) {
+// logger.error("Error resending license:", error);
+// return c.json({ success: false, error: "Error resending license" }, 500);
+// }
+// });
+
+licenseRouter.post(
+ "/send-otp",
+ zValidator("json", z.object({ email: z.string().email() })),
+ async (c) => {
+ const { email } = c.req.valid("json");
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, email.toLowerCase()),
+ });
+
+ if (!user) {
+ return c.json({ success: false, error: "User not found" }, 404);
+ }
+
+ const generateOtpCode = Math.floor(100000 + Math.random() * 900000);
+ const otpCodeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
+
+ await db
+ .update(users)
+ .set({ otpCode: generateOtpCode.toString(), otpCodeExpiresAt })
+ .where(eq(users.id, user.id));
+
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM_ADDRESS,
+ to: user.email,
+ subject: "Your Dokploy License Key ",
+ html: `Your OTP code is ${generateOtpCode}, it will expire in 10 minutes`,
+ });
+
+ return c.json({ success: true });
+ },
+);
+
+licenseRouter.post(
+ "/verify-otp",
+ zValidator(
+ "json",
+ z.object({ email: z.string().email(), otpCode: z.string().length(6) }),
+ ),
+ async (c) => {
+ const { email, otpCode } = c.req.valid("json");
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, email.toLowerCase()),
+ });
+
+ if (!user) {
+ return c.json({ success: false, error: "User not found" }, 404);
+ }
+
+ if (user.otpCode !== otpCode) {
+ return c.json({ success: false, error: "Invalid code" }, 400);
+ }
+
+ if (user.otpCodeExpiresAt && user.otpCodeExpiresAt < new Date()) {
+ return c.json({ success: false, error: "Code expired" }, 400);
+ }
+
+ const result = await db
+ .update(users)
+ .set({
+ otpCode: null,
+ otpCodeExpiresAt: null,
+ temporalId: nanoid(),
+ temporalIdExpiresAt: new Date(Date.now() + 20 * 60 * 1000),
+ })
+ .where(eq(users.id, user.id))
+ .returning();
+
+ return c.json({ success: true, temporalId: result[0].temporalId });
+ },
+);
+
+licenseRouter.get(
+ "/all",
+ zValidator("query", z.object({ temporalId: z.string() })),
+ async (c) => {
+ const { temporalId } = c.req.valid("query");
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.temporalId, temporalId),
+ with: {
+ licenses: true,
+ },
+ });
+
+ if (!user) {
+ return c.json({ success: false, error: "User not found" }, 404);
+ }
+
+ if (user.temporalIdExpiresAt && user.temporalIdExpiresAt < new Date()) {
+ return c.json({ success: false, error: "Session expired" }, 400);
+ }
+
+ const suscriptions: Stripe.Subscription[] = [];
+ for (const license of user.licenses) {
+ const suscription = await stripe.subscriptions.retrieve(
+ license.stripeSubscriptionId,
+ );
+
+ suscriptions.push(suscription);
+ }
+
+ const formated = user.licenses.map((license) => {
+ const suscription = suscriptions.find(
+ (suscription) => suscription.id === license.stripeSubscriptionId,
+ );
+
+ return {
+ license: license,
+ stripeSuscription: {
+ quantity: suscription?.items.data[0].quantity,
+ billingType: suscription?.items.data[0].price.recurring?.interval,
+ },
+ };
+ });
+
+ return c.json({ success: true, licenses: formated });
+ },
+);
diff --git a/apps/licenses/src/api/stripe.ts b/apps/licenses/src/api/stripe.ts
new file mode 100644
index 000000000..463336c93
--- /dev/null
+++ b/apps/licenses/src/api/stripe.ts
@@ -0,0 +1,180 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { createCheckoutSessionSchema } from "../validators/stripe";
+import { getStripeItems } from "../utils/license";
+import { stripe } from "../stripe";
+import { WEBSITE_URL } from "../constants";
+import { logger } from "../logger";
+import { eq } from "drizzle-orm";
+import { licenses } from "../schema";
+import { db } from "../db";
+import { getLicenseFeatures, getLicenseTypeFromPriceId } from "../utils";
+import { z } from "zod";
+import type Stripe from "stripe";
+import { createLicense } from "../utils/license";
+import { render } from "@react-email/render";
+import { LicenseEmail } from "../../templates/emails/license-email";
+import { transporter } from "../email";
+
+export const stripeRouter = new Hono();
+
+stripeRouter.post(
+ "/create-checkout-session",
+ zValidator("json", createCheckoutSessionSchema),
+ async (c) => {
+ const { type, serverQuantity, isAnnual } = c.req.valid("json");
+
+ const items = getStripeItems(type, serverQuantity, isAnnual);
+ const session = await stripe.checkout.sessions.create({
+ mode: "subscription",
+ line_items: items,
+ allow_promotion_codes: true,
+ success_url: `${WEBSITE_URL}/license/success?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${WEBSITE_URL}#pricing`,
+ });
+
+ return c.json({ sessionId: session.id });
+ },
+);
+
+stripeRouter.get(
+ "/get-license-from-session",
+ zValidator("query", z.object({ sessionId: z.string().min(1) })),
+ async (c) => {
+ const { sessionId } = c.req.valid("query");
+ console.log("Session ID", sessionId);
+
+ if (!sessionId) {
+ return c.json({ error: "Session ID is required" }, 400);
+ }
+
+ try {
+ const session = await stripe.checkout.sessions.retrieve(sessionId);
+ if (session.status !== "complete") {
+ return c.json({ error: "Session is not complete" }, 400);
+ }
+
+ const subscription = await stripe.subscriptions.retrieve(
+ session.subscription as string,
+ );
+
+ const license = await db.query.licenses.findFirst({
+ where: eq(licenses.stripeSubscriptionId, subscription.id),
+ });
+
+ const priceId = subscription.items.data[0].price.id;
+ const { type, billingType } = getLicenseTypeFromPriceId(priceId);
+
+ return c.json({ type, billingType, key: license?.licenseKey });
+ } catch (error) {
+ logger.error("Error retrieving session:", error);
+ return c.json({ error: "Error retrieving session" }, 400);
+ }
+ },
+);
+
+stripeRouter.post("/webhook", async (c) => {
+ const rawBody = await c.req.raw.text();
+ const sig = c.req.header("stripe-signature");
+
+ let event: Stripe.Event;
+
+ try {
+ event = stripe.webhooks.constructEvent(
+ rawBody,
+ sig!,
+ process.env.STRIPE_WEBHOOK_SECRET!,
+ );
+ } catch (err) {
+ logger.error("Webhook signature verification failed:", err);
+ return c.json({ error: "Webhook signature verification failed" }, 400);
+ }
+
+ const allowedEvents = ["invoice.paid"];
+
+ if (!allowedEvents.includes(event.type)) {
+ return c.json({ error: "Event not allowed" }, 400);
+ }
+
+ try {
+ switch (event.type) {
+ case "invoice.paid": {
+ const invoice = event.data.object as Stripe.Invoice;
+
+ if (!invoice.subscription) break;
+
+ if (invoice.billing_reason === "subscription_create") {
+ const customerResponse = await stripe.customers.retrieve(
+ invoice.customer as string,
+ );
+
+ if (customerResponse.deleted) {
+ throw new Error("Customer was deleted");
+ }
+
+ const subscriptionId = invoice.subscription as string;
+ const subscription =
+ await stripe.subscriptions.retrieve(subscriptionId);
+ const priceId = subscription.items.data[0].price.id;
+ const { type } = getLicenseTypeFromPriceId(priceId);
+
+ const { license, user } = await createLicense({
+ productId: subscriptionId,
+ email: customerResponse.email!.toLowerCase(),
+ stripeCustomerId: customerResponse.id,
+ stripeSubscriptionId: subscriptionId,
+ });
+
+ const features = getLicenseFeatures(type);
+ const emailHtml = await render(
+ LicenseEmail({
+ customerName: customerResponse.name || "Customer",
+ licenseKey: license.licenseKey,
+ productName: `Dokploy Self Hosted ${type}`,
+ features: features,
+ }),
+ );
+
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM_ADDRESS,
+ to: user.email,
+ subject: "Your Dokploy License Key ",
+ html: emailHtml,
+ });
+ }
+
+ break;
+ }
+ }
+
+ return c.json({ received: true });
+ } catch (error) {
+ console.error("Error processing webhook:", error);
+ if (error instanceof Error) {
+ return c.json({ error: error.message }, 500);
+ }
+ return c.json({ error: "Unknown error occurred" }, 500);
+ }
+});
+
+stripeRouter.post(
+ "/create-customer-portal-session",
+ zValidator("json", z.object({ customerId: z.string().min(1) })),
+ async (c) => {
+ try {
+ const { customerId } = c.req.valid("json");
+
+ console.log("Customer ID", customerId);
+
+ const session = await stripe.billingPortal.sessions.create({
+ customer: customerId,
+ return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
+ });
+
+ return c.json({ url: session.url });
+ } catch (error) {
+ logger.error("Error creating customer portal session:", error);
+ return c.json({ error: "Error creating customer portal session" }, 500);
+ }
+ },
+);
diff --git a/apps/licenses/src/index.ts b/apps/licenses/src/index.ts
index 12d29dc3f..6d72be2a0 100644
--- a/apps/licenses/src/index.ts
+++ b/apps/licenses/src/index.ts
@@ -1,29 +1,12 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
-import { z } from "zod";
-import { zValidator } from "@hono/zod-validator";
import { logger } from "./logger";
-import { render } from "@react-email/render";
-import { LicenseEmail } from "../templates/emails/license-email";
-import { ResendLicenseEmail } from "../templates/emails/resend-license-email";
-import {
- createLicense,
- validateLicense,
- activateLicense,
- deactivateLicense,
- getStripeItems,
-} from "./utils/license";
import { db } from "./db";
-import { eq, sql } from "drizzle-orm";
-import { licenses } from "./schema";
+import { sql } from "drizzle-orm";
import "dotenv/config";
-import { getLicenseFeatures, getLicenseTypeFromPriceId } from "./utils";
-import { transporter } from "./email";
-import type Stripe from "stripe";
-import { stripe } from "./stripe";
-import { WEBSITE_URL } from "./constants";
-import { createCheckoutSessionSchema } from "./validators/stripe";
+import { licenseRouter } from "./api/license";
+import { stripeRouter } from "./api/stripe";
const app = new Hono();
const router = new Hono();
@@ -34,15 +17,6 @@ router.use(
}),
);
-const validateSchema = z.object({
- licenseKey: z.string(),
- serverIp: z.string(),
-});
-
-const resendSchema = z.object({
- licenseKey: z.string(),
-});
-
router.get("/health", async (c) => {
try {
await db.execute(sql`SELECT 1`);
@@ -53,264 +27,9 @@ router.get("/health", async (c) => {
}
});
-router.post("/validate", zValidator("json", validateSchema), async (c) => {
- const { licenseKey, serverIp } = c.req.valid("json");
-
- try {
- const result = await validateLicense(licenseKey, serverIp);
- console.log("Result", result);
- return c.json(result);
- } catch (error) {
- logger.error("Error validating license:", { error });
- return c.json({ isValid: false, error: "Error validating license" }, 500);
- }
-});
-
-router.post("/activate", zValidator("json", validateSchema), async (c) => {
- const { licenseKey, serverIp } = c.req.valid("json");
-
- try {
- const license = await activateLicense(licenseKey, serverIp);
- return c.json({ success: true, license });
- } catch (error) {
- logger.error("Error activating license:", error);
- if (error instanceof Error) {
- return c.json({ success: false, error: error.message }, 400);
- }
- return c.json({ success: false, error: "Unknown error occurred" }, 400);
- }
-});
-
-router.post(
- "/create-checkout-session",
- zValidator("json", createCheckoutSessionSchema),
- async (c) => {
- const { type, serverQuantity, isAnnual } = c.req.valid("json");
-
- const items = getStripeItems(type, serverQuantity, isAnnual);
- const session = await stripe.checkout.sessions.create({
- mode: "subscription",
- line_items: items,
- allow_promotion_codes: true,
- success_url: `${WEBSITE_URL}/license/success`,
- cancel_url: `${WEBSITE_URL}#pricing`,
- });
-
- return c.json({ sessionId: session.id });
- },
-);
-
-router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
- const { licenseKey } = c.req.valid("json");
-
- try {
- const license = await db.query.licenses.findFirst({
- where: eq(licenses.licenseKey, licenseKey),
- });
-
- if (!license) {
- return c.json({ success: false, error: "License not found" }, 404);
- }
-
- const emailHtml = await render(
- ResendLicenseEmail({
- licenseKey: license.licenseKey,
- productName: `Dokploy Self Hosted ${license.type}`,
- // TODO: Add expiration date
- expirationDate: new Date(),
- requestDate: new Date(),
- customerName: license.email,
- }),
- );
-
- await transporter.sendMail({
- from: process.env.SMTP_FROM_ADDRESS,
- to: license.email,
- subject: "Your Dokploy License Key",
- html: emailHtml,
- });
-
- return c.json({ success: true });
- } catch (error) {
- logger.error("Error resending license:", error);
- return c.json({ success: false, error: "Error resending license" }, 500);
- }
-});
-router.post("/stripe/webhook", async (c) => {
- const rawBody = await c.req.raw.text();
- const sig = c.req.header("stripe-signature");
-
- let event: Stripe.Event;
-
- try {
- event = stripe.webhooks.constructEvent(
- rawBody,
- sig!,
- process.env.STRIPE_WEBHOOK_SECRET!,
- );
- } catch (err) {
- logger.error("Webhook signature verification failed:", err);
- 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",
- "invoice.paid",
- ];
-
- if (!allowedEvents.includes(event.type)) {
- return c.json({ error: "Event not allowed" }, 400);
- }
-
- try {
- switch (event.type) {
- case "customer.subscription.updated": {
- const subscription = event.data.object as Stripe.Subscription;
-
- const customerResponse = await stripe.customers.retrieve(
- subscription.customer as string,
- );
-
- if (subscription.status !== "active" || customerResponse.deleted) {
- await deactivateLicense(subscription.id);
- }
- break;
- }
-
- case "invoice.paid": {
- const invoice = event.data.object as Stripe.Invoice;
-
- if (!invoice.subscription) break;
-
- if (invoice.billing_reason === "subscription_create") {
- const customerResponse = await stripe.customers.retrieve(
- invoice.customer as string,
- );
-
- if (customerResponse.deleted) {
- throw new Error("Customer was deleted");
- }
-
- const subscriptionId = invoice.subscription as string;
- const subscription =
- await stripe.subscriptions.retrieve(subscriptionId);
- const priceId = subscription.items.data[0].price.id;
- const { type, billingType } = getLicenseTypeFromPriceId(priceId);
-
- const license = await createLicense({
- productId: subscriptionId,
- type,
- billingType,
- email: customerResponse.email!,
- stripeCustomerId: customerResponse.id,
- stripeSubscriptionId: subscriptionId,
- });
-
- const features = getLicenseFeatures(type);
- const emailHtml = await render(
- LicenseEmail({
- customerName: customerResponse.name || "Customer",
- licenseKey: license.licenseKey,
- productName: `Dokploy Self Hosted ${type}`,
- features: features,
- }),
- );
-
- await transporter.sendMail({
- from: process.env.SMTP_FROM_ADDRESS,
- to: license.email,
- subject: "Your Dokploy License Key ",
- html: emailHtml,
- });
- }
-
- break;
- }
-
- case "invoice.payment_succeeded": {
- const invoice = event.data.object as Stripe.Invoice;
-
- if (!invoice.subscription) break;
-
- const suscription = await stripe.subscriptions.retrieve(
- invoice.subscription as string,
- );
-
- const customerResponse = await stripe.customers.retrieve(
- invoice.customer as string,
- );
- if (suscription.status !== "active" || customerResponse.deleted) {
- await deactivateLicense(invoice.subscription as string);
- break;
- }
-
- const existingLicense = await db.query.licenses.findFirst({
- where: eq(licenses.stripeCustomerId, invoice.customer as string),
- });
-
- if (!existingLicense) break;
-
- await db
- .update(licenses)
- .set({
- status: "active",
- })
- .where(eq(licenses.id, existingLicense.id));
-
- break;
- }
-
- case "invoice.payment_failed": {
- const invoice = event.data.object as Stripe.Invoice;
-
- if (!invoice.subscription) break;
-
- const subscription = await stripe.subscriptions.retrieve(
- invoice.subscription as string,
- );
-
- if (subscription.status !== "active") {
- await deactivateLicense(subscription.id);
- }
-
- break;
- }
-
- case "customer.subscription.deleted": {
- const subscription = event.data.object as Stripe.Subscription;
-
- const existingLicense = await db.query.licenses.findFirst({
- where: eq(licenses.stripeCustomerId, subscription.customer as string),
- });
-
- if (!existingLicense) break;
-
- await db
- .update(licenses)
- .set({
- status: "cancelled",
- })
- .where(eq(licenses.id, existingLicense.id));
-
- break;
- }
- }
-
- return c.json({ received: true });
- } catch (error) {
- console.error("Error processing webhook:", error);
- if (error instanceof Error) {
- return c.json({ error: error.message }, 500);
- }
- return c.json({ error: "Unknown error occurred" }, 500);
- }
-});
-
app.route("/api", router);
+app.route("/api/license", licenseRouter);
+app.route("/api/stripe", stripeRouter);
const port = process.env.PORT || 4002;
console.log(`Server is running on port http://localhost:${port}`);
diff --git a/apps/licenses/src/schema.ts b/apps/licenses/src/schema.ts
index 0c6efcd1d..5c4530694 100644
--- a/apps/licenses/src/schema.ts
+++ b/apps/licenses/src/schema.ts
@@ -1,38 +1,43 @@
import { sql } from "drizzle-orm";
-import { pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
+import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
+import { relations } from "drizzle-orm";
+export const users = pgTable("user", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ email: text("email").notNull().unique(),
+ createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
+ otpCode: text("otp_code"),
+ otpCodeExpiresAt: timestamp("otp_code_expires_at"),
+ temporalId: text("temporal_id"),
+ temporalIdExpiresAt: timestamp("temporal_id_expires_at"),
+});
-export const licenseStatusEnum = pgEnum("license_status", [
- "active",
- "expired",
- "cancelled",
- "payment_pending",
-]);
-
-export const licenseTypeEnum = pgEnum("license_type", [
- "basic",
- "premium",
- "business",
-]);
-
-export const billingTypeEnum = pgEnum("billing_type", ["monthly", "annual"]);
+export const usersRelations = relations(users, ({ many }) => ({
+ licenses: many(licenses),
+}));
export const licenses = pgTable("licenses", {
id: uuid("id").defaultRandom().primaryKey(),
productId: text("product_id").notNull(),
licenseKey: text("license_key").notNull().unique(),
- status: licenseStatusEnum("status").notNull().default("active"),
- type: licenseTypeEnum("type").notNull(),
- billingType: billingTypeEnum("billing_type").notNull(),
- serverIp: text("server_ip"),
+ serverIps: text("server_ips").array(),
activatedAt: timestamp("activated_at"),
lastVerifiedAt: timestamp("last_verified_at"),
stripeCustomerId: text("stripeCustomerId").notNull(),
+
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
metadata: text("metadata"),
- email: text("email").notNull(),
+ userId: uuid("user_id").references(() => users.id),
});
+export const licensesRelations = relations(licenses, ({ one }) => ({
+ user: one(users, {
+ fields: [licenses.userId],
+ references: [users.id],
+ }),
+}));
+
export type License = typeof licenses.$inferSelect;
export type NewLicense = typeof licenses.$inferInsert;
diff --git a/apps/licenses/src/utils/license.ts b/apps/licenses/src/utils/license.ts
index 6cb5c1a59..d30b009a2 100644
--- a/apps/licenses/src/utils/license.ts
+++ b/apps/licenses/src/utils/license.ts
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
-import { licenses } from "../schema";
+import { licenses, users } from "../schema";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import type Stripe from "stripe";
@@ -11,8 +11,6 @@ export const generateLicenseKey = () => {
interface CreateLicenseProps {
productId: string;
- type: "basic" | "premium" | "business";
- billingType: "monthly" | "annual";
email: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
@@ -20,34 +18,54 @@ interface CreateLicenseProps {
export const createLicense = async ({
productId,
- type,
- billingType,
email,
stripeCustomerId,
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
- const license = await db
- .insert(licenses)
- .values({
- productId,
- licenseKey,
- type,
- billingType,
- email,
- stripeCustomerId,
- stripeSubscriptionId,
- })
- .returning();
+ return await db.transaction(async (tx) => {
+ let user = await tx
+ .insert(users)
+ .values({ email })
+ .onConflictDoNothing()
+ .returning()
+ .then((res) => res[0]);
- return license[0];
+ if (!user) {
+ const result = await tx.query.users.findFirst({
+ where: eq(users.email, email),
+ });
+
+ if (!result) {
+ throw new Error("User not found");
+ }
+
+ user = result;
+ }
+
+ console.log("User", user);
+
+ const license = await tx
+ .insert(licenses)
+ .values({
+ productId,
+ licenseKey,
+ stripeCustomerId,
+ stripeSubscriptionId,
+ userId: user.id,
+ })
+ .returning()
+ .then((res) => res[0]);
+
+ return {
+ license,
+ user,
+ };
+ });
};
-export const validateLicense = async (
- licenseKey: string,
- serverIp?: string,
-) => {
+export const validateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
@@ -60,8 +78,6 @@ export const validateLicense = async (
license.stripeSubscriptionId,
);
- console.log("Suscription", suscription);
-
if (suscription.status !== "active") {
return {
isValid: false,
@@ -69,7 +85,7 @@ export const validateLicense = async (
};
}
- if (license.serverIp && serverIp && license.serverIp !== serverIp) {
+ if (license.serverIps && !license.serverIps.includes(serverIp)) {
return { isValid: false, error: "Invalid server IP" };
}
@@ -97,8 +113,16 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
if (suscription.status !== "active") {
throw new Error(`License is ${getLicenseStatus(suscription)}`);
}
+ const currentServerQuantity = license.serverIps?.length || 0;
+ const serversQuantity = suscription.items.data[0].quantity || 0;
- if (license.serverIp && license.serverIp !== serverIp) {
+ if (currentServerQuantity >= serversQuantity) {
+ throw new Error(
+ "You have reached the maximum number of servers, please upgrade your license to add more servers",
+ );
+ }
+
+ if (license.serverIps && !license.serverIps.includes(serverIp)) {
throw new Error("License is already activated on a different server");
}
@@ -106,7 +130,7 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
const updatedLicense = await db
.update(licenses)
.set({
- serverIp,
+ serverIps: [...(license.serverIps || []), serverIp],
activatedAt: new Date(),
lastVerifiedAt: new Date(),
})
@@ -116,21 +140,6 @@ export const activateLicense = async (licenseKey: string, serverIp: string) => {
return updatedLicense[0];
};
-export const deactivateLicense = async (stripeSubscriptionId: string) => {
- const license = await db.query.licenses.findFirst({
- where: eq(licenses.stripeSubscriptionId, stripeSubscriptionId),
- });
-
- if (!license) {
- throw new Error("License not found");
- }
-
- await db
- .update(licenses)
- .set({ status: "cancelled" })
- .where(eq(licenses.id, license.id));
-};
-
export const getLicenseStatus = async (license: Stripe.Subscription) => {
if (license.status === "active") {
return "active";
diff --git a/apps/licenses/templates/emails/resend-license-email.tsx b/apps/licenses/templates/emails/resend-license-email.tsx
index 940a343e6..c874e8a5d 100644
--- a/apps/licenses/templates/emails/resend-license-email.tsx
+++ b/apps/licenses/templates/emails/resend-license-email.tsx
@@ -18,7 +18,6 @@ interface ResendLicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
- expirationDate: Date;
requestDate?: Date;
}
@@ -28,15 +27,8 @@ export const ResendLicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
- expirationDate = new Date(),
requestDate = new Date(),
}: ResendLicenseEmailProps): React.ReactElement => {
- const formattedDate = expirationDate.toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
-
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
@@ -75,12 +67,6 @@ export const ResendLicenseEmail = ({
{licenseKey}
-
-
- 🗓️ Valid until: {formattedDate}
-
-
-
Quick Activation Guide
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dc721ebb7..cda13a187 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -554,6 +554,9 @@ importers:
hono:
specifier: ^4.5.8
version: 4.5.8
+ nanoid:
+ specifier: 5.1.5
+ version: 5.1.5
nodemailer:
specifier: 6.9.14
version: 6.9.14
@@ -6059,6 +6062,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@5.1.5:
+ resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
nanostores@0.11.3:
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -13247,6 +13255,8 @@ snapshots:
nanoid@3.3.8: {}
+ nanoid@5.1.5: {}
+
nanostores@0.11.3: {}
napi-build-utils@1.0.2:
@@ -13718,13 +13728,13 @@ snapshots:
postcss@8.4.31:
dependencies:
- nanoid: 3.3.7
+ nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0
postcss@8.4.40:
dependencies:
- nanoid: 3.3.7
+ nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0