rollup-plugin-htaccess
Version:
A rollup/vite plugin to generate .htaccess files
629 lines • 22.5 kB
JavaScript
import {findAll}from'domutils';import {glob}from'glob';import {parseDocument,ElementType}from'htmlparser2';import {join}from'path';import {readFile as readFile$1,writeFile as writeFile$1}from'fs';function buildAddOutputFilterByType(spec) {
const output = [];
for (const directive of spec) {
output.push(`AddOutputFilterByType ${directive.filters.join(";")} ${directive.mediaTypes.join(" ")}`);
}
return output.join("\n");
}function buildDirectorySlash(spec) {
return `DirectorySlash ${spec.value ? "On" : "Off"}\n`;
}function buildErrorDocument(spec) {
const output = [];
for (const errorCode in spec) {
if (!Object.prototype.hasOwnProperty.call(spec, errorCode)) {
continue;
}
let doc = spec[errorCode];
if (doc.includes(" ")) {
doc = `"${doc}"`;
}
output.push(`ErrorDocument ${errorCode} ${doc}`);
}
return output.join("\n");
}function buildFiles(context, spec) {
if (spec.fileName.includes("/")) {
context.error("The <Files> directive cannot contain files in subfolders. Use a separate .htaccess file or the <If> directive.");
}
return `<Files "${escapeValue(spec.fileName)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</Files>`;
}function buildFilesMatch(context, spec) {
return `<FilesMatch "${escapeValue(spec.regex)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</FilesMatch>`;
}function buildContentSecurityPolicyValue(spec) {
const parts = [];
for (const directive in spec) {
if (!Object.prototype.hasOwnProperty.call(spec, directive)) {
continue;
}
parts.push(buildPart(directive, spec[directive]));
}
return parts.join("; ");
}
function buildPart(directive,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to correctly infer value type
valueSpec) {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- Default is correct here
switch (directive) {
case "report-to":
return `report-to ${valueSpec}`;
case "report-uri":
return ["report-uri", ...valueSpec].join(" ");
case "require-trusted-types-for":
return "require-trusted-types-for 'script'";
case "sandbox":
return buildSandboxPart(valueSpec);
case "trusted-types":
return buildTrustedTypesPart(valueSpec);
case "upgrade-insecure-requests":
return "upgrade-insecure-requests";
default:
return buildSourcePart(directive, valueSpec);
}
}
function buildSandboxPart(valueSpec) {
if (valueSpec !== null) {
return `sandbox ${valueSpec}`;
}
return "sandbox";
}
function buildSourcePart(directive, sourceSpec) {
const sources = [];
for (const source of [
"self",
"unsafe-eval",
"wasm-unsafe-eval",
"unsafe-hashes",
"unsafe-inline",
"strict-dynamic",
"report-sample",
"inline-speculation-rules",
]) {
if (sourceSpec[source] === true) {
sources.push(`'${source}'`);
}
}
if (sourceSpec.schemes !== undefined) {
for (const scheme of [
"data",
"mediastream",
"blob",
"filesystem",
]) {
if (sourceSpec.schemes[scheme] === true) {
sources.push(`${scheme}:`);
}
}
}
if (sourceSpec.nonces !== undefined) {
sources.push(...sourceSpec.nonces.map((nonce) => `'nonce-${nonce}'`));
}
if (sourceSpec.hashes !== undefined) {
for (const algo of ["sha256", "sha384", "sha512"]) {
const hashes = sourceSpec.hashes[algo];
if (hashes !== undefined) {
sources.push(...hashes.map((hash) => `'${algo}-${hash}'`));
}
}
}
if (sourceSpec.hosts !== undefined) {
sources.push(...sourceSpec.hosts.map(escapeValue));
}
if (sources.length === 0) {
sources.push("'none'");
}
return `${directive} ${sources.join(" ")}`;
}
function buildTrustedTypesPart(valueSpec) {
const parts = ["trusted-types", ...(valueSpec.policies ?? [])];
if (valueSpec["allow-duplicates"] === true) {
parts.push("'allow-duplicates'");
}
return parts.join(" ");
}function buildPermissionsPolicyValue(spec) {
const parts = [];
for (const key in spec) {
if (!Object.prototype.hasOwnProperty.call(spec, key)) {
continue;
}
const allowlistSpec = spec[key];
if (allowlistSpec === undefined) {
continue;
}
parts.push(`${key}=${buildAllowlist(allowlistSpec)}`);
}
return parts.join(", ");
}
function buildAllowlist(allowlist) {
if (allowlist === "*") {
return "*";
}
const list = allowlist.origins?.map((origin) => `\\"${escapeValue(origin)}\\"`) ?? [];
if (allowlist.src === true) {
list.unshift("src");
}
if (allowlist.self === true) {
list.unshift("self");
}
return `(${list.join(" ")})`;
}function buildReferrerPolicyValue(spec) {
return spec;
}function buildStrictTransportSecurityValue(context, spec) {
if (spec.preload === true) {
if (spec.maxAge < 31536000) {
context.error("The strict transport security header with preloading requires max age >= 31536000.");
}
if (spec.includeSubDomains !== true) {
context.error("The strict transport security header with preloading requires subdomains to be included.");
}
}
return `max-age=${spec.maxAge.toString()}${spec.includeSubDomains === true ? "; includeSubDomains" : ""}${spec.preload === true ? "; preload" : ""}`;
}function buildXContentTypeOptionsValue() {
return "nosniff";
}// eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal deprecation
function buildXFrameOptionsValue(spec) {
return spec === "sameorigin" ? "SAMEORIGIN" : "DENY";
}// eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal deprecation
function buildXXssProtectionValue(spec) {
switch (spec.mode) {
case "block":
return "1; mode=block";
case "sanitize":
if (spec.reportUri !== undefined) {
return `1; report=${escapeValue(spec.reportUri)}`;
}
return "1";
case "disabled":
default:
return "0";
}
}/* eslint-enable */
function buildHeader(context, spec) {
const parts = ["Header"];
if (spec.always === true) {
parts.push("always");
}
parts.push(spec.action, spec.header);
if (["add", "append", "merge", "set", "setifempty"].includes(spec.action)) {
parts.push(`"${buildHeaderValue(context, spec.header, spec.value)}"`);
}
else if (["edit", "edit*"].includes(spec.action)) {
parts.push(`"${escapeValue(spec.value)}"`);
parts.push(`"${escapeValue(spec.replacement)}"`);
}
if (spec.condition !== undefined) {
if ("envVar" in spec.condition) {
parts.push(`env=${spec.condition.requireUnset === true ? "!" : ""}${spec.condition.envVar}`);
}
else if ("expression" in spec.condition) {
parts.push(`"expr=${escapeValue(spec.condition.expression)}"`);
}
}
return parts.join(" ");
}
// eslint-disable-next-line consistent-return -- Clashes with typescript unreachable code check
function buildHeaderValue(context, header,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to correctly infer value type
value) {
switch (header) {
case "Content-Security-Policy":
return buildContentSecurityPolicyValue(value);
case "Permissions-Policy":
return buildPermissionsPolicyValue(value);
case "Referrer-Policy":
return buildReferrerPolicyValue(value);
case "Strict-Transport-Security":
return buildStrictTransportSecurityValue(context, value);
case "X-Content-Type-Options":
return buildXContentTypeOptionsValue();
case "X-Frame-Options":
return buildXFrameOptionsValue(value);
case "X-Xss-Protection":
return buildXXssProtectionValue(value);
default:
context.error(`Unknown header type "${header}".`);
}
}function buildIf(context, spec) {
let output = `<If "${escapeValue(spec.condition)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</If>`;
for (const elseIfSpec of spec.ElseIf ?? []) {
output += `\n<ElseIf "${escapeValue(elseIfSpec.condition)}">\n${buildInnerSpec(context, elseIfSpec.innerSpec)}\n</ElseIf>`;
}
if (spec.Else !== undefined) {
output += `\n<Else>\n${buildInnerSpec(context, spec.Else.innerSpec)}\n</Else>`;
}
return output;
}function buildIfDefine(context, spec) {
return `<IfDefine ${spec.inverted === true ? "!" : ""}${spec.parameterName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfDefine>`;
}function buildIfDirective(context, spec) {
return `<IfDirective ${spec.inverted === true ? "!" : ""}${spec.directiveName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfDirective>`;
}function buildIfFile(context, spec) {
return `<IfFile ${spec.inverted === true ? "!" : ""}${spec.fileName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfFile>`;
}function buildIfModule(context, spec) {
return `<IfModule ${spec.inverted === true ? "!" : ""}${spec.moduleNameOrIdentifier}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfModule>`;
}function buildIfSection(context, spec) {
return `<IfSection ${spec.inverted === true ? "!" : ""}${spec.sectionName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfSection>`;
}function buildOptions(spec) {
if (spec === "None") {
return "Options None";
}
const output = ["Options"];
if ("set" in spec) {
output.push(spec.set.join(" "));
}
if ("plus" in spec && spec.plus !== undefined) {
output.push(spec.plus.map((option) => `+${option}`).join(" "));
}
if ("minus" in spec && spec.minus !== undefined) {
output.push(spec.minus.map((option) => `-${option}`).join(" "));
}
return output.join(" ");
}function buildRewrite(spec) {
const output = [];
if (spec.base !== undefined) {
output.push(`RewriteBase "${escapeValue(spec.base)}"`);
}
if (spec.options !== undefined) {
output.push(...buildRewriteOptions(spec.options));
}
if (spec.rules !== undefined) {
output.push(...buildRewriteRules(spec.rules));
}
if (output.length > 0) {
output.unshift("RewriteEngine on");
}
return output.join("\n");
}
function buildRewriteCondition(spec) {
const flags = [];
if (spec.flags?.nocase === true) {
flags.push("NC");
}
if (spec.flags?.ornext === true) {
flags.push("OR");
}
if (spec.flags?.novary === true) {
flags.push("NV");
}
return `RewriteCond "${spec.testString}" "${spec.conditionPattern}"${flags.length > 0 ? ` [${flags.join(",")}]` : ""}`;
}
function buildRewriteOptions(spec) {
const output = [];
for (const option in spec) {
if (spec[option] === true) {
output.push(`RewriteOptions ${option}`);
}
}
return output;
}
function buildRewriteRuleCookieFlag(spec) {
const output = [spec.name, spec.value, spec.domain];
if (spec.lifetime !== undefined) {
output.push(spec.lifetime.toString());
}
if ("path" in spec && spec.path !== undefined) {
output.push(spec.path);
}
if ("secure" in spec && spec.secure === true) {
output.push("true");
}
if ("httponly" in spec && spec.httponly === true) {
output.push("true");
}
if ("samesite" in spec && spec.samesite !== undefined) {
output.push(spec.samesite);
}
const containsColon = output.some((field) => field.includes(":"));
return `CO=${containsColon ? ";" : ""}${output.join(containsColon ? ";" : ":")}`;
}
function buildRewriteRuleFlags(flags) {
const output = [];
let requireQuotes = false;
if (flags.B === true) {
output.push("B");
}
if (typeof flags.B === "string") {
output.push(`B=${flags.B}`);
if (flags.B.includes(" ")) {
requireQuotes = true;
}
}
if (flags.backrefnoplus === true) {
output.push("BNP");
}
if (flags.BCTLS === true) {
output.push("BCTLS");
}
if (flags.BNE !== undefined) {
output.push(`BNE=${flags.BNE}`);
if (flags.BNE.includes(" ")) {
requireQuotes = true;
}
}
if (flags.chain === true) {
output.push("C");
}
if (flags.cookie !== undefined) {
output.push(buildRewriteRuleCookieFlag(flags.cookie));
}
if (flags.discardpath === true) {
output.push("DPI");
}
if (flags.env !== undefined) {
let envValue = flags.env.variable;
if (flags.env.value === null) {
envValue = `!${envValue}`;
}
else if (flags.env.value !== "") {
envValue += `:${flags.env.value}`;
}
output.push(`E=${envValue}`);
}
if (flags.END === true) {
output.push("END");
}
if (flags.forbidden === true) {
output.push("F");
}
if (flags.gone === true) {
output.push("G");
}
if (flags.handler !== undefined) {
output.push(`H=${flags.handler}`);
}
if (flags.last === true) {
output.push("L");
}
if (flags.next === true) {
output.push("N");
}
if (flags.nocase === true) {
output.push("NC");
}
if (flags.noescape === true) {
output.push("NE");
}
if (flags.nosubreq === true) {
output.push("NS");
}
if (flags.proxy === true) {
output.push("P");
}
if (flags.qsappend === true) {
output.push("QSA");
}
if (flags.qsdiscard === true) {
output.push("QSD");
}
if (flags.qslast === true) {
output.push("QSL");
}
if (flags.redirect !== undefined) {
output.push(`R=${flags.redirect.toString()}`);
}
if (flags.skip !== undefined) {
output.push(`S=${flags.skip.toString()}`);
}
if (flags.type !== undefined) {
output.push(`T=${flags.type}`);
}
if (flags.UnsafeAllow3F === true) {
output.push("UnsafeAllow3F");
}
if (flags.UnsafePrefixStat === true) {
output.push("UnsafePrefixStat");
}
if (output.length === 0) {
return "";
}
const quote = requireQuotes ? '"' : "";
return ` ${quote}[${output.join(",")}]${quote}`;
}
function buildRewriteRules(spec) {
const output = [];
for (const rule of spec) {
for (const condition of rule.conditions ?? []) {
output.push(buildRewriteCondition(condition));
}
const flags = rule.flags !== undefined ? buildRewriteRuleFlags(rule.flags) : "";
output.push(`RewriteRule "${escapeValue(rule.pattern)}" "${escapeValue(rule.substitution ?? "-")}"${flags}`);
}
return output;
}function buildSpec(context, spec) {
let output = "";
if (spec.AddOutputFilterByType !== undefined) {
output += `${buildAddOutputFilterByType(spec.AddOutputFilterByType)}\n`;
}
if (spec.DirectorySlash !== undefined) {
output += `${buildDirectorySlash(spec.DirectorySlash)}\n`;
}
if (spec.ErrorDocument !== undefined) {
output += `${buildErrorDocument(spec.ErrorDocument)}\n`;
}
if (spec.Options !== undefined) {
output += `${buildOptions(spec.Options)}\n`;
}
for (const header of spec.Header ?? []) {
output += `${buildHeader(context, header)}\n`;
}
if (spec.rewrite !== undefined) {
output += `${buildRewrite(spec.rewrite)}\n`;
}
for (const files of spec.Files ?? []) {
output += `${buildFiles(context, files)}\n`;
}
for (const filesMatch of spec.FilesMatch ?? []) {
output += `${buildFilesMatch(context, filesMatch)}\n`;
}
for (const ifSpec of spec.If ?? []) {
output += `${buildIf(context, ifSpec)}\n`;
}
for (const ifDefineSpec of spec.IfDefine ?? []) {
output += `${buildIfDefine(context, ifDefineSpec)}\n`;
}
for (const ifDirectiveSpec of spec.IfDirective ?? []) {
output += `${buildIfDirective(context, ifDirectiveSpec)}\n`;
}
for (const ifFileSpec of spec.IfFile ?? []) {
output += `${buildIfFile(context, ifFileSpec)}\n`;
}
for (const ifModuleSpec of spec.IfModule ?? []) {
output += `${buildIfModule(context, ifModuleSpec)}\n`;
}
for (const ifSectionSpec of spec.IfSection ?? []) {
output += `${buildIfSection(context, ifSectionSpec)}\n`;
}
return output;
}function buildInnerSpec(context, innerSpec) {
return buildSpec(context, innerSpec).trim().replace(/^/gmu, "\t");
}
function escapeRegexString(input) {
return input
.replace(/([\\^.$|()[*+?{#])/gu, "\\$1")
.replace(/\//gu, "\\x{2f}");
}
function escapeValue(value) {
return value.replace(/"/gu, '\\"');
}
async function readFile(path) {
return new Promise((resolve, reject) => {
readFile$1(path, "utf8", (err, data) => {
if (err !== null) {
reject(err);
}
resolve(data);
});
});
}
async function writeFile(path, contents) {
return new Promise((resolve) => {
writeFile$1(path, contents, () => {
resolve();
});
});
}let outputOptions = undefined;
function extractMetaCSP(options) {
if (!options.extractMetaCSP.enabled) {
return {};
}
return {
closeBundle: closeBundle(options.extractMetaCSP, options.fileName),
renderStart,
};
}
function closeBundle(options, htaccessFileName) {
return {
async handler() {
const outputDir = options.outputDir ?? outputOptions?.dir ?? process.cwd();
const defaultPolicy = options.defaultPolicyFile !== undefined
? await extractCSPValueFromHTMLFile(this, join(outputDir, options.defaultPolicyFile))
: null;
const perFilePolicyFiles = await glob(options.perFilePolicyFiles ?? [], {
cwd: outputDir,
});
perFilePolicyFiles.sort();
const perFilePolicies = Object.fromEntries((await Promise.all(perFilePolicyFiles.map(async (fileName) => [
fileName,
await extractCSPValueFromHTMLFile(this, join(outputDir, fileName)),
]))).filter(([_, policy]) => policy !== null));
await writeCSPValuesToHtaccessFile(defaultPolicy, perFilePolicies, options, htaccessFileName);
},
order: "post",
sequential: true,
};
}
async function extractCSPValueFromHTMLFile(context, fileName) {
let fileContents = "";
try {
fileContents = await readFile(fileName);
}
catch {
return null;
}
const dom = parseDocument(fileContents, {
withEndIndices: true,
withStartIndices: true,
});
const cspMetaElems = findAll((elem) => elem.type === ElementType.Tag &&
elem.name === "meta" &&
"http-equiv" in elem.attribs &&
elem.attribs["http-equiv"].toLowerCase() === "content-security-policy", dom.children);
const cspValues = [
...new Set(cspMetaElems.map((elem) => elem.attribs["content"])),
];
if (cspValues.length < 1) {
return null;
}
if (cspValues.length > 1) {
context.error("Found multiple conflicting CSP directives when extracting from meta tags.");
}
for (const cspMetaElem of cspMetaElems) {
if (cspMetaElem.startIndex !== null && cspMetaElem.endIndex !== null) {
fileContents =
fileContents.substring(0, cspMetaElem.startIndex) +
fileContents.substring(cspMetaElem.endIndex + 1);
}
}
await writeFile(fileName, fileContents);
return cspValues[0];
}
function renderStart(outputOptionsValue) {
outputOptions = outputOptionsValue;
}
async function writeCSPValuesToHtaccessFile(defaultPolicy, perFilePolicies, options, htaccessFileName) {
const path = join(options.outputDir ?? outputOptions?.dir ?? process.cwd(), htaccessFileName);
let fileContents = await readFile(path).catch(() => "");
if (defaultPolicy !== null) {
fileContents += `Header always set Content-Security-Policy "${escapeValue(defaultPolicy)}"\n`;
}
function constructPerFileDirective([fileName, policy]) {
const innerDirective = `\n\tHeader always set Content-Security-Policy "${escapeValue(policy)}"\n`;
if (!fileName.includes("/")) {
return `<Files "${escapeValue(fileName)}">${innerDirective}</Files>\n`;
}
return `<If "%{REQUEST_FILENAME} =~ /${escapeRegexString(fileName)}$/">${innerDirective}</If>\n`;
}
fileContents += Object.entries(perFilePolicies)
.map(constructPerFileDirective)
.join("");
await writeFile(path, fileContents);
}async function readTemplate(context, root, template) {
let fileContents = "";
try {
fileContents = await readFile(join(root, template));
}
catch (err) {
context.error(`Could not read rollup-plugin-htaccess template file, Error: ${err.message}`);
}
return `${fileContents.replace(/\r/gu, "")}\n`;
}/**
* @public
*/
function htaccess(opts) {
const options = {
extractMetaCSP: { enabled: false },
fileName: ".htaccess",
spec: {},
template: undefined,
...opts,
};
let root = "";
const rollupPlugin = {
configResolved: (config) => {
root = config.root;
},
async generateBundle() {
this.emitFile({
fileName: options.fileName,
source: await buildHtaccessFile(this, options, root),
type: "asset",
});
},
name: "htaccess",
...extractMetaCSP(options),
};
return rollupPlugin;
}
async function buildHtaccessFile(context, options, root) {
let output = "";
if (options.template !== undefined) {
output += await readTemplate(context, root, options.template);
}
output += buildSpec(context, options.spec);
return output;
}export{htaccess as default};