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

chore: improve rules engine logic (#181)

Fixes #178
This commit is contained in:
Wassim Chegham 2021-04-13 20:20:07 +02:00 коммит произвёл GitHub
Родитель 4674b70b3b
Коммит c26746d47f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 167 добавлений и 80 удалений

1
.gitignore поставляемый
Просмотреть файл

@ -46,3 +46,4 @@ dist
*.tgz
*.orig
cypress/videos/
cypress/screenshots/

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

@ -1,6 +1,6 @@
/// <reference types="cypress" />
const PROVIDERS = ["github", "twitter", "facebook", "google", "aad"];
const PROVIDERS = ["github", "twitter", "facebook", "aad"];
context("/.auth/me", () => {
let clientPrincipal;
@ -51,6 +51,17 @@ context("/.auth/me", () => {
});
context(`/.auth/login/<provider>`, () => {
// google has a special config (check staticwebapp.config.json)
// { "route": "/*.google", "redirect": "https://www.google.com/" }
describe(`when using provider: google`, () => {
it(`should redirect to https://www.google.com/`, async () => {
cy.visit("http://0.0.0.0:1234/.auth/login/google").then((response) => {
expect(response.status).to.be(302);
expect(response.headers.get("location")).to.be("https://www.google.com/");
});
});
});
for (let index = 0; index < PROVIDERS.length; index++) {
const provider = PROVIDERS[index];
describe(`when using provider: ${provider}`, () => {

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

@ -1,55 +1,52 @@
/// <reference types="cypress" />
context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout: 20000 /* set this for Windows */ }, () => {
beforeEach(() => {
cy.visit("http://0.0.0.0:1234");
});
it("root returns /index.html", async () => {
cy.request("/").then(() => {
cy.visit("http://0.0.0.0:1234/").then(() => {
cy.title().should("eq", "/index.html");
});
});
it("/index.html returns /index.html", async () => {
cy.request("/index.html").then(() => {
cy.visit("http://0.0.0.0:1234/index.html").then(() => {
cy.title().should("eq", "/index.html");
});
});
it("folder returns folder/index.html", async () => {
cy.request("/folder/").then(() => {
cy.visit("http://0.0.0.0:1234/folder/").then(() => {
cy.title().should("eq", "/folder/index.html");
});
});
it("rewrite to file returns correct content", async () => {
cy.request("/rewrite_index2").then(() => {
cy.visit("http://0.0.0.0:1234/rewrite_index2").then(() => {
cy.title().should("eq", "/index2.html");
});
});
it("rewrite to function returns function response", async () => {
cy.request("/rewrite-to-function").then((response) => {
cy.visit("http://0.0.0.0:1234/rewrite-to-function").then((response) => {
expect(response).to.have.property("x-swa-custom");
expect(response["x-swa-custom"]).to.be("/api/headers");
});
});
it("content response contains global headers", async () => {
cy.request("/").then((response) => {
cy.visit("http://0.0.0.0:1234/").then((response) => {
expect(response.headers.get("a")).to.be("b");
});
});
it("route headers override global headers", async () => {
cy.request("/rewrite_index2").then((response) => {
cy.visit("http://0.0.0.0:1234/rewrite_index2").then((response) => {
expect(response.headers.get("a")).to.be("c");
});
});
it("default redirect returns 302 with correct location", async () => {
cy.request("/redirect/foo").as("response");
cy.visit("http://0.0.0.0:1234/redirect/foo").as("response");
cy.get("@response")
.its("headers")
@ -62,7 +59,7 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("redirect with statusCode 302 returns 302 with correct location", async () => {
cy.request("/redirect/302").as("response");
cy.visit("http://0.0.0.0:1234/redirect/302").as("response");
cy.get("@response")
.its("headers")
@ -75,7 +72,7 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("redirect with statusCode 301 returns 301 with correct location", async () => {
cy.request("/redirect/301").as("response");
cy.visit("http://0.0.0.0:1234/redirect/301").as("response");
cy.get("@response")
.its("headers")
@ -88,27 +85,27 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("setting mimetype of unknown file type returns correct mime type", async () => {
cy.request("/test.swaconfig").then((response) => {
cy.visit("http://0.0.0.0:1234/test.swaconfig").then((response) => {
expect(response.status).to.be(200);
expect(response.headers.get("content-type")).to.be("application/json");
});
});
it("navigation fallback returns /index.html", async () => {
cy.request("/does_not_exist.html").then((response) => {
cy.visit("http://0.0.0.0:1234/does_not_exist.html").then((response) => {
expect(response.status).to.be(200);
cy.title().should("eq", "/index.html");
});
});
it("navigation fallback that's excluded returns 404", async () => {
cy.request("/does_not_exist.txt").then((response) => {
cy.visit("http://0.0.0.0:1234/does_not_exist.txt").then((response) => {
expect(response.status).to.be(404);
});
});
it("/*.foo matches extension", async () => {
cy.request("/thing.foo").as("response");
cy.visit("http://0.0.0.0:1234/thing.foo").as("response");
cy.get("@response")
.its("headers")
@ -121,7 +118,7 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("/*.{jpg} matches extension", async () => {
cy.request("/thing.jpg").as("response");
cy.visit("http://0.0.0.0:1234/thing.jpg").as("response");
cy.get("@response")
.its("headers")
@ -134,7 +131,7 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("/*.{png,gif} matches multiple extensions", async () => {
cy.request("/thing.png").as("response1");
cy.visit("http://0.0.0.0:1234/thing.png").as("response1");
cy.get("@response1")
.its("headers")
@ -145,7 +142,7 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
});
cy.request("/thing.gif").as("response2");
cy.visit("http://0.0.0.0:1234/thing.gif").as("response2");
cy.get("@response2")
.its("headers")
@ -158,14 +155,14 @@ context("route rules engine", { failOnStatusCode: false, defaultCommandTimeout:
});
it("redirect can redirect to external URL", async () => {
cy.request("/something.google").then((response) => {
cy.visit("http://0.0.0.0:1234/something.google").then((response) => {
expect(response.status).to.be(302);
expect(response.headers.get("location")).to.be("https://www.google.com/");
});
});
it("rewrite to folder returns folder's default file", async () => {
cy.request("/folder/somefile.html").then((response) => {
cy.visit("http://0.0.0.0:1234/folder/somefile.html").then((response) => {
expect(response.status).to.be(200);
cy.title().should("eq", "/folder/index.html");
});

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

@ -6,7 +6,7 @@ describe("validateCookie()", () => {
});
it("cookies = 'abc'", () => {
expect(validateCookie("")).toBe(false);
expect(validateCookie("abc")).toBe(false);
});
it("cookies = 'foo=bar'", () => {

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

@ -0,0 +1,42 @@
import { globToRegExp } from "./glob";
describe("globToRegExp()", () => {
it("glob = <EMPTY>", () => {
expect(globToRegExp("")).toBe("");
});
it("glob = abc", () => {
expect(globToRegExp("abc")).toBe("abc");
});
it("glob = foo=bar", () => {
expect(globToRegExp("foo=bar")).toBe("foo=bar");
});
it("glob = *", () => {
expect(globToRegExp("*")).toBe("*");
});
it("glob = /*", () => {
expect(globToRegExp("/*")).toBe("\\/.*");
});
it("glob = /foo/*", () => {
expect(globToRegExp("/foo/*")).toBe("\\/foo\\/.*");
});
it("glob = /*.{jpg}", () => {
expect(globToRegExp("/*.{jpg}")).toBe("\\/.*(jpg)");
});
it("glob = /*.{jpg,gif}", () => {
expect(globToRegExp("/*.{jpg,gif}")).toBe("\\/.*(jpg|gif)");
});
it("glob = /foo/*.{jpg,gif}", () => {
expect(globToRegExp("/foo/*.{jpg,gif}")).toBe("\\/foo\\/.*(jpg|gif)");
});
it("glob = {foo,bar}.json", () => {
expect(globToRegExp("{foo,bar}.json")).toBe("(foo|bar).json");
});
});

16
src/core/utils/glob.ts Normal file
Просмотреть файл

@ -0,0 +1,16 @@
/**
* Turn expression into a valid regex
*/
export function globToRegExp(glob: string) {
const filesExtensionMatch = glob.match(/{.*}/);
if (filesExtensionMatch) {
const filesExtensionExpression = filesExtensionMatch[0];
if (filesExtensionExpression) {
// build a regex group (png|jpg|gif)
const filesExtensionRegEx = filesExtensionExpression.replace(/\,/g, "|").replace("{", "(").replace("}", ")");
glob = glob.replace(filesExtensionExpression, filesExtensionRegEx);
}
}
return glob.replace(/\//g, "\\/").replace("*.", ".*").replace("/*", "/.*");
}

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

@ -23,8 +23,6 @@ export async function applyRules(req: IncomingMessage, res: ServerResponse, user
const filepath = path.join(process.env.SWA_CLI_OUTPUT_LOCATION!, req.url!);
const isFileFound = fs.existsSync(filepath);
logger.silly("checking rules...");
// note: these rules are mutating the req and res objects
await navigationFallback(req, res, userConfig.navigationFallback);
@ -40,6 +38,7 @@ export async function applyRules(req: IncomingMessage, res: ServerResponse, user
matchedRoute: userDefinedRoute,
filepath,
isFileFound,
navigationFallback: { statusCode: res.statusCode, url: req.url },
statusCode: res.statusCode,
url: req.url,
});
}

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

@ -180,7 +180,8 @@ describe("customRoutes()", () => {
};
await customRoutes(req, res, userRouteConfig);
expect(res.writeHead).toHaveBeenCalledWith(302, { Location: "/bar.html" });
expect(res.setHeader).toHaveBeenCalledWith("Location", "/bar.html");
expect(res.statusCode).toBe(302);
});
it("should serve with redirect (statusCode=302)", async () => {
@ -191,7 +192,8 @@ describe("customRoutes()", () => {
};
await customRoutes(req, res, userRouteConfig);
expect(res.writeHead).toHaveBeenCalledWith(302, { Location: "/bar" });
expect(res.setHeader).toHaveBeenCalledWith("Location", "/bar");
expect(res.statusCode).toBe(302);
});
it("should serve with redirect (statusCode=301)", async () => {
@ -202,7 +204,8 @@ describe("customRoutes()", () => {
};
await customRoutes(req, res, userRouteConfig);
expect(res.writeHead).toHaveBeenCalledWith(301, { Location: "/bar" });
expect(res.setHeader).toHaveBeenCalledWith("Location", "/bar");
expect(res.statusCode).toBe(301);
});
it("should not serve with redirect (statusCode=200)", async () => {

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

@ -1,7 +1,8 @@
import globalyzer from "globalyzer";
import globrex from "globrex";
// import globalyzer from "globalyzer";
// import globrex from "globrex";
import http from "http";
import { decodeCookie } from "../../../core";
import { decodeCookie, logger } from "../../../core";
import { globToRegExp } from "../../../core/utils/glob";
export const matchRoute = (req: http.IncomingMessage, isLegacyConfigFile: boolean) => {
const sanitizedUrl = new URL(req.url!, `http://${req?.headers?.host}`);
@ -28,30 +29,9 @@ export const matchRoute = (req: http.IncomingMessage, isLegacyConfigFile: boolea
}
// 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("/*", "/**/*");
// extract glob metadata
const globSegments = globalyzer(filter);
// if filter and url segments don't have a commom base path
// don't process regex, just return false
if (originlUrl.startsWith(globSegments.base)) {
const { regex } = globrex(globSegments.glob, { globstar: true, extended: 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
lastSegmentFromUrl = lastSegmentFromUrl.replace(/^\//, "");
return regex.test(lastSegmentFromUrl!);
} else {
return false;
}
// add this little utility to convert a wildcard into a valid glob pattern
const regexp = new RegExp(`^${globToRegExp(filter)}$`);
return regexp.test(originlUrl!);
};
};
@ -61,6 +41,9 @@ export const customRoutes = async (req: http.IncomingMessage, res: http.ServerRe
}
if (userDefinedRoute) {
logger.silly("checking routes rule...");
logger.silly({ userDefinedRoute });
// set headers
if (userDefinedRoute.headers) {
for (const header in userDefinedRoute.headers) {
@ -106,10 +89,8 @@ export const customRoutes = async (req: http.IncomingMessage, res: http.ServerRe
// redirects
// note: adding checks to avoid ERR_TOO_MANY_REDIRECTS
if (route !== req.url) {
res.writeHead(Number(userDefinedRoute.statusCode) || 302, {
Location: route,
});
res.end();
res.setHeader("Location", route);
res.statusCode = Number(userDefinedRoute.statusCode) || 302;
}
}
}

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

@ -1,12 +1,19 @@
import http from "http";
import type http from "http";
import { logger } from "../../../core";
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#global-headers
export const globalHeaders = async (_req: http.IncomingMessage, res: http.ServerResponse, globalHeaders: SWAConfigFileGlobalHeaders) => {
logger.silly("checking globalHeaders rule...");
for (const header in globalHeaders) {
if (globalHeaders[header] === "") {
res.removeHeader(header);
logger.silly(` - removing header: ${header}`);
} else {
res.setHeader(header, globalHeaders[header]);
logger.silly(` - adding header: ${header}=${globalHeaders[header]}`);
}
}
};

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

@ -1,9 +1,10 @@
import fs from "fs";
import globalyzer from "globalyzer";
import globrex from "globrex";
// import globalyzer from "globalyzer";
// import globrex from "globrex";
import http from "http";
import path from "path";
import { logger } from "../../../core";
import { globToRegExp } from "../../../core/utils/glob";
// See: https://docs.microsoft.com/en-us/azure/static-web-apps/configuration#fallback-routes
@ -24,6 +25,8 @@ export const navigationFallback = async (
return;
}
logger.silly("checking navigationFallback rule...");
// make sure we have a leading / in the URL
if (navigationFallback.rewrite.startsWith("/") === false) {
navigationFallback.rewrite = `/${navigationFallback.rewrite}`;
@ -31,29 +34,27 @@ 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_OUTPUT_LOCATION!, filename!);
const isFileFoundOnDisk = fs.existsSync(filepath);
logger.silly(` - url ${originlUrl}`);
// parse the exclusion rules and match at least one rule
const isMatchedFilter = navigationFallback.exclude.some((filter) => {
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("*", "**/*");
// add this little utility to convert a wildcard into a valid glob pattern
const regexp = new RegExp(`^${globToRegExp(filter)}$`);
const isMatch = regexp.test(originlUrl!);
// extract glob metadata
const globSegments = globalyzer(filter);
logger.silly(` - filter= ${filter}`);
logger.silly(` - regexp= ${regexp}`);
logger.silly(` - match= ${isMatch}`);
const { regex } = globrex(globSegments.glob, { globstar: true, extended: true, filepath: true });
// globrex generates regex that doesn't match leading forwardslash, so we remove it
// before processing the regex
let originlUrlWithoutLeadingSlash = originlUrl?.replace(/^\//, "");
return regex.test(originlUrlWithoutLeadingSlash!);
return isMatch;
});
logger.silly(` - isMatchedFilter=${isMatchedFilter}`);
// rules logic:
// 1. if no exclude rules are provided, rewrite by default
// 2. if a file exists on disk, and match exclusion => return it
@ -66,24 +67,38 @@ export const navigationFallback = async (
// 1.
if (!navigationFallback.exclude || navigationFallback.exclude.length === 0) {
newUrl = navigationFallback.rewrite;
logger.silly(` - no exclude rules are provided (rewrite by default)`);
logger.silly(` - url=${newUrl}`);
}
// 2.
else if (isFileFoundOnDisk === true && isMatchedFilter === true) {
newUrl = req.url;
logger.silly(` - file exists on disk, and match exclusion`);
logger.silly(` - url=${newUrl}`);
}
// 3.
else if (isFileFoundOnDisk === false && isMatchedFilter === true) {
res.statusCode = 404;
logger.silly(` - file doesn't exist on disk, and match exclusion`);
logger.silly(` - statusCode=404`);
}
// 4.
else if (isFileFoundOnDisk === true && isMatchedFilter === false) {
newUrl = navigationFallback.rewrite;
logger.silly(` - file exists on disk, and doesn't match exclusion`);
logger.silly(` - url=${newUrl}`);
}
// 5.
else if (isFileFoundOnDisk === false && isMatchedFilter === false) {
newUrl = navigationFallback.rewrite;
logger.silly(` - file doesn't exist on disk, and doesn't match exclusion`);
logger.silly(` - url=${newUrl}`);
}
logger.silly({ filepath, isMatchedFilter });
req.url = newUrl;
};

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

@ -1,5 +1,6 @@
import http from "http";
import { DEFAULT_CONFIG } from "../../../config";
import { logger } from "../../../core";
// 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) => {
@ -9,14 +10,22 @@ export const responseOverrides = async (req: http.IncomingMessage, res: http.Ser
const overridenStatusCode = responseOverrides?.[`${statusCode}`];
if (overridenStatusCode) {
logger.silly("checking responseOverrides rule...");
if (overridenStatusCode.statusCode) {
res.statusCode = overridenStatusCode.statusCode;
logger.silly(` - statusCode: ${statusCode}`);
}
if (overridenStatusCode.redirect) {
res.setHeader("Location", overridenStatusCode.redirect);
logger.silly(` - Location: ${overridenStatusCode.redirect}`);
}
if (overridenStatusCode.rewrite && req.url !== overridenStatusCode.rewrite) {
req.url = `${DEFAULT_CONFIG.customUrlScheme}${overridenStatusCode.rewrite}`;
logger.silly(` - url: ${req.url}`);
}
}
}

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

@ -141,6 +141,12 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
if (userConfig) {
await applyRules(req, res, userConfig);
// in case a redirect rule has been applied, flush response
if (res.getHeader("Location")) {
logRequest(req, null, res.statusCode);
return res.end();
}
if ([401, 403, 404].includes(res.statusCode)) {
const isCustomUrl = req.url.startsWith(DEFAULT_CONFIG.customUrlScheme!);
@ -167,7 +173,7 @@ const requestHandler = (userConfig: SWAConfigFile | null) =>
}
}
// don't serve user custom routes file
// don't serve staticwebapp.config.json / routes.json
if (req.url.endsWith(DEFAULT_CONFIG.swaConfigFilename!) || req.url.endsWith(DEFAULT_CONFIG.swaConfigFilenameLegacy!)) {
req.url = "404.html";
res.statusCode = 404;