mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
Merge remote-tracking branch 'origin/canary' into fix/upgrade-traefik-3.6.20
This commit is contained in:
@@ -22,6 +22,9 @@ export const user = pgTable("user", {
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
@@ -45,6 +48,7 @@ export const session = pgTable(
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
@@ -142,6 +146,9 @@ export const twoFactor = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").default(true),
|
||||
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
@@ -223,6 +230,13 @@ export const invitation = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@@ -37,29 +37,31 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@better-auth/utils": "0.3.1",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@react-email/components": "^0.0.21",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@trpc/server": "11.10.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.5.4",
|
||||
"better-call": "2.0.2",
|
||||
"better-auth": "1.6.23",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
@@ -74,8 +76,8 @@
|
||||
"postgres": "3.4.4",
|
||||
"public-ip": "6.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
@@ -87,7 +89,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "1.4.21",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
@@ -97,8 +98,8 @@
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
@@ -110,7 +111,7 @@
|
||||
"rimraf": "6.1.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsx": "^4.16.2",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
|
||||
@@ -81,7 +81,17 @@ const getDockerConfig = (): Docker => {
|
||||
return new Docker({ ...versionOption });
|
||||
};
|
||||
|
||||
export const docker = getDockerConfig();
|
||||
// Igual que db: el módulo se evalúa una vez por copia bundleada, el cache
|
||||
// evita crear un cliente (y loguear la detección del socket) por cada una.
|
||||
const globalForDocker = globalThis as unknown as {
|
||||
docker?: Docker;
|
||||
};
|
||||
|
||||
if (!globalForDocker.docker) {
|
||||
globalForDocker.docker = getDockerConfig();
|
||||
}
|
||||
|
||||
export const docker = globalForDocker.docker;
|
||||
|
||||
export const paths = (isServer = false) => {
|
||||
const BASE_PATH =
|
||||
|
||||
@@ -9,32 +9,19 @@ export * from "./schema";
|
||||
|
||||
type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
/**
|
||||
* Evita problemas de redeclaración global en monorepos.
|
||||
* No usamos `declare global`.
|
||||
*/
|
||||
// Este módulo se evalúa varias veces por proceso (copias duplicadas por
|
||||
// esbuild y los chunks de Next); el cache en globalThis garantiza un solo
|
||||
// pool de conexiones por proceso.
|
||||
const globalForDb = globalThis as unknown as {
|
||||
db?: Database;
|
||||
};
|
||||
|
||||
let dbConnection: Database;
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// En producción no usamos global cache
|
||||
dbConnection = drizzle(postgres(dbUrl), {
|
||||
if (!globalForDb.db) {
|
||||
globalForDb.db = drizzle(postgres(dbUrl), {
|
||||
schema,
|
||||
});
|
||||
} else {
|
||||
// En desarrollo reutilizamos conexión para evitar múltiples conexiones
|
||||
if (!globalForDb.db) {
|
||||
globalForDb.db = drizzle(postgres(dbUrl), {
|
||||
schema,
|
||||
});
|
||||
}
|
||||
|
||||
dbConnection = globalForDb.db;
|
||||
}
|
||||
|
||||
export const db: Database = dbConnection;
|
||||
export const db: Database = globalForDb.db;
|
||||
|
||||
export { dbUrl };
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { network } from "./network";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { ssoProvider } from "./sso";
|
||||
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
|
||||
references: [user.id],
|
||||
}),
|
||||
servers: many(server),
|
||||
networks: many(network),
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
@@ -214,8 +216,43 @@ export const twoFactor = pgTable("two_factor", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
failedVerificationCount: integer("failed_verification_count")
|
||||
.notNull()
|
||||
.default(0),
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
});
|
||||
|
||||
export const passkey = pgTable(
|
||||
"passkey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("public_key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credential_id").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
backedUp: boolean("backed_up").notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: timestamp("created_at"),
|
||||
aaguid: text("aaguid"),
|
||||
},
|
||||
(table) => [
|
||||
index("passkey_userId_idx").on(table.userId),
|
||||
index("passkey_credentialID_idx").on(table.credentialID),
|
||||
],
|
||||
);
|
||||
|
||||
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [passkey.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
|
||||
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
|
||||
})
|
||||
.omit({ organizationId: true });
|
||||
|
||||
export const aiCustomProviderSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
});
|
||||
|
||||
export const apiSaveAiCustomProviders = z.object({
|
||||
providers: z.array(aiCustomProviderSchema),
|
||||
});
|
||||
|
||||
export const deploySuggestionSchema = z.object({
|
||||
environmentId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
|
||||
@@ -51,7 +51,12 @@ import {
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
export const sourceType = pgEnum("sourceType", [
|
||||
"docker",
|
||||
"git",
|
||||
@@ -82,11 +87,11 @@ export const applications = pgTable("application", {
|
||||
.$defaultFn(() => generateAppName("app"))
|
||||
.unique(),
|
||||
description: text("description"),
|
||||
env: text("env"),
|
||||
previewEnv: text("previewEnv"),
|
||||
env: encryptedText("env"),
|
||||
previewEnv: encryptedText("previewEnv"),
|
||||
watchPaths: text("watchPaths").array(),
|
||||
previewBuildArgs: text("previewBuildArgs"),
|
||||
previewBuildSecrets: text("previewBuildSecrets"),
|
||||
previewBuildArgs: encryptedText("previewBuildArgs"),
|
||||
previewBuildSecrets: encryptedText("previewBuildSecrets"),
|
||||
previewLabels: text("previewLabels").array(),
|
||||
previewWildcard: text("previewWildcard"),
|
||||
previewPort: integer("previewPort").default(3000),
|
||||
@@ -105,8 +110,8 @@ export const applications = pgTable("application", {
|
||||
"previewRequireCollaboratorPermissions",
|
||||
).default(true),
|
||||
rollbackActive: boolean("rollbackActive").default(false),
|
||||
buildArgs: text("buildArgs"),
|
||||
buildSecrets: text("buildSecrets"),
|
||||
buildArgs: encryptedText("buildArgs"),
|
||||
buildSecrets: encryptedText("buildSecrets"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
@@ -228,6 +233,10 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -369,6 +378,8 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
|
||||
@@ -91,4 +91,6 @@ export type AuditResourceType =
|
||||
| "cluster"
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
|
||||
@@ -50,6 +50,7 @@ export const backups = pgTable("backup", {
|
||||
.notNull()
|
||||
.references(() => destinations.destinationId, { onDelete: "cascade" }),
|
||||
keepLatestCount: integer("keepLatestCount"),
|
||||
includeEncryptionKey: boolean("includeEncryptionKey").notNull().default(true),
|
||||
backupType: backupType("backupType").notNull().default("database"),
|
||||
databaseType: databaseType("databaseType").notNull(),
|
||||
composeId: text("composeId").references(
|
||||
@@ -160,6 +161,7 @@ const createSchema = createInsertSchema(backups, {
|
||||
mongoId: z.string().optional(),
|
||||
libsqlId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
includeEncryptionKey: z.boolean().optional(),
|
||||
metadata: z.any().optional(),
|
||||
});
|
||||
|
||||
@@ -180,6 +182,7 @@ export const apiCreateBackup = createSchema.pick({
|
||||
backupType: true,
|
||||
composeId: true,
|
||||
serviceName: true,
|
||||
includeEncryptionKey: true,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
@@ -206,7 +209,10 @@ export const apiUpdateBackup = createSchema
|
||||
metadata: true,
|
||||
databaseType: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
includeEncryptionKey: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiRestoreBackup = z.object({
|
||||
databaseId: z.string(),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -17,7 +24,12 @@ import { schedules } from "./schedule";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||
"git",
|
||||
"github",
|
||||
@@ -39,7 +51,7 @@ export const compose = pgTable("compose", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("compose")),
|
||||
description: text("description"),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
composeFile: text("composeFile").notNull().default(""),
|
||||
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
|
||||
sourceType: sourceTypeCompose("sourceType").notNull().default("github"),
|
||||
@@ -75,6 +87,7 @@ export const compose = pgTable("compose", {
|
||||
),
|
||||
command: text("command").notNull().default(""),
|
||||
//
|
||||
createEnvFile: boolean("createEnvFile").notNull().default(true),
|
||||
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
|
||||
composePath: text("composePath").notNull().default("./docker-compose.yml"),
|
||||
suffix: text("suffix").notNull().default(""),
|
||||
@@ -86,6 +99,7 @@ export const compose = pgTable("compose", {
|
||||
.default(false),
|
||||
triggerType: triggerType("triggerType").default("push"),
|
||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||
icon: text("icon"),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -108,6 +122,15 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
serviceNetworks: jsonb("serviceNetworks")
|
||||
.$type<
|
||||
Array<{
|
||||
serviceName: string;
|
||||
networkIds: string[];
|
||||
detachDokployNetwork: boolean;
|
||||
}>
|
||||
>()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
@@ -161,6 +184,7 @@ const createSchema = createInsertSchema(compose, {
|
||||
environmentId: z.string(),
|
||||
customGitSSHKeyId: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
createEnvFile: z.boolean().optional(),
|
||||
composePath: z.string().min(1),
|
||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
@@ -169,6 +193,20 @@ const createSchema = createInsertSchema(compose, {
|
||||
.optional(),
|
||||
triggerType: z.enum(["push", "tag"]).optional(),
|
||||
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
|
||||
icon: z
|
||||
.string()
|
||||
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
|
||||
.nullable()
|
||||
.optional(),
|
||||
serviceNetworks: z
|
||||
.array(
|
||||
z.object({
|
||||
serviceName: z.string(),
|
||||
networkIds: z.array(z.string()),
|
||||
detachDokployNetwork: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const apiCreateCompose = createSchema.pick({
|
||||
@@ -230,7 +268,10 @@ export const apiSaveEnvironmentVariablesCompose = createSchema
|
||||
composeId: true,
|
||||
env: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
createEnvFile: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiRandomizeCompose = createSchema
|
||||
.pick({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { projects } from "./project";
|
||||
import { redis } from "./redis";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const environments = pgTable("environment", {
|
||||
environmentId: text("environmentId")
|
||||
@@ -22,7 +23,7 @@ export const environments = pgTable("environment", {
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
env: text("env").notNull().default(""),
|
||||
env: encryptedText("env").notNull().default(""),
|
||||
projectId: text("projectId")
|
||||
.notNull()
|
||||
.references(() => projects.projectId, { onDelete: "cascade" }),
|
||||
|
||||
@@ -16,6 +16,7 @@ export const github = pgTable("github", {
|
||||
githubInstallationId: text("githubInstallationId"),
|
||||
githubPrivateKey: text("githubPrivateKey"),
|
||||
githubWebhookSecret: text("githubWebhookSecret"),
|
||||
githubUrl: text("githubUrl").default("https://github.com").notNull(),
|
||||
gitProviderId: text("gitProviderId")
|
||||
.notNull()
|
||||
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
|
||||
@@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
|
||||
githubInstallationId: z.string().optional(),
|
||||
githubPrivateKey: z.string().optional(),
|
||||
githubWebhookSecret: z.string().nullable(),
|
||||
githubUrl: z
|
||||
.url()
|
||||
.startsWith("https://", "Only https is supported for GitHub instances")
|
||||
.optional(),
|
||||
gitProviderId: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./mysql";
|
||||
export * from "./network";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
export * from "./port";
|
||||
@@ -31,6 +32,7 @@ export * from "./redis";
|
||||
export * from "./registry";
|
||||
export * from "./rollbacks";
|
||||
export * from "./schedule";
|
||||
export * from "./scim";
|
||||
export * from "./security";
|
||||
export * from "./server";
|
||||
export * from "./session";
|
||||
@@ -40,5 +42,6 @@ export * from "./sso";
|
||||
export * from "./tag";
|
||||
export * from "./user";
|
||||
export * from "./utils";
|
||||
export * from "./vault-provider";
|
||||
export * from "./volume-backups";
|
||||
export * from "./web-server-settings";
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -58,7 +59,7 @@ export const libsql = pgTable("libsql", {
|
||||
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
// RESOURCES
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -82,6 +83,10 @@ export const libsql = pgTable("libsql", {
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
@@ -145,6 +150,8 @@ const createSchema = createInsertSchema(libsql, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateLibsql = createSchema
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -54,7 +62,7 @@ export const mariadb = pgTable("mariadb", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
// RESOURCES
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -87,6 +95,10 @@ export const mariadb = pgTable("mariadb", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||
@@ -147,6 +159,8 @@ const createSchema = createInsertSchema(mariadb, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMariaDB = createSchema.pick({
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -59,7 +60,7 @@ export const mongo = pgTable("mongo", {
|
||||
dockerImage: text("dockerImage").notNull().default("mongo:8"),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
@@ -91,6 +92,10 @@ export const mongo = pgTable("mongo", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
replicaSets: boolean("replicaSets").default(false),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||
@@ -145,6 +150,8 @@ const createSchema = createInsertSchema(mongo, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMongo = createSchema.pick({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -54,7 +62,7 @@ export const mysql = pgTable("mysql", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
@@ -85,6 +93,10 @@ export const mysql = pgTable("mysql", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||
@@ -144,6 +156,8 @@ const createSchema = createInsertSchema(mysql, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMySql = createSchema.pick({
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
|
||||
export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
|
||||
|
||||
export const network = pgTable("network", {
|
||||
networkId: text("networkId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
enableIPv4: boolean("enableIPv4").notNull().default(true),
|
||||
enableIPv6: boolean("enableIPv6").notNull().default(false),
|
||||
mtu: integer("mtu"),
|
||||
ipam: jsonb("ipam")
|
||||
.$type<{
|
||||
driver?: string;
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
}>()
|
||||
.default({}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const networkRelations = relations(network, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [network.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [network.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(network, {
|
||||
networkId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
driver: z.enum(["bridge", "overlay"]).optional(),
|
||||
internal: z.boolean().optional(),
|
||||
attachable: z.boolean().optional(),
|
||||
enableIPv4: z.boolean().optional(),
|
||||
enableIPv6: z.boolean().optional(),
|
||||
mtu: z.number().int().min(68).max(65535).optional().nullable(),
|
||||
ipam: z
|
||||
.object({
|
||||
driver: z.string().optional(),
|
||||
config: z
|
||||
.array(
|
||||
z.object({
|
||||
subnet: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
organizationId: z.string().min(1),
|
||||
serverId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const validateNetworkInput = (
|
||||
input: {
|
||||
enableIPv4?: boolean;
|
||||
enableIPv6?: boolean;
|
||||
ipam?: {
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
} | null;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (input.enableIPv4 === false && input.enableIPv6 !== true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["enableIPv4"],
|
||||
message: "IPv4 or IPv6 must be enabled",
|
||||
});
|
||||
}
|
||||
for (const [index, entry] of (input.ipam?.config ?? []).entries()) {
|
||||
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ipam", "config", index, "subnet"],
|
||||
message: "Gateway and IP range require a subnet",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const apiCreateNetwork = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
driver: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
mtu: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ name: true })
|
||||
.superRefine(validateNetworkInput);
|
||||
|
||||
export const apiFindOneNetwork = z.object({
|
||||
networkId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveNetwork = z.object({
|
||||
networkId: z.string().min(1),
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -33,6 +40,7 @@ import {
|
||||
APP_NAME_REGEX,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
@@ -53,7 +61,7 @@ export const postgres = pgTable("postgres", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
externalPort: integer("externalPort"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
@@ -85,6 +93,10 @@ export const postgres = pgTable("postgres", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
||||
@@ -139,6 +151,8 @@ const createSchema = createInsertSchema(postgres, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreatePostgres = createSchema.pick({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { environments } from "./environment";
|
||||
import { projectTags } from "./tag";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const projects = pgTable("project", {
|
||||
projectId: text("projectId")
|
||||
@@ -21,7 +22,7 @@ export const projects = pgTable("project", {
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
env: text("env").notNull().default(""),
|
||||
env: encryptedText("env").notNull().default(""),
|
||||
});
|
||||
|
||||
export const projectRelations = relations(projects, ({ many, one }) => ({
|
||||
@@ -37,6 +38,7 @@ const createSchema = createInsertSchema(projects, {
|
||||
projectId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
env: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateProject = createSchema.pick({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -27,7 +34,12 @@ import {
|
||||
type UpdateConfigSwarm,
|
||||
UpdateConfigSwarmSchema,
|
||||
} from "./shared";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
|
||||
import {
|
||||
APP_NAME_MESSAGE,
|
||||
APP_NAME_REGEX,
|
||||
encryptedText,
|
||||
generateAppName,
|
||||
} from "./utils";
|
||||
|
||||
export const redis = pgTable("redis", {
|
||||
redisId: text("redisId")
|
||||
@@ -44,7 +56,7 @@ export const redis = pgTable("redis", {
|
||||
dockerImage: text("dockerImage").notNull(),
|
||||
command: text("command"),
|
||||
args: text("args").array(),
|
||||
env: text("env"),
|
||||
env: encryptedText("env"),
|
||||
memoryReservation: text("memoryReservation"),
|
||||
memoryLimit: text("memoryLimit"),
|
||||
cpuReservation: text("cpuReservation"),
|
||||
@@ -75,6 +87,10 @@ export const redis = pgTable("redis", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const redisRelations = relations(redis, ({ one, many }) => ({
|
||||
@@ -124,6 +140,8 @@ const createSchema = createInsertSchema(redis, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateRedis = createSchema.pick({
|
||||
|
||||
@@ -44,12 +44,11 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Image references require a lowercase namespace (e.g. Docker Hub username).
|
||||
const registryUsernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.transform((s) => s.toLowerCase());
|
||||
// Registry usernames should NOT be lowercased.
|
||||
// Some registries (e.g. AWS ECR) require a specific case: the username must be
|
||||
// exactly "AWS" (uppercase) for ECR authentication. Docker Hub usernames are
|
||||
// case-insensitive for login, so preserving case is safe for all providers.
|
||||
const registryUsernameSchema = z.string().trim().min(1);
|
||||
|
||||
// Registry URLs must be hostname[:port] only — no shell metacharacters
|
||||
// Empty string is allowed (means default/Docker Hub registry)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Application } from "@dokploy/server/services/application";
|
||||
import type { Environment } from "@dokploy/server/services/environment";
|
||||
import type { Mount } from "@dokploy/server/services/mount";
|
||||
import type { Port } from "@dokploy/server/services/port";
|
||||
import type { Project } from "@dokploy/server/services/project";
|
||||
@@ -27,7 +28,7 @@ export const rollbacks = pgTable("rollback", {
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
fullContext: jsonb("fullContext").$type<
|
||||
Application & {
|
||||
environment: {
|
||||
environment: Environment & {
|
||||
project: Project;
|
||||
};
|
||||
mounts: Mount[];
|
||||
|
||||
@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
|
||||
});
|
||||
|
||||
export type Schedule = typeof schedules.$inferSelect;
|
||||
export type ScheduleType = Schedule["scheduleType"];
|
||||
|
||||
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||
application: one(applications, {
|
||||
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||
deployments: many(deployments),
|
||||
}));
|
||||
|
||||
export const createScheduleSchema = createInsertSchema(schedules);
|
||||
export const createScheduleSchema = createInsertSchema(schedules, {
|
||||
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
|
||||
});
|
||||
|
||||
export const updateScheduleSchema = createScheduleSchema.extend({
|
||||
scheduleId: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [scimProvider.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
|
||||
import { mariadb } from "./mariadb";
|
||||
import { mongo } from "./mongo";
|
||||
import { mysql } from "./mysql";
|
||||
import { network } from "./network";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { schedules } from "./schedule";
|
||||
@@ -41,6 +42,7 @@ export const server = pgTable("server", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("server")),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
@@ -124,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
certificates: many(certificates),
|
||||
networks: many(network),
|
||||
organization: one(organization, {
|
||||
fields: [server.organizationId],
|
||||
references: [organization.id],
|
||||
@@ -182,6 +185,11 @@ export const apiUpdateServer = createSchema
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const apiUpdateServerBuildsConcurrency = z.object({
|
||||
serverId: z.string().min(1),
|
||||
buildsConcurrency: z.number().int().min(1).max(100),
|
||||
});
|
||||
|
||||
export const apiUpdateServerMonitoring = createSchema
|
||||
.pick({
|
||||
serverId: true,
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import { decryptValue, encryptValue } from "@dokploy/server/lib/encryption";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { customType } from "drizzle-orm/pg-core";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
/**
|
||||
* Text column encrypted at rest (AES-256-GCM, key derived from
|
||||
* BETTER_AUTH_SECRET). Legacy plaintext values are passed through on read
|
||||
* and get encrypted the next time they are written.
|
||||
*/
|
||||
export const encryptedText = customType<{ data: string; driverData: string }>({
|
||||
dataType() {
|
||||
return "text";
|
||||
},
|
||||
toDriver(value) {
|
||||
return encryptValue(value);
|
||||
},
|
||||
fromDriver(value) {
|
||||
try {
|
||||
return decryptValue(value);
|
||||
} catch {
|
||||
// Fail open so a key mismatch (e.g. restoring a backup under a
|
||||
// different BETTER_AUTH_SECRET) degrades to showing ciphertext
|
||||
// instead of breaking every query that touches the row.
|
||||
console.error(
|
||||
"Failed to decrypt an encrypted column; returning the raw value. This usually means BETTER_AUTH_SECRET changed after the value was encrypted.",
|
||||
);
|
||||
return value;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz123456789";
|
||||
|
||||
const customNanoid = customAlphabet(alphabet, 6);
|
||||
@@ -12,6 +41,12 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
export const APP_NAME_MESSAGE =
|
||||
"App name can only contain letters, numbers, dots, underscores and hyphens";
|
||||
|
||||
/** Docker volume name: must start alphanumeric, then letters/numbers/._- only. Safe for shell. */
|
||||
export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
|
||||
export const VOLUME_NAME_MESSAGE =
|
||||
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
|
||||
|
||||
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
|
||||
export const DATABASE_PASSWORD_REGEX =
|
||||
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
providerType: z.literal("hashicorp"),
|
||||
url: z.string().url(),
|
||||
token: z.string().min(1),
|
||||
namespace: z.string().optional(),
|
||||
mount: z.string().min(1).default("secret"),
|
||||
});
|
||||
|
||||
export const infisicalVaultConfigSchema = z.object({
|
||||
providerType: z.literal("infisical"),
|
||||
siteUrl: z.string().url().default("https://app.infisical.com"),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentSlug: z.string().min(1),
|
||||
secretPath: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const awsVaultConfigSchema = z.object({
|
||||
providerType: z.literal("aws"),
|
||||
region: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
endpoint: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const dopplerVaultConfigSchema = z.object({
|
||||
providerType: z.literal("doppler"),
|
||||
serviceToken: z.string().min(1),
|
||||
project: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
});
|
||||
|
||||
export const azureVaultConfigSchema = z.object({
|
||||
providerType: z.literal("azure"),
|
||||
vaultUri: z.string().url(),
|
||||
tenantId: z.string().min(1),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
awsVaultConfigSchema,
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
export const vaultProviderAssignmentSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
environmentIds: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export type VaultProviderAssignment = z.infer<
|
||||
typeof vaultProviderAssignmentSchema
|
||||
>;
|
||||
|
||||
export const vaultProvider = pgTable(
|
||||
"vault_provider",
|
||||
{
|
||||
vaultProviderId: text("vaultProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: vaultProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
|
||||
assignments: jsonb("assignments")
|
||||
.$type<VaultProviderAssignment[]>()
|
||||
.notNull()
|
||||
.default([]),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("vault_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const vaultProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(vaultProvider);
|
||||
|
||||
export const apiCreateVaultProvider = createSchema.pick({}).extend({
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
|
||||
vaultProviderId: z.string().min(1),
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiFindOneVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1).optional(),
|
||||
config: vaultProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListVaultSecretNames = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentId: z.string().optional(),
|
||||
});
|
||||
@@ -14,7 +14,11 @@ import { serviceType } from "./mount";
|
||||
import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { generateAppName } from "./utils";
|
||||
import {
|
||||
generateAppName,
|
||||
VOLUME_NAME_MESSAGE,
|
||||
VOLUME_NAME_REGEX,
|
||||
} from "./utils";
|
||||
|
||||
export const volumeBackups = pgTable("volume_backup", {
|
||||
volumeBackupId: text("volumeBackupId")
|
||||
@@ -113,7 +117,9 @@ export const volumeBackupsRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
export const createVolumeBackupSchema = createInsertSchema(volumeBackups).omit({
|
||||
export const createVolumeBackupSchema = createInsertSchema(volumeBackups, {
|
||||
volumeName: z.string().regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
|
||||
}).omit({
|
||||
volumeBackupId: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -98,6 +105,8 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
}),
|
||||
// Deployment Configuration (self-hosted only)
|
||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||
// Concurrent builds on the local web server
|
||||
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
|
||||
// Auth Configuration (self-hosted only)
|
||||
enforceSSO: boolean("enforceSSO").notNull().default(false),
|
||||
// Cache Cleanup Configuration
|
||||
@@ -161,6 +170,11 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
remoteServersOnly: z.boolean().optional(),
|
||||
enforceSSO: z.boolean().optional(),
|
||||
buildsConcurrency: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerBuildsConcurrency = z.object({
|
||||
buildsConcurrency: z.number().int().min(1).max(100),
|
||||
});
|
||||
|
||||
export const apiAssignDomain = z
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
INVALID_HOSTNAME_MESSAGE,
|
||||
VALID_HOSTNAME_REGEX,
|
||||
} from "../../utils/hostname-validation";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
@@ -8,7 +12,10 @@ export const domain = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -71,7 +78,10 @@ export const domainCompose = z
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
.transform((val) => val.trim())
|
||||
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
|
||||
message: INVALID_HOSTNAME_MESSAGE,
|
||||
}),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
@@ -35,17 +36,7 @@ export const BuildFailedEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
@@ -35,17 +36,7 @@ export const BuildSuccessEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
@@ -32,17 +33,7 @@ export const DatabaseBackupEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
message: string;
|
||||
@@ -24,17 +25,7 @@ export const DockerCleanupEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
type: "error" | "success";
|
||||
@@ -29,17 +30,7 @@ export const DokployBackupEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
date: string;
|
||||
@@ -22,17 +23,7 @@ export const DokployRestartEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
interface InvitationEmailProps {
|
||||
inviteLink: string;
|
||||
@@ -30,17 +31,7 @@ export const InvitationEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
@@ -38,17 +39,7 @@ export const InvoiceNotificationEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
@@ -38,17 +39,7 @@ export const PaymentFailedEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
serverName: string;
|
||||
type: "CPU" | "Memory";
|
||||
value: string;
|
||||
threshold: string;
|
||||
message: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const ServerThresholdEmail = ({
|
||||
serverName = "my-server",
|
||||
type = "CPU",
|
||||
value = "95.00",
|
||||
threshold = "90.00",
|
||||
message = "Resource usage exceeded the configured threshold",
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Server ${type} alert for ${serverName} ⚠️`;
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
⚠️ Server {type} alert for <strong>{serverName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
The {type} usage on <strong>{serverName}</strong> exceeded the
|
||||
configured threshold.
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Server Name: <strong>{serverName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Type: <strong>{type}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Current Value: <strong>{value}%</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Threshold: <strong>{threshold}%</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Message: </Text>
|
||||
<Text className="text-[12px] leading-[24px]">{message}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerThresholdEmail;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
@@ -27,17 +28,7 @@ export const VerifyEmailTemplate = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
projectName: string;
|
||||
@@ -44,17 +45,7 @@ export const VolumeBackupEmail = ({
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.21",
|
||||
"react-email": "2.1.5",
|
||||
"react": "^18.2.0"
|
||||
"@react-email/components": "^1.0.12",
|
||||
"react-email": "^6.6.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14"
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Tailwind } from "@react-email/components";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type TailwindConfig = NonNullable<ComponentProps<typeof Tailwind>["config"]>;
|
||||
|
||||
export const emailTailwindConfig: TailwindConfig = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export * from "./services/compose";
|
||||
export * from "./services/deployment";
|
||||
export * from "./services/destination";
|
||||
export * from "./services/docker";
|
||||
export * from "./services/docker-volume";
|
||||
export * from "./services/domain";
|
||||
export * from "./services/environment";
|
||||
export * from "./services/git-provider";
|
||||
@@ -28,6 +29,7 @@ export * from "./services/mariadb";
|
||||
export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
export * from "./services/mysql";
|
||||
export * from "./services/network";
|
||||
export * from "./services/notification";
|
||||
export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
@@ -38,6 +40,7 @@ export * from "./services/project";
|
||||
export * from "./services/proprietary/forward-auth";
|
||||
export * from "./services/proprietary/license-key";
|
||||
export * from "./services/proprietary/sso";
|
||||
export * from "./services/proprietary/whitelabeling";
|
||||
export * from "./services/redirect";
|
||||
export * from "./services/redis";
|
||||
export * from "./services/registry";
|
||||
@@ -48,13 +51,13 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/vault-provider";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
export * from "./setup/forward-auth-setup";
|
||||
export * from "./setup/monitoring-setup";
|
||||
export * from "./setup/postgres-setup";
|
||||
export * from "./setup/redis-setup";
|
||||
export * from "./setup/server-audit";
|
||||
export * from "./setup/server-setup";
|
||||
export * from "./setup/server-validate";
|
||||
@@ -100,10 +103,12 @@ export * from "./utils/docker/compose/volume";
|
||||
export * from "./utils/docker/domain";
|
||||
export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/vault";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/gpu-setup";
|
||||
export * from "./utils/hostname-validation";
|
||||
export * from "./utils/notifications/build-error";
|
||||
export * from "./utils/notifications/build-success";
|
||||
export * from "./utils/notifications/database-backup";
|
||||
|
||||
@@ -48,6 +48,7 @@ export const statements = {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
|
||||
notification: [],
|
||||
tag: ["read"],
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
// CLI-only config for `@better-auth/cli generate` — must mirror the plugin set
|
||||
// in auth.ts. Never import this from runtime code.
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName",
|
||||
},
|
||||
additionalFields: {
|
||||
role: { type: "string", input: false },
|
||||
ownerId: { type: "string", input: false },
|
||||
allowImpersonation: { type: "boolean", defaultValue: false },
|
||||
lastName: { type: "string", required: false, defaultValue: "" },
|
||||
enableEnterpriseFeatures: { type: "boolean", required: false },
|
||||
isValidEnterpriseLicense: { type: "boolean", required: false },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
passkey(),
|
||||
organization({
|
||||
ac,
|
||||
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
scim(),
|
||||
admin(),
|
||||
],
|
||||
});
|
||||
+417
-364
@@ -1,10 +1,12 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
@@ -29,404 +31,455 @@ import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
import { betterAuthSecret } from "./auth-secret";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: schema,
|
||||
}),
|
||||
disabledPaths: [
|
||||
"/sso/register",
|
||||
"/organization/create",
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||
],
|
||||
secret: betterAuthSecret,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
async trustedProviders() {
|
||||
const fromDb = await getTrustedProviders();
|
||||
return ["github", "google", ...fromDb];
|
||||
},
|
||||
allowDifferentEmails: true,
|
||||
},
|
||||
},
|
||||
appName: "Dokploy",
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
async trustedOrigins() {
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
return await getTrustedOrigins();
|
||||
}
|
||||
const [trustedOrigins, settings] = await Promise.all([
|
||||
getTrustedOrigins(),
|
||||
getWebServerSettings(),
|
||||
]);
|
||||
if (!settings) return [];
|
||||
const devOrigins =
|
||||
process.env.NODE_ENV === "development"
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...devOrigins,
|
||||
...trustedOrigins,
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve trusted origins:", error);
|
||||
return [];
|
||||
const resolveTrustedOrigins = async () => {
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
return await getTrustedOrigins();
|
||||
}
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
const [trustedOrigins, settings] = await Promise.all([
|
||||
getTrustedOrigins(),
|
||||
getWebServerSettings(),
|
||||
]);
|
||||
|
||||
if (!settings) return [];
|
||||
|
||||
const devOrigins =
|
||||
process.env.NODE_ENV === "development"
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...devOrigins,
|
||||
...trustedOrigins,
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve trusted origins:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const createBetterAuth = () =>
|
||||
betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: schema,
|
||||
}),
|
||||
disabledPaths: [
|
||||
"/sso/register",
|
||||
"/organization/create",
|
||||
"/organization/update",
|
||||
"/organization/delete",
|
||||
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||
],
|
||||
secret: betterAuthSecret,
|
||||
...(!IS_CLOUD
|
||||
? {
|
||||
advanced: {
|
||||
useSecureCookies: false,
|
||||
defaultCookieAttributes: {
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
async trustedProviders() {
|
||||
const fromDb = await getTrustedProviders();
|
||||
return ["github", "google", ...fromDb];
|
||||
},
|
||||
allowDifferentEmails: true,
|
||||
},
|
||||
},
|
||||
appName: "Dokploy",
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
trustedOrigins: resolveTrustedOrigins,
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
ctx.context.trustedOrigins = [
|
||||
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
|
||||
...(await resolveTrustedOrigins()),
|
||||
].filter(Boolean);
|
||||
}),
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: !IS_CLOUD,
|
||||
requireEmailVerification:
|
||||
IS_CLOUD && process.env.NODE_ENV === "production",
|
||||
password: {
|
||||
async hash(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
},
|
||||
async verify({ hash, password }) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
},
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: !IS_CLOUD,
|
||||
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
|
||||
password: {
|
||||
async hash(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
},
|
||||
async verify({ hash, password }) {
|
||||
return bcrypt.compareSync(password, hash);
|
||||
},
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
subject: "Reset your password",
|
||||
text: `
|
||||
subject: "Reset your password",
|
||||
text: `
|
||||
<p>Click the link to reset your password: <a href="${url}">Reset Password</a></p>
|
||||
`,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (_user, context) => {
|
||||
if (!IS_CLOUD) {
|
||||
const xDokployToken =
|
||||
context?.request?.headers?.get("x-dokploy-token");
|
||||
if (xDokployToken) {
|
||||
let invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (_user, context) => {
|
||||
if (!IS_CLOUD) {
|
||||
const xDokployToken =
|
||||
context?.request?.headers?.get("x-dokploy-token");
|
||||
if (xDokployToken) {
|
||||
let invitation: Awaited<ReturnType<typeof getUserByToken>>;
|
||||
try {
|
||||
invitation = await getUserByToken(xDokployToken);
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid invitation token",
|
||||
});
|
||||
}
|
||||
if (invitation.isExpired) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
if (invitation.status !== "pending") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
if (
|
||||
_user.email.toLowerCase().trim() !==
|
||||
invitation.email.toLowerCase().trim()
|
||||
) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Email does not match invitation",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
if (isAdminPresent) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Admin is already created",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
invitation = await getUserByToken(xDokployToken);
|
||||
} catch {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid invitation token",
|
||||
});
|
||||
}
|
||||
if (invitation.isExpired) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
if (invitation.status !== "pending") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
if (
|
||||
_user.email.toLowerCase().trim() !==
|
||||
invitation.email.toLowerCase().trim()
|
||||
) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Email does not match invitation",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
if (isSSORequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
if (isAdminPresent) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Admin is already created",
|
||||
});
|
||||
const hutk = getHubSpotUTK(
|
||||
context?.request?.headers?.get("cookie") || undefined,
|
||||
);
|
||||
// Cast to include additional fields
|
||||
const userWithFields = user as typeof user & {
|
||||
lastName?: string;
|
||||
};
|
||||
const hubspotSuccess = await submitToHubSpot(
|
||||
{
|
||||
email: user.email,
|
||||
firstName: user.name || "", // name is mapped to firstName column
|
||||
lastName: userWithFields.lastName || "",
|
||||
},
|
||||
hutk,
|
||||
);
|
||||
if (!hubspotSuccess) {
|
||||
console.error("Failed to submit to HubSpot");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting to HubSpot", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
const hutk = getHubSpotUTK(
|
||||
context?.request?.headers?.get("cookie") || undefined,
|
||||
);
|
||||
// Cast to include additional fields
|
||||
const userWithFields = user as typeof user & {
|
||||
lastName?: string;
|
||||
};
|
||||
const hubspotSuccess = await submitToHubSpot(
|
||||
{
|
||||
email: user.email,
|
||||
firstName: user.name || "", // name is mapped to firstName column
|
||||
lastName: userWithFields.lastName || "",
|
||||
},
|
||||
hutk,
|
||||
);
|
||||
if (!hubspotSuccess) {
|
||||
console.error("Failed to submit to HubSpot");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting to HubSpot", error);
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
name: "My Organization",
|
||||
ownerId: user.id,
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
.insert(schema.organization)
|
||||
.values({
|
||||
name: "My Organization",
|
||||
ownerId: user.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
await tx.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: organization?.id || "",
|
||||
role: "owner",
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
isDefault: true, // Mark first organization as default
|
||||
});
|
||||
});
|
||||
} else if (isSSORequest) {
|
||||
const providerId = context?.params?.providerId;
|
||||
if (!providerId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider ID is required",
|
||||
});
|
||||
}
|
||||
const provider = await db.query.ssoProvider.findFirst({
|
||||
where: eq(schema.ssoProvider.providerId, providerId),
|
||||
});
|
||||
|
||||
await tx.insert(schema.member).values({
|
||||
if (!provider) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider not found",
|
||||
});
|
||||
}
|
||||
await db.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: organization?.id || "",
|
||||
role: "owner",
|
||||
organizationId: provider?.organizationId || "",
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
isDefault: true, // Mark first organization as default
|
||||
});
|
||||
});
|
||||
} else if (isSSORequest) {
|
||||
const providerId = context?.params?.providerId;
|
||||
if (!providerId) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider ID is required",
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
const provider = await db.query.ssoProvider.findFirst({
|
||||
where: eq(schema.ssoProvider.providerId, providerId),
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Find the default organization for this user
|
||||
// Priority: 1) isDefault=true, 2) most recently created
|
||||
const member = await db.query.member.findFirst({
|
||||
where: eq(schema.member.userId, session.userId),
|
||||
orderBy: [
|
||||
desc(schema.member.isDefault),
|
||||
desc(schema.member.createdAt),
|
||||
],
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Provider not found",
|
||||
});
|
||||
}
|
||||
await db.insert(schema.member).values({
|
||||
userId: user.id,
|
||||
organizationId: provider?.organizationId || "",
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
isDefault: true,
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: member?.organization.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
}
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
});
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "logout",
|
||||
resourceType: "session",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Find the default organization for this user
|
||||
// Priority: 1) isDefault=true, 2) most recently created
|
||||
const member = await db.query.member.findFirst({
|
||||
where: eq(schema.member.userId, session.userId),
|
||||
orderBy: [
|
||||
desc(schema.member.isDefault),
|
||||
desc(schema.member.createdAt),
|
||||
],
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
expiresIn: 60 * 60 * 24 * 3,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||
},
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
ownerId: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
allowImpersonation: {
|
||||
fieldName: "allowImpersonation",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
enableEnterpriseFeatures: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isValidEnterpriseLicense: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso({ trustEmailVerified: true }),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
...session,
|
||||
activeOrganizationId: member?.organization.id,
|
||||
},
|
||||
};
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
});
|
||||
}),
|
||||
twoFactor(),
|
||||
passkey(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
after: async (session) => {
|
||||
const orgId = (
|
||||
session as typeof session & { activeOrganizationId?: string }
|
||||
).activeOrganizationId;
|
||||
if (!orgId) return;
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(schema.member.userId, session.userId),
|
||||
eq(schema.member.organizationId, orgId),
|
||||
),
|
||||
with: { user: true },
|
||||
});
|
||||
if (!memberRecord) return;
|
||||
await createAuditLog({
|
||||
organizationId: orgId,
|
||||
userId: session.userId,
|
||||
userEmail: memberRecord.user.email,
|
||||
userRole: memberRecord.role,
|
||||
action: "logout",
|
||||
resourceType: "session",
|
||||
});
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 3,
|
||||
updateAge: 60 * 60 * 24,
|
||||
},
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||
},
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
ownerId: {
|
||||
type: "string",
|
||||
// required: true,
|
||||
input: false,
|
||||
},
|
||||
allowImpersonation: {
|
||||
fieldName: "allowImpersonation",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
lastName: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
enableEnterpriseFeatures: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isValidEnterpriseLicense: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
},
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
...(IS_CLOUD
|
||||
? [
|
||||
admin({
|
||||
adminUserIds: [process.env.USER_ADMIN_ID as string],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)
|
||||
// maps to the admin plugin's `banned` field and is rejected without it.
|
||||
// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.
|
||||
admin(
|
||||
IS_CLOUD
|
||||
? { adminUserIds: [process.env.USER_ADMIN_ID as string] }
|
||||
: { adminRoles: [] },
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Una sola instancia de better-auth por proceso aunque el módulo esté
|
||||
// duplicado en varios bundles.
|
||||
const globalForAuth = globalThis as unknown as {
|
||||
betterAuthInstance?: ReturnType<typeof createBetterAuth>;
|
||||
};
|
||||
|
||||
if (!globalForAuth.betterAuthInstance) {
|
||||
globalForAuth.betterAuthInstance = createBetterAuth();
|
||||
}
|
||||
|
||||
const { handler, api } = globalForAuth.betterAuthInstance;
|
||||
|
||||
const _auth = {
|
||||
handler,
|
||||
createApiKey: api.createApiKey,
|
||||
registerSSOProvider: api.registerSSOProvider,
|
||||
updateSSOProvider: api.updateSSOProvider,
|
||||
generateSCIMToken: api.generateSCIMToken,
|
||||
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||
};
|
||||
|
||||
export type AuthType = typeof _auth;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readSecret } from "../db/constants";
|
||||
|
||||
const { ENCRYPTION_KEY, ENCRYPTION_KEY_FILE } = process.env;
|
||||
|
||||
function resolveEncryptionSecret(): string | undefined {
|
||||
if (ENCRYPTION_KEY) {
|
||||
return ENCRYPTION_KEY;
|
||||
}
|
||||
if (ENCRYPTION_KEY_FILE) {
|
||||
return readSecret(ENCRYPTION_KEY_FILE);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const encryptionSecret = resolveEncryptionSecret();
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { betterAuthSecret } from "./auth-secret";
|
||||
import { encryptionSecret } from "./encryption-secret";
|
||||
|
||||
export const ENCRYPTION_KEY_BACKUP_FILE = "encryption.key";
|
||||
|
||||
const ENCRYPTION_PREFIX = "enc:v1:";
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
const deriveKey = (secret: string) =>
|
||||
createHmac("sha256", secret).update("dokploy:db-encryption:v1").digest();
|
||||
|
||||
const primaryKey = deriveKey(encryptionSecret ?? betterAuthSecret);
|
||||
|
||||
// Installs that adopt a dedicated ENCRYPTION_KEY still hold values encrypted
|
||||
// with the auth-secret-derived key; keep it as a decrypt fallback so they
|
||||
// lazily re-encrypt on the next write instead of becoming unreadable.
|
||||
const decryptionKeys = encryptionSecret
|
||||
? [primaryKey, deriveKey(betterAuthSecret)]
|
||||
: [primaryKey];
|
||||
|
||||
// Derived keys only — never the raw secrets. A leaked key can decrypt
|
||||
// stored values, but the raw BETTER_AUTH_SECRET could also forge sessions.
|
||||
export const exportEncryptionKeys = () =>
|
||||
decryptionKeys.map((key) => key.toString("hex")).join("\n");
|
||||
|
||||
let restoredKeys: Buffer[] | undefined;
|
||||
|
||||
// A backup created with "include encryption key" places the original
|
||||
// server's keys at BASE_PATH when restored; use them as a last-resort
|
||||
// decryption fallback so restored values keep working on the new server.
|
||||
const loadRestoredKeys = (): Buffer[] => {
|
||||
if (restoredKeys?.length) {
|
||||
return restoredKeys;
|
||||
}
|
||||
try {
|
||||
const { BASE_PATH } = paths();
|
||||
const keys = readFileSync(
|
||||
join(BASE_PATH, ENCRYPTION_KEY_BACKUP_FILE),
|
||||
"utf8",
|
||||
)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((hex) => Buffer.from(hex, "hex"))
|
||||
.filter(
|
||||
(key) =>
|
||||
key.length === 32 && !decryptionKeys.some((own) => own.equals(key)),
|
||||
);
|
||||
if (keys.length) {
|
||||
restoredKeys = keys;
|
||||
}
|
||||
} catch {
|
||||
// No restored key file present.
|
||||
}
|
||||
return restoredKeys ?? [];
|
||||
};
|
||||
|
||||
export const isEncrypted = (value: string) =>
|
||||
value.startsWith(ENCRYPTION_PREFIX);
|
||||
|
||||
export const encryptValue = (value: string): string => {
|
||||
if (!value || isEncrypted(value)) {
|
||||
return value;
|
||||
}
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv("aes-256-gcm", primaryKey, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return `${ENCRYPTION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64")}`;
|
||||
};
|
||||
|
||||
export const decryptValue = (value: string): string => {
|
||||
if (!value || !isEncrypted(value)) {
|
||||
return value;
|
||||
}
|
||||
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
||||
const iv = payload.subarray(0, IV_LENGTH);
|
||||
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
||||
const keys = [...decryptionKeys, ...loadRestoredKeys()];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
} catch {
|
||||
// GCM auth failed for this key; try the next one.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"Failed to decrypt a stored secret. This usually means ENCRYPTION_KEY or BETTER_AUTH_SECRET changed after the value was encrypted.",
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const IO_UNIT_FACTORS: Record<string, number> = {
|
||||
B: 1 / 1e6,
|
||||
kB: 1e-3,
|
||||
KB: 1e-3,
|
||||
MB: 1,
|
||||
GB: 1e3,
|
||||
TB: 1e6,
|
||||
KiB: 1024 / 1e6,
|
||||
MiB: 1024 ** 2 / 1e6,
|
||||
GiB: 1024 ** 3 / 1e6,
|
||||
TiB: 1024 ** 4 / 1e6,
|
||||
};
|
||||
|
||||
export const toMb = (value: number | string | undefined): number => {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
const unit = value.replace(/[0-9.\s]/g, "");
|
||||
return parsed * (IO_UNIT_FACTORS[unit] ?? 1);
|
||||
};
|
||||
|
||||
export const parseIoToMb = (raw: string | undefined): number =>
|
||||
Number(toMb(raw).toFixed(6));
|
||||
|
||||
export const formatMb = (value: number | string | undefined): string => {
|
||||
const mb = toMb(value);
|
||||
if (mb >= 1000) {
|
||||
return `${(mb / 1000).toFixed(2)} GB`;
|
||||
}
|
||||
if (mb < 0.01 && mb > 0) {
|
||||
return `${(mb * 1000).toFixed(2)} kB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { promises } from "node:fs";
|
||||
import { OSUtils } from "node-os-utils";
|
||||
import { paths } from "../constants";
|
||||
import { parseIoToMb } from "./units";
|
||||
|
||||
export interface Container {
|
||||
BlockIO: string;
|
||||
@@ -28,13 +29,13 @@ export const recordAdvancedStats = async (
|
||||
});
|
||||
|
||||
await updateStatsFile(appName, "block", {
|
||||
readMb: stats.BlockIO.split(" ")[0],
|
||||
writeMb: stats.BlockIO.split(" ")[2],
|
||||
readMb: parseIoToMb(stats.BlockIO.split(" ")[0]),
|
||||
writeMb: parseIoToMb(stats.BlockIO.split(" ")[2]),
|
||||
});
|
||||
|
||||
await updateStatsFile(appName, "network", {
|
||||
inputMb: stats.NetIO.split(" ")[0],
|
||||
outputMb: stats.NetIO.split(" ")[2],
|
||||
inputMb: parseIoToMb(stats.NetIO.split(" ")[0]),
|
||||
outputMb: parseIoToMb(stats.NetIO.split(" ")[2]),
|
||||
});
|
||||
|
||||
if (appName === "dokploy") {
|
||||
|
||||
@@ -120,6 +120,10 @@ export const getDokployUrl = async () => {
|
||||
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
|
||||
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
|
||||
|
||||
export const invalidateTrustedOriginsCache = () => {
|
||||
trustedOriginsCache = null;
|
||||
};
|
||||
|
||||
export const getTrustedOrigins = async () => {
|
||||
const runQuery = async () => {
|
||||
const rows = await db
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { ai } from "@dokploy/server/db/schema";
|
||||
import { ai, organization } from "@dokploy/server/db/schema";
|
||||
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText, Output } from "ai";
|
||||
@@ -24,7 +25,7 @@ interface DockerOutput {
|
||||
dockerCompose: string;
|
||||
envVariables: Array<{ name: string; value: string }>;
|
||||
domains: Array<{ host: string; port: number; serviceName: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }>;
|
||||
configFiles?: Array<{ content: string; filePath: string }> | null;
|
||||
}
|
||||
|
||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
|
||||
return aiSetting;
|
||||
};
|
||||
|
||||
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
|
||||
|
||||
const parseOrgMetadata = (metadata: string | null) => {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata || "{}");
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const getCustomAiProviders = async (organizationId: string) => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
const metadata = parseOrgMetadata(org?.metadata ?? null);
|
||||
const result = z
|
||||
.array(aiCustomProviderSchema)
|
||||
.safeParse(metadata.aiProviders);
|
||||
return result.success ? result.data : [];
|
||||
};
|
||||
|
||||
export const saveCustomAiProviders = async (
|
||||
organizationId: string,
|
||||
providers: AiCustomProvider[],
|
||||
) => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
const metadata = parseOrgMetadata(org.metadata);
|
||||
metadata.aiProviders = providers;
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ metadata: JSON.stringify(metadata) })
|
||||
.where(eq(organization.id, organizationId));
|
||||
return providers;
|
||||
};
|
||||
|
||||
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
|
||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
if (settings.apiUrl) {
|
||||
const customProviders = await getCustomAiProviders(organizationId);
|
||||
if (customProviders.length > 0) {
|
||||
const isAllowed = customProviders.some(
|
||||
(provider) =>
|
||||
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"This API URL is not in your organization's allowed AI providers",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(ai)
|
||||
.values({
|
||||
@@ -136,7 +202,7 @@ export const suggestVariants = async ({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -198,12 +264,12 @@ export const suggestVariants = async ({
|
||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:8, etc.)
|
||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||
6. Examples of correct image usage:
|
||||
- image: sendingtk/chatwoot:develop
|
||||
- image: postgres:16-alpine
|
||||
- image: redis:7-alpine
|
||||
- image: redis:8-alpine
|
||||
7. Examples of INCORRECT usage (DO NOT USE):
|
||||
- build: .
|
||||
- build: ./app
|
||||
|
||||
@@ -102,10 +102,24 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
redirects: true,
|
||||
security: true,
|
||||
ports: true,
|
||||
gitlab: true,
|
||||
github: true,
|
||||
bitbucket: true,
|
||||
gitea: true,
|
||||
gitlab: {
|
||||
columns: { secret: false, accessToken: false, refreshToken: false },
|
||||
},
|
||||
github: {
|
||||
columns: {
|
||||
githubClientSecret: false,
|
||||
githubPrivateKey: false,
|
||||
githubWebhookSecret: false,
|
||||
},
|
||||
},
|
||||
bitbucket: { columns: { appPassword: false, apiToken: false } },
|
||||
gitea: {
|
||||
columns: {
|
||||
clientSecret: false,
|
||||
accessToken: false,
|
||||
refreshToken: false,
|
||||
},
|
||||
},
|
||||
server: true,
|
||||
previewDeployments: true,
|
||||
registry: { columns: { password: false } },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import { stringify } from "yaml";
|
||||
import type { z } from "zod";
|
||||
import { encodeBase64 } from "../utils/docker/utils";
|
||||
@@ -63,7 +64,7 @@ export const removeCertificateById = async (certificateId: string) => {
|
||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||
|
||||
if (certificate.serverId) {
|
||||
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
|
||||
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
|
||||
} else {
|
||||
await removeDirectoryIfExistsContent(certDir);
|
||||
}
|
||||
@@ -108,10 +109,10 @@ const createCertificateFiles = async (certificate: Certificate) => {
|
||||
const certificateData = encodeBase64(certificate.certificateData);
|
||||
const privateKey = encodeBase64(certificate.privateKey);
|
||||
const command = `
|
||||
mkdir -p ${certDir};
|
||||
echo "${certificateData}" | base64 -d > "${crtPath}";
|
||||
echo "${privateKey}" | base64 -d > "${keyPath}";
|
||||
echo "${yamlConfig}" > "${configFile}";
|
||||
mkdir -p ${quote([certDir])};
|
||||
echo "${certificateData}" | base64 -d > ${quote([crtPath])};
|
||||
echo "${privateKey}" | base64 -d > ${quote([keyPath])};
|
||||
echo "${yamlConfig}" > ${quote([configFile])};
|
||||
`;
|
||||
|
||||
await execAsyncRemote(certificate.serverId, command);
|
||||
|
||||
@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
|
||||
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { encodeBase64 } from "../utils/docker/utils";
|
||||
import { getDokployUrl } from "./admin";
|
||||
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => {
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const path =
|
||||
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
|
||||
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
|
||||
const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
|
||||
if (compose.composeType === "docker-compose") {
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(
|
||||
|
||||
@@ -83,7 +83,17 @@ export const findDeploymentById = async (deploymentId: string) => {
|
||||
const deployment = await db.query.deployments.findFirst({
|
||||
where: eq(deployments.deploymentId, deploymentId),
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, name: true, serverId: true },
|
||||
},
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { quote } from "shell-quote";
|
||||
import { CONTAINER_FILE_SIZE_LIMIT } from "./docker";
|
||||
|
||||
export interface DockerVolume {
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Scope: string;
|
||||
Mountpoint: string;
|
||||
Labels: string;
|
||||
Availability?: string;
|
||||
Group?: string;
|
||||
Links?: string;
|
||||
Size?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
const VOLUME_MOUNT = "/__volume";
|
||||
|
||||
export const getVolumes = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker volume ls --format '{{json .}}'";
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as DockerVolume);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumesSize = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker system df -v --format '{{json .}}'";
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
const diskUsage = JSON.parse(stdout.trim());
|
||||
return ((diskUsage?.Volumes ?? []) as Partial<DockerVolume>[])
|
||||
.filter((volume) => volume?.Name)
|
||||
.map((volume) => ({
|
||||
name: volume.Name as string,
|
||||
size: volume.Size ?? null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumeConfig = async (
|
||||
volumeName: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker volume inspect ${quote([String(volumeName ?? "")])}`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return JSON.parse(stdout.trim())[0];
|
||||
};
|
||||
|
||||
export const removeVolume = async (volumeName: string, serverId?: string) => {
|
||||
const command = `docker volume rm ${quote([String(volumeName ?? "")])}`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const listVolumeFiles = async (
|
||||
volumeName: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox ls -1Ap ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||
isDirectory: entry.endsWith("/"),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const readVolumeFile = async (
|
||||
volumeName: string,
|
||||
filePath: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox cat ${quote([`${VOLUME_MOUNT}${filePath}`])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||
const { stdout, stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr && !stdout) {
|
||||
throw new Error(stderr);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||
return {
|
||||
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||
};
|
||||
};
|
||||
|
||||
export const writeVolumeFile = async (
|
||||
volumeName: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||
}
|
||||
|
||||
const innerCommand = `printf '%s' ${base64Content} | base64 -d > ${quote([`${VOLUME_MOUNT}${filePath}`])}`;
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox sh -c ${quote([innerCommand])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteVolumeFile = async (
|
||||
volumeName: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox rm -rf ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
ExecError,
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
export const getContainers = async (serverId?: string | null) => {
|
||||
try {
|
||||
@@ -107,6 +109,9 @@ export const getContainersByAppNameMatch = async (
|
||||
serverId?: string,
|
||||
) => {
|
||||
try {
|
||||
if (appType === "stack") {
|
||||
return await getStackTaskContainers(appName, serverId);
|
||||
}
|
||||
let result: string[] = [];
|
||||
const cmd =
|
||||
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'";
|
||||
@@ -165,6 +170,66 @@ export const getContainersByAppNameMatch = async (
|
||||
return [];
|
||||
};
|
||||
|
||||
const getStackTaskContainers = async (appName: string, serverId?: string) => {
|
||||
try {
|
||||
const divider = "__DOKPLOY_DIVIDER__";
|
||||
const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`;
|
||||
const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`;
|
||||
|
||||
let stdout = "";
|
||||
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
if (!stdout) return [];
|
||||
|
||||
const [tasksSection = "", inspectSection = ""] = stdout.split(divider);
|
||||
|
||||
const containerIdByTask = new Map<string, string>();
|
||||
for (const line of inspectSection.trim().split("\n")) {
|
||||
const parts = line.split(" | ");
|
||||
const taskId = parts[0]?.replace("TASK : ", "").trim();
|
||||
const containerId = parts[1]?.replace("ContainerId: ", "").trim();
|
||||
if (taskId && containerId) {
|
||||
containerIdByTask.set(taskId, containerId);
|
||||
}
|
||||
}
|
||||
|
||||
const containers = [];
|
||||
|
||||
for (const line of tasksSection.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const parts = line.split(" | ");
|
||||
const taskId = parts[0]?.replace("TASK : ", "").trim() ?? "";
|
||||
const name = parts[1]?.replace("Name: ", "").trim() ?? "";
|
||||
const node = parts[2]?.replace("Node: ", "").trim() ?? "";
|
||||
const currentState = parts[3]
|
||||
? parts[3].replace("CurrentState: ", "").trim()
|
||||
: "";
|
||||
const error = parts[4] ? parts[4].replace("Error: ", "").trim() : "";
|
||||
const containerId = containerIdByTask.get(taskId) ?? "";
|
||||
|
||||
containers.push({
|
||||
containerId: containerId.slice(0, 12),
|
||||
name: `${name}.${taskId}`,
|
||||
node,
|
||||
state: currentState.split(" ")[0]?.toLowerCase() ?? "No state",
|
||||
status: error ? `${currentState} (${error})` : currentState,
|
||||
});
|
||||
}
|
||||
|
||||
return containers;
|
||||
} catch {}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getStackContainersByAppName = async (
|
||||
appName: string,
|
||||
serverId?: string,
|
||||
@@ -174,7 +239,6 @@ export const getStackContainersByAppName = async (
|
||||
|
||||
const command = `docker stack ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
|
||||
console.log("command ", command);
|
||||
if (serverId) {
|
||||
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -519,7 +583,7 @@ export const getSwarmNodes = async (serverId?: string) => {
|
||||
|
||||
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
|
||||
try {
|
||||
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
|
||||
const command = `docker node inspect ${quote([nodeId])} --format '{{json .}}'`;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (serverId) {
|
||||
@@ -697,3 +761,113 @@ export const uploadFileToContainer = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const CONTAINER_FILE_SIZE_LIMIT = 512 * 1024;
|
||||
|
||||
const NO_SHELL_UTILITIES_ERROR =
|
||||
"This container image has no shell utilities and its filesystem is not accessible from the Dokploy host.";
|
||||
|
||||
const isMissingBinaryError = (error: unknown) =>
|
||||
error instanceof ExecError &&
|
||||
(error.exitCode === 126 ||
|
||||
error.exitCode === 127 ||
|
||||
!!error.stderr?.includes("executable file not found"));
|
||||
|
||||
export const listContainerFiles = async (
|
||||
containerId: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} ls -1Ap ${quote([path])}`;
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command));
|
||||
} catch (error) {
|
||||
if (isMissingBinaryError(error)) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||
isDirectory: entry.endsWith("/"),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const readContainerFile = async (
|
||||
containerId: string,
|
||||
filePath: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} cat ${quote([filePath])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||
const { stdout, stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr && !stdout) {
|
||||
if (stderr.includes("executable file not found")) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw new Error(stderr);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||
return {
|
||||
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||
};
|
||||
};
|
||||
|
||||
export const writeContainerFile = async (
|
||||
containerId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||
}
|
||||
|
||||
const tempPath = `/tmp/dokploy-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const command = `printf '%s' ${quote([base64Content])} | base64 -d > ${quote([tempPath])} && docker cp ${quote([tempPath])} ${quote([`${containerId}:${filePath}`])}; status=$?; rm -f ${quote([tempPath])}; exit $status`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteContainerFile = async (
|
||||
containerId: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} rm -rf ${quote([path])}`;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingBinaryError(error)) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +80,9 @@ export const findDomainById = async (domainId: string) => {
|
||||
const domain = await db.query.domains.findFirst({
|
||||
where: eq(domains.domainId, domainId),
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!domain) {
|
||||
@@ -96,7 +98,9 @@ export const findDomainsByApplicationId = async (applicationId: string) => {
|
||||
const domainsArray = await db.query.domains.findMany({
|
||||
where: eq(domains.applicationId, applicationId),
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +111,9 @@ export const findDomainsByComposeId = async (composeId: string) => {
|
||||
const domainsArray = await db.query.domains.findMany({
|
||||
where: eq(domains.composeId, composeId),
|
||||
with: {
|
||||
compose: true,
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ export const findEnvironmentById = async (environmentId: string) => {
|
||||
composeStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
@@ -201,18 +202,56 @@ export const findEnvironmentById = async (environmentId: string) => {
|
||||
};
|
||||
|
||||
export const findEnvironmentsByProjectId = async (projectId: string) => {
|
||||
const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
|
||||
const projectEnvironments = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
orderBy: asc(environments.createdAt),
|
||||
with: {
|
||||
applications: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
applications: {
|
||||
columns: { ...serviceColumns, applicationId: true, icon: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mariadb: {
|
||||
columns: { ...serviceColumns, mariadbId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mongo: {
|
||||
columns: { ...serviceColumns, mongoId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mysql: {
|
||||
columns: { ...serviceColumns, mysqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
postgres: {
|
||||
columns: { ...serviceColumns, postgresId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
redis: {
|
||||
columns: { ...serviceColumns, redisId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
compose: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
composeId: true,
|
||||
composeStatus: true,
|
||||
icon: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
libsql: {
|
||||
columns: { ...serviceColumns, libsqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
project: true,
|
||||
},
|
||||
columns: {
|
||||
@@ -308,6 +347,48 @@ export const duplicateEnvironment = async (
|
||||
return newEnvironment;
|
||||
};
|
||||
|
||||
interface EnvironmentWithServices {
|
||||
applications: { applicationId: string }[];
|
||||
compose: { composeId: string }[];
|
||||
libsql: { libsqlId: string }[];
|
||||
mariadb: { mariadbId: string }[];
|
||||
mongo: { mongoId: string }[];
|
||||
mysql: { mysqlId: string }[];
|
||||
postgres: { postgresId: string }[];
|
||||
redis: { redisId: string }[];
|
||||
}
|
||||
|
||||
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||
environment: T,
|
||||
accessedServices: string[],
|
||||
): T => ({
|
||||
...environment,
|
||||
applications: environment.applications.filter((app) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
mongo: environment.mongo.filter((db) =>
|
||||
accessedServices.includes(db.mongoId),
|
||||
),
|
||||
mysql: environment.mysql.filter((db) =>
|
||||
accessedServices.includes(db.mysqlId),
|
||||
),
|
||||
postgres: environment.postgres.filter((db) =>
|
||||
accessedServices.includes(db.postgresId),
|
||||
),
|
||||
redis: environment.redis.filter((db) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
});
|
||||
|
||||
export const createProductionEnvironment = async (projectId: string) => {
|
||||
const newEnvironment = await db
|
||||
.insert(environments)
|
||||
|
||||
@@ -119,3 +119,30 @@ export const getAccessibleGitProviderIds = async (session: {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authorizes read access to a specific git provider for the current session.
|
||||
* Throws if the provider belongs to a different organization (cross-org IDOR)
|
||||
* or if the caller is not entitled to it within the active organization.
|
||||
* Must be called before returning any git-provider record that carries secrets
|
||||
* (OAuth tokens, app private keys, webhook secrets).
|
||||
*/
|
||||
export const assertGitProviderAccess = async (
|
||||
session: { userId: string; activeOrganizationId: string },
|
||||
provider: { gitProviderId: string; organizationId: string },
|
||||
) => {
|
||||
if (provider.organizationId !== session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Git provider not found",
|
||||
});
|
||||
}
|
||||
|
||||
const accessibleIds = await getAccessibleGitProviderIds(session);
|
||||
if (!accessibleIds.has(provider.gitProviderId)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this git provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -140,7 +141,7 @@ export const deployLibsql = async (
|
||||
if (libsql.serverId) {
|
||||
await execAsyncRemote(
|
||||
libsql.serverId,
|
||||
`docker pull ${libsql.dockerImage}`,
|
||||
`docker pull ${quote([libsql.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -145,7 +146,7 @@ export const deployMariadb = async (
|
||||
if (mariadb.serverId) {
|
||||
await execAsyncRemote(
|
||||
mariadb.serverId,
|
||||
`docker pull ${mariadb.dockerImage}`,
|
||||
`docker pull ${quote([mariadb.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -160,7 +161,7 @@ export const deployMongo = async (
|
||||
if (mongo.serverId) {
|
||||
await execAsyncRemote(
|
||||
mongo.serverId,
|
||||
`docker pull ${mongo.dockerImage}`,
|
||||
`docker pull ${quote([mongo.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, type SQL, sql } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type Mount = typeof mounts.$inferSelect;
|
||||
@@ -106,81 +107,29 @@ export const createFileMount = async (mountId: string) => {
|
||||
};
|
||||
|
||||
export const findMountById = async (mountId: string) => {
|
||||
const serviceWith = {
|
||||
columns: { serverId: true, appName: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: {},
|
||||
with: {
|
||||
project: { columns: { organizationId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const mount = await db.query.mounts.findFirst({
|
||||
where: eq(mounts.mountId, mountId),
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
application: serviceWith,
|
||||
compose: serviceWith,
|
||||
libsql: serviceWith,
|
||||
mariadb: serviceWith,
|
||||
mongo: serviceWith,
|
||||
mysql: serviceWith,
|
||||
postgres: serviceWith,
|
||||
redis: serviceWith,
|
||||
},
|
||||
});
|
||||
if (!mount) {
|
||||
@@ -317,7 +266,7 @@ export const updateFileMount = async (mountId: string) => {
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
const encodedContent = encodeBase64(mount.content || "");
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${fullPath}`;
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
@@ -337,7 +286,7 @@ export const deleteFileMount = async (mountId: string) => {
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
if (serverId) {
|
||||
const command = `rm -rf ${fullPath}`;
|
||||
const command = `rm -rf ${quote([fullPath])}`;
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await removeFileOrDirectory(fullPath);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -143,7 +144,7 @@ export const deployMySql = async (
|
||||
if (mysql.serverId) {
|
||||
await execAsyncRemote(
|
||||
mysql.serverId,
|
||||
`docker pull ${mysql.dockerImage}`,
|
||||
`docker pull ${quote([mysql.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import type { ApplicationNested } from "../utils/builders";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
|
||||
const RESERVED_NETWORKS = [
|
||||
"bridge",
|
||||
"host",
|
||||
"none",
|
||||
"ingress",
|
||||
"docker_gwbridge",
|
||||
"dokploy-network",
|
||||
];
|
||||
|
||||
type DockerNetworkInfo = {
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
Ingress?: boolean;
|
||||
EnableIPv4?: boolean;
|
||||
EnableIPv6?: boolean;
|
||||
Options?: Record<string, string> | null;
|
||||
IPAM?: {
|
||||
Driver?: string;
|
||||
Config?: Array<{
|
||||
Subnet?: string;
|
||||
Gateway?: string;
|
||||
IPRange?: string;
|
||||
}> | null;
|
||||
};
|
||||
};
|
||||
|
||||
const parseMtu = (value: string | undefined) => {
|
||||
const mtu = Number.parseInt(value ?? "", 10);
|
||||
return Number.isNaN(mtu) ? null : mtu;
|
||||
};
|
||||
|
||||
const isImportableDockerNetwork = (dockerNetwork: DockerNetworkInfo) =>
|
||||
!RESERVED_NETWORKS.includes(dockerNetwork.Name) &&
|
||||
!dockerNetwork.Ingress &&
|
||||
(dockerNetwork.Driver === "bridge" || dockerNetwork.Driver === "overlay");
|
||||
|
||||
const mapDockerNetworkToRow = (
|
||||
dockerNetwork: DockerNetworkInfo,
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => ({
|
||||
name: dockerNetwork.Name,
|
||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
// Older daemons don't report EnableIPv4; IPv4 is always on there
|
||||
enableIPv4: dockerNetwork.EnableIPv4 ?? true,
|
||||
enableIPv6: dockerNetwork.EnableIPv6 ?? false,
|
||||
mtu: parseMtu(dockerNetwork.Options?.["com.docker.network.driver.mtu"]),
|
||||
ipam: {
|
||||
driver: dockerNetwork.IPAM?.Driver,
|
||||
config: (dockerNetwork.IPAM?.Config ?? []).map((c) => ({
|
||||
subnet: c.Subnet,
|
||||
gateway: c.Gateway,
|
||||
ipRange: c.IPRange,
|
||||
})),
|
||||
},
|
||||
organizationId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const findNetworksByServer = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
return await db.query.network.findMany({
|
||||
where: and(
|
||||
eq(network.organizationId, organizationId),
|
||||
serverId ? eq(network.serverId, serverId) : isNull(network.serverId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const findNetworksToSync = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
if (IS_CLOUD && !serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
let dockerNetworks: DockerNetworkInfo[] = [];
|
||||
try {
|
||||
dockerNetworks = (await docker.listNetworks()) as DockerNetworkInfo[];
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to list Docker networks",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
|
||||
|
||||
const importable = dockerNetworks
|
||||
.filter(
|
||||
(dockerNetwork) =>
|
||||
isImportableDockerNetwork(dockerNetwork) &&
|
||||
!existingNames.has(dockerNetwork.Name),
|
||||
)
|
||||
.map((dockerNetwork) => ({
|
||||
name: dockerNetwork.Name,
|
||||
driver: dockerNetwork.Driver,
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
subnets: (dockerNetwork.IPAM?.Config ?? [])
|
||||
.map((c) => c.Subnet)
|
||||
.filter((s): s is string => !!s),
|
||||
}));
|
||||
|
||||
// Rows in Dokploy whose network no longer exists in Docker
|
||||
const missing = existing
|
||||
.filter((row) => !dockerNames.has(row.name))
|
||||
.map((row) => ({ networkId: row.networkId, name: row.name }));
|
||||
|
||||
return { importable, missing };
|
||||
};
|
||||
|
||||
export const importDockerNetworks = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
names: string[],
|
||||
) => {
|
||||
if (IS_CLOUD && !serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
|
||||
const imported: string[] = [];
|
||||
const errors: { name: string; error: string }[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
if (existingNames.has(name)) {
|
||||
errors.push({ name, error: "Already imported" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const info = (await docker
|
||||
.getNetwork(name)
|
||||
.inspect()) as DockerNetworkInfo;
|
||||
if (!isImportableDockerNetwork(info)) {
|
||||
errors.push({ name, error: "Network is reserved or not supported" });
|
||||
continue;
|
||||
}
|
||||
await db
|
||||
.insert(network)
|
||||
.values(mapDockerNetworkToRow(info, organizationId, serverId));
|
||||
imported.push(name);
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
name,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to inspect network",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, errors };
|
||||
};
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const createNetwork = async (
|
||||
input: z.infer<typeof apiCreateNetwork>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
if (IS_CLOUD) {
|
||||
if (!input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(network)
|
||||
.values({
|
||||
...input,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create network",
|
||||
});
|
||||
}
|
||||
|
||||
await createDockerNetworkFromRow(row);
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
const ipam = row.ipam ?? {};
|
||||
const ipamConfig = (ipam.config ?? [])
|
||||
.map((c) => {
|
||||
const entry: Record<string, string> = {};
|
||||
if (c.subnet) entry.Subnet = c.subnet;
|
||||
if (c.gateway) entry.Gateway = c.gateway;
|
||||
if (c.ipRange) entry.IPRange = c.ipRange;
|
||||
return entry;
|
||||
})
|
||||
.filter((e) => Object.keys(e).length > 0);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
CheckDuplicate: true,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
// EnableIPv4 is missing from dockerode's types but supported by
|
||||
// the daemon (API >= 1.47); the body is sent as-is
|
||||
EnableIPv4: row.enableIPv4,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
Options: row.mtu
|
||||
? { "com.docker.network.driver.mtu": String(row.mtu) }
|
||||
: undefined,
|
||||
IPAM: {
|
||||
Driver: ipam.driver || "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
} as Parameters<typeof docker.createNetwork>[0]);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const recreateNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
await createDockerNetworkFromRow(row);
|
||||
return row;
|
||||
};
|
||||
|
||||
export const resolveServiceNetworks = async (
|
||||
application: Partial<ApplicationNested>,
|
||||
) => {
|
||||
if (application.networkSwarm) {
|
||||
return application.networkSwarm;
|
||||
}
|
||||
|
||||
const { networkIds, detachDokployNetwork } = application;
|
||||
const rows =
|
||||
networkIds && networkIds.length > 0
|
||||
? await db.query.network.findMany({
|
||||
where: and(
|
||||
inArray(network.networkId, networkIds),
|
||||
eq(network.driver, "overlay"),
|
||||
),
|
||||
columns: { name: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const networks = detachDokployNetwork ? [] : ["dokploy-network"];
|
||||
for (const row of rows) {
|
||||
networks.push(row.name);
|
||||
}
|
||||
|
||||
return networks.map((name) => ({ Target: name }));
|
||||
};
|
||||
|
||||
export const inspectNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
return await docker.getNetwork(row.name).inspect();
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.getNetwork(row.name).remove();
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode;
|
||||
if (statusCode !== 404) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to remove Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
|
||||
import { cloneGitRepository } from "../utils/providers/git";
|
||||
@@ -85,7 +86,7 @@ export const readPatchRepoDirectory = async (
|
||||
serverId?: string | null,
|
||||
): Promise<DirectoryEntry[]> => {
|
||||
// Use git ls-tree to get tracked files only
|
||||
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
|
||||
const command = `cd ${quote([repoPath])} && git ls-tree -r --name-only HEAD`;
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -168,7 +169,7 @@ export const readPatchRepoFile = async (
|
||||
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
|
||||
const fullPath = join(repoPath, filePath);
|
||||
|
||||
const command = `cat "${fullPath}"`;
|
||||
const command = `cat ${quote([fullPath])}`;
|
||||
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -170,6 +170,8 @@ const getLegacyOverrides = (
|
||||
},
|
||||
gitProviders: {
|
||||
read: !!memberRecord.canAccessToGitProviders,
|
||||
create: !!memberRecord.canAccessToGitProviders,
|
||||
delete: !!memberRecord.canAccessToGitProviders,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,12 +30,8 @@ export const finPortById = async (portId: string) => {
|
||||
where: eq(ports.portId, portId),
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
applicationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -155,7 +156,7 @@ export const deployPostgres = async (
|
||||
if (postgres.serverId) {
|
||||
await execAsyncRemote(
|
||||
postgres.serverId,
|
||||
`docker pull ${postgres.dockerImage}`,
|
||||
`docker pull ${quote([postgres.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { manageDomain } from "../utils/traefik/domain";
|
||||
import { findApplicationById } from "./application";
|
||||
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
|
||||
import { createDomain } from "./domain";
|
||||
import { type Github, getIssueComment } from "./github";
|
||||
import { findGithubById, getIssueComment } from "./github";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
|
||||
@@ -142,7 +142,17 @@ export const createPreviewDeployment = async (
|
||||
org?.ownerId || "",
|
||||
);
|
||||
|
||||
const octokit = authGithub(application?.github as Github);
|
||||
if (!application.githubId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Github Account not configured correctly",
|
||||
});
|
||||
}
|
||||
|
||||
// `findApplicationById` redacts `githubPrivateKey` from the `github`
|
||||
// relation, so the provider must be refetched to authenticate.
|
||||
const githubProvider = await findGithubById(application.githubId);
|
||||
const octokit = authGithub(githubProvider);
|
||||
|
||||
const runningComment = getIssueComment(
|
||||
application.name,
|
||||
|
||||
@@ -48,19 +48,60 @@ export const createProject = async (
|
||||
};
|
||||
|
||||
export const findProjectById = async (projectId: string) => {
|
||||
const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.projectId, projectId),
|
||||
with: {
|
||||
environments: {
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
applications: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
applicationId: true,
|
||||
icon: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
compose: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
composeId: true,
|
||||
composeStatus: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
libsql: {
|
||||
columns: { ...serviceColumns, libsqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mariadb: {
|
||||
columns: { ...serviceColumns, mariadbId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mongo: {
|
||||
columns: { ...serviceColumns, mongoId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mysql: {
|
||||
columns: { ...serviceColumns, mysqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
postgres: {
|
||||
columns: { ...serviceColumns, postgresId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
redis: {
|
||||
columns: { ...serviceColumns, redisId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
@@ -107,27 +148,49 @@ export const updateProjectById = async (
|
||||
|
||||
export const validUniqueServerAppName = async (appName: string) => {
|
||||
const query = await db.query.environments.findMany({
|
||||
columns: { environmentId: true },
|
||||
with: {
|
||||
applications: {
|
||||
where: eq(applications.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
where: eq(libsql.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
where: eq(mariadb.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
where: eq(mongo.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
where: eq(mysql.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
where: eq(postgres.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
where: eq(redis.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
|
||||
export interface PublicWhitelabelingConfig {
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
logoUrl: string | null;
|
||||
loginLogoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
customCss: string | null;
|
||||
metaTitle: string | null;
|
||||
errorPageTitle: string | null;
|
||||
errorPageDescription: string | null;
|
||||
footerText: string | null;
|
||||
}
|
||||
|
||||
// Self-hosted is single-tenant; gate on the oldest organization's license,
|
||||
// mirroring resolveLocalConcurrency in server/queues/concurrency.ts. Used only
|
||||
// for unauthenticated requests (no active organization in session).
|
||||
const hasAnyValidLicense = async (): Promise<boolean> => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
columns: { id: true },
|
||||
orderBy: (organization, { asc }) => [asc(organization.createdAt)],
|
||||
});
|
||||
return org ? await hasValidLicense(org.id) : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public whitelabeling config for unauthenticated contexts (login page, SSR
|
||||
* document shell). No active organization/session is available here.
|
||||
*/
|
||||
export const getPublicWhitelabelingConfig =
|
||||
async (): Promise<PublicWhitelabelingConfig | null> => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
if (!(await hasAnyValidLicense())) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
const config = settings?.whitelabelingConfig;
|
||||
if (!config) return null;
|
||||
|
||||
return {
|
||||
appName: config.appName,
|
||||
appDescription: config.appDescription,
|
||||
logoUrl: config.logoUrl,
|
||||
loginLogoUrl: config.loginLogoUrl,
|
||||
faviconUrl: config.faviconUrl,
|
||||
customCss: config.customCss,
|
||||
metaTitle: config.metaTitle,
|
||||
errorPageTitle: config.errorPageTitle,
|
||||
errorPageDescription: config.errorPageDescription,
|
||||
footerText: config.footerText,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
|
||||
@@ -110,7 +111,7 @@ export const deployRedis = async (
|
||||
if (redis.serverId) {
|
||||
await execAsyncRemote(
|
||||
redis.serverId,
|
||||
`docker pull ${redis.dockerImage}`,
|
||||
`docker pull ${quote([redis.dockerImage])}`,
|
||||
onData,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
} from "../utils/docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../utils/vault";
|
||||
import { type Application, findApplicationById } from "./application";
|
||||
import { findDeploymentById } from "./deployment";
|
||||
import type { Environment } from "./environment";
|
||||
import type { Mount } from "./mount";
|
||||
import { resolveServiceNetworks } from "./network";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import {
|
||||
@@ -105,19 +108,7 @@ export const findRollbackById = async (rollbackId: string) => {
|
||||
const result = await db.query.rollbacks.findFirst({
|
||||
where: eq(rollbacks.rollbackId, rollbackId),
|
||||
with: {
|
||||
deployment: {
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,7 +204,7 @@ const rollbackApplication = async (
|
||||
image: string,
|
||||
serverId?: string | null,
|
||||
fullContext?: Application & {
|
||||
environment: {
|
||||
environment: Environment & {
|
||||
project: Project;
|
||||
};
|
||||
mounts: Mount[];
|
||||
@@ -225,7 +216,9 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
|
||||
const resolvedContext = await withResolvedVaultRefs(fullContext);
|
||||
|
||||
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
@@ -246,7 +239,7 @@ const rollbackApplication = async (
|
||||
cpuReservation,
|
||||
command,
|
||||
ports,
|
||||
} = fullContext;
|
||||
} = resolvedContext;
|
||||
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@@ -257,6 +250,10 @@ const rollbackApplication = async (
|
||||
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(
|
||||
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -265,16 +262,16 @@ const rollbackApplication = async (
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
Ulimits,
|
||||
} = generateConfigContainer(
|
||||
fullContext as Parameters<typeof generateConfigContainer>[0],
|
||||
resolvedContext as Parameters<typeof generateConfigContainer>[0],
|
||||
);
|
||||
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
env,
|
||||
fullContext.environment.project.env,
|
||||
resolvedContext.environment.project.env,
|
||||
resolvedContext.environment.env,
|
||||
);
|
||||
|
||||
let rollbackImage = image;
|
||||
@@ -304,7 +301,7 @@ const rollbackApplication = async (
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { paths } from "../constants";
|
||||
import { IS_CLOUD, paths } from "../constants";
|
||||
import { db } from "../db";
|
||||
import type {
|
||||
createScheduleSchema,
|
||||
@@ -11,9 +11,51 @@ import type {
|
||||
import { type Schedule, schedules } from "../db/schema/schedule";
|
||||
import { encodeBase64 } from "../utils/docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { findMemberByUserId } from "./permission";
|
||||
import { findServerById } from "./server";
|
||||
|
||||
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
|
||||
|
||||
// Host-level schedules (server / dokploy-server) run their script as root on the
|
||||
// host and must stay restricted to owners/admins, regardless of whether the
|
||||
// request is also tied to a service. Attaching an accessible applicationId must
|
||||
// not downgrade this to a service-access check.
|
||||
export const assertHostScheduleAccess = async (
|
||||
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
|
||||
scheduleType: Schedule["scheduleType"] | null | undefined,
|
||||
serverId: string | null | undefined,
|
||||
) => {
|
||||
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
|
||||
|
||||
if (scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Host-level schedules are not available in the cloud version.",
|
||||
});
|
||||
}
|
||||
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (member.role !== "owner" && member.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owners and admins can manage server-level schedules.",
|
||||
});
|
||||
}
|
||||
|
||||
if (scheduleType === "server" && serverId) {
|
||||
const targetServer = await findServerById(serverId);
|
||||
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this server.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createSchedule = async (
|
||||
input: z.infer<typeof createScheduleSchema>,
|
||||
) => {
|
||||
@@ -39,6 +81,11 @@ export const findScheduleById = async (scheduleId: string) => {
|
||||
where: eq(schedules.scheduleId, scheduleId),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
serverId: true,
|
||||
},
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
|
||||
@@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => {
|
||||
return currentServer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the SSH private key material from a server record before it is sent
|
||||
* to a client. `findServerById` eagerly loads the `sshKey` relation (needed for
|
||||
* server-side SSH operations), but the private key must never leave the server:
|
||||
* no client feature consumes it, and returning it exposed it to any member with
|
||||
* only `server:read`. Server-side callers keep using `findServerById` directly.
|
||||
*/
|
||||
export const redactServerSshKey = <
|
||||
T extends { sshKey?: { privateKey: string } | null },
|
||||
>(
|
||||
serverRecord: T,
|
||||
): T => {
|
||||
if (!serverRecord.sshKey) {
|
||||
return serverRecord;
|
||||
}
|
||||
return {
|
||||
...serverRecord,
|
||||
sshKey: { ...serverRecord.sshKey, privateKey: "" },
|
||||
};
|
||||
};
|
||||
|
||||
export const findServersByUserId = async (userId: string) => {
|
||||
const orgs = await db.query.organization.findMany({
|
||||
where: eq(organization.ownerId, userId),
|
||||
@@ -79,15 +100,16 @@ export const deleteServer = async (serverId: string) => {
|
||||
export const haveActiveServices = async (serverId: string) => {
|
||||
const currentServer = await db.query.server.findFirst({
|
||||
where: eq(server.serverId, serverId),
|
||||
columns: { serverId: true },
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
applications: { columns: { applicationId: true } },
|
||||
compose: { columns: { composeId: true } },
|
||||
libsql: { columns: { libsqlId: true } },
|
||||
mariadb: { columns: { mariadbId: true } },
|
||||
mongo: { columns: { mongoId: true } },
|
||||
mysql: { columns: { mysqlId: true } },
|
||||
postgres: { columns: { postgresId: true } },
|
||||
redis: { columns: { redisId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
apikey,
|
||||
invitation,
|
||||
member,
|
||||
passkey,
|
||||
user,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
@@ -396,6 +397,21 @@ export const findMemberById = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findPasskeysByUserId = async (userId: string) => {
|
||||
return db.query.passkey.findMany({
|
||||
where: eq(passkey.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceType: true,
|
||||
backedUp: true,
|
||||
createdAt: true,
|
||||
aaguid: true,
|
||||
},
|
||||
orderBy: [desc(passkey.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrganizationUserWithCredentials = async ({
|
||||
organizationId,
|
||||
email,
|
||||
@@ -430,7 +446,7 @@ export const createOrganizationUserWithCredentials = async ({
|
||||
.insert(user)
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
emailVerified: false,
|
||||
emailVerified: true,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateVaultProvider,
|
||||
projects,
|
||||
type VaultProviderAssignment,
|
||||
type VaultProviderConfig,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getVaultClient } from "@dokploy/server/utils/vault";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type VaultProvider = typeof vaultProvider.$inferSelect;
|
||||
|
||||
export const VAULT_SECRET_MASK = "********";
|
||||
|
||||
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
{
|
||||
hashicorp: ["token"],
|
||||
infisical: ["clientSecret"],
|
||||
aws: ["secretAccessKey"],
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
config: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const masked: Record<string, unknown> = { ...config };
|
||||
for (const field of SENSITIVE_FIELDS[config.providerType]) {
|
||||
if (masked[field]) {
|
||||
masked[field] = VAULT_SECRET_MASK;
|
||||
}
|
||||
}
|
||||
return masked as VaultProviderConfig;
|
||||
};
|
||||
|
||||
export const mergeVaultProviderConfig = (
|
||||
incoming: VaultProviderConfig,
|
||||
existing: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const merged: Record<string, unknown> = { ...incoming };
|
||||
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
|
||||
if (merged[field] === VAULT_SECRET_MASK) {
|
||||
if (incoming.providerType !== existing.providerType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Credentials must be re-entered when changing the provider type",
|
||||
});
|
||||
}
|
||||
merged[field] = (existing as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
return merged as VaultProviderConfig;
|
||||
};
|
||||
|
||||
const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message.includes("vault_provider_org_name_idx");
|
||||
|
||||
const validateAssignments = async (
|
||||
assignments: VaultProviderAssignment[],
|
||||
organizationId: string,
|
||||
) => {
|
||||
const orgProjects = await db.query.projects.findMany({
|
||||
where: eq(projects.organizationId, organizationId),
|
||||
with: { environments: true },
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
const project = orgProjects.find(
|
||||
(p) => p.projectId === assignment.projectId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Assignment references a project outside this organization",
|
||||
});
|
||||
}
|
||||
const environmentIds = new Set(
|
||||
project.environments.map((e) => e.environmentId),
|
||||
);
|
||||
for (const environmentId of assignment.environmentIds) {
|
||||
if (!environmentIds.has(environmentId)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Assignment references an environment outside the selected project",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createVaultProvider = async (
|
||||
input: z.infer<typeof apiCreateVaultProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
await validateAssignments(input.assignments, organizationId);
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(vaultProvider)
|
||||
.values({
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
assignments: input.assignments,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newProvider) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the vault provider",
|
||||
});
|
||||
}
|
||||
return newProvider;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${input.name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findVaultProviderById = async (vaultProviderId: string) => {
|
||||
const provider = await db.query.vaultProvider.findFirst({
|
||||
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
|
||||
});
|
||||
if (!provider) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProviderInOrganization = async (
|
||||
vaultProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findVaultProviderById(vaultProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this vault provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.vaultProvider.findMany({
|
||||
where: eq(vaultProvider.organizationId, organizationId),
|
||||
orderBy: (providers, { asc }) => [asc(providers.name)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateVaultProvider = async (
|
||||
vaultProviderId: string,
|
||||
name: string,
|
||||
config: VaultProviderConfig,
|
||||
assignments: VaultProviderAssignment[],
|
||||
) => {
|
||||
const existing = await findVaultProviderById(vaultProviderId);
|
||||
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
|
||||
await validateAssignments(assignments, existing.organizationId);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(vaultProvider)
|
||||
.set({
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
assignments,
|
||||
})
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the vault provider",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeVaultProvider = async (vaultProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(vaultProvider)
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const testVaultProviderConnection = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
try {
|
||||
await client.testConnection(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error connecting to the vault provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listVaultProviderSecretNames = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
if (!client.listSecretNames) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await client.listSecretNames(config);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,11 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
where: eq(volumeBackups.volumeBackupId, volumeBackupId),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
serverId: true,
|
||||
},
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { docker } from "../constants";
|
||||
import { pullImage } from "../utils/docker/utils";
|
||||
|
||||
export const initializeRedis = async () => {
|
||||
const imageName = "redis:7";
|
||||
const containerName = "dokploy-redis";
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
Name: containerName,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: imageName,
|
||||
Mounts: [
|
||||
{
|
||||
Type: "volume",
|
||||
Source: "dokploy-redis",
|
||||
Target: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
Networks: [{ Target: "dokploy-network" }],
|
||||
Placement: {
|
||||
Constraints: ["node.role==manager"],
|
||||
},
|
||||
},
|
||||
Mode: {
|
||||
Replicated: {
|
||||
Replicas: 1,
|
||||
},
|
||||
},
|
||||
...(process.env.NODE_ENV === "development" && {
|
||||
EndpointSpec: {
|
||||
Ports: [
|
||||
{
|
||||
TargetPort: 6379,
|
||||
PublishedPort: 6379,
|
||||
Protocol: "tcp",
|
||||
PublishMode: "host",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
await pullImage(imageName);
|
||||
|
||||
const service = docker.getService(containerName);
|
||||
const inspect = await service.inspect();
|
||||
await service.update({
|
||||
version: Number.parseInt(inspect.Version.Index),
|
||||
...settings,
|
||||
});
|
||||
console.log("Redis Started ✅");
|
||||
} catch (_) {
|
||||
try {
|
||||
await docker.createService(settings);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 409) {
|
||||
throw error;
|
||||
}
|
||||
console.log("Redis service already exists, continuing...");
|
||||
}
|
||||
console.log("Redis Not Found: Starting ✅");
|
||||
}
|
||||
};
|
||||
@@ -106,6 +106,21 @@ export const serverSetup = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const reportDockerVersion = () => `
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)
|
||||
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
|
||||
INSTALLED_DOCKER_VERSION=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || true)
|
||||
fi
|
||||
if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
|
||||
INSTALLED_DOCKER_VERSION="unknown"
|
||||
fi
|
||||
DOCKER_VERSION_REPORT="$INSTALLED_DOCKER_VERSION (already installed)"
|
||||
else
|
||||
DOCKER_VERSION_REPORT="$DOCKER_VERSION (will be installed)"
|
||||
fi
|
||||
`;
|
||||
|
||||
export const defaultCommand = (isBuildServer = false) => {
|
||||
const bashCommand = `
|
||||
set -e;
|
||||
@@ -174,10 +189,11 @@ arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles |
|
||||
;;
|
||||
esac
|
||||
|
||||
${reportDockerVersion()}
|
||||
echo -e "---------------------------------------------"
|
||||
echo "| CPU Architecture | $SYS_ARCH"
|
||||
echo "| Operating System | $OS_TYPE $OS_VERSION"
|
||||
echo "| Docker | $DOCKER_VERSION"
|
||||
echo "| Docker | $DOCKER_VERSION_REPORT"
|
||||
${isBuildServer ? 'echo "| Server Type | Build Server"' : ""}
|
||||
echo -e "---------------------------------------------\n"
|
||||
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
|
||||
@@ -466,7 +482,7 @@ const installUtilities = () => `
|
||||
|
||||
case "$OS_TYPE" in
|
||||
arch)
|
||||
$SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
|
||||
$SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true
|
||||
;;
|
||||
alpine)
|
||||
$SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
|
||||
|
||||
@@ -74,6 +74,49 @@ export async function fetchTemplatesList(
|
||||
}));
|
||||
}
|
||||
|
||||
const LOGO_MIME_TYPES: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
export async function fetchTemplateLogo(
|
||||
templateId: string,
|
||||
baseUrl = "https://templates.dokploy.com",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const templates = await fetchTemplatesList(baseUrl);
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template?.logo) return null;
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/blueprints/${templateId}/${template.logo}`,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0];
|
||||
const extension = template.logo.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mimeType = contentType?.startsWith("image/")
|
||||
? contentType
|
||||
: LOGO_MIME_TYPES[extension];
|
||||
if (!mimeType) return null;
|
||||
|
||||
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
|
||||
if (dataUrl.length > 2 * 1024 * 1024) return null;
|
||||
|
||||
return dataUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a specific template's files
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,8 @@ export const generateRandomDomain = ({
|
||||
projectName,
|
||||
}: Schema): string => {
|
||||
const hash = randomBytes(3).toString("hex");
|
||||
const slugIp = serverIp.replaceAll(".", "-").replaceAll(":", "-");
|
||||
const effectiveIp = serverIp || "127.0.0.1";
|
||||
const slugIp = effectiveIp.replaceAll(".", "-").replaceAll(":", "-");
|
||||
|
||||
// Domain labels have a max length of 63 characters
|
||||
// Reserve space for: hash (6) + separators (1-2) + ip section + dot + sslip.io (8)
|
||||
@@ -46,7 +47,7 @@ export const generateRandomDomain = ({
|
||||
? projectName.substring(0, maxProjectNameLength)
|
||||
: projectName;
|
||||
|
||||
return `${truncatedProjectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.sslip.io`;
|
||||
return `${truncatedProjectName}-${hash}-${slugIp}.sslip.io`;
|
||||
};
|
||||
|
||||
export const generateHash = (length = 8): string => {
|
||||
|
||||
@@ -32,7 +32,19 @@ export const startLogCleanup = async (
|
||||
await execAsync(
|
||||
`tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
|
||||
);
|
||||
await execAsync("docker exec dokploy-traefik kill -USR1 1");
|
||||
|
||||
// Traefik can run as a standalone container ("dokploy-traefik") or a
|
||||
// swarm service task ("dokploy-traefik.1.<task-id>"), so resolve the
|
||||
// running container id dynamically instead of assuming the name.
|
||||
const { stdout: containerId } = await execAsync(
|
||||
'docker ps -q --filter "name=dokploy-traefik" --filter "status=running" | head -n 1',
|
||||
);
|
||||
const traefikContainerId = containerId.trim();
|
||||
if (!traefikContainerId) {
|
||||
console.error("Traefik container not found, skipping log reopen");
|
||||
return;
|
||||
}
|
||||
await execAsync(`docker exec ${traefikContainerId} kill -USR1 1`);
|
||||
} catch (error) {
|
||||
console.error("Error during log cleanup:", error);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function parseRawConfig(
|
||||
|
||||
if (search) {
|
||||
parsedLogs = parsedLogs.filter((log) =>
|
||||
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
|
||||
(log.RequestHost ?? "").toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
});
|
||||
case "ollama":
|
||||
return createOllama({
|
||||
// optional settings, e.g.
|
||||
baseURL: config.apiUrl,
|
||||
headers: config.apiKey
|
||||
? { Authorization: `Bearer ${config.apiKey}` }
|
||||
: undefined,
|
||||
});
|
||||
case "deepinfra":
|
||||
return createDeepInfra({
|
||||
|
||||
@@ -37,11 +37,10 @@ export const runComposeBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (compose.serverId) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||
|
||||
export const initCronJobs = async () => {
|
||||
@@ -153,6 +154,6 @@ export const keepLatestNBackups = async (
|
||||
await execAsync(rcloneCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(redactRcloneCredentials(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runLibsqlBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (libsql.serverId) {
|
||||
|
||||
@@ -35,11 +35,10 @@ export const runMariadbBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (mariadb.serverId) {
|
||||
|
||||
@@ -32,11 +32,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -33,12 +33,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runPostgresBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (postgres.serverId) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Redacts S3 credentials from rclone command strings.
|
||||
*
|
||||
* Used to prevent credential leakage in structured logs and error output.
|
||||
* Matches the flag format produced by `getS3Credentials()`:
|
||||
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
|
||||
*/
|
||||
export const redactRcloneCredentials = (command: string): string => {
|
||||
return command
|
||||
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
|
||||
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { quote } from "shell-quote";
|
||||
import { keepLatestNBackups } from ".";
|
||||
import { runComposeBackup } from "./compose";
|
||||
import { runLibsqlBackup } from "./libsql";
|
||||
@@ -9,6 +10,7 @@ import { runMariadbBackup } from "./mariadb";
|
||||
import { runMongoBackup } from "./mongo";
|
||||
import { runMySqlBackup } from "./mysql";
|
||||
import { runPostgresBackup } from "./postgres";
|
||||
import { redactRcloneCredentials } from "./redact";
|
||||
import { runWebServerBackup } from "./web-server";
|
||||
|
||||
export const scheduleBackup = (backup: BackupSchedule) => {
|
||||
@@ -70,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
const { accessKey, secretAccessKey, region, endpoint, provider } =
|
||||
destination;
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||
`--s3-region="${region}"`,
|
||||
`--s3-endpoint="${endpoint}"`,
|
||||
`--s3-access-key-id=${quote([accessKey])}`,
|
||||
`--s3-secret-access-key=${quote([secretAccessKey])}`,
|
||||
`--s3-region=${quote([region])}`,
|
||||
`--s3-endpoint=${quote([endpoint])}`,
|
||||
"--s3-no-check-bucket",
|
||||
"--s3-force-path-style",
|
||||
];
|
||||
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
|
||||
}
|
||||
|
||||
if (destination.additionalFlags?.length) {
|
||||
@@ -89,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
return rcloneFlags;
|
||||
};
|
||||
|
||||
// User-controlled values (database name, user, password) are passed to the
|
||||
// container as environment variables via `docker exec -e VAR=<escaped>` and
|
||||
// referenced as "$VAR" inside the inner shell, so they never appear in the
|
||||
// inner command text. The -e value is escaped for the outer shell with
|
||||
// shell-quote; the inner script is single-quoted and reads the env vars.
|
||||
export const getPostgresBackupCommand = (
|
||||
database: string,
|
||||
databaseUser: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMariadbBackupCommand = (
|
||||
@@ -101,14 +108,14 @@ export const getMariadbBackupCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMysqlBackupCommand = (
|
||||
database: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMongoBackupCommand = (
|
||||
@@ -116,11 +123,11 @@ export const getMongoBackupCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;
|
||||
};
|
||||
|
||||
export const getLibsqlBackupCommand = (database: string) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getServiceContainerCommand = (appName: string) => {
|
||||
@@ -252,17 +259,20 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
|
||||
|
||||
export const getBackupCommand = (
|
||||
backup: BackupSchedule,
|
||||
rcloneCommand: string,
|
||||
rcloneFlags: string[],
|
||||
rcloneDestination: string,
|
||||
logPath: string,
|
||||
) => {
|
||||
const containerSearch = getContainerSearchCommand(backup);
|
||||
const backupCommand = generateBackupCommand(backup);
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
const rcloneDeleteCommand = `rclone deletefile ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
containerSearch,
|
||||
backupCommand,
|
||||
rcloneCommand,
|
||||
rcloneCommand: redactRcloneCredentials(rcloneCommand),
|
||||
logPath,
|
||||
},
|
||||
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
|
||||
@@ -272,33 +282,24 @@ export const getBackupCommand = (
|
||||
set -eo pipefail;
|
||||
echo "[$(date)] Starting backup process..." >> ${logPath};
|
||||
echo "[$(date)] Executing backup command..." >> ${logPath};
|
||||
CONTAINER_ID=$(${containerSearch})
|
||||
CONTAINER_ID=$(${containerSearch});
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
|
||||
exit 1;
|
||||
fi
|
||||
fi;
|
||||
|
||||
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
|
||||
echo "[$(date)] Starting backup and upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the backup command and capture the exit status
|
||||
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
|
||||
UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
|
||||
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
|
||||
exit 1;
|
||||
}
|
||||
|
||||
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
|
||||
echo "[$(date)] Starting upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the upload command and capture the exit status
|
||||
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
|
||||
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
|
||||
${rcloneDeleteCommand} >/dev/null 2>&1 || true;
|
||||
exit 1;
|
||||
}
|
||||
};
|
||||
|
||||
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
|
||||
echo "[$(date)] ✅ Backup uploaded to S3 successfully" >> ${logPath};
|
||||
echo "Backup done ✅" >> ${logPath};
|
||||
`;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user