Compare commits

...

44 Commits

Author SHA1 Message Date
detail-app[bot] 65449e0a8c fix(security): write Traefik basic-auth middleware after DB commit on update 2026-09-04 02:52:38 +00:00
Narciso E. Núñez Arias 1572008cdf Merge pull request #5272 from bogdaaamn/bogdaaamn-fix-server-card-title-overflow
fix: Fix server card title overflow for responsiveness
2026-09-03 15:50:18 -04:00
autofix-ci[bot] 12ea7a8920 [autofix.ci] apply automated fixes 2026-09-03 17:16:32 +00:00
Narciso E. Núñez Arias 782bb4e249 Merge pull request #5287 from imrja8/fix/sidebar-truncation
fix(ui): truncate organization name properly in sidebar without clipp…
2026-09-03 11:05:36 -04:00
Narciso E. Núñez Arias f4e1154d31 Merge pull request #4765 from imrja8/feat/organization-logo-drag-drop
feat: add drag-and-drop logo upload for organizations
2026-09-03 10:49:41 -04:00
Narciso E. Núñez Arias bf547e2fd0 Merge pull request #4769 from imrja8/fix/whitelabeling-fouc
fix: eliminate whitelabeling FOUC (Replaces #4753)
2026-09-03 10:49:11 -04:00
autofix-ci[bot] d1fda6368c [autofix.ci] apply automated fixes 2026-09-03 04:16:04 +00:00
Yash Kumar d389b3c8d0 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-03 09:33:29 +05:30
Yash Kumar 0889d763ef Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-03 09:33:04 +05:30
Yash Kumar bbca400a6d fix(ui): truncate organization name properly in sidebar without clipping descenders 2026-09-03 09:33:02 +05:30
Narciso E. Núñez Arias 03cdd00fa8 Merge pull request #5282 from Dokploy/dependabot/npm_and_yarn/packages/server/npm_and_yarn-343aa972c6
chore(deps): bump nanoid from 3.3.11 to 3.3.18 in /packages/server in the npm_and_yarn group across 1 directory
2026-09-02 17:50:12 -04:00
Narciso ad00669f0f fix: sync pnpm-lock.yaml with nanoid 3.3.18
The nanoid specifier in packages/server/package.json was bumped to
3.3.18 but the lockfile entry was not updated, causing
pnpm install --frozen-lockfile to fail in CI.
2026-09-02 17:46:10 -04:00
dependabot[bot] f58ff1062e chore(deps): bump nanoid
Bumps the npm_and_yarn group with 1 update in the /packages/server directory: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 3.3.11 to 3.3.18
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.11...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 21:35:43 +00:00
Yash Kumar c180f7502c refactor: extract resizeImage to shared utility 2026-09-02 20:57:30 +05:30
Yash Kumar 4877c4ffa3 fix: address PR feedback (SVG helper & AddOrganization crash) 2026-09-02 20:49:14 +05:30
Yash Kumar dfa9c17d7d refactor: extract createServerSideHelpers into a reusable function 2026-09-02 19:03:24 +05:30
Yash Kumar e25e241cf5 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 17:25:46 +05:30
Yash Kumar f617561d0b Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 17:25:28 +05:30
Bogdan Covrig f904a7bd55 fix: Fix server card title overflow for responsiveness 2026-09-02 13:48:05 +02:00
Narciso E. Núñez Arias 261ebb2317 Merge pull request #5271 from imrja8/fix/ci-docker-tests 2026-09-02 07:41:19 -04:00
autofix-ci[bot] c901260d81 [autofix.ci] apply automated fixes 2026-09-02 11:35:17 +00:00
Yash Kumar 8c9e473b4e test: fix CI failures and improve local test robustness 2026-09-02 16:59:57 +05:30
Yash Kumar e2c9bd2833 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 14:24:08 +05:30
Yash Kumar 8b38acdebd Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 14:24:01 +05:30
autofix-ci[bot] 3893150f5c [autofix.ci] apply automated fixes 2026-09-01 20:57:36 +00:00
Yash Kumar 8806d4ef80 fix: resolve SVG unicode base64 encoding error and image upload race conditions 2026-09-02 02:27:14 +05:30
autofix-ci[bot] 6d51c100ae [autofix.ci] apply automated fixes 2026-09-02 01:55:05 +05:30
Yash Kumar 13bb1ac5d1 fix: account for all style end-tag variants like </style/> 2026-09-02 01:49:35 +05:30
Yash Kumar 5606a2bd1e fix: account for whitespace in style tag regex to prevent XSS 2026-09-02 01:44:46 +05:30
autofix-ci[bot] 58f3714ddc [autofix.ci] apply automated fixes 2026-09-01 20:01:38 +00:00
Yash Kumar 6282b6ef39 fix: resolve security vulnerabilities in whitelabeling
- Sanitize customCss in _document.tsx to prevent XSS breakouts by stripping </style> tags.
- Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching.
2026-09-02 01:31:12 +05:30
Yash Kumar d56ef890ae Cleanup leftover unused code in side.tsx 2026-09-01 21:31:36 +05:30
Yash Kumar b75785dc6a Merge canary and resolve conflicts 2026-09-01 21:29:49 +05:30
Yash Kumar c0f07f1427 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-01 18:52:38 +05:30
Yash Kumar da3c0b1326 Merge remote-tracking branch 'origin/main' into fix/whitelabeling-fouc
# Conflicts:
#	apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
#	apps/dokploy/pages/_app.tsx
2026-09-01 18:49:17 +05:30
autofix-ci[bot] 5344945f83 [autofix.ci] apply automated fixes 2026-07-09 09:37:31 +00:00
Yash Kumar f23b5b9532 fix(typecheck): fix twoFactorRedirect typescript error 2026-07-09 15:05:51 +05:30
Yash Kumar 79baa24763 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 14:53:50 +05:30
Yash Kumar 6dc22bdeed Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-09 14:52:25 +05:30
Yash Kumar ac2e081134 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 00:01:19 +05:30
Yash Kumar 26d716b34f fix: eliminate FOUC for whitelabeling across all pages 2026-07-08 23:59:02 +05:30
Yash Kumar 2a7668662f Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-08 22:28:31 +05:30
autofix-ci[bot] 49bf39dc18 [autofix.ci] apply automated fixes 2026-07-08 05:00:40 +00:00
Yash Kumar 0393c2635b feat: add drag-and-drop logo upload for organizations
- Integrated Dropzone component into handle-organization.tsx.
- Resolved dialog auto-close bug by decoupling Dialog from DropdownMenu trigger.
- Added DOMPurify to sanitize uploaded SVG logos preventing XSS.
- Implemented client-side compression via canvas to resize raster images to 256x256 WebP.
- Enhanced UX with instant logo preview, masked base64 input strings, and a quick-clear button.
- Updated Dropzone to accept classNameContent for local height overrides.
2026-07-08 10:17:05 +05:30
26 changed files with 1070 additions and 144 deletions
@@ -54,7 +54,16 @@ const inputEncoding: Record<string, string> = {
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {
const hasDocker = () => {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
it("writes special environment values that Docker Compose reads back literally", () => {
mkdirSync(codePath, { recursive: true });
@@ -0,0 +1,202 @@
import { db } from "@dokploy/server/db";
import { updateSecurityById } from "@dokploy/server/services/security";
import { beforeEach, describe, expect, it, vi } from "vitest";
const OLD_ROW = {
securityId: "sec-1",
username: "alice",
password: "oldpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const NEW_ROW = {
securityId: "sec-1",
username: "bob",
password: "newpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const APP = {
applicationId: "app-1",
appName: "myapp",
serverId: null,
};
const mocks = vi.hoisted(() => {
const returningMock = vi.fn();
const chain = {
set: vi.fn(),
where: vi.fn(),
returning: returningMock,
};
chain.set.mockReturnValue(chain);
chain.where.mockReturnValue(chain);
const tx = {
update: vi.fn(),
};
tx.update.mockReturnValue(chain);
return {
returningMock,
chain,
tx,
transaction: vi.fn(),
findFirst: vi.fn(),
findApplicationById: vi.fn(),
createSecurityMiddleware: vi.fn(),
removeSecurityMiddleware: vi.fn(),
replaceSecurityMiddlewareUser: vi.fn(),
};
});
vi.mock("@dokploy/server/db", () => ({
db: {
transaction: mocks.transaction,
query: {
security: {
findFirst: mocks.findFirst,
},
},
},
}));
vi.mock("@dokploy/server/services/application", () => ({
findApplicationById: mocks.findApplicationById,
}));
vi.mock("@dokploy/server/utils/traefik/security", () => ({
createSecurityMiddleware: mocks.createSecurityMiddleware,
removeSecurityMiddleware: mocks.removeSecurityMiddleware,
replaceSecurityMiddlewareUser: mocks.replaceSecurityMiddlewareUser,
}));
describe("updateSecurityById", () => {
beforeEach(() => {
mocks.findFirst.mockReset();
mocks.findApplicationById.mockReset();
mocks.returningMock.mockReset();
mocks.replaceSecurityMiddlewareUser.mockReset();
mocks.removeSecurityMiddleware.mockReset();
mocks.createSecurityMiddleware.mockReset();
mocks.transaction.mockReset();
mocks.tx.update.mockReset();
mocks.chain.set.mockReset();
mocks.chain.where.mockReset();
mocks.chain.set.mockReturnValue(mocks.chain);
mocks.chain.where.mockReturnValue(mocks.chain);
mocks.tx.update.mockReturnValue(mocks.chain);
mocks.transaction.mockImplementation(
async (cb: (tx: typeof mocks.tx) => unknown) => cb(mocks.tx),
);
mocks.findFirst.mockResolvedValue(OLD_ROW);
mocks.findApplicationById.mockResolvedValue(APP);
mocks.returningMock.mockResolvedValue([NEW_ROW]);
mocks.replaceSecurityMiddlewareUser.mockResolvedValue(undefined);
mocks.removeSecurityMiddleware.mockResolvedValue(undefined);
mocks.createSecurityMiddleware.mockResolvedValue(undefined);
});
it("performs a single middleware write after the DB transaction commits with old + new security", async () => {
await updateSecurityById("sec-1", { username: "bob", password: "newpass" });
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.findFirst).toHaveBeenCalledTimes(1);
expect(mocks.findApplicationById).toHaveBeenCalledWith("app-1");
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledWith(
APP,
OLD_ROW,
NEW_ROW,
);
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
});
it("does NOT touch the middleware file when tx.update throws (unique violation rollback)", async () => {
mocks.returningMock.mockRejectedValue(
new Error(
'duplicate key value violates unique constraint "security_username_applicationId_unique"',
),
);
await expect(
updateSecurityById("sec-1", { username: "bob" }),
).rejects.toThrow();
expect(mocks.replaceSecurityMiddlewareUser).not.toHaveBeenCalled();
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
});
it("rethrows a TRPCError BAD_REQUEST wrapping the underlying error on tx.update failure", async () => {
mocks.returningMock.mockRejectedValue(
new Error(
'duplicate key value violates unique constraint "security_username_applicationId_unique"',
),
);
await expect(
updateSecurityById("sec-1", { username: "bob" }),
).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("does NOT touch the middleware file when the security row is missing", async () => {
mocks.findFirst.mockResolvedValue(undefined);
await expect(updateSecurityById("missing", {})).rejects.toThrow();
expect(mocks.replaceSecurityMiddlewareUser).not.toHaveBeenCalled();
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.tx.update).not.toHaveBeenCalled();
});
it("commits the DB row even if the post-commit middleware write fails (stale-credential, not lockout)", async () => {
mocks.returningMock.mockResolvedValue([NEW_ROW]);
mocks.replaceSecurityMiddlewareUser.mockRejectedValue(
new Error("ssh failure"),
);
await expect(
updateSecurityById("sec-1", { username: "bob", password: "newpass" }),
).rejects.toThrow();
expect(mocks.tx.update).toHaveBeenCalledTimes(1);
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
expect(mocks.removeSecurityMiddleware).not.toHaveBeenCalled();
expect(mocks.createSecurityMiddleware).not.toHaveBeenCalled();
});
it("runs the middleware write only after the transaction callback returns (post-commit)", async () => {
let txReturned = false;
mocks.transaction.mockImplementation(
async (cb: (tx: typeof mocks.tx) => unknown) => {
await cb(mocks.tx);
txReturned = true;
return { oldSecurity: OLD_ROW, newSecurity: NEW_ROW, application: APP };
},
);
mocks.replaceSecurityMiddlewareUser.mockImplementation(async () => {
expect(txReturned).toBe(true);
});
await updateSecurityById("sec-1", { username: "bob" });
expect(mocks.replaceSecurityMiddlewareUser).toHaveBeenCalledTimes(1);
});
});
describe("updateSecurityById db mock sanity", () => {
it("exposes the mocked db used by the service under test", () => {
expect((db as unknown as { transaction: unknown }).transaction).toBe(
mocks.transaction,
);
});
});
@@ -154,7 +154,16 @@ const hasRealMonitoring = () => {
);
};
describe.skipIf(hasRealMonitoring())(
const hasDocker = () => {
try {
execSync("docker info", { stdio: "ignore" });
return true;
} catch {
return false;
}
};
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
@@ -0,0 +1,306 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import type { Security } from "@dokploy/server/services/security";
import type { FileConfig } from "@dokploy/server/utils/traefik/file-types";
import { replaceSecurityMiddlewareUser } from "@dokploy/server/utils/traefik/security";
import * as bcrypt from "bcrypt";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parse, stringify } from "yaml";
const remoteMocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
writeFileRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: remoteMocks.execAsyncRemote,
writeFileRemote: remoteMocks.writeFileRemote,
};
});
const OLD: Security = {
securityId: "sec-1",
username: "alice",
password: "oldpass",
applicationId: "app-1",
createdAt: "2024-01-01",
};
const initialMiddlewares = (users: string[]): FileConfig => ({
http: {
middlewares: {
"auth-myapp": {
basicAuth: { removeHeader: true, users },
},
},
},
});
const initialAppConfig = (): FileConfig => ({
http: {
routers: {
"myapp-router-1": {
rule: "Host(`x`)",
service: "myapp-service",
middlewares: ["auth-myapp"],
},
},
services: {
"myapp-service": {
loadBalancer: {
servers: [{ url: "http://myapp:3000" }],
passHostHeader: true,
},
},
},
},
});
type BasicAuthMW = {
basicAuth?: { removeHeader?: boolean; users?: string[] };
};
const hashOf = (line: string): string => line.split(":")[1] ?? "";
const readUsers = (file: string): string[] => {
const parsed = parse(fs.readFileSync(file, "utf8")) as FileConfig;
const mw = parsed.http?.middlewares?.["auth-myapp"] as
| BasicAuthMW
| undefined;
return mw?.basicAuth?.users ?? [];
};
describe("replaceSecurityMiddlewareUser (local filesystem)", () => {
let cwd: string;
let dynamicPath: string;
let middlewaresPath: string;
let appConfigPath: string;
const APP_LOCAL = {
applicationId: "app-1",
appName: "myapp",
serverId: null,
} as unknown as ApplicationNested;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-security-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
middlewaresPath = path.join(dynamicPath, "middlewares.yml");
appConfigPath = path.join(dynamicPath, "myapp.yml");
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
it("replaces the old username's hash with the new one in a single write (rename)", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash", "carol:carolhash"])),
"utf8",
);
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
const [name, hash = ""] = bobLine!.split(":");
expect(name).toBe("bob");
expect(await bcrypt.compare("newpass", hash)).toBe(true);
expect(await bcrypt.compare("oldpass", hash)).toBe(false);
const appCfg = parse(fs.readFileSync(appConfigPath, "utf8")) as FileConfig;
expect(appCfg.http?.routers?.["myapp-router-1"]?.middlewares).toContain(
"auth-myapp",
);
});
it("replaces only the hash when the username is unchanged (password rotation)", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash", "carol:carolhash"])),
"utf8",
);
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "alice",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const aliceLine = users.find((u) => u.startsWith("alice:"));
expect(aliceLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(aliceLine!))).toBe(true);
expect(await bcrypt.compare("oldpass", hashOf(aliceLine!))).toBe(false);
expect(users).toHaveLength(2);
});
it("creates the basicAuth middleware entry if it does not yet exist", async () => {
const otherMiddlewares: FileConfig = {
http: {
middlewares: {
"redirect-other": { redirectScheme: { scheme: "https" } },
},
},
};
fs.writeFileSync(middlewaresPath, stringify(otherMiddlewares), "utf8");
fs.writeFileSync(appConfigPath, stringify(initialAppConfig()), "utf8");
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const parsed = parse(
fs.readFileSync(middlewaresPath, "utf8"),
) as FileConfig;
expect(parsed.http?.middlewares?.["redirect-other"]).toBeDefined();
const users = readUsers(middlewaresPath);
expect(users).toHaveLength(1);
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(bobLine!))).toBe(true);
});
it("attaches the middleware to routers that did not reference it yet", async () => {
fs.writeFileSync(
middlewaresPath,
stringify(initialMiddlewares(["alice:oldhash"])),
"utf8",
);
const appConfigNoMiddleware: FileConfig = {
http: {
routers: {
"myapp-router-1": {
rule: "Host(`x`)",
service: "myapp-service",
},
},
services: {
"myapp-service": {
loadBalancer: {
servers: [{ url: "http://myapp:3000" }],
passHostHeader: true,
},
},
},
},
};
fs.writeFileSync(appConfigPath, stringify(appConfigNoMiddleware), "utf8");
const newSecurity: Security = {
...OLD,
username: "alice",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_LOCAL, OLD, newSecurity);
const appCfg = parse(fs.readFileSync(appConfigPath, "utf8")) as FileConfig;
expect(appCfg.http?.routers?.["myapp-router-1"]?.middlewares).toContain(
"auth-myapp",
);
const users = readUsers(middlewaresPath);
expect(users).not.toContain("alice:oldhash");
expect(await bcrypt.compare("newpass", hashOf(users[0]!))).toBe(true);
});
});
describe("replaceSecurityMiddlewareUser (remote serverId)", () => {
const APP_REMOTE = {
applicationId: "app-1",
appName: "myapp",
serverId: "server-1",
} as unknown as ApplicationNested;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("writes the remote middlewares.yml exactly once with the old user replaced", async () => {
remoteMocks.execAsyncRemote.mockImplementation(
async (_serverId, command) => {
if (command.includes("middlewares.yml")) {
return {
stdout: stringify(
initialMiddlewares(["alice:oldhash", "carol:carolhash"]),
),
stderr: "",
};
}
if (command.includes("myapp.yml")) {
return { stdout: stringify(initialAppConfig()), stderr: "" };
}
return { stdout: "", stderr: "" };
},
);
remoteMocks.writeFileRemote.mockResolvedValue(undefined);
const newSecurity: Security = {
...OLD,
username: "bob",
password: "newpass",
};
await replaceSecurityMiddlewareUser(APP_REMOTE, OLD, newSecurity);
const mwWrites = remoteMocks.writeFileRemote.mock.calls.filter(([, p]) =>
(p as string).includes("middlewares.yml"),
);
expect(mwWrites).toHaveLength(1);
const [, , content] = mwWrites[0]!;
const written = parse(content as string) as FileConfig;
const mw = written.http?.middlewares?.["auth-myapp"] as
| BasicAuthMW
| undefined;
const users = mw?.basicAuth?.users ?? [];
expect(users).not.toContain("alice:oldhash");
expect(users).toContain("carol:carolhash");
const bobLine = users.find((u) => u.startsWith("bob:"));
expect(bobLine).toBeDefined();
expect(await bcrypt.compare("newpass", hashOf(bobLine!))).toBe(true);
const appWrites = remoteMocks.writeFileRemote.mock.calls.filter(([, p]) =>
(p as string).includes("myapp.yml"),
);
expect(appWrites).toHaveLength(1);
expect(remoteMocks.execAsyncRemote).toHaveBeenCalledTimes(2);
});
});
@@ -1,4 +1,3 @@
import DOMPurify from "dompurify";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
import { sanitizeSvg } from "@/utils/sanitize-svg";
interface ShowIconSettingsProps {
serviceId: string;
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
@@ -1,6 +1,7 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { GlobeIcon, PenBoxIcon, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -14,6 +15,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
@@ -24,6 +26,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { resizeImage } from "@/utils/image-processing";
import { sanitizeSvg } from "@/utils/sanitize-svg";
const organizationSchema = z.object({
name: z.string().min(1, {
@@ -37,10 +41,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props {
organizationId?: string;
children?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
export function AddOrganization({
organizationId,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: Props) {
const [internalOpen, setInternalOpen] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const uploadCounter = useRef(0);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled
? controlledOnOpenChange || (() => {})
: setInternalOpen;
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
@@ -65,14 +83,18 @@ export function AddOrganization({ organizationId }: Props) {
useEffect(() => {
if (organization) {
uploadCounter.current++;
setIsUploading(false);
form.reset({
name: organization.name,
logo: organization.logo || "",
});
setUploadedFileName(null);
}
}, [organization, form]);
const onSubmit = async (values: OrganizationFormValues) => {
if (isUploading) return;
await mutateAsync({
name: values.name,
logo: values.logo,
@@ -80,6 +102,7 @@ export function AddOrganization({ organizationId }: Props) {
})
.then(() => {
form.reset();
setUploadedFileName(null);
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
@@ -99,8 +122,94 @@ export function AddOrganization({ organizationId }: Props) {
});
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const currentUploadId = ++uploadCounter.current;
setIsUploading(true);
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"image/webp",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
setIsUploading(false);
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
setIsUploading(false);
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
try {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (currentUploadId !== uploadCounter.current) return;
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
form.setValue("logo", sanitizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing SVG");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
return;
}
// Resize raster images to max 256x256 and convert to WebP to save space
try {
const resizedDataUrl = await resizeImage(file, 256);
if (currentUploadId !== uploadCounter.current) return;
form.setValue("logo", resizedDataUrl);
form.trigger("logo");
setUploadedFileName(file.name);
} catch (error) {
if (currentUploadId === uploadCounter.current) {
toast.error("Error processing image");
}
} finally {
if (currentUploadId === uploadCounter.current) {
setIsUploading(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(val) => {
if (!val) {
uploadCounter.current++;
setIsUploading(false);
}
setOpen(val);
}}
>
<DialogTrigger asChild>
{organizationId ? (
<Button
@@ -162,23 +271,78 @@ export function AddOrganization({ organizationId }: Props) {
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem className="gap-4">
<FormLabel className="text-right">Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
render={({ field }) => {
const isDataUrl = field.value?.startsWith("data:");
const displayValue = isDataUrl
? uploadedFileName || "Uploaded image"
: field.value || "";
return (
<FormItem className="gap-4">
<FormLabel className="text-right">
Logo URL or Upload
</FormLabel>
<FormControl>
<div className="col-span-3 flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 p-1">
{field.value ? (
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
<img
src={field.value}
alt="Logo preview"
className="size-full object-contain"
/>
) : (
<GlobeIcon className="size-5 text-muted-foreground" />
)}
</div>
<div className="relative flex-1">
<Input
placeholder="https://example.com/logo.png"
{...field}
value={displayValue}
readOnly={isDataUrl}
onChange={(e) => {
uploadCounter.current++;
setIsUploading(false);
field.onChange(e);
if (isDataUrl) setUploadedFileName(null);
}}
className="w-full pr-8"
/>
{field.value && (
<button
type="button"
onClick={() => {
uploadCounter.current++;
setIsUploading(false);
form.setValue("logo", "");
setUploadedFileName(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
</div>
<Dropzone
dropMessage="Drag & drop a logo or click to upload"
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
classNameContent="h-32"
/>
</div>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
);
}}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
<Button type="submit" isLoading={isPending || isUploading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
@@ -115,10 +115,13 @@ export const ShowServers = () => {
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-lg wrap-break-word min-w-0">
<CardTitle
className="text-lg truncate min-w-0"
title={server.name}
>
{server.name}
</CardTitle>
</div>
+10 -9
View File
@@ -616,7 +616,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<SidebarMenuItem className={"w-full min-w-0"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@@ -632,14 +632,13 @@ function SidebarLogo() {
>
<div
className={cn(
"flex items-center gap-2",
"flex min-w-0 flex-1 items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
)}
>
<Logo
@@ -652,22 +651,24 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start",
"flex flex-col items-start min-w-0 flex-1",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
<div className="flex items-center gap-1.5 min-w-0 w-full">
<p className="text-sm font-medium truncate">
{activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")}
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>
@@ -1,39 +0,0 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}
+16 -2
View File
@@ -10,13 +10,24 @@ interface DropzoneProps
"value" | "onChange"
> {
classNameWrapper?: string;
classNameContent?: string;
className?: string;
dropMessage: string;
onChange: (acceptedFiles: FileList | null) => void;
}
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
(
{
className,
classNameWrapper,
classNameContent,
dropMessage,
onChange,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement | null>(null);
// Function to handle drag over event
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
)}
>
<CardContent
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
className={cn(
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
classNameContent,
)}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleButtonClick}
-6
View File
@@ -3,12 +3,10 @@ import "@/styles/globals.css";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Analytics } from "@/components/shared/analytics";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -40,9 +38,6 @@ const MyApp = ({
}
`}
</style>
<Head>
<title>Dokploy</title>
</Head>
<TooltipProvider>
<ThemeProvider
attribute="class"
@@ -52,7 +47,6 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<WhitelabelingProvider />
<Analytics />
<Toaster richColors />
<SearchCommand />
+96 -3
View File
@@ -1,10 +1,39 @@
import { Head, Html, Main, NextScript } from "next/document";
import { getPublicWhitelabelingConfig } from "@dokploy/server";
import NextDocument, {
type DocumentContext,
type DocumentInitialProps,
Head,
Html,
Main,
NextScript,
} from "next/document";
export default function Document() {
interface WhitelabelingDocumentProps {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
}
export default function Document({
metaTitle,
faviconHref,
customCss,
}: WhitelabelingDocumentProps) {
const title = metaTitle || "Dokploy";
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* Rendered on the server so the correct branding is present on first
paint (and for social scrapers), avoiding a flash of / fallback to
the default Dokploy branding. */}
<title>{title}</title>
<link rel="icon" href={faviconHref || "/icon.svg"} />
{customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{ __html: customCss }}
/>
)}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />
@@ -13,3 +42,67 @@ export default function Document() {
</Html>
);
}
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
}
Document.getInitialProps = async (
ctx: DocumentContext,
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
const initialProps = await NextDocument.getInitialProps(ctx);
let metaTitle: string | null = null;
let faviconHref: string | null = null;
let customCss: string | null = null;
if (
globalThis.__SETTINGS_CACHE &&
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
globalThis.__SETTINGS_CACHE.data
) {
return {
...initialProps,
...globalThis.__SETTINGS_CACHE.data,
};
}
try {
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
// Remove any </style> tags to prevent XSS breakout
customCss = config.customCss
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
}
globalThis.__SETTINGS_CACHE = {
data: {
metaTitle,
faviconHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
};
return {
...initialProps,
metaTitle,
faviconHref,
customCss,
};
};
+10 -2
View File
@@ -5,6 +5,7 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -46,6 +47,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
return;
}
// @ts-ignore
if (data?.twoFactorRedirect as boolean) {
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
setTwoFactorCode("");
setIsTwoFactor(true);
toast.info("Please enter your 2FA code");
@@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
try {
const { user } = await validateRequest(context.req);
@@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
@@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},
+9
View File
@@ -1,5 +1,6 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -23,6 +24,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, ctx);
// Prefetch the public branding so the invitation logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
const { query } = ctx;
const token = query.token;
@@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (invitation.userAlreadyExists) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
@@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
+9
View File
@@ -1,5 +1,6 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -27,6 +28,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
);
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the onboarding logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
const { user } = await validateRequest(context.req);
@@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: true,
},
};
@@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: false,
},
};
+11 -2
View File
@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -165,7 +167,7 @@ export default function Home() {
Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(_context: GetServerSidePropsContext) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
@@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
};
}
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
return {
props: {},
props: {
trpcState: helpers.dehydrate(),
},
};
}
@@ -14,6 +14,11 @@ import {
publicProcedure,
} from "../../trpc";
/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */
function clearBrandingSSRCache() {
globalThis.__SETTINGS_CACHE = null;
}
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
@@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({
whitelabelingConfig: input.whitelabelingConfig,
});
// Clear the cache so Next.js SSR applies changes immediately
clearBrandingSSRCache();
return { success: true };
}),
@@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({
},
});
// Clear the cache so Next.js SSR applies changes immediately
clearBrandingSSRCache();
return { success: true };
}),
@@ -0,0 +1,21 @@
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import superjson from "superjson";
import type { AppRouter } from "@/server/api/root";
export const generateServerSideHelper = (
router: AppRouter,
context: GetServerSidePropsContext<any>,
) => {
return createServerSideHelpers({
router,
ctx: {
req: context.req as any,
res: context.res as any,
db: null as any,
session: null as any,
user: null as any,
},
transformer: superjson,
});
};
+37
View File
@@ -0,0 +1,37 @@
export const resizeImage = (file: File, maxSize: number): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(event.target?.result as string);
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/webp", 0.8));
};
img.onerror = reject;
img.src = event.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
+18
View File
@@ -0,0 +1,18 @@
import DOMPurify from "dompurify";
export const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
});
if (!clean) return null;
// Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars)
const bytes = new TextEncoder().encode(clean);
let binString = "";
for (let i = 0; i < bytes.length; i++) {
binString += String.fromCharCode(bytes[i]!);
}
return `data:image/svg+xml;base64,${btoa(binString)}`;
};
+1 -1
View File
@@ -66,7 +66,7 @@
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"nanoid": "3.3.18",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
+26 -28
View File
@@ -3,6 +3,7 @@ import { type apiCreateSecurity, security } from "@dokploy/server/db/schema";
import {
createSecurityMiddleware,
removeSecurityMiddleware,
replaceSecurityMiddlewareUser,
} from "@dokploy/server/utils/traefik/security";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
@@ -91,41 +92,38 @@ export const updateSecurityById = async (
data: Partial<Security>,
) => {
try {
await db.transaction(async (tx) => {
const securityResponse = await findSecurityById(securityId);
const { oldSecurity, newSecurity, application } = await db.transaction(
async (tx) => {
const oldSecurity = await findSecurityById(securityId);
const application = await findApplicationById(
securityResponse.applicationId,
);
const application = await findApplicationById(
oldSecurity.applicationId,
);
await removeSecurityMiddleware(application, securityResponse);
const response = await tx
.update(security)
.set({
...data,
})
.where(eq(security.securityId, securityId))
.returning()
.then((res) => res[0]);
const response = await tx
.update(security)
.set({
...data,
})
.where(eq(security.securityId, securityId))
.returning()
.then((res) => res[0]);
if (!response) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Security not found",
});
}
if (!response) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Security not found",
});
}
return { oldSecurity, newSecurity: response, application };
},
);
await createSecurityMiddleware(application, response);
return response;
});
await replaceSecurityMiddlewareUser(application, oldSecurity, newSecurity);
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating this security";
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
throw new TRPCError({ code: "BAD_REQUEST", message });
}
};
@@ -26,9 +26,20 @@ export const getDockerCommand = (application: ApplicationNested) => {
try {
const image = `${appName}`;
const dockerContextPath = getDockerContextPath(application);
const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."];
const dockerContextPath =
getDockerContextPath(application) || defaultContextPath;
const commandArgs = [
"build",
"-t",
image,
"-f",
dockerFilePath,
dockerContextPath,
];
if (dockerBuildStage) {
commandArgs.push("--target", dockerBuildStage);
@@ -138,10 +138,9 @@ export const getDockerContextPath = (application: Application) => {
const { APPLICATIONS_PATH } = paths(!!application.serverId);
const { appName, dockerContextPath } = application;
return path.join(
APPLICATIONS_PATH,
appName,
"code",
dockerContextPath || ".",
);
if (!dockerContextPath) {
return null;
}
return path.join(APPLICATIONS_PATH, appName, "code", dockerContextPath);
};
@@ -121,6 +121,62 @@ export const removeSecurityMiddleware = async (
}
};
export const replaceSecurityMiddlewareUser = async (
application: ApplicationNested,
oldSecurity: Security,
newSecurity: Security,
) => {
const { appName, serverId } = application;
let config: FileConfig;
if (serverId) {
config = await loadRemoteMiddlewares(serverId);
} else {
config = loadMiddlewares<FileConfig>();
}
const middlewareName = `auth-${appName}`;
const newUser = `${newSecurity.username}:${await bcrypt.hash(
newSecurity.password,
10,
)}`;
if (config.http?.middlewares) {
const currentMiddleware = config.http.middlewares[middlewareName];
if (isBasicAuthMiddleware(currentMiddleware)) {
const users =
currentMiddleware.basicAuth.users?.filter((user) => {
const [username] = user.split(":");
return username !== oldSecurity.username;
}) || [];
currentMiddleware.basicAuth.users = [...users, newUser];
} else {
config.http.middlewares[middlewareName] = {
basicAuth: {
removeHeader: true,
users: [newUser],
},
};
}
}
let appConfig: FileConfig;
if (serverId) {
appConfig = await loadOrCreateConfigRemote(serverId, appName);
} else {
appConfig = loadOrCreateConfig(appName);
}
addMiddleware(appConfig, middlewareName);
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
} else {
writeMiddleware(config);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
const isBasicAuthMiddleware = (
middleware: HttpMiddleware | undefined,
): middleware is { basicAuth: BasicAuthMiddleware } => {
+3 -10
View File
@@ -679,8 +679,8 @@ importers:
specifier: 4.0.8
version: 4.0.8
nanoid:
specifier: 3.3.11
version: 3.3.11
specifier: 3.3.18
version: 3.3.18
node-os-utils:
specifier: 2.0.1
version: 2.0.1
@@ -7186,11 +7186,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -16295,8 +16290,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.12: {}
nanoid@3.3.18: {}
nanostores@1.1.1: {}
@@ -16777,7 +16770,7 @@ snapshots:
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1