Merge remote-tracking branch 'upstream/canary' into fix/dns-upsert-preserve-existing-records

# Conflicts:
#	packages/server/src/utils/dns/infomaniak.ts
This commit is contained in:
Yash Kumar 2026-09-08 22:54:00 +05:30
commit be128d5a4a
12 changed files with 9333 additions and 45 deletions

View File

@ -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", () => {

View File

@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
ogImageUrl: null,
footerText: null,
},
cleanupCacheApplications: false,

View File

@ -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<typeof formSchema>;
@ -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() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="ogImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>OG Image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/og.png"
{...field}
/>
</FormControl>
<FormDescription>
Open Graph image used for link previews on social media
and messaging platforms. Recommended size: 1200x630px.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@ -441,32 +462,15 @@ export function WhitelabelingSettings() {
</CardContent>
</Card>
{/* Metadata & Links Section */}
{/* Links Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Metadata & Links</CardTitle>
<CardTitle>Links</CardTitle>
<CardDescription>
Customize the page title, footer text, and sidebar links.
Customize the footer text and sidebar links.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="metaTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Page Title</FormLabel>
<FormControl>
<Input placeholder="Dokploy" {...field} />
</FormControl>
<FormDescription>
Browser tab title. Defaults to "Dokploy" if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="footerText"

View File

@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null}'::jsonb;

File diff suppressed because it is too large Load Diff

View File

@ -1366,6 +1366,13 @@
"when": 1788858506799,
"tag": "0194_acoustic_prima",
"breakpoints": true
},
{
"idx": 195,
"version": "7",
"when": 1788867370653,
"tag": "0195_classy_whirlwind",
"breakpoints": true
}
]
}

View File

@ -9,17 +9,31 @@ import NextDocument, {
} from "next/document";
interface WhitelabelingDocumentProps {
metaTitle: string | null;
appName: string | null;
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
customCss: string | null;
baseUrl: string;
}
export default function Document({
metaTitle,
appName,
appDescription,
ogImageUrl,
faviconHref,
customCss,
baseUrl,
}: WhitelabelingDocumentProps) {
const title = metaTitle || "Dokploy";
const title = appName || "Dokploy";
const description =
appDescription || "The Open Source alternative to Netlify, Vercel, Heroku.";
let ogImage = ogImageUrl || "/og.png";
if (ogImage.startsWith("/")) {
ogImage = `${baseUrl}${ogImage}`;
}
return (
<Html lang="en" className="font-sans">
<Head>
@ -27,6 +41,9 @@ export default function Document({
paint (and for social scrapers), avoiding a flash of / fallback to
the default Dokploy branding. */}
<title>{title}</title>
<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"} />
{customCss && (
<style
@ -48,7 +65,9 @@ const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
declare global {
var __SETTINGS_CACHE: {
data: {
metaTitle: string | null;
appName: string | null;
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
customCss: string | null;
};
@ -61,10 +80,16 @@ Document.getInitialProps = async (
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
const initialProps = await NextDocument.getInitialProps(ctx);
let metaTitle: string | null = null;
let appName: string | null = null;
let appDescription: string | null = null;
let ogImageUrl: string | null = null;
let faviconHref: string | null = null;
let customCss: string | null = null;
const host = ctx.req?.headers?.host || "localhost:3000";
const protocol = ctx.req?.headers?.["x-forwarded-proto"] || "http";
const baseUrl = `${protocol}://${host}`;
if (
globalThis.__SETTINGS_CACHE &&
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
@ -73,13 +98,16 @@ Document.getInitialProps = async (
return {
...initialProps,
...globalThis.__SETTINGS_CACHE.data,
baseUrl,
};
}
try {
const config = await getPublicWhitelabelingConfig();
if (config) {
metaTitle = config.metaTitle;
appName = config.appName;
appDescription = config.appDescription;
ogImageUrl = config.ogImageUrl;
// Remove any </style> 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,
};
};

BIN
apps/dokploy/public/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -85,7 +85,7 @@ export const whitelabelingRouter = createTRPCRouter({
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
ogImageUrl: null,
footerText: null,
},
});

View File

@ -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({

View File

@ -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,

View File

@ -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<InfomaniakRecord[]>(
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<InfomaniakConfig> = {
async listZones(config) {
const domains = await listDomainProducts(config);
@ -171,15 +199,15 @@ export const infomaniakClient: DnsClient<InfomaniakConfig> = {
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));