diff --git a/.github/sponsors/agentdock.png b/.github/sponsors/agentdock.png new file mode 100644 index 000000000..bd76dc171 Binary files /dev/null and b/.github/sponsors/agentdock.png differ diff --git a/.github/sponsors/american-cloud.png b/.github/sponsors/american-cloud.png new file mode 100644 index 000000000..daa902078 Binary files /dev/null and b/.github/sponsors/american-cloud.png differ diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index adcb1bb54..0f65a50c9 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -2,7 +2,7 @@ name: Dokploy Docker Build on: push: - branches: [main, canary, "feat/better-auth-2"] + branches: [main, canary, "1061-custom-docker-service-hostname"] env: IMAGE_NAME: dokploy/dokploy diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 000000000..827ccc709 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,22 @@ +name: autofix.ci + +on: + push: + branches: [canary] + pull_request: + branches: [canary] + +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup biomeJs + uses: biomejs/setup-biome@v2 + + - name: Run Biome formatter + run: biome format . --write + + - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ac542296..e9591f3cc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,7 +12,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build @@ -26,7 +26,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build @@ -39,7 +39,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.9.0 + node-version: 20.16.0 cache: "pnpm" - run: pnpm install --frozen-lockfile - run: pnpm run server:build diff --git a/.nvmrc b/.nvmrc index 43bff1f8c..593cb75bc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.9.0 \ No newline at end of file +20.16.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8584fdf65..0ac5a3581 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v20.9.0 +We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git @@ -61,9 +61,9 @@ pnpm install cp apps/dokploy/.env.example apps/dokploy/.env ``` -## Development +## Requirements -Is required to have **Docker** installed on your machine. +- [Docker](/GUIDES.md#docker) ### Setup @@ -87,6 +87,8 @@ pnpm run dokploy:dev Go to http://localhost:3000 to see the development server +Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off. + ## Build ```bash @@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh ```bash # Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack +curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack ``` - - ## Pull Request - The `main` branch is the source of truth and should always reflect the latest stable release. @@ -165,86 +165,7 @@ Thank you for your contribution! ## Templates -To add a new template, go to `templates` folder and create a new folder with the name of the template. - -Let's take the example of `plausible` template. - -1. create a folder in `templates/plausible` -2. create a `docker-compose.yml` file inside the folder with the content of compose. -3. create a `index.ts` file inside the folder with the following code as base: -4. When creating a pull request, please provide a video of the template working in action. - -```typescript -// EXAMPLE -import { - generateBase64, - generateHash, - generateRandomDomain, - type Template, - type Schema, - type DomainSchema, -} from "../utils"; - -export function generate(schema: Schema): Template { - // do your stuff here, like create a new domain, generate random passwords, mounts. - const mainServiceHash = generateHash(schema.projectName); - const mainDomain = generateRandomDomain(schema); - const secretBase = generateBase64(64); - const toptKeyBase = generateBase64(32); - - const domains: DomainSchema[] = [ - { - host: mainDomain, - port: 8000, - serviceName: "plausible", - }, - ]; - - const envs = [ - `BASE_URL=http://${mainDomain}`, - `SECRET_KEY_BASE=${secretBase}`, - `TOTP_VAULT_KEY=${toptKeyBase}`, - `HASH=${mainServiceHash}`, - ]; - - const mounts: Template["mounts"] = [ - { - filePath: "./clickhouse/clickhouse-config.xml", - content: "some content......", - }, - ]; - - return { - envs, - mounts, - domains, - }; -} -``` - -4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties: - -**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.** - -```typescript -{ - id: "plausible", - name: "Plausible", - version: "v2.1.0", - description: - "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", - logo: "plausible.svg", // we defined the name and the extension of the logo - links: { - github: "https://github.com/plausible/plausible", - website: "https://plausible.io/", - docs: "https://plausible.io/docs", - }, - tags: ["analytics"], - load: () => import("./plausible/index").then((m) => m.generate), -}, -``` - -5. Add the logo or image of the template to `public/templates/plausible.svg` +To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file. ### Recommendations diff --git a/Dockerfile b/Dockerfile index a5bd7e5e4..4d18a99ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM node:20.9-slim AS base +# syntax=docker/dockerfile:1 +FROM node:20.16.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable +RUN corepack prepare pnpm@9.12.0 --activate FROM base AS build COPY . /usr/src/app @@ -29,7 +31,7 @@ WORKDIR /app # Set production ENV NODE_ENV=production -RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/* # Copy only the necessary files COPY --from=build /prod/dokploy/.next ./.next @@ -49,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash -ARG NIXPACKS_VERSION=1.29.1 +ARG NIXPACKS_VERSION=1.39.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ && pnpm install -g tsx # Install Railpack -ARG RAILPACK_VERSION=0.0.37 +ARG RAILPACK_VERSION=0.0.64 RUN curl -sSL https://railpack.com/install.sh | bash # Install buildpacks COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 -CMD [ "pnpm", "start" ] \ No newline at end of file +CMD [ "pnpm", "start" ] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index c1b667963..8e4bac215 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,7 +1,9 @@ -FROM node:20.9-slim AS base +# syntax=docker/dockerfile:1 +FROM node:20.16.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable +RUN corepack prepare pnpm@9.12.0 --activate FROM base AS build COPY . /usr/src/app 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..ecb125e09 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,7 +1,9 @@ -FROM node:20.9-slim AS base +# syntax=docker/dockerfile:1 +FROM node:20.16.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable +RUN corepack prepare pnpm@9.12.0 --activate FROM base AS build COPY . /usr/src/app diff --git a/Dockerfile.server b/Dockerfile.server index 8fef51422..ea6b372e8 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,7 +1,9 @@ -FROM node:20.9-slim AS base +# syntax=docker/dockerfile:1 +FROM node:20.16.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable +RUN corepack prepare pnpm@9.12.0 --activate FROM base AS build COPY . /usr/src/app diff --git a/GUIDES.md b/GUIDES.md new file mode 100644 index 000000000..90fba522d --- /dev/null +++ b/GUIDES.md @@ -0,0 +1,50 @@ +# Docker + +Here's how to install docker on different operating systems: + +## macOS + +1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop) +2. Download the Docker Desktop installer +3. Double-click the downloaded `.dmg` file +4. Drag Docker to your Applications folder +5. Open Docker Desktop from Applications +6. Follow the onboarding tutorial if desired + +## Linux + +### Ubuntu + +```bash +# Uninstall old versions +for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done + +# Update package index +sudo apt-get update + +# Install prerequisites +sudo apt-get install ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings + +# Add Docker's official GPG key +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# Add the repository to Apt sources +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker Engine +sudo apt-get update +sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +## Windows + +1. Enable WSL2 if not already enabled +2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop) +3. Download the installer +4. Run the installer and follow the prompts +5. Start Docker Desktop from the Start menu \ No newline at end of file diff --git a/LICENSE.MD b/LICENSE.MD index 8a508efb4..6cbef2c6d 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -2,7 +2,7 @@ ## Core License (Apache License 2.0) -Copyright 2024 Mauricio Siu. +Copyright 2025 Mauricio Siu. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. +- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. +- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. +- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. For further inquiries or permissions, please contact us directly. diff --git a/README.md b/README.md index 9246cf556..bd27474e0 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,16 @@
-
- - Dokploy - Open Source Alternative to Vercel, Heroku and Netlify. - -
- -
-
-
Join us on Discord for help, feedback, and discussions!
+ + Dokploy - Open Source Alternative to Vercel, Heroku and Netlify. +
+
+

Join us on Discord for help, feedback, and discussions!

Discord Shield
-

+ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. ### Features @@ -61,57 +57,48 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). ### Hero Sponsors 🎖 -
- - Hostinger - - - LX Aer - - - Mandarin - - - Lightnode - - - +
+ Hostinger + LX Aer
+ + + + ### Premium Supporters 🥇 -
-Supafort.com +
+ Supafort.com + agentdock.ai
-### Supporting Members 🥉 +### Elite Contributors 🥈 -
-Lightspeed.run -Cloudblast.io -Startupfame -Itsdb-center -Openalternative -Synexa +
+ AmericanCloud + Tolgee
+### Supporting Members 🥉 + +
+ + Cloudblast.io + + Synexa +
### Community Backers 🤝 -
-Steamsets.com -Rivo.gg -Rivo.gg - -
#### Organizations: -[![Sponsors on Open Collective](https://opencollective.com/dokploy/organizations.svg?width=890)](https://opencollective.com/dokploy) +[Sponsors on Open Collective](https://opencollective.com/dokploy) #### Individuals: @@ -121,27 +108,14 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). - + ## Video Tutorial - Watch the video + Watch the video - - ## Contributing Check out the [Contributing Guide](CONTRIBUTING.md) for more information. 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..98fcea0d3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,25 +9,25 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dokploy/server": "workspace:*", + "@hono/node-server": "^1.14.3", + "@hono/zod-validator": "0.3.0", + "@nerimity/mimiqueue": "1.2.3", + "dotenv": "^16.4.5", + "hono": "^4.7.10", "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", - "dotenv": "^16.3.1", "redis": "4.7.0", - "@nerimity/mimiqueue": "1.2.3" + "zod": "^3.25.32" }, "devDependencies": { - "typescript": "^5.4.2", + "@types/node": "^20.17.51", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "@types/node": "^20.11.17", - "tsx": "^4.7.1" + "tsx": "^4.16.2", + "typescript": "^5.8.3" }, "packageManager": "pnpm@9.5.0" } diff --git a/apps/dokploy/.nvmrc b/apps/dokploy/.nvmrc index 43bff1f8c..593cb75bc 100644 --- a/apps/dokploy/.nvmrc +++ b/apps/dokploy/.nvmrc @@ -1 +1 @@ -20.9.0 \ No newline at end of file +20.16.0 \ No newline at end of file diff --git a/apps/dokploy/CONTRIBUTING.md b/apps/dokploy/CONTRIBUTING.md deleted file mode 100644 index 8686b98a8..000000000 --- a/apps/dokploy/CONTRIBUTING.md +++ /dev/null @@ -1,242 +0,0 @@ - - -# Contributing - -Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute. - - -Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues. - -We have a few guidelines to follow when contributing to this project: - -- [Commit Convention](#commit-convention) -- [Setup](#setup) -- [Development](#development) -- [Build](#build) -- [Pull Request](#pull-request) - -## Commit Convention - -Before you craete a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. - -### Commit Message Format -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -#### Type -Must be one of the following: - -* **feat**: A new feature -* **fix**: A bug fix -* **docs**: Documentation only changes -* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) -* **refactor**: A code change that neither fixes a bug nor adds a feature -* **perf**: A code change that improves performance -* **test**: Adding missing tests or correcting existing tests -* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) -* **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) -* **chore**: Other changes that don't modify `src` or `test` files -* **revert**: Reverts a previous commit - -Example: -``` -feat: add new feature -``` - - - - -## Setup - -Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. - -```bash -git clone https://github.com/dokploy/dokploy.git -cd dokploy -pnpm install -cp .env.example .env -``` - -## Development - -Is required to have **Docker** installed on your machine. - - -### Setup - -Run the command that will spin up all the required services and files. - -```bash -pnpm run setup -``` - -Now run the development server. - -```bash -pnpm run dev -``` - - -Go to http://localhost:3000 to see the development server - -## Build - -```bash -pnpm run build -``` - -## Docker - -To build the docker image -```bash -pnpm run docker:build -``` - -To push the docker image -```bash -pnpm run docker:push -``` - -## Password Reset - -In the case you lost your password, you can reset it using the following command - -```bash -pnpm run reset-password -``` - -If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel` - -```bash -bunx lt --port 3000 -``` - -If you run into permission issues of docker run the following command - -```bash -sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker -``` - -## Application deploy - -In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first. - -```bash -# Install Nixpacks -curl -sSL https://nixpacks.com/install.sh -o install.sh \ - && chmod +x install.sh \ - && ./install.sh -``` - -```bash -# Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack -``` - - -## Pull Request - -- The `main` branch is the source of truth and should always reflect the latest stable release. -- Create a new branch for each feature or bug fix. -- Make sure to add tests for your changes. -- Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes. -- When creating a pull request, please provide a clear and concise description of the changes made. -- If you include a video or screenshot, would be awesome so we can see the changes in action. -- If your pull request fixes an open issue, please reference the issue in the pull request description. -- Once your pull request is merged, you will be automatically added as a contributor to the project. - -Thank you for your contribution! - - - - - -## Templates - -To add a new template, go to `templates` folder and create a new folder with the name of the template. - -Let's take the example of `plausible` template. - -1. create a folder in `templates/plausible` -2. create a `docker-compose.yml` file inside the folder with the content of compose. -3. create a `index.ts` file inside the folder with the following code as base: -4. When creating a pull request, please provide a video of the template working in action. - -```typescript -// EXAMPLE -import { - generateHash, - generateRandomDomain, - type Template, - type Schema, -} from "../utils"; - - -export function generate(schema: Schema): Template { - - // do your stuff here, like create a new domain, generate random passwords, mounts. - const mainServiceHash = generateHash(schema.projectName); - const randomDomain = generateRandomDomain(schema); - const secretBase = generateBase64(64); - const toptKeyBase = generateBase64(32); - - const envs = [ -// If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name. - `PLAUSIBLE_HOST=${randomDomain}`, - "PLAUSIBLE_PORT=8000", - `BASE_URL=http://${randomDomain}`, - `SECRET_KEY_BASE=${secretBase}`, - `TOTP_VAULT_KEY=${toptKeyBase}`, - `HASH=${mainServiceHash}`, - ]; - - const mounts: Template["mounts"] = [ - { - mountPath: "./clickhouse/clickhouse-config.xml", - content: `some content......`, - }, - ]; - - return { - envs, - mounts, - }; -} -``` - -4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties: - -**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.** - -```typescript -{ - id: "plausible", - name: "Plausible", - version: "v2.1.0", - description: - "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", - logo: "plausible.svg", // we defined the name and the extension of the logo - links: { - github: "https://github.com/plausible/plausible", - website: "https://plausible.io/", - docs: "https://plausible.io/docs", - }, - tags: ["analytics"], - load: () => import("./plausible/index").then((m) => m.generate), -}, -``` - -5. Add the logo or image of the template to `public/templates/plausible.svg` - - -### Recomendations -- Use the same name of the folder as the id of the template. -- The logo should be in the public folder. -- If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name. -- Test first on a vps or a server to make sure the template works. - diff --git a/apps/dokploy/Dockerfile b/apps/dokploy/Dockerfile deleted file mode 100644 index f4188c54e..000000000 --- a/apps/dokploy/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:18-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable - -FROM base AS build -COPY . /usr/src/app -WORKDIR /usr/src/app - - -RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/* - -# Install dependencies -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile - -# Build only the dokploy app -RUN pnpm run dokploy:build - -# Deploy only the dokploy app -RUN pnpm deploy --filter=dokploy --prod /prod/dokploy - -FROM base AS dokploy -COPY --from=build /prod/dokploy /prod/dokploy -WORKDIR /prod/dokploy -EXPOSE 3000 -CMD [ "pnpm", "start" ] \ No newline at end of file diff --git a/apps/dokploy/LICENSE.MD b/apps/dokploy/LICENSE.MD deleted file mode 100644 index 8a508efb4..000000000 --- a/apps/dokploy/LICENSE.MD +++ /dev/null @@ -1,26 +0,0 @@ -# License - -## Core License (Apache License 2.0) - -Copyright 2024 Mauricio Siu. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and limitations under the License. - -## Additional Terms for Specific Features - -The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: - -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. - -For further inquiries or permissions, please contact us directly. diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 8bc9fbccb..172bff2af 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -9,6 +9,7 @@ describe("createDomainLabels", () => { port: 8080, https: false, uniqueConfigKey: 1, + customCertResolver: null, certificateType: "none", applicationId: "", composeId: "", @@ -18,6 +19,8 @@ describe("createDomainLabels", () => { path: "/", createdAt: "", previewDeploymentId: "", + internalPath: "/", + stripPath: false, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/compose/volume/volume-2.test.ts b/apps/dokploy/__test__/compose/volume/volume-2.test.ts index bf34ed494..61cba82d3 100644 --- a/apps/dokploy/__test__/compose/volume/volume-2.test.ts +++ b/apps/dokploy/__test__/compose/volume/volume-2.test.ts @@ -1006,7 +1006,7 @@ services: volumes: db-config-testhash: -`) as ComposeSpecification; +`); test("Expect to change the suffix in all the possible places (4 Try)", () => { const composeData = load(composeFileComplex) as ComposeSpecification; @@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => { expect(updatedComposeData).toEqual(expectedDockerComposeExample1); }); + +const composeFileBackrest = ` +services: + backrest: + image: garethgeorge/backrest:v1.7.3 + restart: unless-stopped + ports: + - 9898 + environment: + - BACKREST_PORT=9898 + - BACKREST_DATA=/data + - BACKREST_CONFIG=/config/config.json + - XDG_CACHE_HOME=/cache + - TZ=\${TZ} + volumes: + - backrest/data:/data + - backrest/config:/config + - backrest/cache:/cache + - /:/userdata:ro + +volumes: + backrest: + backrest-cache: +`; + +const expectedDockerComposeBackrest = load(` +services: + backrest: + image: garethgeorge/backrest:v1.7.3 + restart: unless-stopped + ports: + - 9898 + environment: + - BACKREST_PORT=9898 + - BACKREST_DATA=/data + - BACKREST_CONFIG=/config/config.json + - XDG_CACHE_HOME=/cache + - TZ=\${TZ} + volumes: + - backrest-testhash/data:/data + - backrest-testhash/config:/config + - backrest-testhash/cache:/cache + - /:/userdata:ro + +volumes: + backrest-testhash: + backrest-cache-testhash: +`) as ComposeSpecification; + +test("Should handle volume paths with subdirectories correctly", () => { + const composeData = load(composeFileBackrest) as ComposeSpecification; + const suffix = "testhash"; + + const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); + + expect(updatedComposeData).toEqual(expectedDockerComposeBackrest); +}); diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index 4e6f20d3f..9fa68b6bb 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -27,7 +27,16 @@ if (typeof window === "undefined") { const baseApp: ApplicationNested = { applicationId: "", herokuVersion: "", + giteaBranch: "", + giteaBuildPath: "", + giteaId: "", + giteaOwner: "", + giteaRepository: "", + cleanCache: false, + watchPaths: [], + enableSubmodules: false, applicationStatus: "done", + triggerType: "push", appName: "", autoDeploy: true, serverId: "", @@ -37,6 +46,7 @@ const baseApp: ApplicationNested = { isPreviewDeploymentsActive: false, previewBuildArgs: null, previewCertificateType: "none", + previewCustomCertResolver: null, previewEnv: null, previewHttps: false, previewPath: "/", @@ -95,6 +105,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -110,6 +121,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + rollbackActive: false, }; describe("unzipDrop using real zip files", () => { @@ -139,67 +151,68 @@ describe("unzipDrop using real zip files", () => { } finally { } }); - - it("should correctly extract a zip with a single root folder and a subfolder", async () => { - baseApp.appName = "folderwithfile"; - // const appName = "folderwithfile"; - const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); - const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip"); - - const zipBuffer = zip.toBuffer(); - const file = new File([zipBuffer], "single.zip"); - await unzipDrop(file, baseApp); - - const files = await fs.readdir(outputPath, { withFileTypes: true }); - expect(files.some((f) => f.name === "folder1.txt")).toBe(true); - }); - - it("should correctly extract a zip with multiple root folders", async () => { - baseApp.appName = "two-folders"; - // const appName = "two-folders"; - const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); - const zip = new AdmZip("./__test__/drop/zips/two-folders.zip"); - - const zipBuffer = zip.toBuffer(); - const file = new File([zipBuffer], "single.zip"); - await unzipDrop(file, baseApp); - - const files = await fs.readdir(outputPath, { withFileTypes: true }); - - expect(files.some((f) => f.name === "folder1")).toBe(true); - expect(files.some((f) => f.name === "folder2")).toBe(true); - }); - - it("should correctly extract a zip with a single root with a file", async () => { - baseApp.appName = "nested"; - // const appName = "nested"; - const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); - const zip = new AdmZip("./__test__/drop/zips/nested.zip"); - - const zipBuffer = zip.toBuffer(); - const file = new File([zipBuffer], "single.zip"); - await unzipDrop(file, baseApp); - - const files = await fs.readdir(outputPath, { withFileTypes: true }); - - expect(files.some((f) => f.name === "folder1")).toBe(true); - expect(files.some((f) => f.name === "folder2")).toBe(true); - expect(files.some((f) => f.name === "folder3")).toBe(true); - }); - - it("should correctly extract a zip with a single root with a folder", async () => { - baseApp.appName = "folder-with-sibling-file"; - // const appName = "folder-with-sibling-file"; - const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); - const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip"); - - const zipBuffer = zip.toBuffer(); - const file = new File([zipBuffer], "single.zip"); - await unzipDrop(file, baseApp); - - const files = await fs.readdir(outputPath, { withFileTypes: true }); - - expect(files.some((f) => f.name === "folder1")).toBe(true); - expect(files.some((f) => f.name === "test.txt")).toBe(true); - }); }); + +// it("should correctly extract a zip with a single root folder and a subfolder", async () => { +// baseApp.appName = "folderwithfile"; +// // const appName = "folderwithfile"; +// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); +// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip"); + +// const zipBuffer = zip.toBuffer(); +// const file = new File([zipBuffer], "single.zip"); +// await unzipDrop(file, baseApp); + +// const files = await fs.readdir(outputPath, { withFileTypes: true }); +// expect(files.some((f) => f.name === "folder1.txt")).toBe(true); +// }); + +// it("should correctly extract a zip with multiple root folders", async () => { +// baseApp.appName = "two-folders"; +// // const appName = "two-folders"; +// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); +// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip"); + +// const zipBuffer = zip.toBuffer(); +// const file = new File([zipBuffer], "single.zip"); +// await unzipDrop(file, baseApp); + +// const files = await fs.readdir(outputPath, { withFileTypes: true }); + +// expect(files.some((f) => f.name === "folder1")).toBe(true); +// expect(files.some((f) => f.name === "folder2")).toBe(true); +// }); + +// it("should correctly extract a zip with a single root with a file", async () => { +// baseApp.appName = "nested"; +// // const appName = "nested"; +// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); +// const zip = new AdmZip("./__test__/drop/zips/nested.zip"); + +// const zipBuffer = zip.toBuffer(); +// const file = new File([zipBuffer], "single.zip"); +// await unzipDrop(file, baseApp); + +// const files = await fs.readdir(outputPath, { withFileTypes: true }); + +// expect(files.some((f) => f.name === "folder1")).toBe(true); +// expect(files.some((f) => f.name === "folder2")).toBe(true); +// expect(files.some((f) => f.name === "folder3")).toBe(true); +// }); + +// it("should correctly extract a zip with a single root with a folder", async () => { +// baseApp.appName = "folder-with-sibling-file"; +// // const appName = "folder-with-sibling-file"; +// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); +// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip"); + +// const zipBuffer = zip.toBuffer(); +// const file = new File([zipBuffer], "single.zip"); +// await unzipDrop(file, baseApp); + +// const files = await fs.readdir(outputPath, { withFileTypes: true }); + +// expect(files.some((f) => f.name === "folder1")).toBe(true); +// expect(files.some((f) => f.name === "test.txt")).toBe(true); +// }); +// }); diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts new file mode 100644 index 000000000..202abdf2d --- /dev/null +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -0,0 +1,497 @@ +import type { Schema } from "@dokploy/server/templates"; +import type { CompleteTemplate } from "@dokploy/server/templates/processors"; +import { processTemplate } from "@dokploy/server/templates/processors"; +import { describe, expect, it } from "vitest"; + +describe("processTemplate", () => { + // Mock schema for testing + const mockSchema: Schema = { + projectName: "test", + serverIp: "127.0.0.1", + }; + + describe("variables processing", () => { + it("should process basic variables with utility functions", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + totp_key: "${base64:32}", + password: "${password:32}", + hash: "${hash:16}", + }, + config: { + domains: [], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(0); + expect(result.domains).toHaveLength(0); + expect(result.mounts).toHaveLength(0); + }); + + it("should allow referencing variables in other variables", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + api_domain: "api.${main_domain}", + }, + config: { + domains: [], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(0); + expect(result.domains).toHaveLength(0); + expect(result.mounts).toHaveLength(0); + }); + + it("should allow creation of real jwt secret", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ", + anon_payload: JSON.stringify({ + role: "tester", + iss: "dockploy", + iat: "${timestamps:2025-01-01T00:00:00Z}", + exp: "${timestamps:2030-01-01T00:00:00Z}", + }), + anon_key: "${jwt:jwt_secret:anon_payload}", + }, + config: { + domains: [], + env: { + ANON_KEY: "${anon_key}", + }, + }, + }; + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(1); + expect(result.envs).toContain( + "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY", + ); + expect(result.mounts).toHaveLength(0); + expect(result.domains).toHaveLength(0); + }); + }); + + describe("domains processing", () => { + it("should process domains with explicit host", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + }, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${main_domain}", + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain) return; + expect(domain).toMatchObject({ + serviceName: "plausible", + port: 8000, + }); + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + + it("should generate random domain if host is not specified", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain || !domain.host) return; + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + + it("should allow using ${domain} directly in host", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${domain}", + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain || !domain.host) return; + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + }); + + describe("environment variables processing", () => { + it("should process env vars with variable references", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + }, + config: { + domains: [], + env: { + BASE_URL: "http://${main_domain}", + SECRET_KEY_BASE: "${secret_base}", + }, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(2); + const baseUrl = result.envs.find((env: string) => + env.startsWith("BASE_URL="), + ); + const secretKey = result.envs.find((env: string) => + env.startsWith("SECRET_KEY_BASE="), + ); + + expect(baseUrl).toBeDefined(); + expect(secretKey).toBeDefined(); + if (!baseUrl || !secretKey) return; + + expect(baseUrl).toContain(mockSchema.projectName); + const base64Value = secretKey.split("=")[1]; + expect(base64Value).toBeDefined(); + if (!base64Value) return; + expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(base64Value.length).toBeGreaterThanOrEqual(86); + expect(base64Value.length).toBeLessThanOrEqual(88); + }); + + it("should process env vars when provided as an array", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: [ + 'CLOUDFLARE_TUNNEL_TOKEN=""', + 'ANOTHER_VAR="some value"', + "DOMAIN=${domain}", + ], + mounts: [], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(3); + + // Should preserve exact format for static values + expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN=""'); + expect(result.envs[1]).toBe('ANOTHER_VAR="some value"'); + + // Should process variables in array items + expect(result.envs[2]).toContain(mockSchema.projectName); + }); + + it("should allow using utility functions directly in env vars", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: { + RANDOM_DOMAIN: "${domain}", + SECRET_KEY: "${base64:32}", + }, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(2); + const randomDomainEnv = result.envs.find((env: string) => + env.startsWith("RANDOM_DOMAIN="), + ); + const secretKeyEnv = result.envs.find((env: string) => + env.startsWith("SECRET_KEY="), + ); + expect(randomDomainEnv).toBeDefined(); + expect(secretKeyEnv).toBeDefined(); + if (!randomDomainEnv || !secretKeyEnv) return; + + expect(randomDomainEnv).toContain(mockSchema.projectName); + const base64Value = secretKeyEnv.split("=")[1]; + expect(base64Value).toBeDefined(); + if (!base64Value) return; + expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(base64Value.length).toBeGreaterThanOrEqual(42); + expect(base64Value.length).toBeLessThanOrEqual(44); + }); + + it("should handle boolean values in env vars when provided as an array", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: [ + "ENABLE_USER_SIGN_UP=false", + "DEBUG_MODE=true", + "SOME_NUMBER=42", + ], + mounts: [], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(3); + expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false"); + expect(result.envs).toContain("DEBUG_MODE=true"); + expect(result.envs).toContain("SOME_NUMBER=42"); + }); + + it("should handle boolean values in env vars when provided as an object", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: { + ENABLE_USER_SIGN_UP: false, + DEBUG_MODE: true, + SOME_NUMBER: 42, + }, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(3); + expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false"); + expect(result.envs).toContain("DEBUG_MODE=true"); + expect(result.envs).toContain("SOME_NUMBER=42"); + }); + }); + + describe("mounts processing", () => { + it("should process mounts with variable references", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + config_path: "/etc/config", + secret_key: "${base64:32}", + }, + config: { + domains: [], + env: {}, + mounts: [ + { + filePath: "${config_path}/config.xml", + content: "secret_key=${secret_key}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.filePath).toContain("/etc/config"); + expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/); + }); + + it("should allow using utility functions directly in mount content", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: {}, + mounts: [ + { + filePath: "/config/secrets.txt", + content: "random_domain=${domain}\nsecret=${base64:32}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.content).toContain(mockSchema.projectName); + expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/); + }); + }); + + describe("complex template processing", () => { + it("should process a complete template with all features", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + totp_key: "${base64:32}", + }, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${main_domain}", + }, + { + serviceName: "api", + port: 3000, + host: "api.${main_domain}", + }, + ], + env: { + BASE_URL: "http://${main_domain}", + SECRET_KEY_BASE: "${secret_base}", + TOTP_VAULT_KEY: "${totp_key}", + }, + mounts: [ + { + filePath: "/config/app.conf", + content: ` + domain=\${main_domain} + secret=\${secret_base} + totp=\${totp_key} + `, + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + + // Check domains + expect(result.domains).toHaveLength(2); + const [domain1, domain2] = result.domains; + expect(domain1).toBeDefined(); + expect(domain2).toBeDefined(); + if (!domain1 || !domain2) return; + expect(domain1.host).toBeDefined(); + expect(domain1.host).toContain(mockSchema.projectName); + expect(domain2.host).toContain("api."); + expect(domain2.host).toContain(mockSchema.projectName); + + // Check env vars + expect(result.envs).toHaveLength(3); + const baseUrl = result.envs.find((env: string) => + env.startsWith("BASE_URL="), + ); + const secretKey = result.envs.find((env: string) => + env.startsWith("SECRET_KEY_BASE="), + ); + const totpKey = result.envs.find((env: string) => + env.startsWith("TOTP_VAULT_KEY="), + ); + + expect(baseUrl).toBeDefined(); + expect(secretKey).toBeDefined(); + expect(totpKey).toBeDefined(); + if (!baseUrl || !secretKey || !totpKey) return; + + expect(baseUrl).toContain(mockSchema.projectName); + + // Check base64 lengths and format + const secretKeyValue = secretKey.split("=")[1]; + const totpKeyValue = totpKey.split("=")[1]; + + expect(secretKeyValue).toBeDefined(); + expect(totpKeyValue).toBeDefined(); + if (!secretKeyValue || !totpKeyValue) return; + + expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(secretKeyValue.length).toBeGreaterThanOrEqual(86); + expect(secretKeyValue.length).toBeLessThanOrEqual(88); + + expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); + expect(totpKeyValue.length).toBeGreaterThanOrEqual(42); + expect(totpKeyValue.length).toBeLessThanOrEqual(44); + + // Check mounts + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.content).toContain(mockSchema.projectName); + expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/); + expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/); + }); + }); + + describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => { + it("should populate envs, domains and mounts in the case we didn't used any variable", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${hash}", + }, + ], + env: { + BASE_URL: "http://${domain}", + SECRET_KEY_BASE: "${password:32}", + TOTP_VAULT_KEY: "${base64:128}", + }, + mounts: [ + { + filePath: "/config/secrets.txt", + content: "random_domain=${domain}\nsecret=${password:32}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(3); + expect(result.domains).toHaveLength(1); + expect(result.mounts).toHaveLength(1); + }); + }); +}); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts new file mode 100644 index 000000000..1144b65fe --- /dev/null +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -0,0 +1,232 @@ +import type { Schema } from "@dokploy/server/templates"; +import { processValue } from "@dokploy/server/templates/processors"; +import { describe, expect, it } from "vitest"; + +describe("helpers functions", () => { + // Mock schema for testing + const mockSchema: Schema = { + projectName: "test", + serverIp: "127.0.0.1", + }; + // some helpers to test jwt + type JWTParts = [string, string, string]; + const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; + const jwtBase64Decode = (str: string) => { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8"); + return JSON.parse(decoded); + }; + const jwtCheckHeader = (jwtHeader: string) => { + const decodedHeader = jwtBase64Decode(jwtHeader); + expect(decodedHeader).toHaveProperty("alg"); + expect(decodedHeader).toHaveProperty("typ"); + expect(decodedHeader.alg).toEqual("HS256"); + expect(decodedHeader.typ).toEqual("JWT"); + }; + + describe("${domain}", () => { + it("should generate a random domain", () => { + const domain = processValue("${domain}", {}, mockSchema); + expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy(); + expect( + domain.endsWith( + `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`, + ), + ).toBeTruthy(); + }); + }); + + describe("${base64}", () => { + it("should generate a base64 string", () => { + const base64 = processValue("${base64}", {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + }); + it.each([ + [4, 8], + [8, 12], + [16, 24], + [32, 44], + [64, 88], + [128, 172], + ])( + "should generate a base64 string from parameter %d bytes length", + (length, finalLength) => { + const base64 = processValue(`\${base64:${length}}`, {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + expect(base64.length).toBe(finalLength); + }, + ); + }); + + describe("${password}", () => { + it("should generate a password string", () => { + const password = processValue("${password}", {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a password string respecting parameter %d length", + (length) => { + const password = processValue(`\${password:${length}}`, {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + expect(password.length).toBe(length); + }, + ); + }); + + describe("${hash}", () => { + it("should generate a hash string", () => { + const hash = processValue("${hash}", {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a hash string respecting parameter %d length", + (length) => { + const hash = processValue(`\${hash:${length}}`, {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + expect(hash.length).toBe(length); + }, + ); + }); + + describe("${uuid}", () => { + it("should generate a UUID string", () => { + const uuid = processValue("${uuid}", {}, mockSchema); + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + }); + + describe("${timestamp}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestamp}", {}, mockSchema); + const nowLength = Math.floor(Date.now()).toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + }); + describe("${timestampms}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestampms}", {}, mockSchema); + const nowLength = Date.now().toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + it("should generate a timestamp string in milliseconds from parameter", () => { + const timestamp = processValue( + "${timestampms:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamp).toEqual("1735689600000"); + }); + }); + describe("${timestamps}", () => { + it("should generate a timestamp string in seconds", () => { + const timestamps = processValue("${timestamps}", {}, mockSchema); + const nowLength = Math.floor(Date.now() / 1000).toString().length; + expect(timestamps).toMatch(/^\d+$/); + expect(timestamps.length).toBe(nowLength); + }); + it("should generate a timestamp string in seconds from parameter", () => { + const timestamps = processValue( + "${timestamps:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamps).toEqual("1735689600"); + }); + }); + + describe("${randomPort}", () => { + it("should generate a random port string", () => { + const randomPort = processValue("${randomPort}", {}, mockSchema); + expect(randomPort).toMatch(/^\d+$/); + expect(Number(randomPort)).toBeLessThan(65536); + }); + }); + + describe("${username}", () => { + it("should generate a username string", () => { + const username = processValue("${username}", {}, mockSchema); + expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/); + }); + }); + + describe("${email}", () => { + it("should generate an email string", () => { + const email = processValue("${email}", {}, mockSchema); + expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); + }); + }); + + describe("${jwt}", () => { + it("should generate a JWT string", () => { + const jwt = processValue("${jwt}", {}, mockSchema); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a random hex string from parameter %d byte length", + (length) => { + const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema); + expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/); + expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length + expect(jwt.length).toBeLessThanOrEqual(length * 2); + }, + ); + }); + describe("${jwt:secret}", () => { + it("should generate a JWT string respecting parameter secret from variable", () => { + const jwt = processValue( + "${jwt:secret}", + { secret: "mysecret" }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + }); + describe("${jwt:secret:payload}", () => { + it("should generate a JWT string respecting parameters secret and payload from variables", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload.iat).toEqual(iat); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("test-issuer"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + expect(decodedPayload).toHaveProperty("customprop"); + expect(decodedPayload.customprop).toEqual("customvalue"); + expect(jwt).toEqual( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", + ); + }); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index f33b37fd1..6858f0f00 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -14,7 +14,10 @@ import { import { beforeEach, expect, test, vi } from "vitest"; const baseAdmin: User = { + https: false, enablePaidFeatures: false, + allowImpersonation: false, + role: "user", metricsConfig: { containers: { refreshRate: 20, @@ -73,7 +76,6 @@ beforeEach(() => { test("Should read the configuration file", () => { const config: FileConfig = loadOrCreateConfig("dokploy"); - expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( "dokploy-service-app", ); @@ -83,6 +85,7 @@ test("Should apply redirect-to-https", () => { updateServerTraefik( { ...baseAdmin, + https: true, certificateType: "letsencrypt", }, "example.com", diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 955103dec..f2d0f0a50 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -5,24 +5,35 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { + rollbackActive: false, applicationId: "", herokuVersion: "", + giteaRepository: "", + giteaOwner: "", + giteaBranch: "", + giteaBuildPath: "", + giteaId: "", + cleanCache: false, applicationStatus: "done", appName: "", autoDeploy: true, + enableSubmodules: false, serverId: "", branch: null, dockerBuildStage: "", registryUrl: "", + watchPaths: [], buildArgs: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, + triggerType: "push", previewCertificateType: "none", previewEnv: null, previewHttps: false, previewPath: "/", previewPort: 3000, previewLimit: 0, + previewCustomCertResolver: null, previewWildcard: "", project: { env: "", @@ -75,6 +86,7 @@ const baseApp: ApplicationNested = { ports: [], projectId: "", publishDirectory: null, + isStaticSpa: null, redirects: [], refreshToken: "", registry: null, @@ -103,9 +115,12 @@ const baseDomain: Domain = { port: null, serviceName: "", composeId: "", + customCertResolver: null, domainType: "application", uniqueConfigKey: 1, previewDeploymentId: "", + internalPath: "/", + stripPath: false, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts new file mode 100644 index 000000000..2c1e5decc --- /dev/null +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -0,0 +1,61 @@ +import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; +import { describe, expect, test } from "vitest"; + +describe("normalizeS3Path", () => { + test("should handle empty and whitespace-only prefix", () => { + expect(normalizeS3Path("")).toBe(""); + expect(normalizeS3Path("/")).toBe(""); + expect(normalizeS3Path(" ")).toBe(""); + expect(normalizeS3Path("\t")).toBe(""); + expect(normalizeS3Path("\n")).toBe(""); + expect(normalizeS3Path(" \n \t ")).toBe(""); + }); + + test("should trim whitespace from prefix", () => { + expect(normalizeS3Path(" prefix")).toBe("prefix/"); + expect(normalizeS3Path("prefix ")).toBe("prefix/"); + expect(normalizeS3Path(" prefix ")).toBe("prefix/"); + expect(normalizeS3Path("\tprefix\t")).toBe("prefix/"); + expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/"); + }); + + test("should remove leading slashes", () => { + expect(normalizeS3Path("/prefix")).toBe("prefix/"); + expect(normalizeS3Path("///prefix")).toBe("prefix/"); + }); + + test("should remove trailing slashes", () => { + expect(normalizeS3Path("prefix/")).toBe("prefix/"); + expect(normalizeS3Path("prefix///")).toBe("prefix/"); + }); + + test("should remove both leading and trailing slashes", () => { + expect(normalizeS3Path("/prefix/")).toBe("prefix/"); + expect(normalizeS3Path("///prefix///")).toBe("prefix/"); + }); + + test("should handle nested paths", () => { + expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/"); + expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/"); + expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/"); + }); + + test("should preserve middle slashes", () => { + expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/"); + expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/"); + }); + + test("should handle special characters", () => { + expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/"); + expect(normalizeS3Path("prefix_with_underscores")).toBe( + "prefix_with_underscores/", + ); + expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/"); + }); + + test("should handle the cases from the bug report", () => { + expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/"); + expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/"); + expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 95a559f66..b8a272e15 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => { } try { return JSON.parse(str); - } catch (_e) { + } catch { ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); return z.NEVER; } @@ -270,8 +270,8 @@ export const AddSwarmSettings = ({ applicationId }: Props) => { Swarm Settings - - + + Swarm Settings Update certain settings using a json object. @@ -753,7 +753,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => { )} /> - +