mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c897429f | |||
| 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 |
@@ -0,0 +1,287 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Regression coverage for the cloud (`IS_CLOUD`) `update` mutation in
|
||||
* `apps/dokploy/server/api/routers/backup.ts`.
|
||||
*
|
||||
* BullMQ repeatable jobs are keyed by `name + pattern (+ tz)`. When a backup
|
||||
* that was originally scheduled with cron `A` is updated to cron `B` AND
|
||||
* disabled in the same request, the disable branch must ask `apps/schedules`
|
||||
* to remove the repeatable job using the *stored* (pre-update) cron `A` — not
|
||||
* the post-update cron `B` — otherwise `removeRepeatable` misses and the old
|
||||
* schedule keeps firing forever.
|
||||
*
|
||||
* The router fetches the row twice around `updateBackupById`: `existing`
|
||||
* (pre-update) and `backup` (post-update). The disable branch must source the
|
||||
* removal cron from `existing.schedule`.
|
||||
*/
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
// @dokploy/server barrel — backup CRUD + helpers used by the router.
|
||||
findBackupById: vi.fn(),
|
||||
updateBackupById: vi.fn(),
|
||||
// Cloud fetch wrappers in @/server/utils/backup (the things we assert on).
|
||||
removeJob: vi.fn().mockResolvedValue(true),
|
||||
updateJob: vi.fn().mockResolvedValue(true),
|
||||
schedule: vi.fn().mockResolvedValue(true),
|
||||
// Permission + audit side effects.
|
||||
checkServicePermissionAndAccess: vi.fn().mockResolvedValue(undefined),
|
||||
audit: vi.fn().mockResolvedValue(undefined),
|
||||
assertDatabaseBackupLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Cloud fetch wrappers (capture the cron handed to apps/schedules).
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: mocks.removeJob,
|
||||
updateJob: mocks.updateJob,
|
||||
schedule: mocks.schedule,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({ audit: mocks.audit }));
|
||||
|
||||
vi.mock("@/server/api/utils/plan-limits", () => ({
|
||||
assertDatabaseBackupLimit: mocks.assertDatabaseBackupLimit,
|
||||
assertScheduledJobLimit: vi.fn().mockResolvedValue(undefined),
|
||||
assertVolumeBackupLimit: vi.fn().mockResolvedValue(undefined),
|
||||
assertOrganizationLimit: vi.fn().mockResolvedValue(undefined),
|
||||
assertMemberLimit: vi.fn().mockResolvedValue(undefined),
|
||||
assertEnvironmentLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn().mockResolvedValue(undefined),
|
||||
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
|
||||
findMemberByUserId: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/destination", () => ({
|
||||
findDestinationById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/backups/compose", () => ({
|
||||
runComposeBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/backups/utils", () => ({
|
||||
getS3Credentials: vi.fn(),
|
||||
normalizeS3Path: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
execAsyncStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/restore", () => ({
|
||||
restoreComposeBackup: vi.fn(),
|
||||
restoreLibsqlBackup: vi.fn(),
|
||||
restoreMariadbBackup: vi.fn(),
|
||||
restoreMongoBackup: vi.fn(),
|
||||
restoreMySqlBackup: vi.fn(),
|
||||
restorePostgresBackup: vi.fn(),
|
||||
restoreWebServerBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
// trpc.ts imports validateRequest at module load.
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({ validateRequest: vi.fn() }));
|
||||
|
||||
// The @dokploy/server barrel is imported by the router AND by trpc.ts (as
|
||||
// @dokploy/server/index for `hasValidLicense`). Provide every named export the
|
||||
// router references so module load is side-effect free. `IS_CLOUD` pins the
|
||||
// cloud branch under test.
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: true,
|
||||
hasValidLicense: vi.fn().mockResolvedValue(true),
|
||||
// CRUD / lookups used by the router.
|
||||
findBackupById: mocks.findBackupById,
|
||||
updateBackupById: mocks.updateBackupById,
|
||||
createBackup: vi.fn(),
|
||||
findBackupsByDbId: vi.fn().mockResolvedValue([]),
|
||||
findComposeByBackupId: vi.fn(),
|
||||
findComposeById: vi.fn(),
|
||||
findLibsqlByBackupId: vi.fn(),
|
||||
findLibsqlById: vi.fn(),
|
||||
findMariadbByBackupId: vi.fn(),
|
||||
findMariadbById: vi.fn(),
|
||||
findMongoByBackupId: vi.fn(),
|
||||
findMongoById: vi.fn(),
|
||||
findMySqlByBackupId: vi.fn(),
|
||||
findMySqlById: vi.fn(),
|
||||
findPostgresByBackupId: vi.fn(),
|
||||
findPostgresById: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
keepLatestNBackups: vi.fn(),
|
||||
removeBackupById: vi.fn(),
|
||||
removeScheduleBackup: vi.fn(),
|
||||
runLibsqlBackup: vi.fn(),
|
||||
runMariadbBackup: vi.fn(),
|
||||
runMongoBackup: vi.fn(),
|
||||
runMySqlBackup: vi.fn(),
|
||||
runPostgresBackup: vi.fn(),
|
||||
runWebServerBackup: vi.fn(),
|
||||
scheduleBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
// @/server/db/schema re-exports @dokploy/server/db/schema. Provide permissive
|
||||
// Zod input schemas so createCaller parses any input shape we pass.
|
||||
vi.mock("@dokploy/server/db/schema", async () => {
|
||||
const { z } = await import("zod");
|
||||
const any = z.any();
|
||||
return {
|
||||
apiCreateBackup: any,
|
||||
apiFindOneBackup: any,
|
||||
apiRemoveBackup: any,
|
||||
apiRestoreBackup: any,
|
||||
apiUpdateBackup: any,
|
||||
createScheduleSchema: any,
|
||||
updateScheduleSchema: any,
|
||||
createVolumeBackupSchema: any,
|
||||
updateVolumeBackupSchema: any,
|
||||
VOLUME_NAME_MESSAGE: "invalid",
|
||||
VOLUME_NAME_REGEX: /.*/,
|
||||
backups: {},
|
||||
schedules: {},
|
||||
volumeBackups: {},
|
||||
deployments: {},
|
||||
environments: {},
|
||||
member: {},
|
||||
organization: {},
|
||||
};
|
||||
});
|
||||
|
||||
// Distinct subpath modules imported by the sibling routers / plan-limits; mock
|
||||
// them so the barrel re-export above is the single source of schema exports.
|
||||
vi.mock("@dokploy/server/db/schema/schedule", async () => {
|
||||
const { z } = await import("zod");
|
||||
return {
|
||||
createScheduleSchema: z.any(),
|
||||
updateScheduleSchema: z.any(),
|
||||
schedules: {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/db/schema/deployment", () => ({ deployments: {} }));
|
||||
|
||||
import { backupRouter } from "@/server/api/routers/backup";
|
||||
|
||||
const buildBackup = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
backupId: "backup-1",
|
||||
schedule: "*/2 * * * *",
|
||||
enabled: true,
|
||||
postgresId: "pg-1",
|
||||
databaseType: "postgres",
|
||||
backupType: "database",
|
||||
destinationId: "dest-1",
|
||||
prefix: "pg",
|
||||
database: "db",
|
||||
serviceName: "pg",
|
||||
keepLatestCount: 5,
|
||||
metadata: {},
|
||||
...over,
|
||||
});
|
||||
|
||||
const ctx = () =>
|
||||
({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: {
|
||||
id: "u1",
|
||||
email: "u@example.com",
|
||||
role: "owner",
|
||||
ownerId: "u1",
|
||||
enableEnterpriseFeatures: false,
|
||||
isValidEnterpriseLicense: false,
|
||||
},
|
||||
db: {},
|
||||
}) as any;
|
||||
|
||||
describe("backupRouter.update — cloud disable branch", () => {
|
||||
const OLD_CRON = "*/2 * * * *";
|
||||
const NEW_CRON = "0 0 * * *";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findBackupById.mockReset();
|
||||
mocks.updateBackupById.mockReset();
|
||||
mocks.removeJob.mockClear();
|
||||
mocks.updateJob.mockClear();
|
||||
mocks.schedule.mockClear();
|
||||
mocks.checkServicePermissionAndAccess.mockClear();
|
||||
mocks.audit.mockClear();
|
||||
});
|
||||
|
||||
it("removes the repeatable job using the PRE-update cron when cron changes and enabled is flipped off", async () => {
|
||||
// 1st read: pre-update row (scheduled with OLD_CRON, enabled).
|
||||
// 2nd read: post-update row (NEW_CRON, disabled).
|
||||
mocks.findBackupById
|
||||
.mockResolvedValueOnce(buildBackup({ schedule: OLD_CRON, enabled: true }))
|
||||
.mockResolvedValueOnce(
|
||||
buildBackup({ schedule: NEW_CRON, enabled: false }),
|
||||
);
|
||||
|
||||
const caller = backupRouter.createCaller(ctx());
|
||||
await caller.update({
|
||||
backupId: "backup-1",
|
||||
schedule: NEW_CRON,
|
||||
enabled: false,
|
||||
} as any);
|
||||
|
||||
// The disable branch must remove using the stored (old) cron, not the
|
||||
// post-update cron — otherwise the BullMQ repeatable member keyed by
|
||||
// OLD_CRON is left in Redis and keeps firing.
|
||||
expect(mocks.removeJob).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.removeJob).toHaveBeenCalledWith({
|
||||
cronSchedule: OLD_CRON,
|
||||
backupId: "backup-1",
|
||||
type: "backup",
|
||||
});
|
||||
expect(mocks.updateJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still removes correctly when only disabling (cron unchanged)", async () => {
|
||||
mocks.findBackupById
|
||||
.mockResolvedValueOnce(buildBackup({ schedule: OLD_CRON, enabled: true }))
|
||||
.mockResolvedValueOnce(
|
||||
buildBackup({ schedule: OLD_CRON, enabled: false }),
|
||||
);
|
||||
|
||||
const caller = backupRouter.createCaller(ctx());
|
||||
await caller.update({
|
||||
backupId: "backup-1",
|
||||
schedule: OLD_CRON,
|
||||
enabled: false,
|
||||
} as any);
|
||||
|
||||
expect(mocks.removeJob).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.removeJob).toHaveBeenCalledWith({
|
||||
cronSchedule: OLD_CRON,
|
||||
backupId: "backup-1",
|
||||
type: "backup",
|
||||
});
|
||||
expect(mocks.updateJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not regress the enabled path: updateJob receives the post-update cron", async () => {
|
||||
mocks.findBackupById
|
||||
.mockResolvedValueOnce(buildBackup({ schedule: OLD_CRON, enabled: true }))
|
||||
.mockResolvedValueOnce(
|
||||
buildBackup({ schedule: NEW_CRON, enabled: true }),
|
||||
);
|
||||
|
||||
const caller = backupRouter.createCaller(ctx());
|
||||
await caller.update({
|
||||
backupId: "backup-1",
|
||||
schedule: NEW_CRON,
|
||||
enabled: true,
|
||||
} as any);
|
||||
|
||||
// The enabled branch delegates to /update-backup (which does its own
|
||||
// getJobRepeatable lookup), so it receives the NEW cron to reschedule.
|
||||
expect(mocks.updateJob).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateJob).toHaveBeenCalledWith({
|
||||
cronSchedule: NEW_CRON,
|
||||
backupId: "backup-1",
|
||||
type: "backup",
|
||||
});
|
||||
expect(mocks.removeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Defense-in-depth gate test for `apps/schedules/src/utils.ts` `runJobs`
|
||||
* `backup` branch (G6, G7).
|
||||
*
|
||||
* `runJobs` is imported directly from the schedules service source (via
|
||||
* relative path) so this exercises the real module. `bullmq` is mocked so the
|
||||
* module-load-time `new Queue("backupQueue", { connection: ... })` in `queue.ts`
|
||||
* does not open a Redis connection. The `@dokploy/server` barrel and the
|
||||
* schedules `logger` are mocked so we can assert dispatch behaviour.
|
||||
*/
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findBackupById: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
findScheduleById: vi.fn(),
|
||||
findVolumeBackupById: vi.fn(),
|
||||
runPostgresBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runMySqlBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runMongoBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runMariadbBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runLibsqlBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runComposeBackup: vi.fn().mockResolvedValue(undefined),
|
||||
runVolumeBackup: vi.fn().mockResolvedValue(undefined),
|
||||
keepLatestNBackups: vi.fn().mockResolvedValue(undefined),
|
||||
runCommand: vi.fn().mockResolvedValue(undefined),
|
||||
cleanupAll: vi.fn().mockResolvedValue(undefined),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the schedules queue module (imported by utils.ts as ./queue.js) so the
|
||||
// real `queue.ts` — which instantiates `new Queue("backupQueue", { connection })`
|
||||
// and would connect to Redis — is never loaded. Mocking by the path utils.ts
|
||||
// resolves (`./queue.js` -> apps/schedules/src/queue.ts) ensures the stub is
|
||||
// used; a bare `vi.mock("bullmq")` would NOT match because pnpm's nested
|
||||
// node_modules resolves the schedules package's `bullmq` to a different
|
||||
// physical path than this test file's.
|
||||
vi.mock("../../../schedules/src/queue", () => ({
|
||||
jobQueue: {
|
||||
add: vi.fn(),
|
||||
removeRepeatable: vi.fn(),
|
||||
getRepeatableJobs: vi.fn().mockResolvedValue([]),
|
||||
obliterate: vi.fn(),
|
||||
},
|
||||
scheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
removeJob: vi.fn().mockResolvedValue(true),
|
||||
getJobRepeatable: vi.fn().mockResolvedValue(null),
|
||||
cleanQueue: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock the schedules logger (imported by utils.ts as ./logger.js).
|
||||
vi.mock("../../../schedules/src/logger", () => ({
|
||||
logger: { info: mocks.loggerInfo, error: mocks.loggerError, warn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
findBackupById: mocks.findBackupById,
|
||||
findScheduleById: mocks.findScheduleById,
|
||||
findServerById: mocks.findServerById,
|
||||
findVolumeBackupById: mocks.findVolumeBackupById,
|
||||
keepLatestNBackups: mocks.keepLatestNBackups,
|
||||
runCommand: mocks.runCommand,
|
||||
runComposeBackup: mocks.runComposeBackup,
|
||||
runLibsqlBackup: mocks.runLibsqlBackup,
|
||||
runMariadbBackup: mocks.runMariadbBackup,
|
||||
runMongoBackup: mocks.runMongoBackup,
|
||||
runMySqlBackup: mocks.runMySqlBackup,
|
||||
runPostgresBackup: mocks.runPostgresBackup,
|
||||
runVolumeBackup: mocks.runVolumeBackup,
|
||||
cleanupAll: mocks.cleanupAll,
|
||||
CLEANUP_CRON_JOB: "0 0 * * *",
|
||||
}));
|
||||
|
||||
// Relative import to the real schedules source (resolved by vite).
|
||||
import { runJobs } from "../../../schedules/src/utils";
|
||||
|
||||
const activeServer = { serverStatus: "active" } as const;
|
||||
|
||||
describe("runJobs — backup enabled gate (G6)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findBackupById.mockReset();
|
||||
mocks.findServerById.mockReset();
|
||||
mocks.findScheduleById.mockReset();
|
||||
mocks.findVolumeBackupById.mockReset();
|
||||
for (const fn of [
|
||||
mocks.runPostgresBackup,
|
||||
mocks.runMySqlBackup,
|
||||
mocks.runMongoBackup,
|
||||
mocks.runMariadbBackup,
|
||||
mocks.runLibsqlBackup,
|
||||
mocks.runComposeBackup,
|
||||
mocks.runVolumeBackup,
|
||||
mocks.keepLatestNBackups,
|
||||
mocks.runCommand,
|
||||
mocks.cleanupAll,
|
||||
mocks.loggerInfo,
|
||||
mocks.loggerError,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
mocks.findServerById.mockResolvedValue(activeServer);
|
||||
});
|
||||
|
||||
it("skips a disabled (enabled=false) postgres backup without dispatching", async () => {
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
enabled: false,
|
||||
databaseType: "postgres",
|
||||
backupType: "database",
|
||||
postgres: { serverId: "s1" },
|
||||
});
|
||||
|
||||
await runJobs({
|
||||
type: "backup",
|
||||
backupId: "b1",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
|
||||
expect(mocks.runPostgresBackup).not.toHaveBeenCalled();
|
||||
expect(mocks.keepLatestNBackups).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith(
|
||||
"Backup b1 is disabled; skipping",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a backup whose enabled is null (treats as falsy)", async () => {
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
enabled: null,
|
||||
databaseType: "mysql",
|
||||
backupType: "database",
|
||||
mysql: { serverId: "s1" },
|
||||
});
|
||||
|
||||
await runJobs({
|
||||
type: "backup",
|
||||
backupId: "b2",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
|
||||
expect(mocks.runMySqlBackup).not.toHaveBeenCalled();
|
||||
expect(mocks.keepLatestNBackups).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a disabled compose backup without dispatching", async () => {
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
enabled: false,
|
||||
backupType: "compose",
|
||||
compose: { serverId: "s1" },
|
||||
});
|
||||
|
||||
await runJobs({
|
||||
type: "backup",
|
||||
backupId: "b3",
|
||||
cronSchedule: "0 0 * * *",
|
||||
});
|
||||
|
||||
expect(mocks.runComposeBackup).not.toHaveBeenCalled();
|
||||
expect(mocks.keepLatestNBackups).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not gate schedule jobs (already gated on schedule.enabled, unchanged behaviour)", async () => {
|
||||
// schedule disabled → runCommand NOT called; schedule enabled → runCommand called.
|
||||
mocks.findScheduleById.mockResolvedValue({ enabled: false });
|
||||
await runJobs({
|
||||
type: "schedule",
|
||||
scheduleId: "s1",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
expect(mocks.runCommand).not.toHaveBeenCalled();
|
||||
|
||||
mocks.findScheduleById.mockResolvedValue({ enabled: true });
|
||||
await runJobs({
|
||||
type: "schedule",
|
||||
scheduleId: "s2",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
expect(mocks.runCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not gate volume-backup jobs (already gated on volumeBackup.enabled)", async () => {
|
||||
mocks.findVolumeBackupById.mockResolvedValue({ enabled: false });
|
||||
await runJobs({
|
||||
type: "volume-backup",
|
||||
volumeBackupId: "v1",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
expect(mocks.runVolumeBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runJobs — backup enabled dispatch still runs (G7)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findBackupById.mockReset();
|
||||
mocks.findServerById.mockReset();
|
||||
for (const fn of [
|
||||
mocks.runPostgresBackup,
|
||||
mocks.runMySqlBackup,
|
||||
mocks.runMongoBackup,
|
||||
mocks.runMariadbBackup,
|
||||
mocks.runLibsqlBackup,
|
||||
mocks.runComposeBackup,
|
||||
mocks.keepLatestNBackups,
|
||||
mocks.loggerInfo,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
mocks.findServerById.mockResolvedValue(activeServer);
|
||||
});
|
||||
|
||||
const cases: Array<
|
||||
[string, Record<string, unknown>, ReturnType<typeof vi.fn>]
|
||||
> = [
|
||||
[
|
||||
"postgres",
|
||||
{ databaseType: "postgres", backupType: "database" },
|
||||
mocks.runPostgresBackup,
|
||||
],
|
||||
[
|
||||
"mysql",
|
||||
{ databaseType: "mysql", backupType: "database" },
|
||||
mocks.runMySqlBackup,
|
||||
],
|
||||
[
|
||||
"mongo",
|
||||
{ databaseType: "mongo", backupType: "database" },
|
||||
mocks.runMongoBackup,
|
||||
],
|
||||
[
|
||||
"mariadb",
|
||||
{ databaseType: "mariadb", backupType: "database" },
|
||||
mocks.runMariadbBackup,
|
||||
],
|
||||
[
|
||||
"libsql",
|
||||
{ databaseType: "libsql", backupType: "database" },
|
||||
mocks.runLibsqlBackup,
|
||||
],
|
||||
[
|
||||
"compose",
|
||||
{ backupType: "compose" } as Record<string, unknown>,
|
||||
mocks.runComposeBackup,
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, over, runFn] of cases) {
|
||||
it(`enabled=true ${label} backup dispatches to run*Backup + keepLatestNBackups`, async () => {
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
enabled: true,
|
||||
postgres: { serverId: "s1" },
|
||||
mysql: { serverId: "s1" },
|
||||
mongo: { serverId: "s1" },
|
||||
mariadb: { serverId: "s1" },
|
||||
libsql: { serverId: "s1" },
|
||||
compose: { serverId: "s1" },
|
||||
...over,
|
||||
} as any);
|
||||
|
||||
await runJobs({
|
||||
type: "backup",
|
||||
backupId: "b1",
|
||||
cronSchedule: "*/2 * * * *",
|
||||
});
|
||||
|
||||
expect(runFn).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.keepLatestNBackups).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export const backupRouter = createTRPCRouter({
|
||||
});
|
||||
} else {
|
||||
await removeJob({
|
||||
cronSchedule: backup.schedule,
|
||||
cronSchedule: existing.schedule,
|
||||
backupId: backup.backupId,
|
||||
type: "backup",
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
@@ -138,10 +138,10 @@ export const scheduleRouter = createTRPCRouter({
|
||||
});
|
||||
} else {
|
||||
await removeJob({
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
cronSchedule: existingSchedule.cronExpression,
|
||||
scheduleId: existingSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
timezone: updatedSchedule.timezone,
|
||||
timezone: existingSchedule.timezone,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -222,8 +222,8 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
});
|
||||
} else {
|
||||
await removeJob({
|
||||
cronSchedule: updatedVolumeBackup.cronExpression,
|
||||
volumeBackupId: updatedVolumeBackup.volumeBackupId,
|
||||
cronSchedule: existingVb.cronExpression,
|
||||
volumeBackupId: existingVb.volumeBackupId,
|
||||
type: "volume-backup",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -33,6 +33,10 @@ export const runJobs = async (job: QueueJob) => {
|
||||
if (job.type === "backup") {
|
||||
const { backupId } = job;
|
||||
const backup = await findBackupById(backupId);
|
||||
if (!backup.enabled) {
|
||||
logger.info(`Backup ${backupId} is disabled; skipping`);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
databaseType,
|
||||
postgres,
|
||||
|
||||
@@ -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,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);
|
||||
};
|
||||
|
||||
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