From ed3ee64e4b78ad1b7f2895bd31aeb93a4069d14e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 28 Jan 2026 03:04:12 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20support=20pathname=20in=20logo=20UR?= =?UTF-8?q?L=20in=20branding=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- messages/en-US.json | 3 + .../loginPage/upsertLoginPageBranding.ts | 31 ++++++-- src/components/AuthPageBrandingForm.tsx | 70 ++++++++++++++----- .../resource-target-address-item.tsx | 1 + src/lib/validateLocalPath.ts | 16 +++++ 6 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 src/lib/validateLocalPath.ts diff --git a/.gitignore b/.gitignore index df9179a4..d2cdfa69 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ dynamic/ scratch/ tsconfig.json hydrateSaas.ts -CLAUDE.md \ No newline at end of file +CLAUDE.md +zaneops.* \ No newline at end of file diff --git a/messages/en-US.json b/messages/en-US.json index f2affe11..46f9092a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1877,6 +1877,9 @@ "authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?", "authPageBrandingDeleteConfirm": "Confirm Delete Branding", "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logo URL or Path", + "brandingLogoPathDescription": "Enter a URL (https://...) or a local path (/logo.png) from the public/ directory on your Pangolin installation.", + "brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.", "brandingPrimaryColor": "Primary Color", "brandingLogoWidth": "Width (px)", "brandingLogoHeight": "Height (px)", diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index e6e365be..17f5fbbc 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -29,6 +29,7 @@ import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; import config from "@server/private/lib/config"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; const paramsSchema = z.strictObject({ orgId: z.string() @@ -39,14 +40,36 @@ const bodySchema = z.strictObject({ .union([ z.literal(""), z - .url("Must be a valid URL") - .superRefine(async (url, ctx) => { + .string() + .superRefine(async (urlOrPath, ctx) => { + const parseResult = z.url().safeParse(urlOrPath); + if (!parseResult.success) { + if (build !== "enterprise") { + ctx.addIssue({ + code: "custom", + message: "Must be a valid URL" + }); + return; + } else { + try { + validateLocalPath(urlOrPath); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + }); + } finally { + return; + } + } + } + try { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index 7bf563f4..1246244b 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -1,9 +1,5 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { startTransition, useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import z from "zod"; import { Form, FormControl, @@ -13,6 +9,11 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; import { SettingsSection, SettingsSectionBody, @@ -21,20 +22,19 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { useTranslations } from "next-intl"; -import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; -import { Input } from "./ui/input"; -import { ExternalLink, InfoIcon, XIcon } from "lucide-react"; -import { Button } from "./ui/button"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useRouter } from "next/navigation"; -import { toast } from "@app/hooks/useToast"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; +import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { XIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; export type AuthPageCustomizationProps = { orgId: string; @@ -44,13 +44,36 @@ export type AuthPageCustomizationProps = { const AuthPageFormSchema = z.object({ logoUrl: z.union([ z.literal(""), - z.url("Must be a valid URL").superRefine(async (url, ctx) => { + z.string().superRefine(async (urlOrPath, ctx) => { + const parseResult = z.url().safeParse(urlOrPath); + if (!parseResult.success) { + if (build !== "enterprise") { + ctx.addIssue({ + code: "custom", + message: "Must be a valid URL" + }); + return; + } else { + try { + validateLocalPath(urlOrPath); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: + "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + }); + } finally { + return; + } + } + } + try { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { @@ -270,12 +293,25 @@ export default function AuthPageBrandingForm({ render={({ field }) => ( - {t("brandingLogoURL")} + {build === "enterprise" + ? t( + "brandingLogoURLOrPath" + ) + : t("brandingLogoURL")} + + {build === "enterprise" + ? t( + "brandingLogoPathDescription" + ) + : t( + "brandingLogoURLDescription" + )} + )} /> diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 3c4cb927..6479ede7 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -20,6 +20,7 @@ import { import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; +import { useEffect } from "react"; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; diff --git a/src/lib/validateLocalPath.ts b/src/lib/validateLocalPath.ts new file mode 100644 index 00000000..7f87eb44 --- /dev/null +++ b/src/lib/validateLocalPath.ts @@ -0,0 +1,16 @@ +export function validateLocalPath(value: string) { + try { + const url = new URL("https://pangoling.net" + value); + if ( + url.pathname !== value || + value.includes("..") || + value.includes("*") + ) { + throw new Error("Invalid Path"); + } + } catch { + throw new Error( + "should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + ); + } +} \ No newline at end of file