From 26d716b34f839315c95c9070da95c384ecaebf86 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 8 Jul 2026 23:59:02 +0530 Subject: [PATCH 1/8] fix: eliminate FOUC for whitelabeling across all pages --- .../whitelabeling/whitelabeling-provider.tsx | 31 ---- apps/dokploy/pages/_app.tsx | 6 - apps/dokploy/pages/_document.tsx | 148 +++++++++++++++++- apps/dokploy/pages/index.tsx | 21 ++- apps/dokploy/pages/invitation.tsx | 20 +++ apps/dokploy/pages/register.tsx | 20 +++ apps/dokploy/pages/send-reset-password.tsx | 24 ++- .../api/routers/proprietary/whitelabeling.ts | 14 ++ 8 files changed, 241 insertions(+), 43 deletions(-) delete mode 100644 apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx deleted file mode 100644 index a1a327561..000000000 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import Head from "next/head"; -import { api } from "@/utils/api"; - -export function WhitelabelingProvider() { - const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); - - if (!config) return null; - - return ( - <> - - {config.metaTitle && {config.metaTitle}} - {config.faviconUrl && } - - - {config.customCss && ( - - - Dokploy - - {getLayout()} diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 120bb827e..bfe175c80 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -1,10 +1,104 @@ -import { Head, Html, Main, NextScript } from "next/document"; +import { getPublicWhitelabelingConfig } from "@dokploy/server"; +import NextDocument, { + type DocumentContext, + type DocumentInitialProps, + Head, + Html, + Main, + NextScript, +} from "next/document"; -export default function Document() { +interface WhitelabelingDocumentProps { + metaTitle: string | null; + faviconHref: string | null; + customCss: string | null; +} + +// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the +// remote image on every server render. Keyed by the configured favicon URL. +const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour + +const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute + +declare global { + var __SETTINGS_CACHE: { + data: { + metaTitle: string | null; + faviconHref: string | null; + customCss: string | null; + }; + expiresAt: number; + } | null; + var __FAVICON_CACHE: Map; +} + +const faviconCache = + globalThis.__FAVICON_CACHE || + new Map(); +globalThis.__FAVICON_CACHE = faviconCache; + +/** + * Resolve the favicon to an inline data URI so it is present in the initial + * HTML and renders without a network round-trip (no flash of the default + * favicon). Falls back to the raw URL if the image can't be fetched. + */ +async function resolveFaviconHref( + faviconUrl: string | null | undefined, +): Promise { + if (!faviconUrl) return null; + + const cached = faviconCache.get(faviconUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.href; + } + + // Default to the raw URL so the custom favicon still loads if inlining fails. + let href = faviconUrl; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(faviconUrl, { signal: controller.signal }); + clearTimeout(timeout); + + if (response.ok) { + const buffer = Buffer.from(await response.arrayBuffer()); + // Avoid embedding very large images directly in the HTML. + if (buffer.byteLength <= 512 * 1024) { + const contentType = response.headers.get("content-type") || "image/png"; + href = `data:${contentType};base64,${buffer.toString("base64")}`; + } + } + } catch { + // Keep the raw URL fallback. + } + + faviconCache.set(faviconUrl, { + href, + expiresAt: Date.now() + FAVICON_CACHE_TTL, + }); + return href; +} + +export default function Document({ + metaTitle, + faviconHref, + customCss, +}: WhitelabelingDocumentProps) { + const title = metaTitle || "Dokploy"; return ( - + {/* Rendered on the server so the correct branding is present on first + paint (and for social scrapers), avoiding a flash of / fallback to + the default Dokploy branding. */} + {title} + + {customCss && ( + tags. - Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching. --- apps/dokploy/pages/_document.tsx | 81 ++++--------------- .../api/routers/proprietary/whitelabeling.ts | 3 - 2 files changed, 16 insertions(+), 68 deletions(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index bfe175c80..2046b6f9f 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -14,70 +14,7 @@ interface WhitelabelingDocumentProps { customCss: string | null; } -// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the -// remote image on every server render. Keyed by the configured favicon URL. -const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour -const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute - -declare global { - var __SETTINGS_CACHE: { - data: { - metaTitle: string | null; - faviconHref: string | null; - customCss: string | null; - }; - expiresAt: number; - } | null; - var __FAVICON_CACHE: Map; -} - -const faviconCache = - globalThis.__FAVICON_CACHE || - new Map(); -globalThis.__FAVICON_CACHE = faviconCache; - -/** - * Resolve the favicon to an inline data URI so it is present in the initial - * HTML and renders without a network round-trip (no flash of the default - * favicon). Falls back to the raw URL if the image can't be fetched. - */ -async function resolveFaviconHref( - faviconUrl: string | null | undefined, -): Promise { - if (!faviconUrl) return null; - - const cached = faviconCache.get(faviconUrl); - if (cached && cached.expiresAt > Date.now()) { - return cached.href; - } - - // Default to the raw URL so the custom favicon still loads if inlining fails. - let href = faviconUrl; - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const response = await fetch(faviconUrl, { signal: controller.signal }); - clearTimeout(timeout); - - if (response.ok) { - const buffer = Buffer.from(await response.arrayBuffer()); - // Avoid embedding very large images directly in the HTML. - if (buffer.byteLength <= 512 * 1024) { - const contentType = response.headers.get("content-type") || "image/png"; - href = `data:${contentType};base64,${buffer.toString("base64")}`; - } - } - } catch { - // Keep the raw URL fallback. - } - - faviconCache.set(faviconUrl, { - href, - expiresAt: Date.now() + FAVICON_CACHE_TTL, - }); - return href; -} export default function Document({ metaTitle, @@ -108,6 +45,19 @@ export default function Document({ ); } +const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute + +declare global { + var __SETTINGS_CACHE: { + data: { + metaTitle: string | null; + faviconHref: string | null; + customCss: string | null; + }; + expiresAt: number; + } | null; +} + Document.getInitialProps = async ( ctx: DocumentContext, ): Promise => { @@ -132,8 +82,9 @@ Document.getInitialProps = async ( const config = await getPublicWhitelabelingConfig(); if (config) { metaTitle = config.metaTitle; - customCss = config.customCss; - faviconHref = await resolveFaviconHref(config.faviconUrl); + // Remove any tags to prevent XSS breakout + customCss = config.customCss ? config.customCss.replace(/<\/style>/gi, "") : null; + faviconHref = config.faviconUrl || null; } } catch { // Fall back to defaults if settings can't be read (e.g. DB not ready) diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index 6bc0d0f8f..bc38c8ff1 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -17,9 +17,6 @@ import { /** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */ function clearBrandingSSRCache() { globalThis.__SETTINGS_CACHE = null; - if (globalThis.__FAVICON_CACHE) { - globalThis.__FAVICON_CACHE.clear(); - } } export const whitelabelingRouter = createTRPCRouter({ From 58f3714ddc5ad256bb039801623691d9c83b7d78 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- apps/dokploy/pages/_document.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 2046b6f9f..127a153d1 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -14,8 +14,6 @@ interface WhitelabelingDocumentProps { customCss: string | null; } - - export default function Document({ metaTitle, faviconHref, @@ -83,7 +81,9 @@ Document.getInitialProps = async ( if (config) { metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout - customCss = config.customCss ? config.customCss.replace(/<\/style>/gi, "") : null; + customCss = config.customCss + ? config.customCss.replace(/<\/style>/gi, "") + : null; faviconHref = config.faviconUrl || null; } } catch { From 5606a2bd1ebe1278cadae974aa70e70460fd1ace Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 2 Sep 2026 01:44:46 +0530 Subject: [PATCH 6/8] fix: account for whitespace in style tag regex to prevent XSS --- apps/dokploy/pages/_document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 127a153d1..b50fd6f12 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -82,7 +82,7 @@ Document.getInitialProps = async ( metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout customCss = config.customCss - ? config.customCss.replace(/<\/style>/gi, "") + ? config.customCss.replace(/<\/\s*style\s*>/gi, "") : null; faviconHref = config.faviconUrl || null; } From 13bb1ac5d155775ad30d591955d96935801f864b Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 2 Sep 2026 01:49:35 +0530 Subject: [PATCH 7/8] fix: account for all style end-tag variants like --- apps/dokploy/pages/_document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index b50fd6f12..88e2af9a4 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -82,7 +82,7 @@ Document.getInitialProps = async ( metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout customCss = config.customCss - ? config.customCss.replace(/<\/\s*style\s*>/gi, "") + ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") : null; faviconHref = config.faviconUrl || null; } From dfa9c17d7d4569691980acbc19cc417f8ce814a3 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 2 Sep 2026 19:03:24 +0530 Subject: [PATCH 8/8] refactor: extract createServerSideHelpers into a reusable function --- apps/dokploy/pages/index.tsx | 15 ++------------- apps/dokploy/pages/invitation.tsx | 15 ++------------- apps/dokploy/pages/register.tsx | 15 ++------------- apps/dokploy/pages/send-reset-password.tsx | 15 ++------------- apps/dokploy/utils/create-server-helpers.ts | 21 +++++++++++++++++++++ 5 files changed, 29 insertions(+), 52 deletions(-) create mode 100644 apps/dokploy/utils/create-server-helpers.ts diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index 6bc7a6e2d..3e41abae3 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -5,7 +5,7 @@ import { } from "@dokploy/server"; import { validateRequest } from "@dokploy/server/lib/auth"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { createServerSideHelpers } from "@trpc/react-query/server"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Fingerprint } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; @@ -14,7 +14,6 @@ import { useRouter } from "next/router"; import { type ReactElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import superjson from "superjson"; import { z } from "zod"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github"; @@ -495,17 +494,7 @@ Home.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(context: GetServerSidePropsContext) { - const helpers = createServerSideHelpers({ - router: appRouter, - ctx: { - req: context.req as any, - res: context.res as any, - db: null as any, - session: null as any, - user: null as any, - }, - transformer: superjson, - }); + const helpers = generateServerSideHelper(appRouter, context); // Prefetch the public branding so the login/onboarding logo and app name // render correctly on the server (no flash of default branding). await helpers.whitelabeling.getPublic.prefetch(); diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index 153157226..da6482219 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -1,13 +1,12 @@ import { getUserByToken, IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { createServerSideHelpers } from "@trpc/react-query/server"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; import { type ReactElement, useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import superjson from "superjson"; import { z } from "zod"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -333,17 +332,7 @@ Invitation.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(ctx: GetServerSidePropsContext) { - const helpers = createServerSideHelpers({ - router: appRouter, - ctx: { - req: ctx.req as any, - res: ctx.res as any, - db: null as any, - session: null as any, - user: null as any, - }, - transformer: superjson, - }); + const helpers = generateServerSideHelper(appRouter, ctx); // Prefetch the public branding so the invitation logo and app name render // correctly on the server (no flash of default branding). await helpers.whitelabeling.getPublic.prefetch(); diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx index af1879452..20c95bea6 100644 --- a/apps/dokploy/pages/register.tsx +++ b/apps/dokploy/pages/register.tsx @@ -1,6 +1,6 @@ import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { createServerSideHelpers } from "@trpc/react-query/server"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { AlertTriangle } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -8,7 +8,6 @@ import { useRouter } from "next/router"; import { type ReactElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import superjson from "superjson"; import { z } from "zod"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github"; @@ -308,17 +307,7 @@ Register.getLayout = (page: ReactElement) => { ); }; export async function getServerSideProps(context: GetServerSidePropsContext) { - const helpers = createServerSideHelpers({ - router: appRouter, - ctx: { - req: context.req as any, - res: context.res as any, - db: null as any, - session: null as any, - user: null as any, - }, - transformer: superjson, - }); + const helpers = generateServerSideHelper(appRouter, context); // Prefetch the public branding so the onboarding logo and app name render // correctly on the server (no flash of default branding). await helpers.whitelabeling.getPublic.prefetch(); diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx index 5fbedb5ec..2f41e50c9 100644 --- a/apps/dokploy/pages/send-reset-password.tsx +++ b/apps/dokploy/pages/send-reset-password.tsx @@ -1,13 +1,12 @@ import { IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { createServerSideHelpers } from "@trpc/react-query/server"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; import { type ReactElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import superjson from "superjson"; import { z } from "zod"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -178,17 +177,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } - const helpers = createServerSideHelpers({ - router: appRouter, - ctx: { - req: context.req as any, - res: context.res as any, - db: null as any, - session: null as any, - user: null as any, - }, - transformer: superjson, - }); + const helpers = generateServerSideHelper(appRouter, context); // Prefetch the public branding so the logo and app name render // correctly on the server (no flash of default branding). await helpers.whitelabeling.getPublic.prefetch(); diff --git a/apps/dokploy/utils/create-server-helpers.ts b/apps/dokploy/utils/create-server-helpers.ts new file mode 100644 index 000000000..429bc67cf --- /dev/null +++ b/apps/dokploy/utils/create-server-helpers.ts @@ -0,0 +1,21 @@ +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import superjson from "superjson"; +import type { AppRouter } from "@/server/api/root"; + +export const generateServerSideHelper = ( + router: AppRouter, + context: GetServerSidePropsContext, +) => { + return createServerSideHelpers({ + router, + ctx: { + req: context.req as any, + res: context.res as any, + db: null as any, + session: null as any, + user: null as any, + }, + transformer: superjson, + }); +};