Compare commits

...

25 Commits

Author SHA1 Message Date
Mauricio Siu
73d9373c70 Add licenseKey column to user_temp table and update journal and snapshot metadata for version 0090 2025-05-02 03:04:51 -06:00
Mauricio Siu
bbc2cbc78c Merge branch '187-backups-for-docker-compose' into feat/introduce-monitoring-self-hosted-pay 2025-05-01 21:19:59 -06:00
Mauricio Siu
e2e6513ee1 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-05-01 21:05:46 -06:00
Mauricio Siu
4de599745e Remove SQL script that added 'licenseKey' column to 'user_temp' table 2025-04-04 01:05:53 -06:00
Mauricio Siu
60e40f4ad0 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-04-04 01:05:27 -06:00
Mauricio Siu
439d2fe116 feat(licenses): enhance license validation to check for existing server IPs
- Added a condition in the validateLicense function to return success if the server IP is already included in the license's server IPs, improving the validation logic.
2025-03-26 01:20:50 -06:00
Mauricio Siu
09d0435971 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-24 00:15:35 -06:00
Mauricio Siu
5810c94f4b feat(dashboard): enhance monitoring features with alert and toggle functionality
- Added AlertBlock component to display monitoring plan information.
- Introduced Switch component for toggling monitoring settings in both dashboard and service views.
- Updated state management for monitoring toggle to improve user experience.
- Cleaned up commented code for better readability and maintainability.
2025-03-23 20:07:29 -06:00
Mauricio Siu
1acd330462 feat(licenses): implement license deactivation and validation features 2025-03-23 19:40:54 -06:00
Mauricio Siu
4074942dbf feat(licenses): refactor license handling and introduce activation functionality
- Replaced validateLicense with saveLicense in the EnablePaidFeatures component for improved clarity.
- Updated user API to include saveLicense and activateLicense methods for better license management.
- Enhanced validateLicense function to fetch from a new endpoint and added activateLicense for server activation.
- Removed unnecessary console logging in license validation for cleaner code.
2025-03-23 19:04:24 -06:00
Mauricio Siu
5fd8fcfa1e feat(licenses): update Stripe billing portal configuration and clean up logging
- Added configuration ID for the Stripe billing portal session creation to enhance functionality.
- Removed console logging of the customer ID for improved security and cleaner code.
- Commented out the configuration creation code for future reference and potential use.
2025-03-23 18:55:43 -06:00
Mauricio Siu
39e6a98179 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.
2025-03-23 18:32:50 -06:00
Mauricio Siu
5180c785b4 feat(licenses): enhance Stripe webhook handling for license creation
- Added support for the "invoice.paid" event to create licenses upon successful payment.
- Refactored the handling of subscription updates to improve license deactivation logic.
- Removed the expiration date from the LicenseEmail component to simplify email content.
- Improved error handling for customer retrieval and subscription status checks.
2025-03-23 12:58:40 -06:00
Mauricio Siu
c8e6df4c29 feat(licenses): enhance license validation and loading state management
- Added loading state management in the EnablePaidFeatures component to improve user experience during license validation.
- Updated validateLicense function to return detailed error messages for better feedback.
- Modified user API to return validation results instead of a boolean, enhancing error handling.
- Removed unused SQL files and updated package.json scripts for better development workflow.
2025-03-23 12:29:55 -06:00
Mauricio Siu
9e30525569 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.
2025-03-23 12:15:25 -06:00
Mauricio Siu
b7874f053f feat(licenses): enhance license validation process
- Updated the validateLicense function to accept server IP for improved validation.
- Modified user API to utilize the new validateLicense signature.
- Added useEffect in EnablePaidFeatures component to set license key based on user data.
- Improved error handling and user feedback during license validation.
2025-03-23 04:55:16 -06:00
Mauricio Siu
fb0272a64d Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-23 04:42:56 -06:00
Mauricio Siu
2f4a3c964c fix(licenses): update README with new development server URL
- Changed the local development server URL from http://localhost:3000 to http://localhost:4002/api/health to reflect recent updates in the API integration.
2025-03-20 01:32:30 -06:00
Mauricio Siu
feb6970b09 feat(licenses): enhance API and database integration for checkout sessions
- Updated development server port in package.json to 4002.
- Introduced constants for website URLs based on environment.
- Refactored database connection logic to use drizzle with PostgreSQL.
- Added new API endpoint for creating checkout sessions with Stripe integration.
- Implemented utility function to generate Stripe items based on license type and quantity.
- Updated existing API routes to use a router for better organization.
2025-03-20 01:32:13 -06:00
Mauricio Siu
78682fa359 feat(licenses): update database schema and enhance license management
- Refactored migration script to import schema from the correct path.
- Updated package.json scripts for improved execution of migration and truncation tasks.
- Added new SQL file to define license-related types and table structure.
- Enhanced license management with new fields for Stripe customer and subscription IDs.
- Implemented license deactivation logic and improved error handling in license validation.
- Introduced health check endpoint for database connectivity verification.
2025-03-19 01:31:38 -06:00
Mauricio Siu
473d729416 feat(licenses): add migration and truncation scripts for database management
- Introduced migration script to handle database schema updates using Drizzle ORM.
- Added truncation script to clear the database schema and reset the state.
- Updated package.json scripts for easier execution of migration and truncation tasks.
- Refactored email and Stripe integration for better modularity and maintainability.
2025-03-19 00:57:14 -06:00
Mauricio Siu
523720606d Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-19 00:53:57 -06:00
Mauricio Siu
4b6db35f16 feat(licenses): implement license management system with email notifications
- Added database schema for licenses, including types and statuses.
- Implemented license creation, validation, and activation functionalities.
- Integrated email templates for sending license keys and resend requests.
- Updated package dependencies and configuration for PostgreSQL integration.
- Introduced migration and truncation scripts for database management.
- Enhanced API endpoints for license operations with error handling and validation.
2025-03-19 00:36:35 -06:00
Mauricio Siu
9d047164ee Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-18 21:51:09 -06:00
Mauricio Siu
a61436b8f0 feat(licenses): implement license management and validation features
- Added a new licenses application to handle license key management.
- Implemented API endpoints for validating license keys and managing paid features.
- Introduced a new component for enabling paid features in the dashboard.
- Updated database schema to include a licenseKey field for users.
- Refactored server API to remove the admin router and integrate license validation into user operations.
- Enhanced the monitoring setup process to require valid license keys.
- Updated pnpm workspace configuration to include the new licenses app.
2025-03-17 15:50:04 -06:00
44 changed files with 11639 additions and 104 deletions

View File

@@ -0,0 +1,191 @@
import { Card } from "@/components/ui/card";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SparklesIcon } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { api } from "@/utils/api";
import { SetupMonitoring } from "./servers/setup-monitoring";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
export const EnablePaidFeatures = () => {
const { data, refetch } = api.user.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation();
const { mutateAsync: deactivateLicense } =
api.user.deactivateLicense.useMutation();
const { mutateAsync: validateLicense } =
api.user.validateLicense.useMutation();
const { mutateAsync: update } = api.user.update.useMutation();
const [licenseKey, setLicenseKey] = useState("");
useEffect(() => {
if (data?.user?.enablePaidFeatures) {
setLicenseKey(data.user.licenseKey || "");
}
}, [data?.user?.enablePaidFeatures]);
const handleSaveLicense = async () => {
if (!licenseKey) {
toast.error("Please enter a license key");
return;
}
setIsLoading(true);
await saveLicense({
licenseKey,
})
.then(() => {
toast.success("License validated successfully");
})
.catch((e) => {
toast.error("Error validating license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
refetch();
};
const handleValidateLicense = async () => {
if (!licenseKey) {
toast.error("Please enter a license key");
return;
}
setIsLoading(true);
await validateLicense({
licenseKey,
})
.then(() => {
toast.success("License validated successfully");
})
.catch((e) => {
toast.error("Error validating license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
};
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<SparklesIcon className="size-5 text-primary" />
</div>
Paid Features
</CardTitle>
<CardDescription className="mt-2">
Unlock advanced capabilities like monitoring and enhanced
performance tracking
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between p-4 border rounded-lg bg-card/50 hover:bg-card/80 transition-colors">
<div className="space-y-1">
<h3 className="font-medium">Enable Premium Features</h3>
<p className="text-sm text-muted-foreground">
Access advanced monitoring tools and premium capabilities
</p>
</div>
<Switch
className="ml-4"
checked={data?.user?.enablePaidFeatures}
onCheckedChange={() => {
update({
enablePaidFeatures: !data?.user?.enablePaidFeatures,
})
.then(() => {
toast.success(
`Premium features ${
data?.user?.enablePaidFeatures
? "disabled"
: "enabled"
} successfully`,
);
refetch();
})
.catch(() => {
toast.error("Error updating premium features");
});
}}
/>
</div>
{data?.user?.enablePaidFeatures && (
<div className="flex flex-row items-center gap-4 p-4 border rounded-lg bg-card/50">
<div className="flex-grow">
<Input
placeholder="Enter your license key"
value={licenseKey}
disabled={data?.user?.licenseKey !== null}
onChange={(e) => setLicenseKey(e.target.value)}
className="w-full"
/>
</div>
{!data?.user?.licenseKey ? (
<Button
onClick={handleSaveLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save License"}
</Button>
) : (
<div className="flex gap-2">
<Button
onClick={handleValidateLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Validating..." : "Validate"}
</Button>
<Button
onClick={() => {
setIsLoading(true);
deactivateLicense({
licenseKey,
})
.then(() => {
setLicenseKey("");
toast.success("License removed successfully");
refetch();
})
.catch((e) => {
toast.error("Error removing license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
}}
variant="destructive"
disabled={isLoading}
>
Remove
</Button>
</div>
)}
</div>
)}
</div>
</CardContent>
{data?.user?.enablePaidFeatures && <SetupMonitoring />}
</div>
</Card>
);
};

View File

@@ -202,7 +202,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
const { mutateAsync } = serverId
? api.server.setupMonitoring.useMutation()
: api.admin.setupMonitoring.useMutation();
: api.user.setupMonitoring.useMutation();
const generateToken = () => {
const array = new Uint8Array(64);

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "licenseKey" text;

File diff suppressed because it is too large Load Diff

View File

@@ -631,6 +631,13 @@
"when": 1745812150155,
"tag": "0089_dazzling_marrow",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1746156076450,
"tag": "0090_tiny_phil_sheldon",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,10 @@
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Card } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { api } from "@/utils/api";
import { IS_CLOUD } from "@dokploy/server/constants";
@@ -23,7 +26,7 @@ const Dashboard = () => {
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
return (
<div className="space-y-4 pb-10">
{/* <AlertBlock>
<AlertBlock>
You are watching the <strong>Free</strong> plan.{" "}
<a
href="https://dokploy.com#pricing"
@@ -34,7 +37,7 @@ const Dashboard = () => {
Upgrade
</a>{" "}
to get more features.
</AlertBlock> */}
</AlertBlock>
{isLoading ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
@@ -44,15 +47,15 @@ const Dashboard = () => {
</Card>
) : (
<>
{/* {monitoring?.enabledFeatures && (
{monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">Change Monitoring</Label>
<Switch
checked={toggleMonitoring}
onCheckedChange={setToggleMonitoring}
onCheckedChange={_setToggleMonitoring}
/>
</div>
)} */}
)}
{toggleMonitoring ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
<div className="rounded-xl bg-background shadow-md">

View File

@@ -28,6 +28,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
@@ -65,7 +66,7 @@ type TabState =
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const [_toggleMonitoring, _setToggleMonitoring] = useState(false);
const [_toggleMonitoring, _setToggleMonitoring] = useState(true);
const { applicationId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
@@ -86,6 +87,7 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
return (
<div className="pb-10">
@@ -265,21 +267,19 @@ const Service = (
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
isCloud &&
data?.serverId && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
</Label>
<Switch
checked={toggleMonitoring}
onCheckedChange={setToggleMonitoring}
/>
</div>
)} */}
{monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
</Label>
<Switch
checked={_toggleMonitoring}
onCheckedChange={_setToggleMonitoring}
/>
</div>
)}
{/* {toggleMonitoring ? (
{_toggleMonitoring ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
@@ -287,13 +287,13 @@ const Service = (
monitoring?.metricsConfig?.server?.token || ""
}
/>
) : ( */}
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
{/* )} */}
) : (
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
)}
</>
)}
</div>

View File

@@ -1,5 +1,6 @@
import { WebDomain } from "@/components/dashboard/settings/web-domain";
import { WebServer } from "@/components/dashboard/settings/web-server";
import { EnablePaidFeatures } from "@/components/dashboard/settings/enable-paid-features";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
@@ -12,6 +13,7 @@ import { api } from "@/utils/api";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { Card } from "@/components/ui/card";
const Page = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
@@ -27,6 +29,7 @@ const Page = () => {
/>
</Card>
</div>
{!isCloud && <EnablePaidFeatures />}
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import { createTRPCRouter } from "../api/trpc";
import { adminRouter } from "./routers/admin";
import { aiRouter } from "./routers/ai";
import { applicationRouter } from "./routers/application";
import { backupRouter } from "./routers/backup";
@@ -42,7 +41,6 @@ import { userRouter } from "./routers/user";
*/
export const appRouter = createTRPCRouter({
admin: adminRouter,
docker: dockerRouter,
project: projectRouter,
application: applicationRouter,

View File

@@ -1,61 +0,0 @@
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
import {
IS_CLOUD,
findUserById,
setupWebMonitoring,
updateUser,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.ownerId);
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
refreshRate: input.metricsConfig.server.refreshRate,
port: input.metricsConfig.server.port,
token: input.metricsConfig.server.token,
cronJob: input.metricsConfig.server.cronJob,
urlCallback: input.metricsConfig.server.urlCallback,
retentionDays: input.metricsConfig.server.retentionDays,
thresholds: {
cpu: input.metricsConfig.server.thresholds.cpu,
memory: input.metricsConfig.server.thresholds.memory,
},
},
containers: {
refreshRate: input.metricsConfig.containers.refreshRate,
services: {
include: input.metricsConfig.containers.services.include || [],
exclude: input.metricsConfig.containers.services.exclude || [],
},
},
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;
}
}),
});

View File

@@ -5,6 +5,7 @@ import {
findUserById,
getUserByToken,
removeUserById,
setupWebMonitoring,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -13,6 +14,7 @@ import {
apiAssignPermissions,
apiFindOneToken,
apiUpdateUser,
apiUpdateWebServerMonitoring,
apikey,
invitation,
member,
@@ -27,7 +29,11 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import {
validateLicense,
activateLicense,
deactivateLicense,
} from "@/server/utils/validate-license";
const apiCreateApiKey = z.object({
name: z.string().min(1),
prefix: z.string().optional(),
@@ -160,6 +166,73 @@ export const userRouter = createTRPCRouter({
}
return await updateUser(ctx.user.id, input);
}),
saveLicense: adminProcedure
.input(
z.object({
licenseKey: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await activateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
await updateUser(ctx.user.id, {
licenseKey: input.licenseKey,
});
return result;
}),
deactivateLicense: adminProcedure
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await deactivateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
await updateUser(ctx.user.id, {
licenseKey: null,
});
return result;
}),
validateLicense: adminProcedure
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await validateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
return result;
}),
getUserByToken: publicProcedure
.input(apiFindOneToken)
.query(async ({ input }) => {
@@ -349,4 +422,62 @@ export const userRouter = createTRPCRouter({
return organizations.length;
}),
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.id);
if (!validateLicense(user?.licenseKey || "", user?.serverIp || "")) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid license key",
});
}
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
refreshRate: input.metricsConfig.server.refreshRate,
port: input.metricsConfig.server.port,
token: input.metricsConfig.server.token,
cronJob: input.metricsConfig.server.cronJob,
urlCallback: input.metricsConfig.server.urlCallback,
retentionDays: input.metricsConfig.server.retentionDays,
thresholds: {
cpu: input.metricsConfig.server.thresholds.cpu,
memory: input.metricsConfig.server.thresholds.memory,
},
},
containers: {
refreshRate: input.metricsConfig.containers.refreshRate,
services: {
include: input.metricsConfig.containers.services.include || [],
exclude: input.metricsConfig.containers.services.exclude || [],
},
},
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;
}
}),
});

View File

@@ -0,0 +1,47 @@
const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002";
export const validateLicense = async (licenseKey: string, serverIp: string) => {
const response = await fetch(`${licensesUrl}/api/license/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
if (!response.ok && data.error?.issues) {
console.log("Validation errors:", data.error.issues);
}
return data;
};
export const activateLicense = async (licenseKey: string, serverIp: string) => {
const response = await fetch(`${licensesUrl}/api/license/activate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
return data;
};
export const deactivateLicense = async (
licenseKey: string,
serverIp: string,
) => {
const response = await fetch(`${licensesUrl}/api/license/deactivate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
return data;
};

View File

@@ -0,0 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""

28
apps/licenses/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

8
apps/licenses/README.md Normal file
View File

@@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:4002/api/health
```

View File

@@ -0,0 +1,15 @@
import type { Config } from "drizzle-kit";
import "dotenv/config";
const connectionString = process.env.DATABASE_URL!;
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
verbose: true,
strict: true,
} satisfies Config;

View File

@@ -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;

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1742773711509,
"tag": "0000_famous_vermin",
"breakpoints": true
}
]
}

22
apps/licenses/migrate.ts Normal file
View File

@@ -0,0 +1,22 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import "dotenv/config";
import * as schema from "./src/schema";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql, { schema });
await migrate(db, { migrationsFolder: "drizzle" })
.then(() => {
console.log("Migration complete");
sql.end();
})
.catch((error) => {
console.error("Migration failed", error);
process.exit(1);
})
.finally(() => {
sql.end();
});

View File

@@ -0,0 +1,48 @@
{
"name": "@dokploy/licenses",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "PORT=4002 tsx watch -r dotenv/config src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate",
"drop": "drizzle-kit drop",
"push": "drizzle-kit push",
"migrate": "tsx ./migrate.ts",
"truncate": "tsx ./truncate.ts",
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
"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",
"@react-email/render": "^1.0.5",
"@types/pg": "^8.11.11",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.39.1",
"hono": "^4.5.8",
"nodemailer": "6.9.14",
"pg": "^8.14.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"stripe": "17.2.0",
"zod": "^3.23.4"
},
"devDependencies": {
"@types/node": "^20.11.17",
"@types/nodemailer": "^6.4.16",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"drizzle-kit": "^0.30.4",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
},
"packageManager": "pnpm@9.5.0"
}

View File

@@ -0,0 +1,265 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import {
activateLicense,
deactivateLicense,
validateLicense,
cleanLicense,
} from "../utils/license";
import { logger } from "../logger";
import { eq, desc } from "drizzle-orm";
import { users, licenses } from "../schema";
import { db } from "../db";
import { transporter } from "../email";
import { nanoid } from "nanoid";
import { stripe } from "../stripe";
import type Stripe from "stripe";
import { getLicenseTypeFromPriceId } from "../utils";
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);
return c.json(result);
} catch (error) {
logger.error("Error validating license:", { error });
return c.json({ success: 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);
}
},
);
licenseRouter.post(
"/deactivate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await deactivateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error deactivating license:", error);
return c.json(
{ success: false, error: "Error deactivating license" },
500,
);
}
},
);
licenseRouter.post(
"/remove-server",
zValidator(
"json",
z.object({ licenseKey: z.string().min(1), serverIp: z.string().min(1) }),
),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await cleanLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error cleaning license:", error);
return c.json({ success: false, error: "Error cleaning license" }, 500);
}
},
);
// 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,
},
orderBy: desc(licenses.createdAt),
});
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,
);
const { type } = getLicenseTypeFromPriceId(
suscription?.items.data[0].price.id || "",
);
return {
license: license,
stripeSuscription: {
quantity: suscription?.items.data[0].quantity,
billingType: suscription?.items.data[0].price.recurring?.interval,
type: type,
},
};
});
return c.json({ success: true, licenses: formated });
},
);

View File

@@ -0,0 +1,220 @@
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");
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
configuration: process.env.STRIPE_PORTAL_CONFIGURATION_ID,
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);
}
},
);
// Execute 1 time to create the configuration
// const configuration = await stripe.billingPortal.configurations.create({
// business_profile: {
// headline: "Manage your Dokploy subscription",
// },
// features: {
// subscription_update: {
// enabled: true,
// products: [
// {
// product: "prod_RxSOzNrwlfVWmv",
// prices: [
// "price_1R3XTqF3cxQuHeOzZjgaG262",
// "price_1R3XmmF3cxQuHeOzCEjz0HFz",
// ],
// },
// {
// product: "prod_RxSVDolGpUy97g",
// prices: [
// "price_1R3XanF3cxQuHeOzh7VdbbUs",
// "price_1R3Xp4F3cxQuHeOzUTNYDC9I",
// ],
// },
// {
// product: "prod_RxSaEQpbsiPFgv",
// prices: [
// "price_1R3XfOF3cxQuHeOzYFUa0eNy",
// "price_1R3XptF3cxQuHeOzZEBGMsEm",
// ],
// },
// ],
// default_allowed_updates: ["price", "quantity"],
// },
// subscription_cancel: {
// enabled: true,
// },
// payment_method_update: {
// enabled: true,
// },
// },
// });

View File

@@ -0,0 +1,4 @@
export const WEBSITE_URL =
process.env.NODE_ENV === "development"
? "http://localhost:3001"
: process.env.SITE_URL;

20
apps/licenses/src/db.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
declare global {
var db: PostgresJsDatabase<typeof schema> | undefined;
}
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
db = global.db;
}

View File

@@ -0,0 +1,10 @@
import { createTransport } from "nodemailer";
export const transporter = createTransport({
host: process.env.SMTP_SERVER,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
});

View File

@@ -0,0 +1,39 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "./logger";
import { db } from "./db";
import { sql } from "drizzle-orm";
import "dotenv/config";
import { licenseRouter } from "./api/license";
import { stripeRouter } from "./api/stripe";
const app = new Hono();
const router = new Hono();
router.use(
"/*",
cors({
origin: ["http://localhost:3001"],
}),
);
router.get("/health", async (c) => {
try {
await db.execute(sql`SELECT 1`);
return c.json({ status: "ok" });
} catch (error) {
logger.error("Database connection error:", error);
return c.json({ status: "error" }, 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}`);
serve({
fetch: app.fetch,
port: Number(port),
});

View File

@@ -0,0 +1,10 @@
import pino from "pino";
export const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});

View File

@@ -0,0 +1,43 @@
import { sql } from "drizzle-orm";
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 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(),
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"),
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;

View File

@@ -0,0 +1,5 @@
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});

View File

@@ -0,0 +1,70 @@
export const getLicenseFeatures = (type: string): string[] => {
const baseFeatures = [
"Unlimited deployments",
"Basic monitoring",
"Email support",
];
const premiumFeatures = [
...baseFeatures,
"Priority support",
"Advanced monitoring",
"Custom domains",
"Team collaboration",
];
const businessFeatures = [
...premiumFeatures,
"24/7 support",
"Custom integrations",
"SLA guarantees",
"Dedicated account manager",
];
switch (type) {
case "basic":
return baseFeatures;
case "premium":
return premiumFeatures;
case "business":
return businessFeatures;
default:
return baseFeatures;
}
};
export const getLicenseTypeFromPriceId = (
priceId: string,
): {
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
} => {
const priceMap = {
[process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID!]: {
type: "basic",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID!]: {
type: "basic",
billingType: "annual",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID!]: {
type: "premium",
billingType: "monthly",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID!]: {
type: "premium",
billingType: "annual",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID!]: {
type: "business",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID!]: {
type: "business",
billingType: "annual",
},
} as const;
return priceMap[priceId] || { type: "basic", billingType: "monthly" };
};

View File

@@ -0,0 +1,270 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { licenses, users } from "../schema";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import type Stripe from "stripe";
export const generateLicenseKey = () => {
return randomBytes(32).toString("hex");
};
interface CreateLicenseProps {
productId: string;
email: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}
export const createLicense = async ({
productId,
email,
stripeCustomerId,
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
return await db.transaction(async (tx) => {
let user = await tx
.insert(users)
.values({ email })
.onConflictDoNothing()
.returning()
.then((res) => res[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;
}
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) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
return { success: false, error: "License not found" };
}
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
const currentServerQuantity = license.serverIps?.length || 0;
const serversQuantity = suscription.items.data[0].quantity || 0;
if (license.serverIps?.includes(serverIp)) {
return {
success: true,
license,
};
}
if (currentServerQuantity >= serversQuantity) {
return {
success: false,
error:
"You have reached the maximum number of servers, please upgrade your license to add more servers",
};
}
if (suscription.status !== "active") {
return {
success: false,
error: `License is ${getLicenseStatus(suscription)}`,
};
}
if (license.serverIps && !license.serverIps.includes(serverIp)) {
return {
success: false,
error:
"This server is not authorized to use this license, please remove the current license from the UI, and activate a new one",
};
}
await db
.update(licenses)
.set({ lastVerifiedAt: new Date() })
.where(eq(licenses.id, license.id));
return { success: true, license };
};
export const activateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
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 (currentServerQuantity >= serversQuantity) {
throw new Error(
"You have reached the maximum number of servers, please upgrade your license to add more servers",
);
}
if (license.serverIps?.includes(serverIp)) {
return license;
}
// Activate the license with the server IP
const updatedLicense = await db
.update(licenses)
.set({
serverIps: [...(license.serverIps || []), serverIp],
activatedAt: new Date(),
lastVerifiedAt: new Date(),
})
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const deactivateLicense = async (
licenseKey: string,
serverIp: string,
) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const updatedLicense = await db
.update(licenses)
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const cleanLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const updatedLicense = await db
.update(licenses)
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const getLicenseStatus = async (license: Stripe.Subscription) => {
if (license.status === "active") {
return "active";
}
if (license.status === "canceled") {
return "canceled";
}
if (license.status === "incomplete") {
return "incomplete";
}
if (license.status === "incomplete_expired") {
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 = (
type: "basic" | "premium" | "business",
serverQuantity: number,
isAnnual: boolean,
) => {
const items = [];
if (type === "basic") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
} else if (type === "premium") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
} else if (type === "business") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
return items;
}
return items;
};

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const createCheckoutSessionSchema = z.object({
type: z.enum(["basic", "premium", "business"]),
serverQuantity: z.number().min(1),
isAnnual: z.boolean(),
});

View File

@@ -0,0 +1,289 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface LicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
features: string[];
}
const baseUrl = "https://dokploy.com";
export const LicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
features = ["Feature 1", "Feature 2", "Feature 3"],
}: LicenseEmailProps): React.ReactElement => {
return (
<Html>
<Head />
<Preview>Your Dokploy License Key is Here! 🚀</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>
Welcome to the Future of Deployment
</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
Thank you for choosing {productName}! We're excited to have you on
board. Your premium license key is ready to unlock all the
powerful features:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={featuresContainer}>
<Heading as="h2" style={h2}>
🎉 Your Premium Features
</Heading>
<div style={featureGrid}>
{features.map((feature, index) => (
<Text key={index} style={featureItem}>
<span style={checkmark}>✓</span> {feature}
</Text>
))}
</div>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Getting Started
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Activate Your License
</Button>
</Section>
<Hr style={hr} />
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const featuresContainer = {
margin: "40px 0",
};
const featureGrid = {
display: "grid",
gridTemplateColumns: "1fr",
gap: "12px",
};
const featureItem = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.5",
margin: "0",
display: "flex",
alignItems: "center",
};
const checkmark = {
color: "#2563eb",
fontWeight: "bold",
marginRight: "12px",
fontSize: "18px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default LicenseEmail;

View File

@@ -0,0 +1,278 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface ResendLicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
requestDate?: Date;
}
const baseUrl = "https://dokploy.com";
export const ResendLicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
requestDate = new Date(),
}: ResendLicenseEmailProps): React.ReactElement => {
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<Html>
<Head />
<Preview>Your Requested Dokploy License Key 🔑</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>Here's Your License Key</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
As requested on {formattedRequestDate}, here is your {productName}{" "}
license key. This is the same active license key associated with
your account:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your Active License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Quick Activation Guide
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key above
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Go to Dashboard
</Button>
</Section>
<Hr style={hr} />
<Section style={securitySection}>
<Text style={securityText}>
🔒 For security: If you didn't request this license key, please
contact our support team immediately.
</Text>
</Section>
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const securitySection = {
backgroundColor: "#fff5f5",
borderRadius: "8px",
padding: "16px",
margin: "0 0 32px 0",
};
const securityText = {
color: "#e53e3e",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
textAlign: "center" as const,
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default ResendLicenseEmail;

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

3495
apps/licenses/templates/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "react-email-starter",
"version": "0.1.10",
"private": true,
"type": "module",
"scripts": {
"build": "email build",
"dev": "email dev",
"export": "email export"
},
"dependencies": {
"@react-email/components": "0.0.34",
"react-dom": "18.3.1",
"react": "18.3.1"
},
"devDependencies": {
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"react-email": "3.0.7"
}
}

View File

@@ -0,0 +1,27 @@
# React Email Starter
A live preview right in your browser so you don't need to keep sending real emails during development.
## Getting Started
First, install the dependencies:
```sh
npm install
# or
yarn
```
Then, run the development server:
```sh
npm run dev
# or
yarn dev
```
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
## License
MIT License

24
apps/licenses/truncate.ts Normal file
View File

@@ -0,0 +1,24 @@
import { sql } from "drizzle-orm";
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
import { drizzle } from "drizzle-orm/postgres-js";
import "dotenv/config";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);
const clearDb = async (): Promise<void> => {
try {
const tablesQuery = sql<string>`DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP schema drizzle CASCADE;`;
const tables = await db.execute(tablesQuery);
console.log(tables);
await pg.end();
} catch (error) {
console.error("Error cleaning database", error);
} finally {
}
};
clearDb();

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]
}
},
"include": ["src/**/*", "truncate.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -58,6 +58,7 @@ export const users_temp = pgTable("user_temp", {
logCleanupCron: text("logCleanupCron"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
licenseKey: text("licenseKey"),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {

299
pnpm-lock.yaml generated
View File

@@ -300,10 +300,10 @@ importers:
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
fancy-ansi:
specifier: ^0.1.3
version: 0.1.3
@@ -525,6 +525,85 @@ importers:
specifier: ^1.6.0
version: 1.6.0(@types/node@18.19.42)(terser@5.31.3)
apps/licenses:
dependencies:
'@hono/node-server':
specifier: ^1.12.1
version: 1.12.1
'@hono/zod-validator':
specifier: 0.3.0
version: 0.3.0(hono@4.5.8)(zod@3.24.1)
'@react-email/components':
specifier: ^0.0.21
version: 0.0.21(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-email/render':
specifier: ^1.0.5
version: 1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/pg':
specifier: ^8.11.11
version: 8.11.11
dotenv:
specifier: ^16.3.1
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
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
pg:
specifier: ^8.14.1
version: 8.14.1
pino:
specifier: 9.4.0
version: 9.4.0
pino-pretty:
specifier: 11.2.2
version: 11.2.2
postgres:
specifier: 3.4.4
version: 3.4.4
react:
specifier: 18.2.0
version: 18.2.0
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
stripe:
specifier: 17.2.0
version: 17.2.0
zod:
specifier: ^3.23.4
version: 3.24.1
devDependencies:
'@types/node':
specifier: ^20.11.17
version: 20.14.10
'@types/nodemailer':
specifier: ^6.4.16
version: 6.4.16
'@types/react':
specifier: 18.3.5
version: 18.3.5
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
drizzle-kit:
specifier: ^0.30.4
version: 0.30.4
tsx:
specifier: ^4.7.1
version: 4.16.2
typescript:
specifier: ^5.4.2
version: 5.7.2
apps/schedules:
dependencies:
'@dokploy/server':
@@ -544,7 +623,7 @@ importers:
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
hono:
specifier: ^4.5.8
version: 4.5.8
@@ -659,13 +738,13 @@ importers:
version: 16.4.5
drizzle-dbml-generator:
specifier: 0.10.0
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
hi-base32:
specifier: ^0.5.1
version: 0.5.1
@@ -3075,6 +3154,13 @@ packages:
react: ^18.2.0
react-dom: ^18.2.0
'@react-email/render@1.0.5':
resolution: {integrity: sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/row@0.0.8':
resolution: {integrity: sha512-JsB6pxs/ZyjYpEML3nbwJRGAerjcN/Pa/QG48XUwnT/MioDWrUuyQuefw+CwCrSUZ2P1IDrv2tUD3/E3xzcoKw==}
engines: {node: '>=18.0.0'}
@@ -3533,6 +3619,9 @@ packages:
'@types/nodemailer@6.4.16':
resolution: {integrity: sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==}
'@types/pg@8.11.11':
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
'@types/prop-types@15.7.12':
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
@@ -5766,6 +5855,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}
@@ -5928,6 +6022,9 @@ packages:
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
engines: {node: '>= 0.4'}
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
octokit@3.1.2:
resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==}
engines: {node: '>= 18'}
@@ -6072,6 +6169,48 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
pg-cloudflare@1.1.1:
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
pg-connection-string@2.7.0:
resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-numeric@1.0.2:
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
engines: {node: '>=4'}
pg-pool@3.8.0:
resolution: {integrity: sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.8.0:
resolution: {integrity: sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg-types@4.0.2:
resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==}
engines: {node: '>=10'}
pg@8.14.1:
resolution: {integrity: sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==}
engines: {node: '>= 8.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
@@ -6166,6 +6305,41 @@ packages:
resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-array@3.0.4:
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
engines: {node: '>=12'}
postgres-bytea@1.0.0:
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
engines: {node: '>=0.10.0'}
postgres-bytea@3.0.0:
resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==}
engines: {node: '>= 6'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-date@2.1.0:
resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==}
engines: {node: '>=12'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres-interval@3.0.0:
resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==}
engines: {node: '>=12'}
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
postgres@3.4.4:
resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==}
engines: {node: '>=12'}
@@ -6175,6 +6349,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
prettier@3.4.2:
resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -8129,6 +8308,11 @@ snapshots:
hono: 4.5.8
zod: 3.23.8
'@hono/zod-validator@0.3.0(hono@4.5.8)(zod@3.24.1)':
dependencies:
hono: 4.5.8
zod: 3.24.1
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
dependencies:
react-hook-form: 7.52.1(react@18.2.0)
@@ -9539,6 +9723,14 @@ snapshots:
react-dom: 18.2.0(react@18.2.0)
react-promise-suspense: 0.3.4
'@react-email/render@1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
html-to-text: 9.0.5
prettier: 3.4.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-promise-suspense: 0.3.4
'@react-email/row@0.0.8(react@18.2.0)':
dependencies:
react: 18.2.0
@@ -10208,6 +10400,12 @@ snapshots:
dependencies:
'@types/node': 20.14.10
'@types/pg@8.11.11':
dependencies:
'@types/node': 20.14.10
pg-protocol: 1.8.0
pg-types: 4.0.2
'@types/prop-types@15.7.12': {}
'@types/qrcode@1.5.5':
@@ -11169,9 +11367,9 @@ snapshots:
drange@1.1.1: {}
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
dependencies:
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-kit@0.30.4:
dependencies:
@@ -11182,18 +11380,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.11.11
'@types/react': 18.3.5
kysely: 0.27.6
pg: 8.14.1
postgres: 3.4.4
react: 18.2.0
sqlite3: 5.1.7
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
dependencies:
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
zod: 3.23.8
eastasianwidth@0.2.0: {}
@@ -12673,6 +12873,8 @@ snapshots:
nanoid@3.3.8: {}
nanoid@5.1.5: {}
nanostores@0.11.3: {}
napi-build-utils@1.0.2:
@@ -12845,6 +13047,8 @@ snapshots:
object-inspect@1.13.2: {}
obuf@1.1.2: {}
octokit@3.1.2:
dependencies:
'@octokit/app': 14.1.0
@@ -12991,6 +13195,53 @@ snapshots:
peberminta@0.9.0: {}
pg-cloudflare@1.1.1:
optional: true
pg-connection-string@2.7.0: {}
pg-int8@1.0.1: {}
pg-numeric@1.0.2: {}
pg-pool@3.8.0(pg@8.14.1):
dependencies:
pg: 8.14.1
pg-protocol@1.8.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.0
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg-types@4.0.2:
dependencies:
pg-int8: 1.0.1
pg-numeric: 1.0.2
postgres-array: 3.0.4
postgres-bytea: 3.0.0
postgres-date: 2.1.0
postgres-interval: 3.0.0
postgres-range: 1.1.4
pg@8.14.1:
dependencies:
pg-connection-string: 2.7.0
pg-pool: 3.8.0(pg@8.14.1)
pg-protocol: 1.8.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.1.1
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.0.1: {}
picomatch@2.3.1: {}
@@ -13096,10 +13347,32 @@ snapshots:
postcss@8.4.40:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0
postgres-array@2.0.0: {}
postgres-array@3.0.4: {}
postgres-bytea@1.0.0: {}
postgres-bytea@3.0.0:
dependencies:
obuf: 1.1.2
postgres-date@1.0.7: {}
postgres-date@2.1.0: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres-interval@3.0.0: {}
postgres-range@1.1.4: {}
postgres@3.4.4: {}
prebuild-install@7.1.2:
@@ -13118,6 +13391,8 @@ snapshots:
tunnel-agent: 0.6.0
optional: true
prettier@3.4.2: {}
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3

View File

@@ -5,3 +5,4 @@ packages:
- "apps/schedules"
- "apps/models"
- "packages/server"
- "apps/licenses"