diff --git a/apps/dokploy/__test__/dns/infomaniak.test.ts b/apps/dokploy/__test__/dns/infomaniak.test.ts index d8bfe1f18..f5ba70027 100644 --- a/apps/dokploy/__test__/dns/infomaniak.test.ts +++ b/apps/dokploy/__test__/dns/infomaniak.test.ts @@ -348,6 +348,58 @@ describe("infomaniakClient.upsertRecord", () => { expect(lastBody().target).toBe('"token-value"'); }); + + it("queries the API with a source and type filter instead of the whole zone", async () => { + mockFetch + .mockResolvedValueOnce(ikSuccess([])) + .mockResolvedValueOnce(ikSuccess({ id: 50 })); + + await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("filter%5Bsource%5D=app"); + expect(url).toContain("filter%5Btypes%5D%5B%5D=A"); + }); + + it("ignores a partial filter hit rather than overwriting a different record", async () => { + // filter[source] matches substrings: asking for "auto" also returns + // "autoconfig" and "autodiscover". Trusting it would overwrite one of them. + mockFetch + .mockResolvedValueOnce( + ikSuccess([ + { + id: 61, + type: "CNAME", + source: "autoconfig", + target: "a.example.net", + ttl: 300, + }, + { + id: 62, + type: "CNAME", + source: "autodiscover", + target: "b.example.net", + ttl: 300, + }, + ]), + ) + .mockResolvedValueOnce(ikSuccess({ id: 63 })); + + const result = await infomaniakClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "auto.example.com", + content: "c.example.net", + }); + + expect(result).toEqual({ id: "63" }); + expect(lastCall()[1].method).toBe("POST"); + }); }); describe("infomaniakClient.updateRecord", () => { diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index ba09c2c80..d1a03fedc 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = { docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, + ogImageUrl: null, footerText: null, }, cleanupCacheApplications: false, diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx index c20615109..3ba9e80bd 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx @@ -47,8 +47,8 @@ const formSchema = z.object({ docsUrl: safeUrlField, errorPageTitle: z.string(), errorPageDescription: z.string(), - metaTitle: z.string(), footerText: z.string(), + ogImageUrl: safeUrlField, }); type FormSchema = z.infer; @@ -193,8 +193,8 @@ export function WhitelabelingSettings() { docsUrl: "", errorPageTitle: "", errorPageDescription: "", - metaTitle: "", footerText: "", + ogImageUrl: "", }, resolver: zodResolver(formSchema), }); @@ -212,8 +212,8 @@ export function WhitelabelingSettings() { docsUrl: data.docsUrl ?? "", errorPageTitle: data.errorPageTitle ?? "", errorPageDescription: data.errorPageDescription ?? "", - metaTitle: data.metaTitle ?? "", footerText: data.footerText ?? "", + ogImageUrl: data.ogImageUrl ?? "", }); } }, [data, form]); @@ -242,8 +242,8 @@ export function WhitelabelingSettings() { docsUrl: values.docsUrl || null, errorPageTitle: values.errorPageTitle || null, errorPageDescription: values.errorPageDescription || null, - metaTitle: values.metaTitle || null, footerText: values.footerText || null, + ogImageUrl: values.ogImageUrl || null, }, }) .then(async () => { @@ -388,6 +388,27 @@ export function WhitelabelingSettings() { )} /> + + ( + + OG Image URL + + + + + Open Graph image used for link previews on social media + and messaging platforms. Recommended size: 1200x630px. + + + + )} + /> @@ -441,32 +462,15 @@ export function WhitelabelingSettings() { - {/* Metadata & Links Section */} + {/* Links Section */} - Metadata & Links + Links - Customize the page title, footer text, and sidebar links. + Customize the footer text and sidebar links. - ( - - Page Title - - - - - Browser tab title. Defaults to "Dokploy" if empty. - - - - )} - /> - @@ -27,6 +41,9 @@ export default function Document({ paint (and for social scrapers), avoiding a flash of / fallback to the default Dokploy branding. */} {title} + + + {customCss && ( tags to prevent XSS breakout customCss = config.customCss ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") @@ -92,7 +120,9 @@ Document.getInitialProps = async ( globalThis.__SETTINGS_CACHE = { data: { - metaTitle, + appName, + appDescription, + ogImageUrl, faviconHref, customCss, }, @@ -101,8 +131,11 @@ Document.getInitialProps = async ( return { ...initialProps, - metaTitle, + appName, + appDescription, + ogImageUrl, faviconHref, customCss, + baseUrl, }; }; diff --git a/apps/dokploy/public/og.png b/apps/dokploy/public/og.png new file mode 100644 index 000000000..68faf81a1 Binary files /dev/null and b/apps/dokploy/public/og.png differ diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index bc38c8ff1..81dbb9f53 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -85,7 +85,7 @@ export const whitelabelingRouter = createTRPCRouter({ docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, + ogImageUrl: null, footerText: null, }, }); diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index c5e84d7b7..ab513e283 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -86,8 +86,8 @@ export const webServerSettings = pgTable("webServerSettings", { docsUrl: string | null; errorPageTitle: string | null; errorPageDescription: string | null; - metaTitle: string | null; footerText: string | null; + ogImageUrl: string | null; }>() .default({ appName: null, @@ -100,8 +100,8 @@ export const webServerSettings = pgTable("webServerSettings", { docsUrl: null, errorPageTitle: null, errorPageDescription: null, - metaTitle: null, footerText: null, + ogImageUrl: null, }), // Deployment Configuration (self-hosted only) remoteServersOnly: boolean("remoteServersOnly").notNull().default(false), @@ -223,8 +223,8 @@ export const whitelabelingConfigSchema = z.object({ docsUrl: safeUrl, errorPageTitle: z.string().nullable(), errorPageDescription: z.string().nullable(), - metaTitle: z.string().nullable(), footerText: z.string().nullable(), + ogImageUrl: safeUrl, }); export const apiUpdateWhitelabeling = z.object({ diff --git a/packages/server/src/services/proprietary/whitelabeling.ts b/packages/server/src/services/proprietary/whitelabeling.ts index c1d6e39db..ea024774f 100644 --- a/packages/server/src/services/proprietary/whitelabeling.ts +++ b/packages/server/src/services/proprietary/whitelabeling.ts @@ -10,7 +10,7 @@ export interface PublicWhitelabelingConfig { loginLogoUrl: string | null; faviconUrl: string | null; customCss: string | null; - metaTitle: string | null; + ogImageUrl: string | null; errorPageTitle: string | null; errorPageDescription: string | null; footerText: string | null; @@ -50,7 +50,7 @@ export const getPublicWhitelabelingConfig = loginLogoUrl: config.loginLogoUrl, faviconUrl: config.faviconUrl, customCss: config.customCss, - metaTitle: config.metaTitle, + ogImageUrl: config.ogImageUrl, errorPageTitle: config.errorPageTitle, errorPageDescription: config.errorPageDescription, footerText: config.footerText, diff --git a/packages/server/src/utils/dns/infomaniak.ts b/packages/server/src/utils/dns/infomaniak.ts index e161c9f0d..014de9cff 100644 --- a/packages/server/src/utils/dns/infomaniak.ts +++ b/packages/server/src/utils/dns/infomaniak.ts @@ -148,6 +148,34 @@ const listZoneRecords = async (config: InfomaniakConfig, zoneId: string) => `/2/zones/${encodeURIComponent(zoneId)}/records?with=records_description`, ); +// The API filters server-side, which avoids pulling a whole zone just to find +// one record. The match is still checked here: filter[source] is documented with +// a bare subdomain example, so nothing guarantees it compares exactly the way +// toSource writes the apex, and a filter that silently over-matches would +// otherwise turn an update into a duplicate. +const findRecord = async ( + config: InfomaniakConfig, + zoneId: string, + type: string, + source: string, + expectedContent: string, +) => { + const query = new URLSearchParams({ + "filter[source]": source, + "filter[types][]": type, + }); + const candidates = await ikFetch( + config, + `/2/zones/${encodeURIComponent(zoneId)}/records?${query}`, + ); + return candidates.find( + (candidate) => + candidate.type === type && + normalizeSource(candidate.source) === source && + unquoteTarget(candidate.target) === expectedContent, + ); +}; + export const infomaniakClient: DnsClient = { async listZones(config) { const domains = await listDomainProducts(config); @@ -171,15 +199,15 @@ export const infomaniakClient: DnsClient = { async upsertRecord(config, record) { const source = toSource(record.name, record.zoneId); - const existing = await listZoneRecords(config, record.zoneId); const expectedContent = unquoteTarget( quoteTarget(record.type, record.content), ); - const match = existing.find( - (candidate) => - candidate.type === record.type && - normalizeSource(candidate.source) === source && - unquoteTarget(candidate.target) === expectedContent, + const match = await findRecord( + config, + record.zoneId, + record.type, + source, + expectedContent, ); const body = JSON.stringify(recordPayload(record, record.zoneId));