mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 23:21:36 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65449e0a8c | |||
| 1572008cdf | |||
| 12ea7a8920 | |||
| 782bb4e249 | |||
| f4e1154d31 | |||
| bf547e2fd0 | |||
| d1fda6368c | |||
| d389b3c8d0 | |||
| 0889d763ef | |||
| bbca400a6d | |||
| 03cdd00fa8 | |||
| ad00669f0f | |||
| f58ff1062e | |||
| c180f7502c | |||
| 4877c4ffa3 | |||
| dfa9c17d7d | |||
| e25e241cf5 | |||
| f617561d0b | |||
| f904a7bd55 | |||
| 261ebb2317 | |||
| c901260d81 | |||
| 8c9e473b4e | |||
| e2c9bd2833 | |||
| 8b38acdebd | |||
| 3893150f5c | |||
| 8806d4ef80 | |||
| 6d51c100ae | |||
| 13bb1ac5d1 | |||
| 5606a2bd1e | |||
| 58f3714ddc | |||
| 6282b6ef39 | |||
| d56ef890ae | |||
| b75785dc6a | |||
| c0f07f1427 | |||
| da3c0b1326 | |||
| 5344945f83 | |||
| f23b5b9532 | |||
| 79baa24763 | |||
| 6dc22bdeed | |||
| ac2e081134 | |||
| 26d716b34f | |||
| 2a7668662f | |||
| 49bf39dc18 | |||
| 0393c2635b |
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
Generated
+3
-10
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user