mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
Response headers now use list order when more than one rule sets the same name on a path: the last matching rule wins. New or edited rules currently move to the top of the Builder list, which would make older rules override them. Append those rules instead and explain the precedence in the settings UI. Authentication list behavior is unchanged. Validation: SDK response-header tests and typecheck, CLI response-header tests, SDK build and declaration build passed.
230 lines
7.4 KiB
TypeScript
230 lines
7.4 KiB
TypeScript
import { useState } from "react";
|
|
import { useStore } from "@nanostores/react";
|
|
import { ZodError } from "zod";
|
|
import {
|
|
Flex,
|
|
Grid,
|
|
LinkButton,
|
|
ProChip,
|
|
Text,
|
|
Tooltip,
|
|
cssVar,
|
|
theme,
|
|
} from "@webstudio-is/design-system";
|
|
import { InfoCircleIcon } from "@webstudio-is/icons";
|
|
import {
|
|
customResponseHeader,
|
|
customResponseHeaderKey,
|
|
editCustomResponseHeaders,
|
|
type CustomResponseHeader,
|
|
} from "@webstudio-is/sdk";
|
|
import { validatePathnamePattern } from "@webstudio-is/wsauth";
|
|
import { $pages, $projectSettings } from "~/shared/sync/data-stores";
|
|
import { $permissions } from "~/shared/nano-states";
|
|
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
|
import {
|
|
ProjectSettingsDeleteRuleButton,
|
|
ProjectSettingsRuleList,
|
|
} from "./rule-list";
|
|
import {
|
|
getResponseHeaderName,
|
|
getResponseHeaderValueSuggestions,
|
|
responseHeaderNames,
|
|
} from "./response-header-suggestions";
|
|
import { getExistingRoutePaths, sectionSpacing } from "./utils";
|
|
|
|
export const SectionHeaders = ({
|
|
executeMutation = executeRuntimeMutation,
|
|
}: {
|
|
projectId?: string;
|
|
executeMutation?: (
|
|
args: Parameters<typeof executeRuntimeMutation>[0]
|
|
) => ReturnType<typeof executeRuntimeMutation>;
|
|
} = {}) => {
|
|
const { allowDynamicData } = useStore($permissions);
|
|
const settings = useStore($projectSettings);
|
|
const pages = useStore($pages);
|
|
const [saveError, setSaveError] = useState("");
|
|
const configured = settings?.meta.customHeaders ?? [];
|
|
const routeSuggestions = [
|
|
...new Set(["/*", "/", ...Array.from(getExistingRoutePaths(pages)).sort()]),
|
|
];
|
|
|
|
const save = (
|
|
next: CustomResponseHeader | undefined,
|
|
previousKey: string
|
|
) => {
|
|
let headers: CustomResponseHeader[];
|
|
try {
|
|
headers = editCustomResponseHeaders(
|
|
$projectSettings.get()?.meta.customHeaders ?? [],
|
|
previousKey,
|
|
next
|
|
);
|
|
} catch (error) {
|
|
setSaveError(
|
|
error instanceof ZodError
|
|
? error.issues.map((issue) => issue.message).join(". ")
|
|
: "Invalid response header"
|
|
);
|
|
return false;
|
|
}
|
|
try {
|
|
const mutation = executeMutation({
|
|
id: "projectSettings.update",
|
|
input: {
|
|
meta: { customHeaders: headers.length ? headers : null },
|
|
},
|
|
});
|
|
if (mutation !== undefined) {
|
|
setSaveError("");
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Keep the form values or existing row available for retry.
|
|
}
|
|
setSaveError("Changes could not be saved. Please try again.");
|
|
return false;
|
|
};
|
|
|
|
return (
|
|
<Grid gap={3} css={sectionSpacing}>
|
|
<Flex align="center" gap={1}>
|
|
<Text variant="titles">Headers</Text>
|
|
{allowDynamicData === false && <ProChip>Pro</ProChip>}
|
|
<Tooltip
|
|
variant="wrapped"
|
|
content={
|
|
<>
|
|
<Text>
|
|
Set response headers for all paths or a route. Routes use the
|
|
same syntax as Authentication, including :params and *. If
|
|
multiple rules set the same header on a path, the last matching
|
|
rule in this list wins.
|
|
</Text>
|
|
<br />
|
|
<Text>
|
|
/* applies to every path; / applies only to the root. Webstudio
|
|
Cloud supplies CSP, X-Frame-Options, and Referrer-Policy when
|
|
they are not set. X-Powered-By, X-Content-Type-Options, and
|
|
Strict-Transport-Security are managed by the platform and cannot
|
|
be configured here. Cookie, connection, and response-body
|
|
framing headers are also unavailable. Publish to apply changes.
|
|
</Text>
|
|
{allowDynamicData === false && (
|
|
<>
|
|
<br />
|
|
<Text>
|
|
Custom headers on a custom domain require Pro. Defaults and
|
|
staging are free.
|
|
</Text>
|
|
<LinkButton
|
|
color="primary"
|
|
css={{ marginTop: theme.spacing[5], width: "100%" }}
|
|
href="https://webstudio.is/pricing"
|
|
target="_blank"
|
|
>
|
|
Upgrade
|
|
</LinkButton>
|
|
</>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<InfoCircleIcon
|
|
color={cssVar("--foreground-secondary")}
|
|
tabIndex={0}
|
|
aria-label="About response headers"
|
|
/>
|
|
</Tooltip>
|
|
</Flex>
|
|
{saveError && <Text color="destructive">{saveError}</Text>}
|
|
<ProjectSettingsRuleList
|
|
fields={[
|
|
{
|
|
name: "route",
|
|
placeholder: "/* or /private/*",
|
|
suggestions: routeSuggestions,
|
|
},
|
|
{
|
|
name: "name",
|
|
placeholder: "Header name",
|
|
suggestions: responseHeaderNames,
|
|
},
|
|
{
|
|
name: "value",
|
|
placeholder: "Header value",
|
|
suggestions: (values) =>
|
|
getResponseHeaderValueSuggestions(values.name ?? ""),
|
|
},
|
|
]}
|
|
validate={(values) => {
|
|
const route = values.route?.trim() ?? "";
|
|
const name = values.name?.trim() ?? "";
|
|
const value = values.value?.trim() ?? "";
|
|
const errors: Record<string, string[]> = {};
|
|
const routeError = validatePathnamePattern(route);
|
|
if (routeError) {
|
|
errors.route = [routeError];
|
|
}
|
|
if (value === "") {
|
|
errors.value = ["Enter a header value"];
|
|
}
|
|
const result = customResponseHeader.safeParse({
|
|
route,
|
|
name,
|
|
value,
|
|
});
|
|
if (!result.success) {
|
|
for (const issue of result.error.issues) {
|
|
const field = String(issue.path[0] ?? "value");
|
|
(errors[field] ??= []).push(issue.message);
|
|
}
|
|
}
|
|
return errors;
|
|
}}
|
|
onSubmit={(values) => {
|
|
const route = values.route.trim();
|
|
const value = values.value?.trim() ?? "";
|
|
const name =
|
|
getResponseHeaderName(values.name.trim()) ?? values.name.trim();
|
|
const next: CustomResponseHeader = {
|
|
...(route === "/*" ? {} : { route }),
|
|
name,
|
|
value,
|
|
};
|
|
return save(next, customResponseHeaderKey({ route, name }));
|
|
}}
|
|
columns="1fr 1.5fr 1.5fr"
|
|
columnLabels={["Path", "Header", "Value"]}
|
|
label="Response header rules"
|
|
rules={configured.map((header) => {
|
|
const route = header.route ?? "/*";
|
|
const key = customResponseHeaderKey(header);
|
|
return {
|
|
key,
|
|
values: [
|
|
<Tooltip content={route} key="route">
|
|
<Text truncate>{route}</Text>
|
|
</Tooltip>,
|
|
<Tooltip content={header.name} key="name">
|
|
<Text truncate>{header.name}</Text>
|
|
</Tooltip>,
|
|
<Tooltip content={header.value} key="value">
|
|
<Text truncate>{header.value}</Text>
|
|
</Tooltip>,
|
|
],
|
|
actions: (
|
|
<ProjectSettingsDeleteRuleButton
|
|
label={`Remove ${header.name} for ${route}`}
|
|
description={`${header.name} for ${route}`}
|
|
onDelete={() => save(undefined, key)}
|
|
/>
|
|
),
|
|
};
|
|
})}
|
|
/>
|
|
</Grid>
|
|
);
|
|
};
|