Compare commits

...

25 Commits

Author SHA1 Message Date
Yash Kumar bfe24f89f9 Update undici version to 6.28.1 2026-10-15 01:07:55 +05:30
Yash Kumar 502d6c8a18 Merge branch 'canary' into dependabots-alerts 2026-10-15 00:44:53 +05:30
Yash Kumar 9e87b9e401 Merge pull request #5613 from Dokploy/fix/member-server-list
fix(server): let members list servers in server pickers
2026-10-15 00:30:43 +05:30
Yash Kumar a7d8dff360 Merge pull request #5624 from Dokploy/feat/logs-and-metrics
feat(logs-and-metrics): ship container logs and metrics to external providers via Vector
2026-10-15 00:08:48 +05:30
Narciso E. Núñez Arias 931f5c1335 Merge pull request #5654 from olivier-drieux/fix/restore-backup-service-name
fix(backups): preserve service name when changing database type
2026-10-12 10:46:09 -04:00
Narciso E. Núñez Arias aae2dd9377 Merge pull request #5656 from Totopo27/fix/gh-5600-service-card-description-overflow
fix(ui): wrap and clamp service card description on environment page
2026-10-12 10:38:37 -04:00
Narciso E. Núñez Arias fe3912dc4b Merge pull request #5657 from Totopo27/feat/gh-5546-clickable-projects-card-home
feat(ui): make projects card clickable on home dashboard
2026-10-12 10:37:53 -04:00
Narciso E. Núñez Arias ccd26e946b Merge pull request #5655 from ahmedisam99/fix/gitlab-token-refresh-race
fix(gitlab): share one token refresh between concurrent callers
2026-10-12 10:08:10 -04:00
Narciso E. Núñez Arias 29107d88fc Merge pull request #5649 from imrja8/feat/dual-mode-branding
feat(enterprise): dual-mode branding with separate light/dark theme logos
2026-10-12 09:59:25 -04:00
Narciso E. Núñez Arias c7ee0430f7 Merge pull request #5614 from tomron/feat/ovh-optional-zone
feat(dns): add optional zone to OVH provider
2026-10-12 09:38:51 -04:00
Totopo27 3cc8158fdc feat(ui): make projects card clickable on home dashboard 2026-10-12 07:35:35 -06:00
Totopo27 726664ab3c fix(ui): wrap and clamp service card description on environment page 2026-10-12 07:30:51 -06:00
ahmedisam99 20701ba272 test(gitlab): cover concurrent token refreshes 2026-10-12 11:54:35 +03:00
ahmedisam99 412516d47b fix(gitlab): share one token refresh between concurrent callers
GitLab invalidates a refresh token when it is used. Two deployments that found the token expired at the same time sent the same refresh token, and the second one failed with "Failed to refresh token: Bad Request".
2026-10-12 11:54:35 +03:00
Olivier Drieux b4681b318f fix(backups): preserve service selection when changing database type 2026-10-12 09:55:31 +02:00
Yash Kumar 0898642e32 fix(enterprise): preserve light-mode URLs when toggling dual branding off 2026-10-11 20:00:32 +05:30
Yash Kumar d2779136c5 fix(enterprise): address review feedback for dual-mode branding 2026-10-11 19:51:21 +05:30
Yash Kumar 0b537a815d feat(enterprise): dual-mode branding with separate light/dark theme logos 2026-10-11 19:10:06 +05:30
Tom Ron 2674eb200d fix(dns): enforce the configured OVH zone on record operations
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 09:13:50 +03:00
Tom Ron e8f236c9db feat(dns): add optional zone to OVH provider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 22:02:45 +03:00
Narciso 749bb2c1ce fix(server): let members list servers in server pickers 2026-10-07 14:51:15 -04:00
Narciso 9992659f24 fix(deps): bump nodemailer and patch transitive high advisories 2026-10-07 09:49:40 -04:00
Narciso 88cb057e06 Merge remote-tracking branch 'origin/canary' into dependabots-alerts 2026-10-07 08:49:24 -04:00
Narciso 7289a3f68e fix(deps): bump next, shell-quote and proxy-addr for critical advisories 2026-10-07 08:49:23 -04:00
Narciso Nunez 5e0b021ec2 fix(deps): resolve open Dependabot alerts 2026-09-23 17:40:39 -04:00
35 changed files with 2550 additions and 1886 deletions
+3 -3
View File
@@ -10,12 +10,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"inngest": "3.40.1",
"inngest": "3.54.0",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/node-server": "^1.19.15",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"hono": "^4.13.5",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"react": "19.2.7",
+61
View File
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
it("returns only the configured zone without listing every zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
expect(apiCalls()).toHaveLength(0);
});
});
describe("ovhClient.listRecords", () => {
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
"Invalid signature",
);
});
it("checks the configured zone instead of listing all zones", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess({ name: "example.com" }));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
const calls = apiCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const store = vi.hoisted(() => ({
provider: {} as Record<string, unknown>,
}));
vi.mock("@dokploy/server/services/gitlab", () => ({
findGitlabById: vi.fn(async () => ({ ...store.provider })),
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
store.provider = { ...store.provider, ...values };
}),
}));
const { refreshGitlabToken } = await import(
"@dokploy/server/utils/providers/gitlab"
);
const GITLAB_ID = "gitlab-1";
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
const createGitlab = () => {
let validRefreshToken = "refresh-1";
let issued = 1;
let failNext = false;
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
const body = new URLSearchParams(init.body as string);
// Keeps the request open, so concurrent callers overlap as two deployments do
await new Promise((resolve) => setTimeout(resolve, 20));
if (failNext) {
failNext = false;
return new Response(null, {
status: 500,
statusText: "Internal Server Error",
});
}
if (body.get("refresh_token") !== validRefreshToken) {
return new Response(null, { status: 400, statusText: "Bad Request" });
}
issued++;
validRefreshToken = `refresh-${issued}`;
return Response.json({
access_token: `access-${issued}`,
refresh_token: validRefreshToken,
expires_in: 7200,
});
});
return {
fetch,
failNextRequest: () => {
failNext = true;
},
};
};
describe("refreshGitlabToken", () => {
let gitlab: ReturnType<typeof createGitlab>;
beforeEach(() => {
store.provider = {
gitlabId: GITLAB_ID,
gitlabUrl: "https://gitlab.example.com",
gitlabInternalUrl: null,
applicationId: "app-id",
secret: "app-secret",
accessToken: "access-1",
refreshToken: "refresh-1",
expiresAt: 0,
};
gitlab = createGitlab();
vi.stubGlobal("fetch", gitlab.fetch);
});
it("sends one refresh request when two callers find the token expired at the same time", async () => {
await Promise.all([
refreshGitlabToken(GITLAB_ID),
refreshGitlabToken(GITLAB_ID),
]);
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
expect(store.provider.refreshToken).toBe("refresh-2");
});
it("refreshes again after a failed refresh", async () => {
gitlab.failNextRequest();
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
"Failed to refresh token: Internal Server Error",
);
await refreshGitlabToken(GITLAB_ID);
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
expect(store.provider.refreshToken).toBe("refresh-2");
});
});
@@ -64,6 +64,10 @@ const baseSettings: WebServerSettings = {
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
value={field.value}
onValueChange={(value: DatabaseType) => {
field.onChange(value);
form.setValue("metadata", {});
form.setValue("metadata", {
serviceName:
form.getValues("metadata.serviceName") ?? "",
});
}}
>
<SelectTrigger>
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
@@ -45,13 +45,19 @@ function StatCard({
label,
value,
delta,
href,
}: {
label: string;
value: string;
delta?: string;
href?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
const content = (
<div
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
}`}
>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
@@ -63,6 +69,16 @@ function StatCard({
</div>
</div>
);
if (href) {
return (
<Link href={href} className="block h-full">
{content}
</Link>
);
}
return content;
}
function StatusListCard({
@@ -182,6 +198,7 @@ export const ShowHome = () => {
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
href="/dashboard/projects"
/>
<StatCard
label="Services"
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
<Logo
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
/>
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
applicationKey: z.string(),
applicationSecret: z.string(),
consumerKey: z.string(),
zone: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
applicationKey: "",
applicationSecret: "",
consumerKey: "",
zone: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
applicationKey: data.applicationKey,
applicationSecret: data.applicationSecret,
consumerKey: data.consumerKey,
zone: data.zone.trim() || undefined,
};
}
};
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
applicationKey: provider.config.applicationKey,
applicationSecret: provider.config.applicationSecret,
consumerKey: provider.config.consumerKey,
zone: provider.config.zone ?? "",
}),
});
} else if (!dnsProviderId) {
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="zone"
render={({ field }) => (
<FormItem>
<FormLabel>Zone (optional)</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
Restrict this provider to a single DNS zone: Dokploy
will only list and change records in it. Leave empty to
use every zone the credentials can access. When set, the{" "}
<code>GET /domain/zone</code> right is not needed. This
is enforced by Dokploy only, so also limit the OVH token
to that zone if you need a hard guarantee.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
const logoUrl =
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
const logoLightUrl =
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
href="/"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" logoUrl={logoUrl} />
<Logo
className="size-10"
logoUrl={logoUrl}
logoLightUrl={logoLightUrl}
/>
{appName}
</Link>
<div className="relative z-20 mt-auto">
@@ -1,5 +1,6 @@
"use client";
import { useTheme } from "next-themes";
import {
Card,
CardContent,
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
config: {
appName?: string;
logoUrl?: string;
logoLightUrl?: string;
dualModeBranding?: boolean;
footerText?: string;
};
}
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
const appName = config.appName || "Dokploy";
const { resolvedTheme } = useTheme();
const effectiveLogoUrl =
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
? config.logoLightUrl
: config.logoUrl;
return (
<Card className="bg-transparent">
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
<div className="rounded-lg border overflow-hidden">
{/* Simulated sidebar header */}
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
{config.logoUrl ? (
{effectiveLogoUrl ? (
<img
src={config.logoUrl}
src={effectiveLogoUrl}
alt="Preview Logo"
className="size-8 rounded-sm object-contain"
/>
@@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { WhitelabelingPreview } from "./whitelabeling-preview";
@@ -36,20 +37,42 @@ const safeUrlField = z
message: "Only http:// and https:// URLs are allowed",
});
const formSchema = z.object({
appName: z.string(),
appDescription: z.string(),
logoUrl: safeUrlField,
faviconUrl: safeUrlField,
customCss: z.string(),
loginLogoUrl: safeUrlField,
supportUrl: safeUrlField,
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
footerText: z.string(),
ogImageUrl: safeUrlField,
});
const formSchema = z
.object({
appName: z.string(),
appDescription: z.string(),
logoUrl: safeUrlField,
faviconUrl: safeUrlField,
customCss: z.string(),
loginLogoUrl: safeUrlField,
supportUrl: safeUrlField,
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
footerText: z.string(),
ogImageUrl: safeUrlField,
logoLightUrl: z.string(),
loginLogoLightUrl: z.string(),
faviconLightUrl: z.string(),
dualModeBranding: z.boolean(),
})
.superRefine((data, ctx) => {
if (!data.dualModeBranding) return;
for (const field of [
"logoLightUrl",
"loginLogoLightUrl",
"faviconLightUrl",
] as const) {
const val = data[field];
if (val !== "" && !/^https?:\/\//i.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Only http:// and https:// URLs are allowed",
path: [field],
});
}
}
});
type FormSchema = z.infer<typeof formSchema>;
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
errorPageDescription: "",
footerText: "",
ogImageUrl: "",
logoLightUrl: "",
loginLogoLightUrl: "",
faviconLightUrl: "",
dualModeBranding: false,
},
resolver: zodResolver(formSchema),
});
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
errorPageDescription: data.errorPageDescription ?? "",
footerText: data.footerText ?? "",
ogImageUrl: data.ogImageUrl ?? "",
logoLightUrl: data.logoLightUrl ?? "",
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
faviconLightUrl: data.faviconLightUrl ?? "",
dualModeBranding: data.dualModeBranding ?? false,
});
}
}, [data, form]);
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
errorPageDescription: values.errorPageDescription || null,
footerText: values.footerText || null,
ogImageUrl: values.ogImageUrl || null,
logoLightUrl: values.logoLightUrl || null,
loginLogoLightUrl: values.loginLogoLightUrl || null,
faviconLightUrl: values.faviconLightUrl || null,
dualModeBranding: values.dualModeBranding,
},
})
.then(async () => {
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
</CardContent>
</Card>
{/* Light Mode Logos Section */}
<Card className="bg-transparent">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<CardTitle>Light Mode Logos</CardTitle>
<CardDescription>
Use separate logos for light and dark themes. When enabled,
the logos in Branding are used for dark mode.
</CardDescription>
</div>
<FormField
control={form.control}
name="dualModeBranding"
render={({ field }) => (
<FormItem>
<FormControl>
<Switch
aria-label="Enable light mode logos"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.clearErrors([
"logoLightUrl",
"loginLogoLightUrl",
"faviconLightUrl",
]);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardHeader>
<div
className="grid transition-all duration-300 ease-in-out"
style={{
gridTemplateRows: form.watch("dualModeBranding")
? "1fr"
: "0fr",
}}
>
<div className="overflow-hidden">
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="logoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="loginLogoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login Page Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon-light.ico"
{...field}
/>
</FormControl>
<FormDescription>
Follows the browser/OS theme, not the website theme.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</div>
</div>
</Card>
{/* Appearance Section */}
<Card className="bg-transparent">
<CardHeader>
+13 -4
View File
@@ -1,16 +1,25 @@
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface Props {
className?: string;
logoUrl?: string;
logoLightUrl?: string;
}
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
export const Logo = ({
className = "size-14",
logoUrl,
logoLightUrl,
}: Props) => {
const { resolvedTheme } = useTheme();
const effectiveUrl =
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
if (effectiveUrl) {
return (
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
<img
src={logoUrl}
src={effectiveUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>
+14 -17
View File
@@ -67,7 +67,7 @@
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.18",
"@faker-js/faker": "^8.4.1",
"@faker-js/faker": "^10.5.0",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
@@ -87,7 +87,7 @@
"@xterm/addon-attach": "0.10.0",
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"adm-zip": "^0.6.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
@@ -100,24 +100,24 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dompurify": "^3.4.13",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "16.3.4",
"nanoid": "3.3.18",
"next": "16.3.8",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"nodemailer": "10.0.15",
"octokit": "3.1.2",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -135,7 +135,7 @@
"resend": "^6.0.2",
"semver": "7.7.3",
"shadcn": "^4.11.0",
"shell-quote": "^1.8.4",
"shell-quote": "^1.11.0",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
@@ -144,19 +144,18 @@
"swagger-ui-react": "^5.32.6",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
"toml": "4.2.0",
"tw-animate-css": "^1.4.0",
"undici": "^6.21.3",
"undici": "^6.28.0",
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"ws": "8.21.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"yaml": "2.8.3",
"zod": "^4.3.6",
"zod-form-data": "^3.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dompurify": "^3.2.0",
"@types/js-cookie": "^3.0.6",
@@ -164,17 +163,15 @@
"@types/micromatch": "4.0.9",
"@types/node": "^24.4.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@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",
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"lint-staged": "^15.5.2",
"memfs": "^4.17.2",
"simple-icons": "^16.14.0",
+23 -1
View File
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
baseUrl: string;
}
@@ -22,6 +23,7 @@ export default function Document({
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
}: WhitelabelingDocumentProps) {
@@ -44,7 +46,22 @@ export default function Document({
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<link rel="icon" href={faviconHref || "/icon.svg"} />
{faviconLightHref ? (
<>
<link
rel="icon"
href={faviconHref || "/icon.svg"}
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
href={faviconLightHref}
media="(prefers-color-scheme: light)"
/>
</>
) : (
<link rel="icon" href={faviconHref || "/icon.svg"} />
)}
{customCss && (
<style
id="whitelabeling-styles"
@@ -69,6 +86,7 @@ declare global {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
};
expiresAt: number;
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
let appDescription: string | null = null;
let ogImageUrl: string | null = null;
let faviconHref: string | null = null;
let faviconLightHref: string | null = null;
let customCss: string | null = null;
const host = ctx.req?.headers?.host || "localhost:3000";
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
faviconLightHref = config.faviconLightUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
};
+2 -1
View File
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const logoUrl = whitelabeling?.logoUrl || undefined;
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
const errorTitle = whitelabeling?.errorPageTitle;
const errorDescription = whitelabeling?.errorPageDescription;
@@ -22,7 +23,7 @@ export default function ErrorPage({ statusCode }: Props) {
<header className="mb-auto flex justify-center z-50 w-full py-4">
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo logoUrl={logoUrl} />
<Logo logoUrl={logoUrl} logoLightUrl={logoLightUrl} />
<span className="font-medium text-sm">{appName}</span>
</Link>
</nav>
@@ -1663,19 +1663,19 @@ const EnvironmentPage = (
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
<div className="flex flex-row items-center gap-2 justify-between w-full min-w-0">
<div className="flex flex-col gap-2 min-w-0 flex-1">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap break-words">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground wrap-anywhere line-clamp-3">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
<span className="text-sm font-medium text-muted-foreground self-start shrink-0">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
+6 -1
View File
@@ -5,7 +5,6 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const LoginSchema = z.object({
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
Sign in
</div>
+6 -1
View File
@@ -1,6 +1,5 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -164,6 +164,11 @@ const Invitation = ({
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Invitation
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
{isCloud ? "Sign Up" : "Setup the server"}
+5
View File
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Reset Password
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -24,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -92,6 +92,11 @@ export default function Home() {
logoUrl={
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
<span className="font-medium text-sm">
{whitelabeling?.appName || "Dokploy"}
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
});
+30 -5
View File
@@ -20,7 +20,10 @@ import {
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { findMemberByUserId } from "@dokploy/server/services/permission";
import {
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
@@ -52,6 +55,22 @@ import {
} from "@/server/db/schema";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
// Users without server.read only get what the server pickers render
const toServerOptions = async (
ctx: Parameters<typeof hasPermission>[0],
servers: (typeof server.$inferSelect)[],
) => {
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
if (await hasPermission(ctx, { server: ["read"] })) {
return servers;
}
return servers.map(({ serverId, name, ipAddress }) => ({
serverId,
name,
ipAddress,
}));
};
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
.input(apiCreateServer)
@@ -217,7 +236,7 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0;
}),
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -235,9 +254,12 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "deploy"),
),
});
return result.filter((s) => accessibleIds.has(s.serverId));
return toServerOptions(
ctx,
result.filter((s) => accessibleIds.has(s.serverId)),
);
}),
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
buildServers: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -255,7 +277,10 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "build"),
),
});
return result.filter((s) => accessibleIds.has(s.serverId));
return toServerOptions(
ctx,
result.filter((s) => accessibleIds.has(s.serverId)),
);
}),
setup: withPermission("server", "create")
.input(apiFindOneServer)
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20
require (
github.com/gofiber/fiber/v2 v2.52.11
github.com/gofiber/fiber/v2 v2.52.14
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
+2 -2
View File
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+3 -3
View File
@@ -9,12 +9,12 @@
},
"dependencies": {
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/node-server": "^1.19.15",
"@hono/zod-validator": "0.7.6",
"bullmq": "5.67.3",
"dotenv": "^16.4.5",
"drizzle-orm": "0.45.1",
"hono": "^4.11.7",
"drizzle-orm": "0.45.2",
"hono": "^4.13.5",
"ioredis": "5.4.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
+23 -3
View File
@@ -26,7 +26,7 @@
"@biomejs/biome": "2.5.7",
"@types/node": "^24.4.0",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"lint-staged": "^15.5.2",
"tsx": "4.22.4"
},
@@ -46,11 +46,31 @@
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1",
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
"protobufjs": ">=7.5.5 <8.0.0",
"js-yaml": ">=4.3.2 <5.0.0",
"mysql2": ">=3.23.1 <4.0.0",
"deepmerge-ts": ">=8.0.0 <9.0.0",
"@ai-sdk/provider-utils": ">=4.0.33 <5.0.0",
"hono": ">=4.13.5 <5.0.0",
"@hono/node-server": ">=1.19.15 <2.0.0",
"lodash": "4.18.1",
"postcss": ">=8.5.23 <9.0.0",
"effect": ">=3.20.0 <4.0.0",
"h3": ">=1.15.9 <2.0.0",
"tar-fs": ">=2.1.4 <3.0.0",
"uuid": ">=11.1.1 <12.0.0",
"valibot": ">=1.4.2 <2.0.0",
"proxy-addr": ">=2.0.8 <3.0.0",
"axios": ">=1.20.0 <2.0.0",
"source-map-js": ">=1.2.2 <2.0.0",
"ip-address": ">=10.7.1 <11.0.0",
"sharp": ">=0.35.5 <0.36.0",
"@modelcontextprotocol/sdk": ">=1.31.0 <2.0.0",
"fast-copy": ">=3.1.0 <4.0.0"
},
"peerDependencyRules": {
"ignoreMissing": [
+10 -13
View File
@@ -47,14 +47,14 @@
"@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@better-auth/utils": "0.4.2",
"@faker-js/faker": "^8.4.1",
"@faker-js/faker": "^10.5.0",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^1.0.12",
"@trpc/server": "11.10.0",
"adm-zip": "^0.5.16",
"adm-zip": "^0.6.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
@@ -67,13 +67,13 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"lodash": "4.18.1",
"micromatch": "4.0.8",
"nanoid": "3.3.18",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"nodemailer": "10.0.15",
"octokit": "3.1.2",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -84,35 +84,32 @@
"react-dom": "19.2.7",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.4",
"shell-quote": "^1.11.0",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"toml": "3.0.0",
"toml": "4.2.0",
"undici": "^6.21.3",
"ws": "8.16.0",
"yaml": "2.8.1",
"ws": "8.21.0",
"yaml": "2.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^24.4.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@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",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",
"postcss": "^8.5.23",
"rimraf": "6.1.3",
"tailwindcss": "^3.4.17",
"tsc-alias": "1.8.10",
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
applicationKey: z.string().trim().min(1),
applicationSecret: z.string().trim().min(1),
consumerKey: z.string().trim().min(1),
zone: z.string().trim().min(1).optional(),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
@@ -97,6 +97,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: string | null;
footerText: string | null;
ogImageUrl: string | null;
logoLightUrl: string | null;
loginLogoLightUrl: string | null;
faviconLightUrl: string | null;
dualModeBranding: boolean;
}>()
.default({
appName: null,
@@ -111,6 +115,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: null,
footerText: null,
ogImageUrl: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
@@ -240,6 +248,10 @@ export const whitelabelingConfigSchema = z.object({
errorPageDescription: z.string().nullable(),
footerText: z.string().nullable(),
ogImageUrl: safeUrl,
logoLightUrl: safeUrl,
loginLogoLightUrl: safeUrl,
faviconLightUrl: safeUrl,
dualModeBranding: z.boolean(),
});
export const apiUpdateWhitelabeling = z.object({
@@ -14,6 +14,10 @@ export interface PublicWhitelabelingConfig {
errorPageTitle: string | null;
errorPageDescription: string | null;
footerText: string | null;
logoLightUrl: string | null;
loginLogoLightUrl: string | null;
faviconLightUrl: string | null;
dualModeBranding: boolean;
}
// Self-hosted is single-tenant; gate on the oldest organization's license,
@@ -54,5 +58,15 @@ export const getPublicWhitelabelingConfig =
errorPageTitle: config.errorPageTitle,
errorPageDescription: config.errorPageDescription,
footerText: config.footerText,
dualModeBranding: config.dualModeBranding ?? false,
logoLightUrl: config.dualModeBranding
? (config.logoLightUrl ?? null)
: null,
loginLogoLightUrl: config.dualModeBranding
? (config.loginLogoLightUrl ?? null)
: null,
faviconLightUrl: config.dualModeBranding
? (config.faviconLightUrl ?? null)
: null,
};
};
+1 -1
View File
@@ -163,7 +163,7 @@ export function processValue(
}
if (varName === "username") {
return faker.internet.userName().toLowerCase();
return faker.internet.username().toLowerCase();
}
if (varName === "email") {
+19
View File
@@ -252,13 +252,25 @@ const listZoneNames = async (config: OvhConfig) => {
}
};
const assertZoneAllowed = (config: OvhConfig, zoneId: string) => {
if (config.zone && zoneId !== config.zone) {
throw new Error(
`OVH: this provider is restricted to the zone "${config.zone}", not "${zoneId}"`,
);
}
};
export const ovhClient: DnsClient<OvhConfig> = {
async listZones(config) {
if (config.zone) {
return [{ id: config.zone, name: config.zone }];
}
const zones = await listZoneNames(config);
return zones.map((zone) => ({ id: zone, name: zone }));
},
async listRecords(config, zoneId) {
assertZoneAllowed(config, zoneId);
const zone = encodeURIComponent(zoneId);
// The listing endpoint only returns ids, so each record is fetched on its own.
const ids = await ovhFetch<number[]>(config, `/domain/zone/${zone}/record`);
@@ -276,6 +288,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async upsertRecord(config, record) {
assertZoneAllowed(config, record.zoneId);
const zone = encodeURIComponent(record.zoneId);
const subDomain = toSubDomain(record.name, record.zoneId);
const existing = await ovhFetch<number[]>(
@@ -319,6 +332,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async updateRecord(config, zoneId, recordId, record) {
assertZoneAllowed(config, zoneId);
const zone = encodeURIComponent(zoneId);
const existing = await ovhFetch<OvhRecord>(
config,
@@ -363,6 +377,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async deleteRecord(config, zoneId, recordId) {
assertZoneAllowed(config, zoneId);
await ovhFetch(
config,
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,
@@ -372,6 +387,10 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async testConnection(config) {
if (config.zone) {
await ovhFetch(config, `/domain/zone/${encodeURIComponent(config.zone)}`);
return;
}
await listZoneNames(config);
},
};
+18 -1
View File
@@ -11,7 +11,7 @@ import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
const currentTime = Math.floor(Date.now() / 1000);
@@ -56,6 +56,23 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => {
return data;
};
// GitLab invalidates a refresh token when it is used, so concurrent callers must share one refresh
const pendingRefreshes = new Map<
string,
ReturnType<typeof refreshGitlabTokenIfExpired>
>();
export const refreshGitlabToken = (gitlabProviderId: string) => {
let refresh = pendingRefreshes.get(gitlabProviderId);
if (!refresh) {
refresh = refreshGitlabTokenIfExpired(gitlabProviderId).finally(() => {
pendingRefreshes.delete(gitlabProviderId);
});
pendingRefreshes.set(gitlabProviderId, refresh);
}
return refresh;
};
export const haveGitlabRequirements = (gitlabProvider: Gitlab) => {
return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken);
};
+1936 -1801
View File
File diff suppressed because it is too large Load Diff