mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge branch 'canary' into feat/quick-service-switcher
This commit is contained in:
@@ -1,274 +1,299 @@
|
||||
// import { relations } from "drizzle-orm";
|
||||
// import {
|
||||
// pgTable,
|
||||
// text,
|
||||
// timestamp,
|
||||
// boolean,
|
||||
// integer,
|
||||
// index,
|
||||
// uniqueIndex,
|
||||
// } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// export const user = pgTable("user", {
|
||||
// id: text("id").primaryKey(),
|
||||
// firstName: text("first_name").notNull(),
|
||||
// email: text("email").notNull().unique(),
|
||||
// emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
// image: text("image"),
|
||||
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
// updatedAt: timestamp("updated_at")
|
||||
// .defaultNow()
|
||||
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
// .notNull(),
|
||||
// twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
// role: text("role"),
|
||||
// ownerId: text("owner_id"),
|
||||
// allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
// lastName: text("last_name").default(""),
|
||||
// });
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
firstName: text("first_name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||
});
|
||||
|
||||
// export const session = pgTable(
|
||||
// "session",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// expiresAt: timestamp("expires_at").notNull(),
|
||||
// token: text("token").notNull().unique(),
|
||||
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
// updatedAt: timestamp("updated_at")
|
||||
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
// .notNull(),
|
||||
// ipAddress: text("ip_address"),
|
||||
// userAgent: text("user_agent"),
|
||||
// userId: text("user_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// activeOrganizationId: text("active_organization_id"),
|
||||
// },
|
||||
// (table) => [index("session_userId_idx").on(table.userId)],
|
||||
// );
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
// export const account = pgTable(
|
||||
// "account",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// accountId: text("account_id").notNull(),
|
||||
// providerId: text("provider_id").notNull(),
|
||||
// userId: text("user_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// accessToken: text("access_token"),
|
||||
// refreshToken: text("refresh_token"),
|
||||
// idToken: text("id_token"),
|
||||
// accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
// refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
// scope: text("scope"),
|
||||
// password: text("password"),
|
||||
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
// updatedAt: timestamp("updated_at")
|
||||
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
// .notNull(),
|
||||
// },
|
||||
// (table) => [index("account_userId_idx").on(table.userId)],
|
||||
// );
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
// export const verification = pgTable(
|
||||
// "verification",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// identifier: text("identifier").notNull(),
|
||||
// value: text("value").notNull(),
|
||||
// expiresAt: timestamp("expires_at").notNull(),
|
||||
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
// updatedAt: timestamp("updated_at")
|
||||
// .defaultNow()
|
||||
// .$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
// .notNull(),
|
||||
// },
|
||||
// (table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
// );
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
// export const apikey = pgTable(
|
||||
// "apikey",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// name: text("name"),
|
||||
// start: text("start"),
|
||||
// prefix: text("prefix"),
|
||||
// key: text("key").notNull(),
|
||||
// userId: text("user_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// refillInterval: integer("refill_interval"),
|
||||
// refillAmount: integer("refill_amount"),
|
||||
// lastRefillAt: timestamp("last_refill_at"),
|
||||
// enabled: boolean("enabled").default(true),
|
||||
// rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
// rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
// rateLimitMax: integer("rate_limit_max").default(10),
|
||||
// requestCount: integer("request_count").default(0),
|
||||
// remaining: integer("remaining"),
|
||||
// lastRequest: timestamp("last_request"),
|
||||
// expiresAt: timestamp("expires_at"),
|
||||
// createdAt: timestamp("created_at").notNull(),
|
||||
// updatedAt: timestamp("updated_at").notNull(),
|
||||
// permissions: text("permissions"),
|
||||
// metadata: text("metadata"),
|
||||
// },
|
||||
// (table) => [
|
||||
// index("apikey_key_idx").on(table.key),
|
||||
// index("apikey_userId_idx").on(table.userId),
|
||||
// ],
|
||||
// );
|
||||
export const apikey = pgTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
);
|
||||
|
||||
// export const ssoProvider = pgTable("sso_provider", {
|
||||
// id: text("id").primaryKey(),
|
||||
// issuer: text("issuer").notNull(),
|
||||
// oidcConfig: text("oidc_config"),
|
||||
// samlConfig: text("saml_config"),
|
||||
// userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
// providerId: text("provider_id").notNull().unique(),
|
||||
// organizationId: text("organization_id"),
|
||||
// domain: text("domain").notNull(),
|
||||
// });
|
||||
export const ssoProvider = pgTable("sso_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
});
|
||||
|
||||
// export const twoFactor = pgTable(
|
||||
// "two_factor",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// secret: text("secret").notNull(),
|
||||
// backupCodes: text("backup_codes").notNull(),
|
||||
// userId: text("user_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// },
|
||||
// (table) => [
|
||||
// index("twoFactor_secret_idx").on(table.secret),
|
||||
// index("twoFactor_userId_idx").on(table.userId),
|
||||
// ],
|
||||
// );
|
||||
export const twoFactor = pgTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// export const organization = pgTable(
|
||||
// "organization",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// name: text("name").notNull(),
|
||||
// slug: text("slug").notNull().unique(),
|
||||
// logo: text("logo"),
|
||||
// createdAt: timestamp("created_at").notNull(),
|
||||
// metadata: text("metadata"),
|
||||
// },
|
||||
// (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
// );
|
||||
export const organization = pgTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
);
|
||||
|
||||
// export const member = pgTable(
|
||||
// "member",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// organizationId: text("organization_id")
|
||||
// .notNull()
|
||||
// .references(() => organization.id, { onDelete: "cascade" }),
|
||||
// userId: text("user_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// role: text("role").default("member").notNull(),
|
||||
// createdAt: timestamp("created_at").notNull(),
|
||||
// },
|
||||
// (table) => [
|
||||
// index("member_organizationId_idx").on(table.organizationId),
|
||||
// index("member_userId_idx").on(table.userId),
|
||||
// ],
|
||||
// );
|
||||
export const organizationRole = pgTable(
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
|
||||
// export const invitation = pgTable(
|
||||
// "invitation",
|
||||
// {
|
||||
// id: text("id").primaryKey(),
|
||||
// organizationId: text("organization_id")
|
||||
// .notNull()
|
||||
// .references(() => organization.id, { onDelete: "cascade" }),
|
||||
// email: text("email").notNull(),
|
||||
// role: text("role"),
|
||||
// status: text("status").default("pending").notNull(),
|
||||
// expiresAt: timestamp("expires_at").notNull(),
|
||||
// createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
// inviterId: text("inviter_id")
|
||||
// .notNull()
|
||||
// .references(() => user.id, { onDelete: "cascade" }),
|
||||
// },
|
||||
// (table) => [
|
||||
// index("invitation_organizationId_idx").on(table.organizationId),
|
||||
// index("invitation_email_idx").on(table.email),
|
||||
// ],
|
||||
// );
|
||||
export const member = pgTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// export const userRelations = relations(user, ({ many }) => ({
|
||||
// sessions: many(session),
|
||||
// accounts: many(account),
|
||||
// apikeys: many(apikey),
|
||||
// ssoProviders: many(ssoProvider),
|
||||
// twoFactors: many(twoFactor),
|
||||
// members: many(member),
|
||||
// invitations: many(invitation),
|
||||
// }));
|
||||
export const invitation = pgTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
// export const sessionRelations = relations(session, ({ one }) => ({
|
||||
// user: one(user, {
|
||||
// fields: [session.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
twoFactors: many(twoFactor),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
// export const accountRelations = relations(account, ({ one }) => ({
|
||||
// user: one(user, {
|
||||
// fields: [account.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||
// user: one(user, {
|
||||
// fields: [apikey.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
// user: one(user, {
|
||||
// fields: [ssoProvider.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
// user: one(user, {
|
||||
// fields: [twoFactor.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
// members: many(member),
|
||||
// invitations: many(invitation),
|
||||
// }));
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
organizationRoles: many(organizationRole),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
// export const memberRelations = relations(member, ({ one }) => ({
|
||||
// organization: one(organization, {
|
||||
// fields: [member.organizationId],
|
||||
// references: [organization.id],
|
||||
// }),
|
||||
// user: one(user, {
|
||||
// fields: [member.userId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const organizationRoleRelations = relations(
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
// organization: one(organization, {
|
||||
// fields: [invitation.organizationId],
|
||||
// references: [organization.id],
|
||||
// }),
|
||||
// user: one(user, {
|
||||
// fields: [invitation.inviterId],
|
||||
// references: [user.id],
|
||||
// }),
|
||||
// }));
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dbml:generate": "npx tsx src/db/schema/dbml.ts",
|
||||
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
|
||||
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
@@ -37,6 +37,8 @@
|
||||
"@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",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
@@ -44,13 +46,13 @@
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@react-email/components": "^0.0.21",
|
||||
"@better-auth/sso": "1.5.0-beta.16",
|
||||
"@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.0-beta.16",
|
||||
"better-auth": "1.5.4",
|
||||
"better-call": "2.0.2",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"date-fns": "3.6.0",
|
||||
@@ -59,7 +61,6 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"yaml": "2.8.1",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
@@ -76,19 +77,17 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "1.15.0",
|
||||
"toml": "3.0.0",
|
||||
"ws": "8.16.0",
|
||||
"zod": "^4.3.6",
|
||||
"semver": "7.7.3",
|
||||
"better-call": "1.3.2"
|
||||
"yaml": "2.8.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "6.1.3",
|
||||
"@better-auth/cli": "1.5.0-beta.13",
|
||||
"@types/semver": "7.7.1",
|
||||
"@better-auth/cli": "1.4.21",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
@@ -100,6 +99,7 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/ws": "8.5.10",
|
||||
@@ -107,6 +107,7 @@
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild-plugin-alias": "0.2.1",
|
||||
"postcss": "^8.5.3",
|
||||
"rimraf": "6.1.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -2,22 +2,23 @@ import path from "node:path";
|
||||
import Docker from "dockerode";
|
||||
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
|
||||
export const DOCKER_HOST = process.env.DOCKER_HOST;
|
||||
export const DOCKER_PORT = process.env.DOCKER_PORT
|
||||
? Number(process.env.DOCKER_PORT)
|
||||
export const DOKPLOY_DOCKER_API_VERSION =
|
||||
process.env.DOKPLOY_DOCKER_API_VERSION;
|
||||
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
|
||||
export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
? Number(process.env.DOKPLOY_DOCKER_PORT)
|
||||
: undefined;
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
export const docker = new Docker({
|
||||
...(DOCKER_API_VERSION && {
|
||||
version: DOCKER_API_VERSION,
|
||||
...(DOKPLOY_DOCKER_API_VERSION && {
|
||||
version: DOKPLOY_DOCKER_API_VERSION,
|
||||
}),
|
||||
...(DOCKER_HOST && {
|
||||
host: DOCKER_HOST,
|
||||
...(DOKPLOY_DOCKER_HOST && {
|
||||
host: DOKPLOY_DOCKER_HOST,
|
||||
}),
|
||||
...(DOCKER_PORT && {
|
||||
port: DOCKER_PORT,
|
||||
...(DOKPLOY_DOCKER_PORT && {
|
||||
port: DOKPLOY_DOCKER_PORT,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
@@ -69,6 +70,36 @@ export const organization = pgTable("organization", {
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const organizationRole = pgTable(
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
|
||||
export const organizationRoleRelations = relations(
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const organizationRelations = relations(
|
||||
organization,
|
||||
({ one, many }) => ({
|
||||
@@ -80,6 +111,7 @@ export const organizationRelations = relations(
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
roles: many(organizationRole),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -93,7 +125,9 @@ export const member = pgTable("member", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
|
||||
role: text("role")
|
||||
.notNull()
|
||||
.$type<"owner" | "member" | "admin" | (string & {})>(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
teamId: text("team_id"),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
@@ -148,7 +182,7 @@ export const invitation = pgTable("invitation", {
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role").$type<"owner" | "member" | "admin">(),
|
||||
role: text("role").$type<"owner" | "member" | "admin" | (string & {})>(),
|
||||
status: text("status").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
@@ -180,7 +214,8 @@ export const apikey = pgTable("apikey", {
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
userId: text("user_id")
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
referenceId: text("reference_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
refillInterval: integer("refill_interval"),
|
||||
@@ -202,7 +237,7 @@ export const apikey = pgTable("apikey", {
|
||||
|
||||
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [apikey.userId],
|
||||
fields: [apikey.referenceId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
94
packages/server/src/db/schema/audit-log.ts
Normal file
94
packages/server/src/db/schema/audit-log.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { organization } from "./account";
|
||||
import { user } from "./user";
|
||||
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
userEmail: text("user_email").notNull(),
|
||||
userRole: text("user_role").notNull(),
|
||||
action: text("action").notNull(),
|
||||
resourceType: text("resource_type").notNull(),
|
||||
resourceId: text("resource_id"),
|
||||
resourceName: text("resource_name"),
|
||||
metadata: text("metadata"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
orgIdx: index("auditLog_organizationId_idx").on(t.organizationId),
|
||||
userIdx: index("auditLog_userId_idx").on(t.userId),
|
||||
createdAtIdx: index("auditLog_createdAt_idx").on(t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [auditLog.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [auditLog.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type AuditLog = typeof auditLog.$inferSelect;
|
||||
export type NewAuditLog = typeof auditLog.$inferInsert;
|
||||
|
||||
export type AuditAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "deploy"
|
||||
| "cancel"
|
||||
| "redeploy"
|
||||
| "login"
|
||||
| "logout"
|
||||
| "restore"
|
||||
| "run"
|
||||
| "start"
|
||||
| "stop"
|
||||
| "reload"
|
||||
| "rebuild"
|
||||
| "move";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "project"
|
||||
| "service"
|
||||
| "environment"
|
||||
| "deployment"
|
||||
| "user"
|
||||
| "customRole"
|
||||
| "domain"
|
||||
| "certificate"
|
||||
| "registry"
|
||||
| "server"
|
||||
| "sshKey"
|
||||
| "gitProvider"
|
||||
| "destination"
|
||||
| "notification"
|
||||
| "settings"
|
||||
| "session"
|
||||
| "port"
|
||||
| "redirect"
|
||||
| "security"
|
||||
| "schedule"
|
||||
| "backup"
|
||||
| "volumeBackup"
|
||||
| "docker"
|
||||
| "swarm"
|
||||
| "previewDeployment"
|
||||
| "organization"
|
||||
| "cluster"
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose";
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./account";
|
||||
export * from "./ai";
|
||||
export * from "./audit-log";
|
||||
export * from "./application";
|
||||
export * from "./backups";
|
||||
export * from "./bitbucket";
|
||||
|
||||
@@ -202,6 +202,7 @@ export const apiUpdateMariaDB = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mariadbId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export const apiUpdateMongo = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mongoId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ export const apiUpdateMySql = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mysqlId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ export const apiUpdatePostgres = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
postgresId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ export const apiUpdateRedis = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
redisId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -66,6 +66,36 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Whitelabeling Configuration (Enterprise / Proprietary)
|
||||
whitelabelingConfig: jsonb("whitelabelingConfig")
|
||||
.$type<{
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
customCss: string | null;
|
||||
loginLogoUrl: string | null;
|
||||
supportUrl: string | null;
|
||||
docsUrl: string | null;
|
||||
errorPageTitle: string | null;
|
||||
errorPageDescription: string | null;
|
||||
metaTitle: string | null;
|
||||
footerText: string | null;
|
||||
}>()
|
||||
.default({
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
}),
|
||||
// Cache Cleanup Configuration
|
||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||
.notNull()
|
||||
@@ -154,6 +184,33 @@ export const apiUpdateDockerCleanup = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Whitelabeling validation schemas
|
||||
const safeUrl = z
|
||||
.string()
|
||||
.refine((url) => /^https?:\/\//i.test(url), {
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export const whitelabelingConfigSchema = z.object({
|
||||
appName: z.string().nullable(),
|
||||
appDescription: z.string().nullable(),
|
||||
logoUrl: safeUrl,
|
||||
faviconUrl: safeUrl,
|
||||
customCss: z.string().nullable(),
|
||||
loginLogoUrl: safeUrl,
|
||||
supportUrl: safeUrl,
|
||||
docsUrl: safeUrl,
|
||||
errorPageTitle: z.string().nullable(),
|
||||
errorPageDescription: z.string().nullable(),
|
||||
metaTitle: z.string().nullable(),
|
||||
footerText: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabeling = z.object({
|
||||
whitelabelingConfig: whitelabelingConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerMonitoring = z.object({
|
||||
metricsConfig: z
|
||||
.object({
|
||||
|
||||
190
packages/server/src/lib/access-control.ts
Normal file
190
packages/server/src/lib/access-control.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
/**
|
||||
* Dokploy Access Control Statements
|
||||
*
|
||||
* Defines all resources and their possible actions across the platform.
|
||||
* The first 5 (organization, member, invitation, team, ac) are better-auth defaults
|
||||
* used internally by the organization plugin.
|
||||
* The rest are Dokploy-specific resources.
|
||||
*
|
||||
* Enterprise-only resources (only assignable via custom roles):
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
|
||||
*/
|
||||
export const statements = {
|
||||
// better-auth organization plugin defaults
|
||||
organization: ["update", "delete"],
|
||||
member: ["read", "create", "update", "delete"],
|
||||
invitation: ["create", "cancel"],
|
||||
team: ["create", "update", "delete"],
|
||||
ac: ["create", "read", "update", "delete"],
|
||||
|
||||
// Dokploy core resources (free tier)
|
||||
project: ["create", "delete"],
|
||||
service: ["create", "read", "delete"],
|
||||
environment: ["create", "read", "delete"],
|
||||
docker: ["read"],
|
||||
sshKeys: ["read", "create", "delete"],
|
||||
gitProviders: ["read", "create", "delete"],
|
||||
traefikFiles: ["read", "write"],
|
||||
api: ["read"],
|
||||
|
||||
// Enterprise-only resources (custom roles only)
|
||||
volume: ["read", "create", "delete"],
|
||||
deployment: ["read", "create", "cancel"],
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
volumeBackup: ["read", "create", "update", "delete", "restore"],
|
||||
schedule: ["read", "create", "update", "delete"],
|
||||
domain: ["read", "create", "delete"],
|
||||
destination: ["read", "create", "delete"],
|
||||
notification: ["read", "create", "update", "delete"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Enterprise-only resources. For static roles (owner/admin/member),
|
||||
* permission checks on these resources are bypassed — they only apply
|
||||
* when using custom roles with an enterprise license.
|
||||
*/
|
||||
export const enterpriseOnlyResources = new Set<string>([
|
||||
"volume",
|
||||
"deployment",
|
||||
"envVars",
|
||||
"projectEnvVars",
|
||||
"environmentEnvVars",
|
||||
"server",
|
||||
"registry",
|
||||
"certificate",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
"schedule",
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
|
||||
/**
|
||||
* Owner role — full access to everything
|
||||
*/
|
||||
export const ownerRole = ac.newRole({
|
||||
organization: ["update", "delete"],
|
||||
member: ["read", "create", "update", "delete"],
|
||||
invitation: ["create", "cancel"],
|
||||
team: ["create", "update", "delete"],
|
||||
ac: ["create", "read", "update", "delete"],
|
||||
project: ["create", "delete"],
|
||||
service: ["create", "read", "delete"],
|
||||
environment: ["create", "read", "delete"],
|
||||
docker: ["read"],
|
||||
sshKeys: ["read", "create", "delete"],
|
||||
gitProviders: ["read", "create", "delete"],
|
||||
traefikFiles: ["read", "write"],
|
||||
api: ["read"],
|
||||
volume: ["read", "create", "delete"],
|
||||
deployment: ["read", "create", "cancel"],
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
volumeBackup: ["read", "create", "update", "delete", "restore"],
|
||||
schedule: ["read", "create", "update", "delete"],
|
||||
domain: ["read", "create", "delete"],
|
||||
destination: ["read", "create", "delete"],
|
||||
notification: ["read", "create", "update", "delete"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin role — same as owner but cannot delete the organization
|
||||
*/
|
||||
export const adminRole = ac.newRole({
|
||||
organization: ["update"],
|
||||
member: ["read", "create", "update", "delete"],
|
||||
invitation: ["create", "cancel"],
|
||||
team: ["create", "update", "delete"],
|
||||
ac: ["create", "read", "update", "delete"],
|
||||
project: ["create", "delete"],
|
||||
service: ["create", "read", "delete"],
|
||||
environment: ["create", "read", "delete"],
|
||||
docker: ["read"],
|
||||
sshKeys: ["read", "create", "delete"],
|
||||
gitProviders: ["read", "create", "delete"],
|
||||
traefikFiles: ["read", "write"],
|
||||
api: ["read"],
|
||||
volume: ["read", "create", "delete"],
|
||||
deployment: ["read", "create", "cancel"],
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
volumeBackup: ["read", "create", "update", "delete", "restore"],
|
||||
schedule: ["read", "create", "update", "delete"],
|
||||
domain: ["read", "create", "delete"],
|
||||
destination: ["read", "create", "delete"],
|
||||
notification: ["read", "create", "update", "delete"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
});
|
||||
|
||||
/**
|
||||
* Member role (free tier) — read-only base permissions.
|
||||
* Members can read projects/services/environments they have access to,
|
||||
* but cannot create, delete, or access admin resources.
|
||||
* Enterprise resources are not available to the base member role.
|
||||
*/
|
||||
export const memberRole = ac.newRole({
|
||||
organization: [],
|
||||
member: [],
|
||||
invitation: [],
|
||||
team: [],
|
||||
ac: ["read"],
|
||||
project: [],
|
||||
service: ["read"],
|
||||
environment: ["read"],
|
||||
docker: [],
|
||||
sshKeys: [],
|
||||
gitProviders: [],
|
||||
traefikFiles: [],
|
||||
api: [],
|
||||
// Service-level enterprise resources — member can do everything within services they have access to
|
||||
volume: ["read", "create", "delete"],
|
||||
deployment: ["read", "create", "cancel"],
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
volumeBackup: ["read", "create", "update", "delete", "restore"],
|
||||
schedule: ["read", "create", "update", "delete"],
|
||||
domain: ["read", "create", "delete"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
// Org-level enterprise resources — member cannot manage these
|
||||
server: [],
|
||||
registry: [],
|
||||
certificate: [],
|
||||
destination: [],
|
||||
notification: [],
|
||||
auditLog: [],
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
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 { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
|
||||
import { db } from "../db";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getTrustedProviders,
|
||||
getUserByToken,
|
||||
} from "../services/admin";
|
||||
import { createAuditLog } from "../services/proprietary/audit-log";
|
||||
import {
|
||||
getWebServerSettings,
|
||||
updateWebServerSettings,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -113,7 +116,7 @@ const { handler, api } = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: !IS_CLOUD,
|
||||
requireEmailVerification: IS_CLOUD,
|
||||
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
|
||||
password: {
|
||||
async hash(password) {
|
||||
return bcrypt.hashSync(password, 10);
|
||||
@@ -269,6 +272,52 @@ const { handler, api } = betterAuth({
|
||||
},
|
||||
};
|
||||
},
|
||||
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",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -318,10 +367,21 @@ const { handler, api } = betterAuth({
|
||||
plugins: [
|
||||
apiKey({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
},
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
async sendInvitationEmail(data, _request) {
|
||||
if (IS_CLOUD) {
|
||||
const host =
|
||||
|
||||
431
packages/server/src/services/permission.ts
Normal file
431
packages/server/src/services/permission.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { member, organizationRole } from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
ac,
|
||||
adminRole,
|
||||
enterpriseOnlyResources,
|
||||
memberRole,
|
||||
ownerRole,
|
||||
statements,
|
||||
} from "../lib/access-control";
|
||||
|
||||
type Statements = typeof statements;
|
||||
type Resource = keyof Statements;
|
||||
type Action<R extends Resource> = Statements[R][number];
|
||||
type Permissions = {
|
||||
[R in Resource]?: Action<R>[];
|
||||
};
|
||||
|
||||
export type PermissionCtx = {
|
||||
user: { id: string };
|
||||
session: { activeOrganizationId: string };
|
||||
};
|
||||
|
||||
export type ResolvedPermissions = {
|
||||
[R in Resource]: {
|
||||
[A in Statements[R][number]]: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const staticRoles: Record<string, ReturnType<typeof ac.newRole>> = {
|
||||
owner: ownerRole,
|
||||
admin: adminRole,
|
||||
member: memberRole,
|
||||
};
|
||||
|
||||
const resolveRole = async (
|
||||
roleName: string,
|
||||
organizationId: string,
|
||||
): Promise<ReturnType<typeof ac.newRole> | null> => {
|
||||
if (staticRoles[roleName]) {
|
||||
return staticRoles[roleName];
|
||||
}
|
||||
|
||||
const licensed = await hasValidLicense(organizationId);
|
||||
if (!licensed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customRoles = await db.query.organizationRole.findMany({
|
||||
where: and(
|
||||
eq(organizationRole.organizationId, organizationId),
|
||||
eq(organizationRole.role, roleName),
|
||||
),
|
||||
});
|
||||
|
||||
if (customRoles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merged: Record<string, string[]> = {};
|
||||
for (const entry of customRoles) {
|
||||
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
|
||||
for (const [resource, actions] of Object.entries(parsed)) {
|
||||
merged[resource] = [
|
||||
...new Set([...(merged[resource] ?? []), ...actions]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ac.newRole(merged as any);
|
||||
};
|
||||
|
||||
export const checkPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
permissions: Permissions,
|
||||
) => {
|
||||
const { id: userId } = ctx.user;
|
||||
const { activeOrganizationId: organizationId } = ctx.session;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
const isStaticRole = memberRecord.role in staticRoles;
|
||||
|
||||
if (isStaticRole) {
|
||||
const allEnterprise = Object.keys(permissions).every((r) =>
|
||||
enterpriseOnlyResources.has(r),
|
||||
);
|
||||
if (allEnterprise) return;
|
||||
}
|
||||
|
||||
const role = await resolveRole(memberRecord.role, organizationId);
|
||||
|
||||
if (!role) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid role",
|
||||
});
|
||||
}
|
||||
|
||||
const result = role.authorize(permissions);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memberRecord.role === "member") {
|
||||
const overrides = getLegacyOverrides(memberRecord);
|
||||
const allGranted = Object.entries(permissions).every(
|
||||
([resource, actions]) =>
|
||||
(actions as string[]).every(
|
||||
(action) =>
|
||||
!!(overrides[resource] as Record<string, boolean> | undefined)?.[
|
||||
action
|
||||
],
|
||||
),
|
||||
);
|
||||
if (allGranted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: result.error || "Permission denied",
|
||||
});
|
||||
};
|
||||
|
||||
export const hasPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
permissions: Permissions,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await checkPermission(ctx, permissions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getLegacyOverrides = (
|
||||
memberRecord: Awaited<ReturnType<typeof findMemberByUserId>>,
|
||||
): Partial<Record<string, Record<string, boolean>>> => {
|
||||
return {
|
||||
project: {
|
||||
create: !!memberRecord.canCreateProjects,
|
||||
delete: !!memberRecord.canDeleteProjects,
|
||||
},
|
||||
service: {
|
||||
create: !!memberRecord.canCreateServices,
|
||||
delete: !!memberRecord.canDeleteServices,
|
||||
},
|
||||
environment: {
|
||||
create: !!memberRecord.canCreateEnvironments,
|
||||
delete: !!memberRecord.canDeleteEnvironments,
|
||||
},
|
||||
traefikFiles: {
|
||||
read: !!memberRecord.canAccessToTraefikFiles,
|
||||
},
|
||||
docker: {
|
||||
read: !!memberRecord.canAccessToDocker,
|
||||
},
|
||||
api: {
|
||||
read: !!memberRecord.canAccessToAPI,
|
||||
},
|
||||
sshKeys: {
|
||||
read: !!memberRecord.canAccessToSSHKeys,
|
||||
},
|
||||
gitProviders: {
|
||||
read: !!memberRecord.canAccessToGitProviders,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resolvePermissions = async (
|
||||
ctx: PermissionCtx,
|
||||
): Promise<ResolvedPermissions> => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
const role = await resolveRole(memberRecord.role, organizationId);
|
||||
|
||||
const legacyOverrides =
|
||||
memberRecord.role === "member" ? getLegacyOverrides(memberRecord) : {};
|
||||
|
||||
const isPrivilegedRole =
|
||||
memberRecord.role === "owner" || memberRecord.role === "admin";
|
||||
const result = {} as ResolvedPermissions;
|
||||
|
||||
for (const [resource, actions] of Object.entries(statements)) {
|
||||
const resourcePerms = {} as Record<string, boolean>;
|
||||
for (const action of actions) {
|
||||
if (isPrivilegedRole && enterpriseOnlyResources.has(resource)) {
|
||||
resourcePerms[action] = true;
|
||||
continue;
|
||||
}
|
||||
if (!role) {
|
||||
resourcePerms[action] = false;
|
||||
continue;
|
||||
}
|
||||
const check = role.authorize({ [resource]: [action] });
|
||||
resourcePerms[action] =
|
||||
check.success ||
|
||||
!!(legacyOverrides[resource] as Record<string, boolean> | undefined)?.[
|
||||
action
|
||||
];
|
||||
}
|
||||
(result as any)[resource] = resourcePerms;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
action: "create" | "delete",
|
||||
projectId?: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { project: [action] });
|
||||
|
||||
if (
|
||||
action !== "create" &&
|
||||
projectId &&
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin"
|
||||
) {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkServicePermissionAndAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
serviceId: string,
|
||||
permissions: Permissions,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await checkPermission(ctx, permissions);
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedServices.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this service",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkServiceAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
serviceId: string,
|
||||
action: "create" | "read" | "delete" = "read",
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { service: [action] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (action === "create") {
|
||||
if (!memberRecord.accessedProjects.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!memberRecord.accessedServices.includes(serviceId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this service",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentAccess = async (
|
||||
ctx: PermissionCtx,
|
||||
environmentId: string,
|
||||
action: "read" | "create" | "delete" = "read",
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: [action] });
|
||||
|
||||
if (
|
||||
action !== "create" &&
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin"
|
||||
) {
|
||||
if (!memberRecord.accessedEnvironments.includes(environmentId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this environment",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentCreationPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
projectId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: ["create"] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEnvironmentDeletionPermission = async (
|
||||
ctx: PermissionCtx,
|
||||
projectId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
|
||||
await checkPermission(ctx, { environment: ["delete"] });
|
||||
|
||||
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
|
||||
if (!memberRecord.accessedProjects.includes(projectId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const addNewProject = async (ctx: PermissionCtx, projectId: string) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedProjects: [...memberRecord.accessedProjects, projectId],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const addNewEnvironment = async (
|
||||
ctx: PermissionCtx,
|
||||
environmentId: string,
|
||||
) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedEnvironments: [
|
||||
...memberRecord.accessedEnvironments,
|
||||
environmentId,
|
||||
],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const addNewService = async (ctx: PermissionCtx, serviceId: string) => {
|
||||
const userId = ctx.user.id;
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
const memberRecord = await findMemberByUserId(userId, organizationId);
|
||||
await db
|
||||
.update(member)
|
||||
.set({
|
||||
accessedServices: [...memberRecord.accessedServices, serviceId],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.id, memberRecord.id),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const findMemberByUserId = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const result = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, userId),
|
||||
eq(member.organizationId, organizationId),
|
||||
),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { auditLog } from "@dokploy/server/db/schema";
|
||||
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";
|
||||
|
||||
export type { AuditAction, AuditResourceType };
|
||||
|
||||
export interface CreateAuditLogInput {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userRole: string;
|
||||
action: AuditAction;
|
||||
resourceType: AuditResourceType;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an audit log entry. Fire-and-forget safe — errors are swallowed
|
||||
* so a logging failure never breaks the main operation.
|
||||
*/
|
||||
export const createAuditLog = async (input: CreateAuditLogInput) => {
|
||||
try {
|
||||
const licensed = await hasValidLicense(input.organizationId);
|
||||
if (!licensed) return;
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
userEmail: input.userEmail,
|
||||
userRole: input.userRole,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
resourceName: input.resourceName,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[audit-log] Failed to create audit log entry:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export interface GetAuditLogsInput {
|
||||
organizationId: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
resourceName?: string;
|
||||
action?: AuditAction;
|
||||
resourceType?: AuditResourceType;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const getAuditLogs = async (input: GetAuditLogsInput) => {
|
||||
const {
|
||||
organizationId,
|
||||
userId,
|
||||
userEmail,
|
||||
resourceName,
|
||||
action,
|
||||
resourceType,
|
||||
from,
|
||||
to,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = input;
|
||||
|
||||
const conditions = [eq(auditLog.organizationId, organizationId)];
|
||||
|
||||
if (userId) conditions.push(eq(auditLog.userId, userId));
|
||||
if (userEmail) conditions.push(ilike(auditLog.userEmail, `%${userEmail}%`));
|
||||
if (resourceName)
|
||||
conditions.push(ilike(auditLog.resourceName, `%${resourceName}%`));
|
||||
if (action) conditions.push(eq(auditLog.action, action));
|
||||
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType));
|
||||
if (from) conditions.push(gte(auditLog.createdAt, from));
|
||||
if (to) conditions.push(lte(auditLog.createdAt, to));
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
db.query.auditLog.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [desc(auditLog.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db.$count(auditLog, and(...conditions)),
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
};
|
||||
@@ -432,7 +432,7 @@ export const createApiKey = async (
|
||||
refillInterval?: number;
|
||||
},
|
||||
) => {
|
||||
const apiKey = await auth.createApiKey({
|
||||
const result = await auth.createApiKey({
|
||||
body: {
|
||||
name: input.name,
|
||||
expiresIn: input.expiresIn,
|
||||
@@ -450,10 +450,9 @@ export const createApiKey = async (
|
||||
if (input.metadata) {
|
||||
await db
|
||||
.update(apikey)
|
||||
.set({
|
||||
metadata: JSON.stringify(input.metadata),
|
||||
})
|
||||
.where(eq(apikey.id, apiKey.id));
|
||||
.set({ metadata: JSON.stringify(input.metadata) })
|
||||
.where(eq(apikey.id, result.id));
|
||||
}
|
||||
return apiKey;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
await execAsync(
|
||||
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
);
|
||||
|
||||
writeStream.write("Copied filesystem to temp directory\n");
|
||||
|
||||
@@ -182,7 +182,11 @@ export const mechanizeDockerContainer = async (
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
await docker.createService(settings);
|
||||
if (authConfig) {
|
||||
await docker.createService(authConfig, settings);
|
||||
} else {
|
||||
await docker.createService(settings);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { db } from "../../db/index";
|
||||
import { user as userSchema } from "../../db/schema/user";
|
||||
|
||||
export const LICENSE_KEY_URL =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:4002"
|
||||
: "https://licenses-api.dokploy.com";
|
||||
// process.env.NODE_ENV === "development"
|
||||
// ? "http://localhost:4002"
|
||||
"https://licenses-api.dokploy.com";
|
||||
|
||||
export const initEnterpriseBackupCronJobs = async () => {
|
||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||
|
||||
@@ -741,3 +741,177 @@ export const getComposeContainer = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
type ServiceHealthStatus = {
|
||||
status: "healthy" | "unhealthy";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const checkSwarmServiceRunning = async (
|
||||
serviceName: string,
|
||||
): Promise<ServiceHealthStatus> => {
|
||||
try {
|
||||
const service = docker.getService(serviceName);
|
||||
const info = await service.inspect();
|
||||
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
|
||||
if (replicas === 0) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: "Service has 0 replicas configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Check that at least one task is actually running
|
||||
const tasks = await docker.listTasks({
|
||||
filters: JSON.stringify({
|
||||
service: [serviceName],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
|
||||
const runningTask = tasks.find((t) => t.Status?.State === "running");
|
||||
|
||||
if (!runningTask) {
|
||||
const latestTask = tasks[0];
|
||||
const taskState = latestTask?.Status?.State ?? "unknown";
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `No running tasks (current state: ${taskState})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: error instanceof Error ? error.message : "Service not found",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSwarmServiceContainerId = async (
|
||||
serviceName: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const tasks = await docker.listTasks({
|
||||
filters: JSON.stringify({
|
||||
service: [serviceName],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
|
||||
const runningTask = tasks.find((t) => t.Status?.State === "running");
|
||||
|
||||
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
|
||||
if (serviceCheck.status === "unhealthy") {
|
||||
return serviceCheck;
|
||||
}
|
||||
|
||||
// Verify PostgreSQL actually accepts connections
|
||||
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
|
||||
if (!containerId) {
|
||||
return { status: "unhealthy", message: "Could not find running container" };
|
||||
}
|
||||
|
||||
try {
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: ["pg_isready", "-U", "dokploy"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await exec.start({});
|
||||
|
||||
const output = await new Promise<string>((resolve) => {
|
||||
let data = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(data));
|
||||
});
|
||||
|
||||
const inspectResult = await exec.inspect();
|
||||
if (inspectResult.ExitCode !== 0) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `PostgreSQL not ready: ${output.trim()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to check PostgreSQL",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
|
||||
if (serviceCheck.status === "unhealthy") {
|
||||
return serviceCheck;
|
||||
}
|
||||
|
||||
// Verify Redis actually responds to PING
|
||||
const containerId = await getSwarmServiceContainerId("dokploy-redis");
|
||||
if (!containerId) {
|
||||
return { status: "unhealthy", message: "Could not find running container" };
|
||||
}
|
||||
|
||||
try {
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: ["redis-cli", "ping"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await exec.start({});
|
||||
|
||||
const output = await new Promise<string>((resolve) => {
|
||||
let data = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(data));
|
||||
});
|
||||
|
||||
if (!output.includes("PONG")) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `Redis did not respond with PONG: ${output.trim()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: error instanceof Error ? error.message : "Failed to check Redis",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
// Traefik can run as a standalone container or a swarm service
|
||||
try {
|
||||
const container = docker.getContainer("dokploy-traefik");
|
||||
const info = await container.inspect();
|
||||
if (!info.State.Running) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: "Container is not running",
|
||||
};
|
||||
}
|
||||
return { status: "healthy" };
|
||||
} catch {
|
||||
// Not a standalone container, check as swarm service
|
||||
return checkSwarmServiceRunning("dokploy-traefik");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export const sendDatabaseBackupNotifications = async ({
|
||||
? [
|
||||
{
|
||||
name: decorate("`⚠️`", "Error Message"),
|
||||
value: `\`\`\`${errorMessage}\`\`\``,
|
||||
value: `\`\`\`${errorMessage.length > 1010 ? `${errorMessage.substring(0, 1010)}...` : errorMessage}\`\`\``,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -161,7 +161,7 @@ export const sendVolumeBackupNotifications = async ({
|
||||
? [
|
||||
{
|
||||
name: decorate("`⚠️`", "Error Message"),
|
||||
value: `\`\`\`${errorMessage}\`\`\``,
|
||||
value: `\`\`\`${errorMessage.substring(0, 1010)}\`\`\``,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -39,7 +39,7 @@ export const backupVolume = async (
|
||||
|
||||
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
|
||||
|
||||
const baseCommand = `
|
||||
const backupCommand = `
|
||||
set -e
|
||||
echo "Volume name: ${volumeName}"
|
||||
echo "Backup file name: ${backupFileName}"
|
||||
@@ -52,6 +52,9 @@ export const backupVolume = async (
|
||||
ubuntu \
|
||||
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
|
||||
echo "Volume backup done ✅"
|
||||
`;
|
||||
|
||||
const uploadCommand = `
|
||||
echo "Starting upload to S3..."
|
||||
${rcloneCommand}
|
||||
echo "Upload to S3 done ✅"
|
||||
@@ -61,7 +64,10 @@ export const backupVolume = async (
|
||||
`;
|
||||
|
||||
if (!turnOff) {
|
||||
return baseCommand;
|
||||
return `
|
||||
${backupCommand}
|
||||
${uploadCommand}
|
||||
`;
|
||||
}
|
||||
|
||||
const serviceLockId =
|
||||
@@ -110,9 +116,10 @@ export const backupVolume = async (
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service update --replicas=0 ${volumeBackup.application?.appName}
|
||||
${baseCommand}
|
||||
${backupCommand}
|
||||
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
if (serviceType === "compose") {
|
||||
@@ -147,8 +154,9 @@ export const backupVolume = async (
|
||||
}
|
||||
return lockWrapper(`
|
||||
${stopCommand}
|
||||
${baseCommand}
|
||||
${backupCommand}
|
||||
${startCommand}
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
} from "../utils/notifications/utils";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
@@ -26,26 +23,3 @@ export const sendEmail = async ({
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendDiscordNotificationWelcome = async (email: string) => {
|
||||
await sendDiscordNotification(
|
||||
{
|
||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
|
||||
},
|
||||
{
|
||||
title: "New User Registered",
|
||||
color: 0x00ff00,
|
||||
fields: [
|
||||
{
|
||||
name: "Email",
|
||||
value: email,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
timestamp: new Date(),
|
||||
footer: {
|
||||
text: "Dokploy User Registration Notification",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user