diff --git a/Dockerfile b/Dockerfile index 00043b0c2..c41df8c73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.cloud b/Dockerfile.cloud index c1b667963..c234259dc 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.monitoring b/Dockerfile.monitoring index 814625dbf..c54580ee1 100644 --- a/Dockerfile.monitoring +++ b/Dockerfile.monitoring @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Build stage FROM golang:1.21-alpine3.19 AS builder diff --git a/Dockerfile.schedule b/Dockerfile.schedule index eba08f7ba..70976523c 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/Dockerfile.server b/Dockerfile.server index 8fef51422..e911c8780 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 FROM node:20.9-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..47633ab95 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Dokploy Security Policy + +At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities. + +## How to Report a Vulnerability + +If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines: + +1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com). +2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include: + * A clear description of the vulnerability. + * Steps to reproduce the vulnerability. + * Any sample code, screenshots, or videos that might be helpful. + * The potential impact of the vulnerability. +3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users. +4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity. + +## What We Expect From You + +* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability. +* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering. +* Do not modify or destroy data that does not belong to you. + +## Our Commitment + +We are committed to working with you quickly and responsibly to address any legitimate security vulnerability. + +Thank you for helping us keep Dokploy secure for everyone. diff --git a/apps/api/package.json b/apps/api/package.json index 56ea56952..65f9d4ad9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,25 +9,25 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "pino": "9.4.0", - "pino-pretty": "11.2.2", - "@hono/zod-validator": "0.3.0", - "zod": "^3.23.4", - "react": "18.2.0", - "react-dom": "18.2.0", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.12.1", - "hono": "^4.5.8", + "@hono/zod-validator": "0.3.0", + "@nerimity/mimiqueue": "1.2.3", "dotenv": "^16.3.1", + "hono": "^4.5.8", + "pino": "9.4.0", + "pino-pretty": "11.2.2", + "react": "18.2.0", + "react-dom": "18.2.0", "redis": "4.7.0", - "@nerimity/mimiqueue": "1.2.3" + "zod": "^3.23.4" }, "devDependencies": { - "typescript": "^5.4.2", + "@types/node": "^20.11.17", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "@types/node": "^20.11.17", - "tsx": "^4.7.1" + "tsx": "^4.7.1", + "typescript": "^5.4.2" }, "packageManager": "pnpm@9.5.0" } diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index b18d7b4b1..9fa68b6bb 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -121,6 +121,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + rollbackActive: false, }; describe("unzipDrop using real zip files", () => { diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 6c136b259..5cd033af5 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -5,6 +5,7 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { + rollbackActive: false, applicationId: "", herokuVersion: "", giteaRepository: "", diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 8da09b58b..d185b2160 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -247,7 +247,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content @@ -256,7 +256,7 @@ export const UpdateVolume = ({ placeholder={`NODE_ENV=production PORT=3000 `} - className="h-96 font-mono" + className="h-96 font-mono w-full" {...field} /> diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 3cb18f98e..d095e0efb 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -10,11 +10,14 @@ import { CardTitle, } from "@/components/ui/card"; import { type RouterOutputs, api } from "@/utils/api"; -import { Clock, Loader2, RocketIcon } from "lucide-react"; +import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react"; import React, { useEffect, useState } from "react"; import { CancelQueues } from "./cancel-queues"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; +import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { toast } from "sonner"; interface Props { id: string; @@ -57,6 +60,9 @@ export const ShowDeployments = ({ }, ); + const { mutateAsync: rollback, isLoading: isRollingBack } = + api.rollback.rollback.useMutation(); + const [url, setUrl] = React.useState(""); useEffect(() => { setUrl(document.location.origin); @@ -71,9 +77,18 @@ export const ShowDeployments = ({ See all the 10 last deployments for this {type} - {(type === "application" || type === "compose") && ( - - )} +
+ {(type === "application" || type === "compose") && ( + + )} + {type === "application" && ( + + + + )} +
{refreshToken && ( @@ -154,13 +169,47 @@ export const ShowDeployments = ({ )} - +
+ + + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
))} diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index fe6373537..7bb58dfbe 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -39,6 +39,7 @@ export type ValidationState = { error?: string; resolvedIp?: string; message?: string; + cdnProvider?: string; }; export type ValidationStates = Record; @@ -119,6 +120,7 @@ export const ShowDomains = ({ id, type }: Props) => { isValid: result.isValid, error: result.error, resolvedIp: result.resolvedIp, + cdnProvider: result.cdnProvider, message: result.error && result.isValid ? result.error : undefined, }, })); @@ -354,8 +356,9 @@ export const ShowDomains = ({ id, type }: Props) => { ) : validationState?.isValid ? ( <> - {validationState.message - ? "Behind Cloudflare" + {validationState.message && + validationState.cdnProvider + ? `Behind ${validationState.cdnProvider}` : "DNS Valid"} ) : validationState?.error ? ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 6f504959c..35ddc51b8 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -49,7 +49,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { currentBuildArgs !== (data?.buildArgs || ""); useEffect(() => { - if (data) { + if (data && !hasChanges) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx index 905fe7113..13d3a6d8f 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx @@ -16,9 +16,11 @@ import { api } from "@/utils/api"; import { GitBranch, Loader2, UploadCloud } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { toast } from "sonner"; import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveGitlabProvider } from "./save-gitlab-provider"; +import { UnauthorizedGitProvider } from "./unauthorized-git-provider"; type TabState = | "github" @@ -43,12 +45,31 @@ export const ShowProviderForm = ({ applicationId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: application } = api.application.one.useQuery({ applicationId }); + const { data: application, refetch } = api.application.one.useQuery({ + applicationId, + }); + const { mutateAsync: disconnectGitProvider } = + api.application.disconnectGitProvider.useMutation(); + const [tab, setSab] = useState(application?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ applicationId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -77,6 +98,38 @@ export const ShowProviderForm = ({ applicationId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + application && + !application.hasGitProviderAccess && + application.sourceType !== "docker" && + application.sourceType !== "drop" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx new file mode 100644 index 000000000..4dbdf7a69 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/general/generic/unauthorized-git-provider.tsx @@ -0,0 +1,149 @@ +import { + BitbucketIcon, + GitIcon, + GiteaIcon, + GithubIcon, + GitlabIcon, +} from "@/components/icons/data-tools-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { RouterOutputs } from "@/utils/api"; +import { AlertCircle, GitBranch, Unlink } from "lucide-react"; + +interface Props { + service: + | RouterOutputs["application"]["one"] + | RouterOutputs["compose"]["one"]; + onDisconnect: () => void; +} + +export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => { + const getProviderIcon = (sourceType: string) => { + switch (sourceType) { + case "github": + return ; + case "gitlab": + return ; + case "bitbucket": + return ; + case "gitea": + return ; + case "git": + return ; + default: + return ; + } + }; + + const getRepositoryInfo = () => { + switch (service.sourceType) { + case "github": + return { + repo: service.repository, + branch: service.branch, + owner: service.owner, + }; + case "gitlab": + return { + repo: service.gitlabRepository, + branch: service.gitlabBranch, + owner: service.gitlabOwner, + }; + case "bitbucket": + return { + repo: service.bitbucketRepository, + branch: service.bitbucketBranch, + owner: service.bitbucketOwner, + }; + case "gitea": + return { + repo: service.giteaRepository, + branch: service.giteaBranch, + owner: service.giteaOwner, + }; + case "git": + return { + repo: service.customGitUrl, + branch: service.customGitBranch, + owner: null, + }; + default: + return { repo: null, branch: null, owner: null }; + } + }; + + const { repo, branch, owner } = getRepositoryInfo(); + + return ( +
+ + + + This application is connected to a {service.sourceType} repository + through a git provider that you don't have access to. You can see + basic repository information below, but cannot modify the + configuration. + + + + + + + {getProviderIcon(service.sourceType)} + + {service.sourceType} Repository + + + + + {owner && ( +
+ + Owner: + +

{owner}

+
+ )} + {repo && ( +
+ + Repository: + +

{repo}

+
+ )} + {branch && ( +
+ + Branch: + +

{branch}

+
+ )} + +
+ { + onDisconnect(); + }} + > + + +

+ Disconnecting will allow you to configure a new repository with + your own git providers. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx new file mode 100644 index 000000000..4b2edca04 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/rollbacks/show-rollback-settings.tsx @@ -0,0 +1,117 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const formSchema = z.object({ + rollbackActive: z.boolean(), +}); + +type FormValues = z.infer; + +interface Props { + applicationId: string; + children?: React.ReactNode; +} + +export const ShowRollbackSettings = ({ applicationId, children }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const { data: application, refetch } = api.application.one.useQuery( + { + applicationId, + }, + { + enabled: !!applicationId, + }, + ); + + const { mutateAsync: updateApplication, isLoading } = + api.application.update.useMutation(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + rollbackActive: application?.rollbackActive ?? false, + }, + }); + + const onSubmit = async (data: FormValues) => { + await updateApplication({ + applicationId, + rollbackActive: data.rollbackActive, + }) + .then(() => { + toast.success("Rollback settings updated"); + setIsOpen(false); + refetch(); + }) + .catch(() => { + toast.error("Failed to update rollback settings"); + }); + }; + + return ( + + {children} + + + Rollback Settings + + Configure how rollbacks work for this application + + + +
+ + ( + +
+ + Enable Rollbacks + + + Allow rolling back to previous deployments + +
+ + + +
+ )} + /> + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 50f0f4ab5..63ff189de 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => { resolver: zodResolver(AddComposeFile), }); + const composeFile = form.watch("composeFile"); + useEffect(() => { - if (data) { + if (data && !composeFile) { form.reset({ composeFile: data.composeFile || "", }); diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx index afdfbfba4..cd510ad69 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx @@ -18,6 +18,8 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose"; import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; +import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider"; +import { toast } from "sonner"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea"; interface Props { @@ -34,12 +36,29 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { const { data: giteaProviders, isLoading: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); - const { data: compose } = api.compose.one.useQuery({ composeId }); + const { mutateAsync: disconnectGitProvider } = + api.compose.disconnectGitProvider.useMutation(); + + const { data: compose, refetch } = api.compose.one.useQuery({ composeId }); const [tab, setSab] = useState(compose?.sourceType || "github"); const isLoading = isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea; + const handleDisconnect = async () => { + try { + await disconnectGitProvider({ composeId }); + toast.success("Repository disconnected successfully"); + await refetch(); + } catch (error) { + toast.error( + `Failed to disconnect repository: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } + }; + if (isLoading) { return ( @@ -68,6 +87,37 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => { ); } + // Check if user doesn't have access to the current git provider + if ( + compose && + !compose.hasGitProviderAccess && + compose.sourceType !== "raw" + ) { + return ( + + + +
+ Provider +

+ Repository connection through unauthorized provider +

+
+
+ +
+
+
+ + + +
+ ); + } + return ( diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index ddc1303e4..01d66fbaa 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -38,7 +38,7 @@ const AddProjectSchema = z.object({ (name) => { const trimmedName = name.trim(); const validNameRegex = - /^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; return validNameRegex.test(trimmedName); }, { diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index 80f332d8d..e2aa59ef3 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -1,80 +1,93 @@ +// @ts-nocheck + export const extractExpirationDate = (certData: string): Date | null => { try { - const match = certData.match( - /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, - ); - if (!match?.[1]) return null; - - const base64Cert = match[1].replace(/\s/g, ""); - const binaryStr = window.atob(base64Cert); - const bytes = new Uint8Array(binaryStr.length); - - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i); + // Decode PEM base64 to DER binary + const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); + const binStr = atob(b64); + const der = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { + der[i] = binStr.charCodeAt(i); } - // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 - // We need to find the second occurrence of either tag as it's the "not after" (expiration) date - let dateFound = false; - for (let i = 0; i < bytes.length - 2; i++) { - // Look for sequence containing validity period (0x30) - if (bytes[i] === 0x30) { - // Check next bytes for UTCTime or GeneralizedTime - let j = i + 1; - while (j < bytes.length - 2) { - if (bytes[j] === 0x17 || bytes[j] === 0x18) { - const dateType = bytes[j]; - const dateLength = bytes[j + 1]; - if (typeof dateLength === "undefined") break; + let offset = 0; - if (!dateFound) { - // Skip "not before" date - dateFound = true; - j += dateLength + 2; - continue; - } - - // Found "not after" date - let dateStr = ""; - for (let k = 0; k < dateLength; k++) { - const charCode = bytes[j + 2 + k]; - if (typeof charCode === "undefined") continue; - dateStr += String.fromCharCode(charCode); - } - - if (dateType === 0x17) { - // UTCTime (YYMMDDhhmmssZ) - const year = Number.parseInt(dateStr.slice(0, 2)); - const fullYear = year >= 50 ? 1900 + year : 2000 + year; - return new Date( - Date.UTC( - fullYear, - Number.parseInt(dateStr.slice(2, 4)) - 1, - Number.parseInt(dateStr.slice(4, 6)), - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - ), - ); - } - - // GeneralizedTime (YYYYMMDDhhmmssZ) - return new Date( - Date.UTC( - Number.parseInt(dateStr.slice(0, 4)), - Number.parseInt(dateStr.slice(4, 6)) - 1, - Number.parseInt(dateStr.slice(6, 8)), - Number.parseInt(dateStr.slice(8, 10)), - Number.parseInt(dateStr.slice(10, 12)), - Number.parseInt(dateStr.slice(12, 14)), - ), - ); - } - j++; + // Helper: read ASN.1 length field + function readLength(pos: number): { length: number; offset: number } { + // biome-ignore lint/style/noParameterAssign: + let len = der[pos++]; + if (len & 0x80) { + const bytes = len & 0x7f; + len = 0; + for (let i = 0; i < bytes; i++) { + // biome-ignore lint/style/noParameterAssign: + len = (len << 8) + der[pos++]; } } + return { length: len, offset: pos }; } - return null; + + // Skip the outer certificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected sequence"); + ({ offset } = readLength(offset)); + + // Skip tbsCertificate sequence + if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate"); + ({ offset } = readLength(offset)); + + // Check for optional version field (context-specific tag [0]) + if (der[offset] === 0xa0) { + offset++; + const versionLen = readLength(offset); + offset = versionLen.offset + versionLen.length; + } + + // Skip serialNumber, signature, issuer + for (let i = 0; i < 3; i++) { + if (der[offset] !== 0x30 && der[offset] !== 0x02) + throw new Error("Unexpected structure"); + offset++; + const fieldLen = readLength(offset); + offset = fieldLen.offset + fieldLen.length; + } + + // Validity sequence (notBefore and notAfter) + if (der[offset++] !== 0x30) throw new Error("Expected validity sequence"); + const validityLen = readLength(offset); + offset = validityLen.offset; + + // notBefore + offset++; + const notBeforeLen = readLength(offset); + offset = notBeforeLen.offset + notBeforeLen.length; + + // notAfter + offset++; + const notAfterLen = readLength(offset); + const notAfterStr = new TextDecoder().decode( + der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length), + ); + + // Parse GeneralizedTime (15 chars) or UTCTime (13 chars) + function parseTime(str: string): Date { + if (str.length === 13) { + // UTCTime YYMMDDhhmmssZ + const year = Number.parseInt(str.slice(0, 2), 10); + const fullYear = year < 50 ? 2000 + year : 1900 + year; + return new Date( + `${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`, + ); + } + if (str.length === 15) { + // GeneralizedTime YYYYMMDDhhmmssZ + return new Date( + `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`, + ); + } + throw new Error("Invalid ASN.1 time format"); + } + + return parseTime(notAfterStr); } catch (error) { console.error("Error parsing certificate:", error); return null; diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 90cefe592..af7d58544 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from "react"; export const AddGithubProvider = () => { const [isOpen, setIsOpen] = useState(false); const { data: activeOrganization } = authClient.useActiveOrganization(); + const { data: session } = authClient.useSession(); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); @@ -27,7 +28,7 @@ export const AddGithubProvider = () => { const url = document.location.origin; const manifest = JSON.stringify( { - redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`, + redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`, url: origin, hook_attributes: { diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index d05409fb7..24e8f34e6 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -41,6 +41,7 @@ const addInvitation = z.object({ .min(1, "Email is required") .email({ message: "Invalid email" }), role: z.enum(["member", "admin"]), + notificationId: z.string().optional(), }); type AddInvitation = z.infer; @@ -49,6 +50,10 @@ export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); const [isLoading, setIsLoading] = useState(false); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: emailProviders } = + api.notification.getEmailProviders.useQuery(); + const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); const [error, setError] = useState(null); const { data: activeOrganization } = authClient.useActiveOrganization(); @@ -56,6 +61,7 @@ export const AddInvitation = () => { defaultValues: { email: "", role: "member", + notificationId: "", }, resolver: zodResolver(addInvitation), }); @@ -74,7 +80,20 @@ export const AddInvitation = () => { if (result.error) { setError(result.error.message || ""); } else { - toast.success("Invitation created"); + if (!isCloud && data.notificationId) { + await sendInvitation({ + invitationId: result.data.id, + notificationId: data.notificationId || "", + }) + .then(() => { + toast.success("Invitation created and email sent"); + }) + .catch((error: any) => { + toast.error(error.message); + }); + } else { + toast.success("Invitation created"); + } setError(null); setOpen(false); } @@ -149,6 +168,47 @@ export const AddInvitation = () => { ); }} /> + + {!isCloud && ( + { + return ( + + Email Provider + + + Select the email provider to send the invitation + + + + ); + }} + /> + )}