mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
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:
commit
be128d5a4a
@ -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", () => {
|
||||
|
||||
@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
|
||||
@ -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"
|
||||
|
||||
1
apps/dokploy/drizzle/0195_classy_whirlwind.sql
Normal file
1
apps/dokploy/drizzle/0195_classy_whirlwind.sql
Normal 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;
|
||||
9163
apps/dokploy/drizzle/meta/0195_snapshot.json
Normal file
9163
apps/dokploy/drizzle/meta/0195_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1366,6 +1366,13 @@
|
||||
"when": 1788858506799,
|
||||
"tag": "0194_acoustic_prima",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 195,
|
||||
"version": "7",
|
||||
"when": 1788867370653,
|
||||
"tag": "0195_classy_whirlwind",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
BIN
apps/dokploy/public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@ -85,7 +85,7 @@ export const whitelabelingRouter = createTRPCRouter({
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user