mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-03 01:06:39 +00:00
✨ support pathname in logo URL in branding page
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,4 +51,5 @@ dynamic/
|
|||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
hydrateSaas.ts
|
hydrateSaas.ts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
zaneops.*
|
||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
16
src/lib/validateLocalPath.ts
Normal file
16
src/lib/validateLocalPath.ts
Normal 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 `*`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user