mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-23 08:55:25 +00:00
Merge pull request #5752 from Dokploy/fix/security-issues
fix: security issues
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createBackup: vi.fn(),
|
||||
findBackupById: vi.fn(),
|
||||
updateBackupById: vi.fn(),
|
||||
findPostgresById: vi.fn(),
|
||||
restorePostgresBackup: vi.fn(),
|
||||
restoreWebServerBackup: vi.fn(),
|
||||
findDestinationRow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
createBackup: mocks.createBackup,
|
||||
findBackupById: mocks.findBackupById,
|
||||
updateBackupById: mocks.updateBackupById,
|
||||
findPostgresById: mocks.findPostgresById,
|
||||
scheduleBackup: vi.fn(),
|
||||
removeScheduleBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/restore", () => ({
|
||||
restorePostgresBackup: mocks.restorePostgresBackup,
|
||||
restoreWebServerBackup: mocks.restoreWebServerBackup,
|
||||
}));
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/plan-limits", () => ({
|
||||
assertDatabaseBackupLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
updateJob: vi.fn(),
|
||||
}));
|
||||
|
||||
const { backupRouter } = await import("@/server/api/routers/backup");
|
||||
|
||||
const caller = backupRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof backupRouter.createCaller>[0]);
|
||||
|
||||
const ownDestination = { destinationId: "dest-a", organizationId: "org-a" };
|
||||
const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" };
|
||||
|
||||
const createInput = {
|
||||
schedule: "0 0 * * *",
|
||||
prefix: "/",
|
||||
database: "app",
|
||||
databaseType: "postgres" as const,
|
||||
postgresId: "pg-1",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const updateInput = {
|
||||
backupId: "backup-1",
|
||||
schedule: "0 0 * * *",
|
||||
enabled: false,
|
||||
prefix: "/",
|
||||
database: "app",
|
||||
keepLatestCount: 0,
|
||||
serviceName: null,
|
||||
metadata: {},
|
||||
databaseType: "postgres" as const,
|
||||
};
|
||||
|
||||
const restoreInput = {
|
||||
databaseId: "pg-1",
|
||||
databaseType: "postgres" as const,
|
||||
backupType: "database" as const,
|
||||
databaseName: "app",
|
||||
backupFile: "app/2024.sql.gz",
|
||||
};
|
||||
|
||||
const drain = async (stream: unknown) => {
|
||||
for await (const _ of stream as AsyncIterable<string>) {
|
||||
}
|
||||
};
|
||||
|
||||
describe("backup endpoints scope destinations to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.createBackup.mockResolvedValue({ backupId: "backup-1" });
|
||||
mocks.findBackupById.mockResolvedValue({
|
||||
backupId: "backup-1",
|
||||
postgresId: "pg-1",
|
||||
destinationId: "dest-a",
|
||||
enabled: false,
|
||||
databaseType: "postgres",
|
||||
backupType: "database",
|
||||
});
|
||||
mocks.findPostgresById.mockResolvedValue({ postgresId: "pg-1" });
|
||||
});
|
||||
|
||||
it("rejects creating a backup on a destination from another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(
|
||||
caller.create({ ...createInput, destinationId: "dest-b" }),
|
||||
).rejects.toMatchObject({
|
||||
message: "You don't have access to this destination.",
|
||||
});
|
||||
expect(mocks.createBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a backup on a destination of the active organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(ownDestination);
|
||||
|
||||
await caller.create({ ...createInput, destinationId: "dest-a" });
|
||||
|
||||
expect(mocks.createBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects moving a backup to a destination from another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(
|
||||
caller.update({ ...updateInput, destinationId: "dest-b" }),
|
||||
).rejects.toMatchObject({
|
||||
message: "You don't have access to this destination.",
|
||||
});
|
||||
expect(mocks.updateBackupById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects restoring from a destination of another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
const stream = await caller.restoreBackupWithLogs({
|
||||
...restoreInput,
|
||||
destinationId: "dest-b",
|
||||
});
|
||||
|
||||
await expect(drain(stream)).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
expect(mocks.restorePostgresBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from a destination of the active organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(ownDestination);
|
||||
|
||||
const stream = await caller.restoreBackupWithLogs({
|
||||
...restoreInput,
|
||||
destinationId: "dest-a",
|
||||
});
|
||||
await drain(stream);
|
||||
|
||||
expect(mocks.restorePostgresBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves service-less web-server schedules unscoped (maintainer decision)", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await caller.create({
|
||||
...createInput,
|
||||
postgresId: undefined,
|
||||
databaseType: "web-server",
|
||||
destinationId: "dest-b",
|
||||
});
|
||||
|
||||
expect(mocks.createBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findDestinationRow: vi.fn(),
|
||||
createDeploymentBackup: vi.fn(),
|
||||
createDeploymentVolumeBackup: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
findVolumeBackupById: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
paths: () => ({ VOLUME_BACKUPS_PATH: "/tmp/volume-backups" }),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeploymentBackup: mocks.createDeploymentBackup,
|
||||
createDeploymentVolumeBackup: mocks.createDeploymentVolumeBackup,
|
||||
updateDeploymentStatus: mocks.updateDeploymentStatus,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/environment", () => ({
|
||||
findEnvironmentById: vi.fn(async () => ({ projectId: "project-1" })),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/project", () => ({
|
||||
findProjectById: vi.fn(async () => ({
|
||||
name: "project",
|
||||
organizationId: "org-a",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/volume-backups", () => ({
|
||||
findVolumeBackupById: mocks.findVolumeBackupById,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/backups/utils", () => ({
|
||||
getBackupCommand: vi.fn(() => "echo backup"),
|
||||
getBackupTimestamp: vi.fn(() => "2024"),
|
||||
getS3Credentials: vi.fn(() => []),
|
||||
normalizeS3Path: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/volume-backups/backup", () => ({
|
||||
backupVolume: vi.fn(async () => "echo backup"),
|
||||
getVolumeServiceAppName: vi.fn(() => "app"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/database-backup", () => ({
|
||||
sendDatabaseBackupNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/volume-backup", () => ({
|
||||
sendVolumeBackupNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
const { runPostgresBackup } = await import(
|
||||
"@dokploy/server/utils/backups/postgres"
|
||||
);
|
||||
const { runVolumeBackup } = await import(
|
||||
"@dokploy/server/utils/volume-backups/utils"
|
||||
);
|
||||
|
||||
const ownDestination = {
|
||||
destinationId: "dest-a",
|
||||
organizationId: "org-a",
|
||||
bucket: "bucket",
|
||||
};
|
||||
const foreignDestination = {
|
||||
destinationId: "dest-b",
|
||||
organizationId: "org-b",
|
||||
bucket: "bucket",
|
||||
};
|
||||
|
||||
const postgres = {
|
||||
name: "db",
|
||||
appName: "db-app",
|
||||
environmentId: "env-1",
|
||||
serverId: null,
|
||||
databaseUser: "postgres",
|
||||
};
|
||||
|
||||
const backup = {
|
||||
backupId: "backup-1",
|
||||
prefix: "/",
|
||||
database: "app",
|
||||
databaseType: "postgres",
|
||||
backupType: "database",
|
||||
};
|
||||
|
||||
const volumeBackup = {
|
||||
volumeBackupId: "vb-1",
|
||||
name: "data",
|
||||
appName: "vb-app",
|
||||
volumeName: "app_data",
|
||||
serviceType: "application",
|
||||
destinationId: "dest-b",
|
||||
keepLatestCount: 0,
|
||||
application: {
|
||||
appName: "app",
|
||||
serverId: null,
|
||||
environment: { project: { name: "project", organizationId: "org-a" } },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.createDeploymentBackup.mockResolvedValue({
|
||||
deploymentId: "dep-1",
|
||||
logPath: "/tmp/dep-1.log",
|
||||
});
|
||||
mocks.createDeploymentVolumeBackup.mockResolvedValue({
|
||||
deploymentId: "dep-1",
|
||||
logPath: "/tmp/dep-1.log",
|
||||
});
|
||||
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.findVolumeBackupById.mockResolvedValue(volumeBackup);
|
||||
});
|
||||
|
||||
describe("runPostgresBackup checks the destination organization before side effects", () => {
|
||||
it("aborts before creating a deployment when the destination belongs to another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(
|
||||
runPostgresBackup(postgres as never, backup as never),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
|
||||
expect(mocks.createDeploymentBackup).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs when the destination belongs to the project organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(ownDestination);
|
||||
|
||||
await runPostgresBackup(postgres as never, backup as never);
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runVolumeBackup checks the destination organization before side effects", () => {
|
||||
it("aborts before creating a deployment when the destination belongs to another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(runVolumeBackup("vb-1")).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
expect(mocks.createDeploymentVolumeBackup).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs when the destination belongs to the project organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(ownDestination);
|
||||
|
||||
await runVolumeBackup("vb-1");
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createVolumeBackup: vi.fn(),
|
||||
findVolumeBackupById: vi.fn(),
|
||||
updateVolumeBackup: vi.fn(),
|
||||
findDestinationRow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
createVolumeBackup: mocks.createVolumeBackup,
|
||||
findVolumeBackupById: mocks.findVolumeBackupById,
|
||||
updateVolumeBackup: mocks.updateVolumeBackup,
|
||||
scheduleVolumeBackup: vi.fn(),
|
||||
removeVolumeBackupJob: vi.fn(),
|
||||
removeVolumeBackup: vi.fn(),
|
||||
restoreVolume: vi.fn(),
|
||||
runVolumeBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
execAsyncStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/plan-limits", () => ({
|
||||
assertVolumeBackupLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
updateJob: vi.fn(),
|
||||
}));
|
||||
|
||||
const { volumeBackupsRouter } = await import(
|
||||
"@/server/api/routers/volume-backups"
|
||||
);
|
||||
|
||||
const caller = volumeBackupsRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof volumeBackupsRouter.createCaller>[0]);
|
||||
|
||||
const ownDestination = { destinationId: "dest-a", organizationId: "org-a" };
|
||||
const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" };
|
||||
|
||||
const createInput = {
|
||||
name: "data",
|
||||
volumeName: "app_data",
|
||||
prefix: "/",
|
||||
cronExpression: "0 0 * * *",
|
||||
serviceType: "application" as const,
|
||||
applicationId: "app-1",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
describe("volume backup endpoints scope destinations to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.createVolumeBackup.mockResolvedValue({
|
||||
volumeBackupId: "vb-1",
|
||||
enabled: false,
|
||||
});
|
||||
mocks.findVolumeBackupById.mockResolvedValue({
|
||||
volumeBackupId: "vb-1",
|
||||
applicationId: "app-1",
|
||||
destinationId: "dest-a",
|
||||
});
|
||||
mocks.updateVolumeBackup.mockResolvedValue({
|
||||
volumeBackupId: "vb-1",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects creating a volume backup on a destination from another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(
|
||||
caller.create({ ...createInput, destinationId: "dest-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.createVolumeBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a volume backup on a destination of the active organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(ownDestination);
|
||||
|
||||
await caller.create({ ...createInput, destinationId: "dest-a" });
|
||||
|
||||
expect(mocks.createVolumeBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects moving a volume backup to a destination from another organization", async () => {
|
||||
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
|
||||
|
||||
await expect(
|
||||
caller.update({
|
||||
...createInput,
|
||||
volumeBackupId: "vb-1",
|
||||
destinationId: "dest-b",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.updateVolumeBackup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const CERTIFICATES_PATH = "/etc/dokploy/traefik/dynamic/certificates";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findCertificateRow: vi.fn(),
|
||||
insertReturning: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
removeDirectoryIfExistsContent: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
default: {
|
||||
existsSync: mocks.existsSync,
|
||||
mkdirSync: mocks.mkdirSync,
|
||||
writeFileSync: mocks.writeFileSync,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { certificates: { findFirst: mocks.findCertificateRow } },
|
||||
insert: () => ({
|
||||
values: () => ({ returning: mocks.insertReturning }),
|
||||
}),
|
||||
delete: () => ({
|
||||
where: () => ({ returning: mocks.deleteWhere }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
paths: () => ({ CERTIFICATES_PATH }),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/filesystem/directory", () => ({
|
||||
removeDirectoryIfExistsContent: mocks.removeDirectoryIfExistsContent,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
encodeBase64: (value: string) => Buffer.from(value).toString("base64"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
const { apiCreateCertificate } = await import("@dokploy/server/db/schema");
|
||||
const { createCertificate, removeCertificateById } = await import(
|
||||
"@dokploy/server/services/certificate"
|
||||
);
|
||||
|
||||
const certificate = {
|
||||
certificateId: "cert-1",
|
||||
name: "example",
|
||||
certificateData: "CERT",
|
||||
privateKey: "KEY",
|
||||
organizationId: "org-a",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.existsSync.mockReturnValue(false);
|
||||
mocks.deleteWhere.mockResolvedValue([certificate]);
|
||||
});
|
||||
|
||||
describe("certificate create input", () => {
|
||||
it("ignores a client-supplied certificatePath", () => {
|
||||
const parsed = apiCreateCertificate.parse({
|
||||
name: "example",
|
||||
certificateData: "CERT",
|
||||
privateKey: "KEY",
|
||||
organizationId: "org-a",
|
||||
certificatePath: "../../../../tmp/escape",
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("certificatePath");
|
||||
});
|
||||
});
|
||||
|
||||
describe("certificate files stay inside CERTIFICATES_PATH", () => {
|
||||
it.each(["../../../../tmp/escape", "/tmp/escape", "", "."])(
|
||||
"refuses to delete the directory resolved from %j",
|
||||
async (certificatePath) => {
|
||||
mocks.findCertificateRow.mockResolvedValue({
|
||||
...certificate,
|
||||
certificatePath,
|
||||
});
|
||||
|
||||
await expect(removeCertificateById("cert-1")).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.removeDirectoryIfExistsContent).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses to delete an escaping directory on a remote server", async () => {
|
||||
mocks.findCertificateRow.mockResolvedValue({
|
||||
...certificate,
|
||||
serverId: "server-1",
|
||||
certificatePath: "../../../../tmp/escape",
|
||||
});
|
||||
|
||||
await expect(removeCertificateById("cert-1")).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a directory inside CERTIFICATES_PATH", async () => {
|
||||
mocks.findCertificateRow.mockResolvedValue({
|
||||
...certificate,
|
||||
certificatePath: "certificate-abc123",
|
||||
});
|
||||
|
||||
await removeCertificateById("cert-1");
|
||||
|
||||
expect(mocks.removeDirectoryIfExistsContent).toHaveBeenCalledWith(
|
||||
`${CERTIFICATES_PATH}/certificate-abc123`,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to write certificate files outside CERTIFICATES_PATH", async () => {
|
||||
mocks.insertReturning.mockResolvedValue([
|
||||
{ ...certificate, certificatePath: "../../../../tmp/escape" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
createCertificate(
|
||||
{
|
||||
name: "example",
|
||||
certificateData: "CERT",
|
||||
privateKey: "KEY",
|
||||
organizationId: "org-a",
|
||||
},
|
||||
"org-a",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.mkdirSync).not.toHaveBeenCalled();
|
||||
expect(mocks.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes certificate files inside CERTIFICATES_PATH", async () => {
|
||||
mocks.insertReturning.mockResolvedValue([
|
||||
{ ...certificate, certificatePath: "certificate-abc123" },
|
||||
]);
|
||||
|
||||
await createCertificate(
|
||||
{
|
||||
name: "example",
|
||||
certificateData: "CERT",
|
||||
privateKey: "KEY",
|
||||
organizationId: "org-a",
|
||||
},
|
||||
"org-a",
|
||||
);
|
||||
|
||||
expect(mocks.mkdirSync).toHaveBeenCalledWith(
|
||||
`${CERTIFICATES_PATH}/certificate-abc123`,
|
||||
{ recursive: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiUpdateCompose } from "@dokploy/server/db/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const branchFields = [
|
||||
"branch",
|
||||
"gitlabBranch",
|
||||
"bitbucketBranch",
|
||||
"giteaBranch",
|
||||
"customGitBranch",
|
||||
] as const;
|
||||
|
||||
describe("compose update validates provider branch names", () => {
|
||||
it.each(branchFields)("rejects shell metacharacters in %s", (field) => {
|
||||
const result = apiUpdateCompose.safeParse({
|
||||
composeId: "compose-1",
|
||||
[field]: "main; rm -rf /",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(branchFields)("accepts a valid %s", (field) => {
|
||||
const result = apiUpdateCompose.safeParse({
|
||||
composeId: "compose-1",
|
||||
[field]: "feature/login-v2.0",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it.each(branchFields)("still allows clearing %s with null", (field) => {
|
||||
const result = apiUpdateCompose.safeParse({
|
||||
composeId: "compose-1",
|
||||
[field]: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/services/github", () => ({
|
||||
findGithubById: vi.fn(async () => ({
|
||||
githubId: "gh-1",
|
||||
githubUrl: "https://github.com",
|
||||
githubAppId: 1,
|
||||
githubPrivateKey: "key",
|
||||
githubInstallationId: "42",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@octokit/auth-app", () => ({
|
||||
createAppAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("octokit", () => ({
|
||||
Octokit: class {
|
||||
auth = async () => ({ token: "gh-token" });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitlab", () => ({
|
||||
findGitlabById: vi.fn(async () => ({
|
||||
gitlabId: "gl-1",
|
||||
gitlabUrl: "https://gitlab.example.com",
|
||||
gitlabInternalUrl: null,
|
||||
accessToken: "gl-token",
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
})),
|
||||
updateGitlab: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitea", () => ({
|
||||
findGiteaById: vi.fn(async () => ({
|
||||
giteaId: "gt-1",
|
||||
giteaUrl: "https://gitea.example.com:3000",
|
||||
giteaInternalUrl: null,
|
||||
accessToken: "gt-token",
|
||||
})),
|
||||
updateGitea: vi.fn(),
|
||||
}));
|
||||
|
||||
const { cloneGithubRepository } = await import(
|
||||
"@dokploy/server/utils/providers/github"
|
||||
);
|
||||
const { cloneGitlabRepository } = await import(
|
||||
"@dokploy/server/utils/providers/gitlab"
|
||||
);
|
||||
const { cloneGiteaRepository } = await import(
|
||||
"@dokploy/server/utils/providers/gitea"
|
||||
);
|
||||
|
||||
const basic = (token: string) =>
|
||||
Buffer.from(`oauth2:${token}`).toString("base64");
|
||||
|
||||
const base = { appName: "my-app", enableSubmodules: true, serverId: null };
|
||||
|
||||
describe("clone commands keep the token out of the URL (#5618)", () => {
|
||||
it.each([
|
||||
{
|
||||
provider: "github",
|
||||
token: "gh-token",
|
||||
origin: "https://github.com",
|
||||
url: "https://github.com/acme/web.git",
|
||||
build: () =>
|
||||
cloneGithubRepository({
|
||||
...base,
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
branch: "main",
|
||||
githubId: "gh-1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: "gitlab",
|
||||
token: "gl-token",
|
||||
origin: "https://gitlab.example.com",
|
||||
url: "https://gitlab.example.com/acme/web.git",
|
||||
build: () =>
|
||||
cloneGitlabRepository({
|
||||
...base,
|
||||
gitlabBranch: "main",
|
||||
gitlabId: "gl-1",
|
||||
gitlabPathNamespace: "acme/web",
|
||||
gitlabOwner: "acme",
|
||||
gitlabRepository: "web",
|
||||
} as Parameters<typeof cloneGitlabRepository>[0]),
|
||||
},
|
||||
{
|
||||
provider: "gitea",
|
||||
token: "gt-token",
|
||||
origin: "https://gitea.example.com:3000",
|
||||
url: "https://gitea.example.com:3000/acme/web.git",
|
||||
build: () =>
|
||||
cloneGiteaRepository({
|
||||
...base,
|
||||
giteaBranch: "main",
|
||||
giteaId: "gt-1",
|
||||
giteaOwner: "acme",
|
||||
giteaRepository: "web",
|
||||
}),
|
||||
},
|
||||
])(
|
||||
"$provider sends the token as a host-scoped header",
|
||||
async ({ token, origin, url, build }) => {
|
||||
const command = (await build()).replace(/\\/g, "");
|
||||
|
||||
expect(command).not.toContain(`${token}@`);
|
||||
expect(command).toContain(
|
||||
`http.${origin}/.extraHeader=Authorization: Basic ${basic(token)}`,
|
||||
);
|
||||
expect(command).toContain(`--recurse-submodules ${url} `);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -53,9 +53,7 @@ describe("cloneGithubRepository host", () => {
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||
);
|
||||
expect(command).toContain("https://github.com/acme/web.git");
|
||||
expect(command).not.toContain("ghe.com");
|
||||
});
|
||||
|
||||
@@ -64,9 +62,7 @@ describe("cloneGithubRepository host", () => {
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
|
||||
);
|
||||
expect(command).toContain("https://acme.ghe.com/acme/web.git");
|
||||
expect(command).not.toContain("github.com");
|
||||
});
|
||||
|
||||
@@ -77,9 +73,7 @@ describe("cloneGithubRepository host", () => {
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
|
||||
);
|
||||
expect(command).toContain("https://github.corp.acme.com/acme/web.git");
|
||||
});
|
||||
|
||||
it("keeps an explicit port in the clone host", async () => {
|
||||
@@ -89,9 +83,7 @@ describe("cloneGithubRepository host", () => {
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
|
||||
);
|
||||
expect(command).toContain("https://github.acme.com:8443/acme/web.git");
|
||||
});
|
||||
|
||||
it("falls back to github.com for a provider stored before this feature", async () => {
|
||||
@@ -99,8 +91,6 @@ describe("cloneGithubRepository host", () => {
|
||||
|
||||
const command = await clone();
|
||||
|
||||
expect(command).toContain(
|
||||
"https://oauth2:gh-token@github.com/acme/web.git",
|
||||
);
|
||||
expect(command).toContain("https://github.com/acme/web.git");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockExecAsync = vi.hoisted(() => vi.fn());
|
||||
const mockExecAsyncRemote = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mockExecAsync,
|
||||
execAsyncRemote: mockExecAsyncRemote,
|
||||
}));
|
||||
|
||||
import {
|
||||
formatBuildLogTail,
|
||||
getBuildErrorMessage,
|
||||
} from "@dokploy/server/utils/notifications/build-error-message";
|
||||
import { ExecError } from "@dokploy/server/utils/process/ExecError";
|
||||
|
||||
// Synthetic key material, not a real key.
|
||||
const SCRIPT = `set -e; echo "-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
c3ludGhldGljLXRlc3Qta2V5
|
||||
-----END OPENSSH PRIVATE KEY-----" > /tmp/id_rsa; nixpacks build /code`;
|
||||
const LOG_PATH = "/etc/dokploy/logs/web-abc/web-abc-2026.log";
|
||||
|
||||
const buildFailure = () =>
|
||||
new ExecError(`Command execution failed: Command failed: ${SCRIPT}`, {
|
||||
command: SCRIPT,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("formatBuildLogTail", () => {
|
||||
it("keeps only the last lines of the log", () => {
|
||||
const log = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join(
|
||||
"\n",
|
||||
);
|
||||
const tail = formatBuildLogTail(log, 3);
|
||||
expect(tail).toBe("line 48\nline 49\nline 50");
|
||||
});
|
||||
|
||||
it("ignores trailing blank lines", () => {
|
||||
expect(formatBuildLogTail("a\nb\n\n\n", 2)).toBe("a\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBuildErrorMessage", () => {
|
||||
it("uses the build log, never the build script, for a failed build", async () => {
|
||||
mockExecAsync.mockResolvedValue({
|
||||
stdout:
|
||||
"src/index.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.\n❌ Nixpacks build failed\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const message = await getBuildErrorMessage(buildFailure(), LOG_PATH);
|
||||
|
||||
expect(message).toContain("error TS2322");
|
||||
expect(message).not.toContain("PRIVATE KEY");
|
||||
expect(message).not.toContain("c3ludGhldGljLXRlc3Qta2V5");
|
||||
expect(mockExecAsync).toHaveBeenCalledWith(`tail -n 30 ${LOG_PATH}`);
|
||||
expect(mockExecAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the log on the build server for remote builds", async () => {
|
||||
mockExecAsyncRemote.mockResolvedValue({
|
||||
stdout: "build failed\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const message = await getBuildErrorMessage(
|
||||
buildFailure(),
|
||||
LOG_PATH,
|
||||
"server-1",
|
||||
);
|
||||
|
||||
expect(message).toBe("build failed");
|
||||
expect(mockExecAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-1",
|
||||
`tail -n 30 ${LOG_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a generic message when the log cannot be read", async () => {
|
||||
mockExecAsync.mockRejectedValue(new Error("No such file"));
|
||||
|
||||
const message = await getBuildErrorMessage(buildFailure(), LOG_PATH);
|
||||
|
||||
expect(message).toBe("Error building");
|
||||
});
|
||||
|
||||
it("keeps the message of errors that are not build commands", async () => {
|
||||
const message = await getBuildErrorMessage(
|
||||
new Error("Repository not found"),
|
||||
LOG_PATH,
|
||||
);
|
||||
|
||||
expect(message).toBe("Repository not found");
|
||||
expect(mockExecAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
accessedServices: string[] = [],
|
||||
accessedProjects: string[] = [],
|
||||
accessedEnvironments: string[] = [],
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-a",
|
||||
accessedProjects,
|
||||
accessedServices,
|
||||
accessedEnvironments,
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> = mockMemberData("owner");
|
||||
|
||||
// Two organizations; every id encodes the org that owns it.
|
||||
const serviceOrgs: Record<string, string> = {
|
||||
"app-a": "org-a",
|
||||
"app-b": "org-b",
|
||||
"postgres-b": "org-b",
|
||||
};
|
||||
const projectOrgs: Record<string, string> = {
|
||||
"project-a": "org-a",
|
||||
"project-b": "org-b",
|
||||
};
|
||||
const environmentOrgs: Record<string, string> = {
|
||||
"env-a": "org-a",
|
||||
"env-b": "org-b",
|
||||
};
|
||||
|
||||
// Pulls the bound id out of a drizzle eq() expression.
|
||||
const idFromWhere = (where: unknown) => {
|
||||
const chunks = (where as { queryChunks?: unknown[] })?.queryChunks ?? [];
|
||||
const param = chunks.find(
|
||||
(c) => typeof (c as { value?: unknown })?.value === "string",
|
||||
) as { value: string } | undefined;
|
||||
return param?.value;
|
||||
};
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const selectChain = {
|
||||
from: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
where: (where: unknown) => {
|
||||
const id = idFromWhere(where);
|
||||
const organizationId = id ? serviceOrgs[id] : undefined;
|
||||
return Promise.resolve(organizationId ? [{ organizationId }] : []);
|
||||
},
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => selectChain),
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
projects: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const id = idFromWhere(where);
|
||||
const organizationId = id ? projectOrgs[id] : undefined;
|
||||
return Promise.resolve(
|
||||
organizationId ? { organizationId } : undefined,
|
||||
);
|
||||
}),
|
||||
},
|
||||
environments: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const id = idFromWhere(where);
|
||||
const organizationId = id ? environmentOrgs[id] : undefined;
|
||||
return Promise.resolve(
|
||||
organizationId ? { project: { organizationId } } : undefined,
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const {
|
||||
checkEnvironmentCreationPermission,
|
||||
checkServiceAccess,
|
||||
checkServicePermissionAndAccess,
|
||||
} = await import("@dokploy/server/services/permission");
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("organization scope of service helpers", () => {
|
||||
it("admin of org A cannot pass checkServicePermissionAndAccess for a service of org B", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "app-b", { envVars: ["write"] }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("owner of org A cannot pass checkServiceAccess for a database of org B", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "postgres-b", "read"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "postgres-b", "delete"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("owner of org A cannot create a service in a project of org B", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-b", "create"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("owner of org A cannot create an environment in a project of org B", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkEnvironmentCreationPermission(ctx, "project-b"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
await expect(
|
||||
checkEnvironmentCreationPermission(ctx, "project-a"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member of org A with a stale access entry for an org B service is rejected", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-b"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "app-b", { deployment: ["read"] }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("unknown service id is NOT_FOUND", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "missing", { deployment: ["read"] }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("owner and admin still pass for services and projects of their own org", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "app-a", { envVars: ["write"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "app-a", "read"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-a", "create"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member with access still passes for a service of their own org", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-a"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "app-a", { deployment: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,20 +29,32 @@ const mockMemberData = (
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
// Every service/project id used below belongs to the caller's org-1.
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const selectChain = {
|
||||
from: () => selectChain,
|
||||
innerJoin: () => selectChain,
|
||||
where: () => Promise.resolve([{ organizationId: "org-1" }]),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => selectChain),
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
projects: {
|
||||
findFirst: vi.fn(() => Promise.resolve({ organizationId: "org-1" })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
scheduleBackup,
|
||||
updateBackupById,
|
||||
} from "@dokploy/server";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import {
|
||||
findDestinationById,
|
||||
findDestinationByIdForOrganization,
|
||||
} from "@dokploy/server/services/destination";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
|
||||
import {
|
||||
@@ -95,6 +98,10 @@ export const backupRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
backup: ["create"],
|
||||
});
|
||||
await findDestinationByIdForOrganization(
|
||||
input.destinationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
@@ -206,6 +213,10 @@ export const backupRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
backup: ["update"],
|
||||
});
|
||||
await findDestinationByIdForOrganization(
|
||||
input.destinationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}
|
||||
|
||||
await updateBackupById(input.backupId, input);
|
||||
@@ -579,7 +590,12 @@ export const backupRouter = createTRPCRouter({
|
||||
backup: ["restore"],
|
||||
});
|
||||
}
|
||||
const destination = await findDestinationById(input.destinationId);
|
||||
const destination = input.databaseId
|
||||
? await findDestinationByIdForOrganization(
|
||||
input.destinationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
)
|
||||
: await findDestinationById(input.destinationId);
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
const onLog = (log: string) => queue.push(log);
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
findAllDeploymentsByComposeId,
|
||||
findAllDeploymentsByServerId,
|
||||
findAllDeploymentsCentralized,
|
||||
findBackupById,
|
||||
findDeploymentById,
|
||||
findPreviewDeploymentById,
|
||||
findScheduleById,
|
||||
findVolumeBackupById,
|
||||
getAccessibleServerIds,
|
||||
IS_CLOUD,
|
||||
removeDeployment,
|
||||
resolveServicePath,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
checkPermission,
|
||||
checkServicePermissionAndAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
@@ -145,10 +150,48 @@ export const deploymentRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (input.type === "server") {
|
||||
const accessibleServerIds = await getAccessibleServerIds(ctx.session);
|
||||
if (!accessibleServerIds.has(input.id)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
await checkPermission(ctx, { deployment: ["read"] });
|
||||
} else {
|
||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
let serviceId: string | null | undefined = input.id;
|
||||
if (input.type === "backup") {
|
||||
const backup = await findBackupById(input.id);
|
||||
serviceId =
|
||||
backup.postgresId ||
|
||||
backup.mysqlId ||
|
||||
backup.mariadbId ||
|
||||
backup.mongoId ||
|
||||
backup.libsqlId ||
|
||||
backup.composeId;
|
||||
} else if (input.type === "volumeBackup") {
|
||||
const volumeBackup = await findVolumeBackupById(input.id);
|
||||
serviceId =
|
||||
volumeBackup.applicationId ||
|
||||
volumeBackup.composeId ||
|
||||
volumeBackup.postgresId ||
|
||||
volumeBackup.mysqlId ||
|
||||
volumeBackup.mariadbId ||
|
||||
volumeBackup.mongoId ||
|
||||
volumeBackup.redisId ||
|
||||
volumeBackup.libsqlId;
|
||||
} else if (input.type === "previewDeployment") {
|
||||
const preview = await findPreviewDeploymentById(input.id);
|
||||
serviceId = preview.applicationId;
|
||||
}
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
deployment: ["read"],
|
||||
});
|
||||
} else {
|
||||
await checkPermission(ctx, { deployment: ["read"] });
|
||||
}
|
||||
}
|
||||
const deploymentsList = await db.query.deployments.findMany({
|
||||
where: eq(deployments[`${input.type}Id`], input.id),
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
VOLUME_NAME_REGEX,
|
||||
volumeBackups,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import {
|
||||
findDestinationById,
|
||||
findDestinationByIdForOrganization,
|
||||
} from "@dokploy/server/services/destination";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import {
|
||||
@@ -100,6 +103,10 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
volumeBackup: ["create"],
|
||||
});
|
||||
}
|
||||
await findDestinationByIdForOrganization(
|
||||
input.destinationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (IS_CLOUD && serviceType && serviceId) {
|
||||
const existingVolumeBackups = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
|
||||
@@ -201,6 +208,10 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
volumeBackup: ["update"],
|
||||
});
|
||||
}
|
||||
await findDestinationByIdForOrganization(
|
||||
input.destinationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const updatedVolumeBackup = await updateVolumeBackup(
|
||||
input.volumeBackupId,
|
||||
input,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const apiCreateCertificate = createInsertSchema(certificates, {
|
||||
privateKey: z.string().min(1),
|
||||
autoRenew: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
}).omit({ certificatePath: true });
|
||||
|
||||
export const apiFindCertificate = z.object({
|
||||
certificateId: z.string().min(1),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
@@ -171,8 +172,20 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
patches: many(patch),
|
||||
}));
|
||||
|
||||
const branchField = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name")
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const createSchema = createInsertSchema(compose, {
|
||||
name: z.string().min(1),
|
||||
branch: branchField,
|
||||
gitlabBranch: branchField,
|
||||
bitbucketBranch: branchField,
|
||||
giteaBranch: branchField,
|
||||
customGitBranch: branchField,
|
||||
appName: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
mechanizeDockerContainer,
|
||||
} from "@dokploy/server/utils/builders";
|
||||
import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error";
|
||||
import { getBuildErrorMessage } from "@dokploy/server/utils/notifications/build-error-message";
|
||||
import { sendBuildSuccessNotifications } from "@dokploy/server/utils/notifications/build-success";
|
||||
import {
|
||||
ExecError,
|
||||
@@ -245,6 +246,11 @@ export const deployApplication = async ({
|
||||
environmentName: application.environment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = await getBuildErrorMessage(
|
||||
error,
|
||||
deployment.logPath,
|
||||
serverId,
|
||||
);
|
||||
let command = "";
|
||||
|
||||
// Only log details for non-ExecError errors
|
||||
@@ -267,8 +273,7 @@ export const deployApplication = async ({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
applicationType: "application",
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
errorMessage,
|
||||
buildLink,
|
||||
organizationId: application.environment.project.organizationId,
|
||||
});
|
||||
|
||||
@@ -53,15 +53,28 @@ export const createCertificate = async (
|
||||
|
||||
const cer = certificate[0];
|
||||
|
||||
createCertificateFiles(cer);
|
||||
await createCertificateFiles(cer);
|
||||
|
||||
return cer;
|
||||
};
|
||||
|
||||
// certificatePath feeds rm -rf and mkdir -p; a tampered row must not escape the certificates dir
|
||||
const resolveCertificateDir = (certificate: Certificate) => {
|
||||
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||
const base = path.resolve(CERTIFICATES_PATH);
|
||||
const certDir = path.resolve(base, certificate.certificatePath);
|
||||
if (!certDir.startsWith(`${base}${path.sep}`)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid certificate path",
|
||||
});
|
||||
}
|
||||
return certDir;
|
||||
};
|
||||
|
||||
export const removeCertificateById = async (certificateId: string) => {
|
||||
const certificate = await findCertificateById(certificateId);
|
||||
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||
const certDir = resolveCertificateDir(certificate);
|
||||
|
||||
if (certificate.serverId) {
|
||||
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
|
||||
@@ -85,8 +98,7 @@ export const removeCertificateById = async (certificateId: string) => {
|
||||
};
|
||||
|
||||
const createCertificateFiles = async (certificate: Certificate) => {
|
||||
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||
const certDir = resolveCertificateDir(certificate);
|
||||
const crtPath = path.join(certDir, "chain.crt");
|
||||
const keyPath = path.join(certDir, "privkey.key");
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@dokploy/server/utils/docker/domain";
|
||||
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
|
||||
import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error";
|
||||
import { getBuildErrorMessage } from "@dokploy/server/utils/notifications/build-error-message";
|
||||
import { sendBuildSuccessNotifications } from "@dokploy/server/utils/notifications/build-success";
|
||||
import {
|
||||
ExecError,
|
||||
@@ -322,6 +323,11 @@ export const deployCompose = async ({
|
||||
environmentName: compose.environment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = await getBuildErrorMessage(
|
||||
error,
|
||||
deployment.logPath,
|
||||
compose.serverId,
|
||||
);
|
||||
let command = "";
|
||||
|
||||
// Only log details for non-ExecError errors
|
||||
@@ -345,8 +351,7 @@ export const deployCompose = async ({
|
||||
projectName: compose.environment.project.name,
|
||||
applicationName: compose.name,
|
||||
applicationType: "compose",
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
errorMessage,
|
||||
buildLink,
|
||||
organizationId: compose.environment.project.organizationId,
|
||||
});
|
||||
|
||||
@@ -45,6 +45,20 @@ export const findDestinationById = async (destinationId: string) => {
|
||||
return destination;
|
||||
};
|
||||
|
||||
export const findDestinationByIdForOrganization = async (
|
||||
destinationId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const destination = await findDestinationById(destinationId);
|
||||
if (destination.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this destination.",
|
||||
});
|
||||
}
|
||||
return destination;
|
||||
};
|
||||
|
||||
export const removeDestinationById = async (
|
||||
destinationId: string,
|
||||
organizationId: string,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { member, organizationRole } from "@dokploy/server/db/schema";
|
||||
import {
|
||||
applications,
|
||||
compose,
|
||||
environments,
|
||||
libsql,
|
||||
mariadb,
|
||||
member,
|
||||
mongo,
|
||||
mysql,
|
||||
organizationRole,
|
||||
postgres,
|
||||
projects,
|
||||
redis,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -215,6 +228,88 @@ export const resolvePermissions = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
const serviceTables = [
|
||||
[applications, applications.applicationId],
|
||||
[compose, compose.composeId],
|
||||
[postgres, postgres.postgresId],
|
||||
[mysql, mysql.mysqlId],
|
||||
[mariadb, mariadb.mariadbId],
|
||||
[mongo, mongo.mongoId],
|
||||
[redis, redis.redisId],
|
||||
[libsql, libsql.libsqlId],
|
||||
] as const;
|
||||
|
||||
// Callers pass the id of any service type, so the owning table is unknown.
|
||||
const findServiceOrganizationId = async (serviceId: string) => {
|
||||
for (const [table, idColumn] of serviceTables) {
|
||||
const [row] = await db
|
||||
.select({ organizationId: projects.organizationId })
|
||||
.from(table)
|
||||
.innerJoin(
|
||||
environments,
|
||||
eq(table.environmentId, environments.environmentId),
|
||||
)
|
||||
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||
.where(eq(idColumn, serviceId));
|
||||
if (row) {
|
||||
return row.organizationId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findProjectOrganizationId = async (projectId: string) => {
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.projectId, projectId),
|
||||
columns: { organizationId: true },
|
||||
});
|
||||
return project?.organizationId;
|
||||
};
|
||||
|
||||
const notFoundMessages = {
|
||||
service: "Service not found",
|
||||
project: "Project not found",
|
||||
};
|
||||
|
||||
const assertOrganizationOwns = (
|
||||
ctx: PermissionCtx,
|
||||
resource: keyof typeof notFoundMessages,
|
||||
ownerOrganizationId: string | undefined,
|
||||
) => {
|
||||
if (!ownerOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: notFoundMessages[resource],
|
||||
});
|
||||
}
|
||||
if (ownerOrganizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: `You are not authorized to access this ${resource}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const assertServiceInOrganization = async (
|
||||
ctx: PermissionCtx,
|
||||
serviceId: string,
|
||||
) =>
|
||||
assertOrganizationOwns(
|
||||
ctx,
|
||||
"service",
|
||||
await findServiceOrganizationId(serviceId),
|
||||
);
|
||||
|
||||
const assertProjectInOrganization = async (
|
||||
ctx: PermissionCtx,
|
||||
projectId: string,
|
||||
) =>
|
||||
assertOrganizationOwns(
|
||||
ctx,
|
||||
"project",
|
||||
await findProjectOrganizationId(projectId),
|
||||
);
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
action: "create" | "delete",
|
||||
@@ -249,6 +344,7 @@ export const checkServicePermissionAndAccess = async (
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await assertServiceInOrganization(ctx, serviceId);
|
||||
await checkPermission(ctx, permissions);
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedServices.includes(serviceId)) {
|
||||
@@ -269,6 +365,13 @@ export const checkServiceAccess = async (
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
// For "create" the id is the target project, not an existing service.
|
||||
if (action === "create") {
|
||||
await assertProjectInOrganization(ctx, serviceId);
|
||||
} else {
|
||||
await assertServiceInOrganization(ctx, serviceId);
|
||||
}
|
||||
|
||||
await checkPermission(ctx, { service: [action] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
@@ -323,6 +426,8 @@ export const checkEnvironmentCreationPermission = async (
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await assertProjectInOrganization(ctx, projectId);
|
||||
|
||||
await checkPermission(ctx, { environment: ["create"] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
@@ -24,7 +24,10 @@ export const runComposeBackup = async (
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix, databaseType, serviceName } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
|
||||
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Libsql } from "@dokploy/server/services/libsql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -23,6 +23,10 @@ export const runLibsqlBackup = async (
|
||||
const { name, environmentId, appName } = libsql;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
@@ -30,7 +34,6 @@ export const runLibsqlBackup = async (
|
||||
description: "Initializing Backup",
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -24,7 +24,10 @@ export const runMariadbBackup = async (
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -21,7 +21,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -21,7 +21,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -23,6 +23,10 @@ export const runPostgresBackup = async (
|
||||
const { name, environmentId, appName } = postgres;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const destination = await findDestinationByIdForOrganization(
|
||||
backup.destinationId,
|
||||
project.organizationId,
|
||||
);
|
||||
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
@@ -30,7 +34,6 @@ export const runPostgresBackup = async (
|
||||
description: "Initializing Backup",
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { quote } from "shell-quote";
|
||||
import { ExecError } from "../process/ExecError";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
const LOG_TAIL_LINES = 30;
|
||||
const FALLBACK_MESSAGE = "Error building";
|
||||
|
||||
export const formatBuildLogTail = (log: string, maxLines = LOG_TAIL_LINES) =>
|
||||
log.trim().split("\n").slice(-maxLines).join("\n").trim();
|
||||
|
||||
// ExecError.message no longer carries the build output; the log tail does.
|
||||
export const getBuildErrorMessage = async (
|
||||
error: unknown,
|
||||
logPath: string,
|
||||
serverId?: string | null,
|
||||
): Promise<string> => {
|
||||
if (!(error instanceof ExecError)) {
|
||||
return (error instanceof Error && error.message) || FALLBACK_MESSAGE;
|
||||
}
|
||||
|
||||
try {
|
||||
const command = `tail -n ${LOG_TAIL_LINES} ${quote([logPath])}`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
return formatBuildLogTail(stdout) || FALLBACK_MESSAGE;
|
||||
} catch {
|
||||
return FALLBACK_MESSAGE;
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,12 @@ interface CloneGitRepository {
|
||||
outputPathOverride?: string;
|
||||
}
|
||||
|
||||
// Header auth keeps the token out of build logs.
|
||||
export const getGitTokenAuthConfig = (cloneUrl: string, token: string) => {
|
||||
const credentials = Buffer.from(`oauth2:${token}`).toString("base64");
|
||||
return `-c ${quote([`http.${new URL(cloneUrl).origin}/.extraHeader=Authorization: Basic ${credentials}`])}`;
|
||||
};
|
||||
|
||||
export const cloneGitRepository = async ({
|
||||
type = "application",
|
||||
...entity
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { getGitTokenAuthConfig } from "./git";
|
||||
|
||||
export const getErrorCloneRequirements = (entity: {
|
||||
giteaRepository?: string | null;
|
||||
@@ -99,14 +100,13 @@ export const refreshGiteaToken = async (giteaProviderId: string) => {
|
||||
|
||||
const buildGiteaCloneUrl = (
|
||||
giteaUrl: string,
|
||||
accessToken: string,
|
||||
owner: string,
|
||||
repository: string,
|
||||
) => {
|
||||
const protocol = giteaUrl.startsWith("http://") ? "http" : "https";
|
||||
const baseUrl = giteaUrl.replace(/^https?:\/\//, "");
|
||||
const repoClone = `${owner}/${repository}.git`;
|
||||
const cloneUrl = `${protocol}://oauth2:${accessToken}@${baseUrl}/${repoClone}`;
|
||||
const cloneUrl = `${protocol}://${baseUrl}/${repoClone}`;
|
||||
return cloneUrl;
|
||||
};
|
||||
|
||||
@@ -172,13 +172,12 @@ export const cloneGiteaRepository = async ({
|
||||
const repoClone = `${giteaOwner}/${giteaRepository}.git`;
|
||||
const cloneUrl = buildGiteaCloneUrl(
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl,
|
||||
giteaProvider.accessToken!,
|
||||
giteaOwner!,
|
||||
giteaRepository!,
|
||||
);
|
||||
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
command += `git ${getGitTokenAuthConfig(cloneUrl, giteaProvider.accessToken!)} clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { Octokit } from "octokit";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getGitTokenAuthConfig } from "./git";
|
||||
|
||||
export const DEFAULT_GITHUB_URL = "https://github.com";
|
||||
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
|
||||
@@ -229,10 +230,10 @@ export const cloneGithubRepository = async ({
|
||||
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
|
||||
command += `rm -rf ${outputPath};`;
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
|
||||
const cloneUrl = `${cloneBase.protocol}//${repoclone}`;
|
||||
|
||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
command += `git ${getGitTokenAuthConfig(cloneUrl, token)} clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
|
||||
return command;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getGitTokenAuthConfig } from "./git";
|
||||
|
||||
const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => {
|
||||
const gitlabProvider = await findGitlabById(gitlabProviderId);
|
||||
@@ -118,7 +119,7 @@ const getGitlabRepoClone = (
|
||||
const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => {
|
||||
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
|
||||
const isSecure = url?.startsWith("https://");
|
||||
const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`;
|
||||
const cloneUrl = `http${isSecure ? "s" : ""}://${repoClone}`;
|
||||
return cloneUrl;
|
||||
};
|
||||
|
||||
@@ -172,7 +173,7 @@ export const cloneGitlabRepository = async ({
|
||||
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
|
||||
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
command += `git ${getGitTokenAuthConfig(cloneUrl, String(gitlab?.accessToken))} clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
createDeploymentVolumeBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import {
|
||||
findDestinationById,
|
||||
findDestinationByIdForOrganization,
|
||||
} from "@dokploy/server/services/destination";
|
||||
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import {
|
||||
execAsync,
|
||||
@@ -106,13 +109,17 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
const projectName = getProjectName(volumeBackup);
|
||||
const organizationId = getOrganizationId(volumeBackup);
|
||||
await findDestinationByIdForOrganization(
|
||||
volumeBackup.destinationId,
|
||||
organizationId,
|
||||
);
|
||||
const deployment = await createDeploymentVolumeBackup({
|
||||
volumeBackupId: volumeBackup.volumeBackupId,
|
||||
title: "Volume Backup",
|
||||
description: "Volume Backup",
|
||||
});
|
||||
const projectName = getProjectName(volumeBackup);
|
||||
const organizationId = getOrganizationId(volumeBackup);
|
||||
try {
|
||||
const command = await backupVolume(volumeBackup);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user