Merge branch 'canary' into feat/log-management

This commit is contained in:
Narciso
2026-10-07 08:49:17 -04:00
88 changed files with 2415 additions and 506 deletions
+3 -1
View File
@@ -35,6 +35,8 @@ import {
UpdateConfigSwarmSchema,
} from "./shared";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
encryptedText,
@@ -115,7 +117,7 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
const createSchema = createInsertSchema(libsql, {
libsqlId: z.string(),
name: z.string().min(1),
appName: z.string().min(1),
appName: z.string().min(1).max(63).regex(APP_NAME_REGEX, APP_NAME_MESSAGE),
createdAt: z.string(),
databaseUser: z.string().min(1),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
@@ -0,0 +1,87 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
userName: string;
code: string;
expiresInMinutes: number;
};
export const AccountDeletionCodeTemplate = ({
userName = "User",
code = "123456",
expiresInMinutes = 10,
}: TemplateProps) => {
const previewText = `${code} is your account deletion code`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={emailTailwindConfig}>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Confirm your account deletion
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, we received a request to permanently delete
your Dokploy account. Enter the code below to confirm it.
</Text>
<Section className="text-center mb-[24px]">
<Text className="inline-block bg-[#f4f4f5] rounded-lg text-[#09090b] text-[32px] font-semibold tracking-[8px] px-[24px] py-[16px] m-0">
{code}
</Text>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
This code expires in {expiresInMinutes} minutes. Once confirmed,
your subscriptions are cancelled and every organization you own
is deleted. This cannot be undone.
</Text>
</Section>
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
If you did not request this, someone else may have access to
your account. Change your password and contact{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy support
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default AccountDeletionCodeTemplate;
+1
View File
@@ -7,6 +7,7 @@ export * from "./db/validations/index";
export * from "./lib/auth";
export * from "./lib/logger";
export * from "./monitoring/utils";
export * from "./services/account-deletion";
export * from "./services/admin";
export * from "./services/application";
export * from "./services/backup";
+6
View File
@@ -126,6 +126,12 @@ const createBetterAuth = () =>
...(await resolveTrustedOrigins()),
].filter(Boolean);
if (ctx.path === "/admin/remove-user") {
throw new APIError("FORBIDDEN", {
message: "Use the account deletion flow to remove users",
});
}
const isBlockedAuthPath =
ctx.path.startsWith("/sign-in/email") ||
ctx.path.startsWith("/sign-in/social") ||
+15 -7
View File
@@ -59,6 +59,19 @@ export const recordAdvancedStats = async (
}
};
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const NON_PHYSICAL_DEVICE_PATTERNS = [
/^(loop|ram|zram)/,
/^(sr|fd)\d+$/,
// Partitions and md/dm devices repeat I/O already counted on the underlying disk.
/^(sd|hd|vd|xvd)[a-z]+\d+$/,
/^(nvme\d+n\d+|mmcblk\d+)p\d+$/,
/^(md|dm-)/,
];
export const isPhysicalDisk = (device: string) =>
!NON_PHYSICAL_DEVICE_PATTERNS.some((pattern) => pattern.test(device));
/**
* Get host system statistics using node-os-utils
* This is used when monitoring "dokploy" to show host stats instead of container stats
@@ -99,14 +112,9 @@ export const getHostSystemStats = async (): Promise<Container> => {
let blockWriteBytes = 0;
const diskStats = await osutils.disk.stats();
if (diskStats.success && diskStats.data.length > 0) {
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
const excludePatterns = [/^loop/, /^ram/, /^sr\d+$/, /^fd\d+$/];
for (const stat of diskStats.data) {
// Skip virtual devices
if (
stat.device &&
excludePatterns.some((pattern) => pattern.test(stat.device))
) {
// Skip virtual, partition and stacked devices
if (stat.device && !isPhysicalDisk(stat.device)) {
continue;
}
// readBytes and writeBytes are DataSize objects with .toBytes() method
@@ -0,0 +1,179 @@
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { db } from "@dokploy/server/db";
import {
auditLog,
backups,
invitation,
organization,
user,
verification,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq, inArray } from "drizzle-orm";
import { nanoid } from "nanoid";
export const ANONYMIZED_EMAIL = "deleted-user";
export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10;
const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60;
const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5;
const codeIdentifier = (userId: string) => `account-deletion:${userId}`;
const hashCode = (code: string) =>
createHash("sha256").update(code.trim()).digest("hex");
export const generateDeletionCode = () =>
randomInt(0, 1_000_000).toString().padStart(6, "0");
export const serializeDeletionCode = (code: string) =>
JSON.stringify({ hash: hashCode(code), attempts: 0 });
export const checkDeletionCode = (value: string, code: string) => {
const stored = JSON.parse(value) as { hash: string; attempts: number };
const expected = Buffer.from(stored.hash, "hex");
const actual = Buffer.from(hashCode(code), "hex");
if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
return { valid: true as const, attempts: stored.attempts };
}
const attempts = stored.attempts + 1;
return {
valid: false as const,
attempts,
nextValue: JSON.stringify({ ...stored, attempts }),
};
};
export const createAccountDeletionCode = async (userId: string) => {
const identifier = codeIdentifier(userId);
const existing = await db.query.verification.findFirst({
where: eq(verification.identifier, identifier),
});
if (
existing?.createdAt &&
Date.now() - existing.createdAt.getTime() <
ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000
) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`,
});
}
await db.delete(verification).where(eq(verification.identifier, identifier));
const code = generateDeletionCode();
const now = new Date();
await db.insert(verification).values({
id: nanoid(),
identifier,
value: serializeDeletionCode(code),
expiresAt: new Date(
now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000,
),
createdAt: now,
updatedAt: now,
});
return code;
};
export const consumeAccountDeletionCode = async (
userId: string,
code: string,
) => {
const row = await db.query.verification.findFirst({
where: eq(verification.identifier, codeIdentifier(userId)),
});
if (!row) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Request a confirmation code first",
});
}
if (row.expiresAt.getTime() < Date.now()) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code has expired, request a new one",
});
}
const result = checkDeletionCode(row.value, code);
if (!result.valid) {
if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) {
await db.delete(verification).where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "Too many incorrect attempts, request a new code",
});
}
await db
.update(verification)
.set({ value: result.nextValue, updatedAt: new Date() })
.where(eq(verification.id, row.id));
throw new TRPCError({
code: "BAD_REQUEST",
message: "The code is incorrect",
});
}
await db.delete(verification).where(eq(verification.id, row.id));
return { requestedAt: row.createdAt };
};
export const deleteUserAccountData = async (userId: string) => {
return db.transaction(async (tx) => {
const target = await tx.query.user.findFirst({
where: eq(user.id, userId),
columns: { id: true, email: true },
});
if (!target) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const ownedOrganizations = await tx.query.organization.findMany({
where: eq(organization.ownerId, userId),
columns: { id: true },
});
const organizationIds = ownedOrganizations.map((org) => org.id);
if (organizationIds.length > 0) {
await tx
.delete(auditLog)
.where(inArray(auditLog.organizationId, organizationIds));
}
// Actions performed in organizations owned by someone else stay in
// their audit trail, but without the deleted user's PII.
await tx
.update(auditLog)
.set({ userEmail: ANONYMIZED_EMAIL })
.where(eq(auditLog.userId, userId));
await tx.delete(invitation).where(eq(invitation.email, target.email));
// backups.userId has no ON DELETE clause, so a leftover reference would
// abort the user delete.
await tx
.update(backups)
.set({ userId: null })
.where(eq(backups.userId, userId));
await tx.delete(user).where(eq(user.id, userId));
return {
email: target.email,
organizationsDeleted: organizationIds.length,
};
});
};
+10
View File
@@ -566,3 +566,13 @@ export const createApiKey = async (
return result;
};
export const findCredentialAccount = async (userId: string) => {
return db.query.account.findFirst({
where: and(
eq(account.userId, userId),
eq(account.providerId, "credential"),
),
columns: { id: true, password: true },
});
};
@@ -65,6 +65,7 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"X-Pplx-Integration": "dokploy",
},
});
case "mistral":
+15 -3
View File
@@ -1,12 +1,24 @@
// A shell word as produced by shell-quote: bare, 'single' or "double" quoted.
const SHELL_WORD = String.raw`(?:[^\s'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+`;
const ACCESS_KEY_PATTERN = new RegExp(
`(--s3-access-key-id=)${SHELL_WORD}`,
"g",
);
const SECRET_KEY_PATTERN = new RegExp(
`(--s3-secret-access-key=)${SHELL_WORD}`,
"g",
);
/**
* 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"
* --s3-access-key-id=VALUE and --s3-secret-access-key=VALUE (shell-quoted)
*/
export const redactRcloneCredentials = (command: string): string => {
return command
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
.replace(ACCESS_KEY_PATTERN, '$1"[REDACTED]"')
.replace(SECRET_KEY_PATTERN, '$1"[REDACTED]"');
};
@@ -123,3 +123,48 @@ export async function submitToHubSpot(
return false;
}
}
export type HubSpotDeletionResult =
| "deleted"
| "not_found"
| "skipped"
| "failed";
export async function deleteHubSpotContactByEmail(
email: string,
accessToken = process.env.HUBSPOT_ACCESS_TOKEN,
): Promise<HubSpotDeletionResult> {
if (!accessToken) {
return "skipped";
}
try {
const response = await fetch(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ idProperty: "email", objectId: email }),
},
);
if (response.ok) {
return "deleted";
}
if (response.status === 404) {
return "not_found";
}
console.error(
"HubSpot GDPR delete error:",
response.status,
await response.text(),
);
return "failed";
} catch (error) {
console.error("Error deleting HubSpot contact:", error);
return "failed";
}
}
@@ -1,4 +1,5 @@
import { render } from "@react-email/components";
import AccountDeletionCodeTemplate from "../emails/emails/account-deletion-code";
import InvitationEmail from "../emails/emails/invitation";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
@@ -91,3 +92,28 @@ export const sendInvitationEmail = async ({
text: html,
});
};
export const sendAccountDeletionCodeEmail = async ({
email,
userName,
code,
expiresInMinutes,
}: {
email: string;
userName: string;
code: string;
expiresInMinutes: number;
}) => {
const html = await render(
AccountDeletionCodeTemplate({
userName: userName || "User",
code,
expiresInMinutes,
}),
);
await sendEmail({
email,
subject: `${code} is your account deletion code`,
text: html,
});
};