зеркало из
1
0
Форкнуть 0

fix: allow users to set custom error pages (#179)

* fix: allow users to set custom error pages
* ci: disable failOnStatusCode

Fixes #168 #178
This commit is contained in:
Wassim Chegham 2021-04-09 16:09:26 +02:00 коммит произвёл GitHub
Родитель cd7821ba23
Коммит 39fbb9c085
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
18 изменённых файлов: 132 добавлений и 82 удалений

Просмотреть файл

@ -1,4 +1,5 @@
{
"viewportWidth": 1300,
"viewportHeight": 900
"viewportHeight": 900,
"projectId": "jctfne"
}

Просмотреть файл

@ -1 +0,0 @@
{}

Просмотреть файл

@ -0,0 +1 @@
<h1>custom 401</h1>

Просмотреть файл

@ -0,0 +1 @@
<h1>custom 403</h1>

Просмотреть файл

@ -0,0 +1 @@
<h1>custom 404</h1>

Просмотреть файл

Просмотреть файл

@ -53,22 +53,33 @@
{
"route": "/logout",
"rewrite": "/.auth/logout"
},
{
"route": "/status-code-401.txt",
"statusCode": "401"
},
{
"route": "/status-code-403.txt",
"statusCode": "403"
},
{
"route": "/status-code-404.txt",
"statusCode": "404"
}
],
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["*.{txt}"]
},
"responseErrorOverrides": {
"400": {
"rewrite": "/custom_error.html",
"statusCode": 201
"responseOverrides": {
"401": {
"rewrite": "/custom-401.html"
},
"500": {
"redirect": "/custom_error.html"
"403": {
"rewrite": "/custom-403.html"
},
"501": {
"rewrite": "custom_error.html"
"404": {
"rewrite": "/custom-404.html"
}
},
"mimeTypes": {

Просмотреть файл

@ -0,0 +1,22 @@
/// <reference types="cypress" />
const testSuite = (code) => () => {
it(`should respond with ${code} status code`, () => {
cy.request({ url: `http://0.0.0.0:1234/status-code-${code}.txt`, failOnStatusCode: false }).then((response) => {
expect(response.status).to.eq(code);
});
});
it(`should display custom ${code} page`, () => {
cy.visit({ url: `http://0.0.0.0:1234/status-code-${code}.txt`, failOnStatusCode: false });
cy.get("h1").should("contain.text", `custom ${code}`);
});
};
context("Error pages", () => {
const ERROR_STATUS_CODES = [401, 403, 404];
for (let index = 0; index < ERROR_STATUS_CODES.length; index++) {
const code = ERROR_STATUS_CODES[index];
describe(`Custom ${code} page`, testSuite(code));
}
});

Просмотреть файл

@ -1,6 +1,6 @@
/// <reference types="cypress" />
context("route rules engine", { defaultCommandTimeout: 20000 /* set this for Windows */ }, () => {
context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout: 20000 /* set this for Windows */ }, () => {
beforeEach(() => {
cy.visit("http://0.0.0.0:1234");
});

Просмотреть файл

@ -109,7 +109,7 @@ export async function start(startContext: string, options: SWACLIConfig) {
SWA_CLI_DEBUG: options.verbose,
SWA_CLI_API_PORT: `${apiPort}`,
SWA_CLI_APP_LOCATION: userConfig?.appLocation as string,
SWA_CLI_APP_ARTIFACT_LOCATION: userConfig?.outputLocation as string,
SWA_CLI_OUTPUT_LOCATION: userConfig?.outputLocation as string,
SWA_CLI_API_LOCATION: userConfig?.apiLocation as string,
SWA_CLI_ROUTES_LOCATION: options.routesLocation,
SWA_CLI_HOST: options.host,

Просмотреть файл

@ -16,4 +16,5 @@ export const DEFAULT_CONFIG: SWACLIConfig = {
swaConfigFilenameLegacy: "routes.json",
run: undefined,
verbose: "log",
customUrlScheme: "swa://",
};

Просмотреть файл

@ -20,24 +20,26 @@ import { responseOverrides } from "./rules/responseOverrides";
*/
export async function applyRules(req: IncomingMessage, res: ServerResponse, userConfig: SWAConfigFile) {
const userDefinedRoute = userConfig.routes?.find(matchRoute(req, userConfig.isLegacyConfigFile));
const filepath = path.join(process.env.SWA_CLI_APP_ARTIFACT_LOCATION!, req.url!);
const filepath = path.join(process.env.SWA_CLI_OUTPUT_LOCATION!, req.url!);
const isFileFound = fs.existsSync(filepath);
logger.silly("checking rule...");
logger.silly({
appArticatLocation: process.env.SWA_CLI_APP_ARTIFACT_LOCATION,
matchedRoute: userDefinedRoute,
filepath,
isFileFound,
});
logger.silly("checking rules...");
if (isFileFound === false && userConfig.navigationFallback) {
await navigationFallback(req, res, userConfig.navigationFallback);
}
// note: these rules are mutating the req and res objects
await navigationFallback(req, res, userConfig.navigationFallback);
await globalHeaders(req, res, userConfig.globalHeaders);
await mimeTypes(req, res, userConfig.mimeTypes);
await customRoutes(req, res, userDefinedRoute);
await responseOverrides(req, res, userConfig.responseOverrides);
logger.silly({
outputLocation: process.env.SWA_CLI_OUTPUT_LOCATION,
matchedRoute: userDefinedRoute,
filepath,
isFileFound,
navigationFallback: { statusCode: res.statusCode, url: req.url },
});
}

Просмотреть файл

@ -56,7 +56,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: [],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
await navigationFallback(req, res, userConfig);
@ -69,7 +69,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/images/*.{png,jpg,gif}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/images/foo.png": "",
@ -86,7 +86,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/images/*.{png,jpg,gif}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/no-file": "",
@ -103,7 +103,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/images/*.{gif}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/images/foo.png": "",
@ -120,7 +120,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/images/*.{gif}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/no-file": "",
@ -137,7 +137,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/*.{png}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/no-file": "",
@ -154,7 +154,7 @@ describe("navigationFallback()", () => {
rewrite: "/bar",
exclude: ["/images/*.{png}"],
};
process.env.SWA_CLI_APP_ARTIFACT_LOCATION = "/";
process.env.SWA_CLI_OUTPUT_LOCATION = "/";
mockFs({
"/no-file": "",

Просмотреть файл

@ -1,14 +1,15 @@
import http from "http";
import fs from "fs";
import path from "path";
import globalyzer from "globalyzer";
import globrex from "globrex";
import http from "http";
import path from "path";
import { logger } from "../../../core";
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes
export const navigationFallback = async (
req: http.IncomingMessage,
_res: http.ServerResponse,
res: http.ServerResponse,
navigationFallback: SWAConfigFileNavigationFallback
) => {
let originlUrl = req.url;
@ -31,7 +32,7 @@ export const navigationFallback = async (
// is the requested file available on disk?
const filename = originlUrl?.endsWith("/") ? `${originlUrl}/index.html` : originlUrl;
const filepath = path.join(process.env.SWA_CLI_APP_ARTIFACT_LOCATION!, filename!);
const filepath = path.join(process.env.SWA_CLI_OUTPUT_LOCATION!, filename!);
const isFileFoundOnDisk = fs.existsSync(filepath);
@ -39,50 +40,50 @@ export const navigationFallback = async (
const isMatchedFilter = navigationFallback.exclude.some((filter) => {
// we don't support full globs in the config file.
// add this little workaround to convert a wildcard into a valid glob pattern
filter = filter.replace("/*", "/**/*");
filter = filter.replace("*", "**/*");
// extract glob metadata
const globSegments = globalyzer(filter);
if (originlUrl?.startsWith(globSegments.base)) {
const { regex } = globrex(globSegments.glob, { globstar: true, extended: true });
const { regex } = globrex(globSegments.glob, { globstar: true, extended: true, filepath: true });
// extract the last segment (what comes after the base) from the URL:
// / => <empty string>
// /bar.gif => bar.gif
// /images/foo/bar.gif => bar.gif
let lastSegmentFromUrl = originlUrl.replace(`${globSegments.base}`, "");
// globrex generates regex that doesn't match leading forwardslash, so we remove it
// before processing the regex
let originlUrlWithoutLeadingSlash = originlUrl?.replace(/^\//, "");
// globrex generates regex that doesn't match leading forwardslash, so we remove it
// before processing the regex
lastSegmentFromUrl = lastSegmentFromUrl.replace(/^\//, "");
return regex.test(lastSegmentFromUrl!);
} else {
return false;
}
return regex.test(originlUrlWithoutLeadingSlash!);
});
// rules logic:
// if no exclude rules are provided, rewrite by default
// if a file exists on disk, and match exclusion => return it
// if a file doesn't exist on disk, and match exclusion => 404
// if a file exists on disk, and doesn't match exclusion => /index.html
// if a file doesn't exist on disk, and doesn't match exclusion => /index.html
// 1. if no exclude rules are provided, rewrite by default
// 2. if a file exists on disk, and match exclusion => return it
// 3. if a file doesn't exist on disk, and match exclusion => 404
// 4. if a file exists on disk, and doesn't match exclusion => /index.html
// 5. if a file doesn't exist on disk, and doesn't match exclusion => /index.html
// note: given the complexity of all possible combinations, don't refactor the code below
let newUrl = req.url;
// 1.
if (!navigationFallback.exclude || navigationFallback.exclude.length === 0) {
newUrl = navigationFallback.rewrite;
} else if (isFileFoundOnDisk === true && isMatchedFilter === true) {
}
// 2.
else if (isFileFoundOnDisk === true && isMatchedFilter === true) {
newUrl = req.url;
} else if (isFileFoundOnDisk === false && isMatchedFilter === true) {
newUrl = req.url;
} else if (isFileFoundOnDisk === true && isMatchedFilter === false) {
}
// 3.
else if (isFileFoundOnDisk === false && isMatchedFilter === true) {
res.statusCode = 404;
}
// 4.
else if (isFileFoundOnDisk === true && isMatchedFilter === false) {
newUrl = navigationFallback.rewrite;
} else if (isFileFoundOnDisk === false && isMatchedFilter === false) {
}
// 5.
else if (isFileFoundOnDisk === false && isMatchedFilter === false) {
newUrl = navigationFallback.rewrite;
}
logger.silly({ filepath, isMatchedFilter });
req.url = newUrl;
};

Просмотреть файл

@ -1,3 +1,4 @@
import { DEFAULT_CONFIG } from "../../../config";
import { responseOverrides } from "./responseOverrides";
describe("responseOverrides()", () => {
@ -52,7 +53,7 @@ describe("responseOverrides()", () => {
};
await responseOverrides(req, res, userConfig);
expect(req.url).toBe("/foo");
expect(req.url).toBe(`${DEFAULT_CONFIG.customUrlScheme}/foo`);
});
it("should override redirect rule", async () => {

Просмотреть файл

@ -1,4 +1,5 @@
import http from "http";
import { DEFAULT_CONFIG } from "../../../config";
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#response-overrides
export const responseOverrides = async (req: http.IncomingMessage, res: http.ServerResponse, responseOverrides: SWAConfigFileResponseOverrides) => {
@ -15,7 +16,7 @@ export const responseOverrides = async (req: http.IncomingMessage, res: http.Ser
res.setHeader("Location", overridenStatusCode.redirect);
}
if (overridenStatusCode.rewrite && req.url !== overridenStatusCode.rewrite) {
req.url = overridenStatusCode.rewrite;
req.url = `${DEFAULT_CONFIG.customUrlScheme}${overridenStatusCode.rewrite}`;
}
}
}

Просмотреть файл

@ -19,7 +19,7 @@ const SWA_CLI_PORT = parseInt((process.env.SWA_CLI_PORT || DEFAULT_CONFIG.port)
const SWA_CLI_API_URI = address(SWA_CLI_HOST, process.env.SWA_CLI_API_PORT);
const SWA_CLI_APP_LOCATION = (process.env.SWA_CLI_APP_LOCATION || DEFAULT_CONFIG.appLocation) as string;
const SWA_CLI_ROUTES_LOCATION = (process.env.SWA_CLI_ROUTES_LOCATION || DEFAULT_CONFIG.routesLocation) as string;
const SWA_CLI_APP_ARTIFACT_LOCATION = (process.env.SWA_CLI_APP_ARTIFACT_LOCATION || DEFAULT_CONFIG.outputLocation) as string;
const SWA_CLI_OUTPUT_LOCATION = (process.env.SWA_CLI_OUTPUT_LOCATION || DEFAULT_CONFIG.outputLocation) as string;
const SWA_CLI_API_LOCATION = (process.env.SWA_CLI_API_LOCATION || DEFAULT_CONFIG.apiLocation) as string;
const SWA_CLI_APP_SSL = process.env.SWA_CLI_APP_SSL === "true" || DEFAULT_CONFIG.ssl === true;
const SWA_CLI_APP_SSL_KEY = process.env.SWA_CLI_APP_SSL_KEY as string;
@ -29,7 +29,7 @@ const PROTOCOL = SWA_CLI_APP_SSL ? `https` : `http`;
const proxyApi = httpProxy.createProxyServer({ autoRewrite: true });
const proxyApp = httpProxy.createProxyServer({ autoRewrite: true });
const isStaticDevServer = isHttpUrl(SWA_CLI_APP_ARTIFACT_LOCATION);
const isStaticDevServer = isHttpUrl(SWA_CLI_OUTPUT_LOCATION);
const isApiDevServer = isHttpUrl(SWA_CLI_API_LOCATION);
if (!isHttpUrl(SWA_CLI_API_URI)) {
@ -142,21 +142,28 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
await applyRules(req, res, userConfig);
if ([401, 403, 404].includes(res.statusCode)) {
logRequest(req, null, res.statusCode);
const isCustomUrl = req.url.startsWith(DEFAULT_CONFIG.customUrlScheme!);
switch (res.statusCode) {
case 401:
req.url = "unauthorized.html";
break;
case 403:
// @TODO provide a Forbidden HTML template
req.url = "unauthorized.html";
break;
case 404:
req.url = "404.html";
break;
if (isCustomUrl) {
// extract user custom url
req.url = req.url.replace(`${DEFAULT_CONFIG.customUrlScheme}`, "");
} else {
switch (res.statusCode) {
case 401:
req.url = "unauthorized.html";
break;
case 403:
// @TODO provide a Forbidden HTML template
req.url = "unauthorized.html";
break;
case 404:
req.url = "404.html";
break;
}
return serve(SWA_PUBLIC_DIR, req, res);
}
return serve(SWA_PUBLIC_DIR, req, res);
logRequest(req, null, res.statusCode);
}
}
@ -204,7 +211,7 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
// proxy APP requests
else {
const target = SWA_CLI_APP_ARTIFACT_LOCATION;
const target = SWA_CLI_OUTPUT_LOCATION;
// is this a dev server?
if (isStaticDevServer) {
@ -237,7 +244,7 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
const onWsUpgrade = function (req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
socketConnection = socket;
const target = SWA_CLI_APP_ARTIFACT_LOCATION;
const target = SWA_CLI_OUTPUT_LOCATION;
if (isStaticDevServer) {
logger.log(chalk.green("** WebSocket connection established **"));
@ -259,13 +266,13 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
// prettier-ignore
logger.log(
`\nUsing dev server for static content:\n`+
` ${chalk.green(SWA_CLI_APP_ARTIFACT_LOCATION)}`
` ${chalk.green(SWA_CLI_OUTPUT_LOCATION)}`
);
} else {
// prettier-ignore
logger.log(
`\nServing static content:\n` +
` ${chalk.green(SWA_CLI_APP_ARTIFACT_LOCATION)}`
` ${chalk.green(SWA_CLI_OUTPUT_LOCATION)}`
);
}

3
src/swa.d.ts поставляемый
Просмотреть файл

@ -4,7 +4,7 @@ declare global {
SWA_CLI_DEBUG: DebugFilterLevel;
SWA_CLI_API_URI: string;
SWA_CLI_APP_URI: string;
SWA_CLI_APP_ARTIFACT_LOCATION: string;
SWA_CLI_OUTPUT_LOCATION: string;
SWA_CLI_ROUTES_LOCATION: String;
SWA_CLI_HOST: string;
SWA_CLI_PORT: string;
@ -73,6 +73,7 @@ declare type SWACLIConfig = GithubActionWorkflow & {
verbose?: string;
run?: string;
routesLocation?: string;
customUrlScheme?: string;
};
declare type ResponseOptions = {