Compare commits

...

44 Commits

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-02 21:35:43 +00:00
Yash Kumar c180f7502c refactor: extract resizeImage to shared utility 2026-09-02 20:57:30 +05:30
Yash Kumar 4877c4ffa3 fix: address PR feedback (SVG helper & AddOrganization crash) 2026-09-02 20:49:14 +05:30
Yash Kumar dfa9c17d7d refactor: extract createServerSideHelpers into a reusable function 2026-09-02 19:03:24 +05:30
Yash Kumar e25e241cf5 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 17:25:46 +05:30
Yash Kumar f617561d0b Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 17:25:28 +05:30
Bogdan Covrig f904a7bd55 fix: Fix server card title overflow for responsiveness 2026-09-02 13:48:05 +02:00
Narciso E. Núñez Arias 261ebb2317 Merge pull request #5271 from imrja8/fix/ci-docker-tests 2026-09-02 07:41:19 -04:00
autofix-ci[bot] c901260d81 [autofix.ci] apply automated fixes 2026-09-02 11:35:17 +00:00
Yash Kumar 8c9e473b4e test: fix CI failures and improve local test robustness 2026-09-02 16:59:57 +05:30
Yash Kumar e2c9bd2833 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-02 14:24:08 +05:30
Yash Kumar 8b38acdebd Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-09-02 14:24:01 +05:30
autofix-ci[bot] 3893150f5c [autofix.ci] apply automated fixes 2026-09-01 20:57:36 +00:00
Yash Kumar 8806d4ef80 fix: resolve SVG unicode base64 encoding error and image upload race conditions 2026-09-02 02:27:14 +05:30
autofix-ci[bot] 6d51c100ae [autofix.ci] apply automated fixes 2026-09-02 01:55:05 +05:30
Yash Kumar 13bb1ac5d1 fix: account for all style end-tag variants like </style/> 2026-09-02 01:49:35 +05:30
Yash Kumar 5606a2bd1e fix: account for whitespace in style tag regex to prevent XSS 2026-09-02 01:44:46 +05:30
autofix-ci[bot] 58f3714ddc [autofix.ci] apply automated fixes 2026-09-01 20:01:38 +00:00
Yash Kumar 6282b6ef39 fix: resolve security vulnerabilities in whitelabeling
- Sanitize customCss in _document.tsx to prevent XSS breakouts by stripping </style> tags.
- Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching.
2026-09-02 01:31:12 +05:30
Yash Kumar d56ef890ae Cleanup leftover unused code in side.tsx 2026-09-01 21:31:36 +05:30
Yash Kumar b75785dc6a Merge canary and resolve conflicts 2026-09-01 21:29:49 +05:30
Yash Kumar c0f07f1427 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-09-01 18:52:38 +05:30
Yash Kumar da3c0b1326 Merge remote-tracking branch 'origin/main' into fix/whitelabeling-fouc
# Conflicts:
#	apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
#	apps/dokploy/pages/_app.tsx
2026-09-01 18:49:17 +05:30
autofix-ci[bot] 5344945f83 [autofix.ci] apply automated fixes 2026-07-09 09:37:31 +00:00
Yash Kumar f23b5b9532 fix(typecheck): fix twoFactorRedirect typescript error 2026-07-09 15:05:51 +05:30
Yash Kumar 79baa24763 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 14:53:50 +05:30
Yash Kumar 6dc22bdeed Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-09 14:52:25 +05:30
Yash Kumar ac2e081134 Merge branch 'Dokploy:canary' into fix/whitelabeling-fouc 2026-07-09 00:01:19 +05:30
Yash Kumar 26d716b34f fix: eliminate FOUC for whitelabeling across all pages 2026-07-08 23:59:02 +05:30
Yash Kumar 2a7668662f Merge branch 'Dokploy:canary' into feat/organization-logo-drag-drop 2026-07-08 22:28:31 +05:30
autofix-ci[bot] 49bf39dc18 [autofix.ci] apply automated fixes 2026-07-08 05:00:40 +00:00
Yash Kumar 0393c2635b feat: add drag-and-drop logo upload for organizations
- Integrated Dropzone component into handle-organization.tsx.
- Resolved dialog auto-close bug by decoupling Dialog from DropdownMenu trigger.
- Added DOMPurify to sanitize uploaded SVG logos preventing XSS.
- Implemented client-side compression via canvas to resize raster images to 256x256 WebP.
- Enhanced UX with instant logo preview, masked base64 input strings, and a quick-clear button.
- Updated Dropzone to accept classNameContent for local height overrides.
2026-07-08 10:17:05 +05:30
28 changed files with 1046 additions and 122 deletions
@@ -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>
+10 -9
View File
@@ -616,7 +616,7 @@ function SidebarLogo() {
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<SidebarMenuItem className={"w-full min-w-0"}>
<Popover
open={organizationSelectorOpen}
onOpenChange={setOrganizationSelectorOpen}
@@ -632,14 +632,13 @@ function SidebarLogo() {
>
<div
className={cn(
"flex items-center gap-2",
"flex min-w-0 flex-1 items-center gap-2",
isCollapsed && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
)}
>
<Logo
@@ -652,22 +651,24 @@ function SidebarLogo() {
</div>
<div
className={cn(
"flex flex-col items-start",
"flex flex-col items-start min-w-0 flex-1",
isCollapsed && "hidden",
)}
>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium leading-none">
<div className="flex items-center gap-1.5 min-w-0 w-full">
<p className="text-sm font-medium truncate">
{activeOrganization?.name ?? "Select Organization"}
</p>
{haveValidLicense && (
<Badge variant="blue">Enterprise</Badge>
<Badge variant="blue" className="shrink-0">
Enterprise
</Badge>
)}
</div>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")}
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</PopoverTrigger>
@@ -1,39 +0,0 @@
"use client";
import Head from "next/head";
import { useTheme } from "next-themes";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { resolvedTheme } = useTheme();
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const faviconHref =
config?.faviconUrl ??
(resolvedTheme === "dark"
? "/icon-dark.svg"
: resolvedTheme === "light"
? "/icon-light.svg"
: "/icon.svg");
return (
<>
<Head>
{config?.metaTitle && <title>{config.metaTitle}</title>}
<link rel="icon" href={faviconHref} key="app-favicon" />
</Head>
{config?.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}
+16 -2
View File
@@ -10,13 +10,24 @@ interface DropzoneProps
"value" | "onChange"
> {
classNameWrapper?: string;
classNameContent?: string;
className?: string;
dropMessage: string;
onChange: (acceptedFiles: FileList | null) => void;
}
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
(
{
className,
classNameWrapper,
classNameContent,
dropMessage,
onChange,
...props
},
ref,
) => {
const inputRef = useRef<HTMLInputElement | null>(null);
// Function to handle drag over event
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
)}
>
<CardContent
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
className={cn(
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
classNameContent,
)}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleButtonClick}
-6
View File
@@ -3,12 +3,10 @@ import "@/styles/globals.css";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Analytics } from "@/components/shared/analytics";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -40,9 +38,6 @@ const MyApp = ({
}
`}
</style>
<Head>
<title>Dokploy</title>
</Head>
<TooltipProvider>
<ThemeProvider
attribute="class"
@@ -52,7 +47,6 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<WhitelabelingProvider />
<Analytics />
<Toaster richColors />
<SearchCommand />
+96 -3
View File
@@ -1,10 +1,39 @@
import { Head, Html, Main, NextScript } from "next/document";
import { getPublicWhitelabelingConfig } from "@dokploy/server";
import NextDocument, {
type DocumentContext,
type DocumentInitialProps,
Head,
Html,
Main,
NextScript,
} from "next/document";
export default function Document() {
interface WhitelabelingDocumentProps {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
}
export default function Document({
metaTitle,
faviconHref,
customCss,
}: WhitelabelingDocumentProps) {
const title = metaTitle || "Dokploy";
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* Rendered on the server so the correct branding is present on first
paint (and for social scrapers), avoiding a flash of / fallback to
the default Dokploy branding. */}
<title>{title}</title>
<link rel="icon" href={faviconHref || "/icon.svg"} />
{customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{ __html: customCss }}
/>
)}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />
@@ -13,3 +42,67 @@ export default function Document() {
</Html>
);
}
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
faviconHref: string | null;
customCss: string | null;
};
expiresAt: number;
} | null;
}
Document.getInitialProps = async (
ctx: DocumentContext,
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
const initialProps = await NextDocument.getInitialProps(ctx);
let metaTitle: string | null = null;
let faviconHref: string | null = null;
let customCss: string | null = null;
if (
globalThis.__SETTINGS_CACHE &&
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
globalThis.__SETTINGS_CACHE.data
) {
return {
...initialProps,
...globalThis.__SETTINGS_CACHE.data,
};
}
try {
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
// Remove any </style> tags to prevent XSS breakout
customCss = config.customCss
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
}
globalThis.__SETTINGS_CACHE = {
data: {
metaTitle,
faviconHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
};
return {
...initialProps,
metaTitle,
faviconHref,
customCss,
};
};
+10 -2
View File
@@ -5,6 +5,7 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -46,6 +47,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
return;
}
// @ts-ignore
if (data?.twoFactorRedirect as boolean) {
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
setTwoFactorCode("");
setIsTwoFactor(true);
toast.info("Please enter your 2FA code");
@@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the login/onboarding logo and app name
// render correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
try {
const { user } = await validateRequest(context.req);
@@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
@@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},
+9
View File
@@ -1,5 +1,6 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -23,6 +24,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, ctx);
// Prefetch the public branding so the invitation logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
const { query } = ctx;
const token = query.token;
@@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (invitation.userAlreadyExists) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
@@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
+9
View File
@@ -1,5 +1,6 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -27,6 +28,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
);
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the onboarding logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
if (IS_CLOUD) {
const { user } = await validateRequest(context.req);
@@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: true,
},
};
@@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: false,
},
};
+11 -2
View File
@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -165,7 +167,7 @@ export default function Home() {
Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(_context: GetServerSidePropsContext) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
@@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
};
}
const helpers = generateServerSideHelper(appRouter, context);
// Prefetch the public branding so the logo and app name render
// correctly on the server (no flash of default branding).
await helpers.whitelabeling.getPublic.prefetch();
return {
props: {},
props: {
trpcState: helpers.dehydrate(),
},
};
}
+1 -1
View File
@@ -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 };
}),
+3 -3
View File
@@ -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,
});
};
+37
View File
@@ -0,0 +1,37 @@
export const resizeImage = (file: File, maxSize: number): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = Math.round((height * maxSize) / width);
width = maxSize;
} else {
width = Math.round((width * maxSize) / height);
height = maxSize;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(event.target?.result as string);
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/webp", 0.8));
};
img.onerror = reject;
img.src = event.target?.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
+18
View File
@@ -0,0 +1,18 @@
import DOMPurify from "dompurify";
export const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
});
if (!clean) return null;
// Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars)
const bytes = new TextEncoder().encode(clean);
let binString = "";
for (let i = 0; i < bytes.length; i++) {
binString += String.fromCharCode(bytes[i]!);
}
return `data:image/svg+xml;base64,${btoa(binString)}`;
};
+4
View File
@@ -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,
+1 -1
View File
@@ -66,7 +66,7 @@
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"nanoid": "3.3.18",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
@@ -26,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);
};
+3 -10
View File
@@ -679,8 +679,8 @@ importers:
specifier: 4.0.8
version: 4.0.8
nanoid:
specifier: 3.3.11
version: 3.3.11
specifier: 3.3.18
version: 3.3.18
node-os-utils:
specifier: 2.0.1
version: 2.0.1
@@ -7186,11 +7186,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -16295,8 +16290,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.12: {}
nanoid@3.3.18: {}
nanostores@1.1.1: {}
@@ -16777,7 +16770,7 @@ snapshots:
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1