support pathname in logo URL in branding page

This commit is contained in:
Fred KISSIE
2026-01-28 03:04:12 +01:00
parent 12aea2901d
commit ed3ee64e4b
6 changed files with 102 additions and 22 deletions

3
.gitignore vendored
View File

@@ -51,4 +51,5 @@ dynamic/
scratch/ scratch/
tsconfig.json tsconfig.json
hydrateSaas.ts hydrateSaas.ts
CLAUDE.md CLAUDE.md
zaneops.*

View File

@@ -1877,6 +1877,9 @@
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?", "authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
"authPageBrandingDeleteConfirm": "Confirm Delete Branding", "authPageBrandingDeleteConfirm": "Confirm Delete Branding",
"brandingLogoURL": "Logo URL", "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", "brandingPrimaryColor": "Primary Color",
"brandingLogoWidth": "Width (px)", "brandingLogoWidth": "Width (px)",
"brandingLogoHeight": "Height (px)", "brandingLogoHeight": "Height (px)",

View File

@@ -29,6 +29,7 @@ import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build"; import { build } from "@server/build";
import config from "@server/private/lib/config"; import config from "@server/private/lib/config";
import { validateLocalPath } from "@app/lib/validateLocalPath";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -39,14 +40,36 @@ const bodySchema = z.strictObject({
.union([ .union([
z.literal(""), z.literal(""),
z z
.url("Must be a valid URL") .string()
.superRefine(async (url, ctx) => { .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 { try {
const response = await fetch(url, { const response = await fetch(urlOrPath, {
method: "HEAD" method: "HEAD"
}).catch(() => { }).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET // If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" }); return fetch(urlOrPath, { method: "GET" });
}); });
if (response.status !== 200) { if (response.status !== 200) {

View File

@@ -1,9 +1,5 @@
"use client"; "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 { import {
Form, Form,
FormControl, FormControl,
@@ -13,6 +9,11 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } 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 { import {
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
@@ -21,20 +22,19 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; 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 { 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 { 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 = { export type AuthPageCustomizationProps = {
orgId: string; orgId: string;
@@ -44,13 +44,36 @@ export type AuthPageCustomizationProps = {
const AuthPageFormSchema = z.object({ const AuthPageFormSchema = z.object({
logoUrl: z.union([ logoUrl: z.union([
z.literal(""), 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 { try {
const response = await fetch(url, { const response = await fetch(urlOrPath, {
method: "HEAD" method: "HEAD"
}).catch(() => { }).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET // If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" }); return fetch(urlOrPath, { method: "GET" });
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -270,12 +293,25 @@ export default function AuthPageBrandingForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-3"> <FormItem className="md:col-span-3">
<FormLabel> <FormLabel>
{t("brandingLogoURL")} {build === "enterprise"
? t(
"brandingLogoURLOrPath"
)
: t("brandingLogoURL")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
{build === "enterprise"
? t(
"brandingLogoPathDescription"
)
: t(
"brandingLogoURLDescription"
)}
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -20,6 +20,7 @@ import {
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { useEffect } from "react";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];

View File

@@ -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 `*`"
);
}
}