260 lines
15 KiB
JavaScript
260 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import vm from "node:vm";
|
|
|
|
class ClassList {
|
|
constructor() { this.values = new Set(); }
|
|
toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); }
|
|
}
|
|
class Element {
|
|
constructor(id = "") {
|
|
this.id = id;
|
|
this.value = "";
|
|
this.checked = false;
|
|
this.disabled = false;
|
|
this.hidden = false;
|
|
this.open = false;
|
|
this.dataset = {};
|
|
this.children = [];
|
|
this.listeners = new Map();
|
|
this.classList = new ClassList();
|
|
this.attributes = new Map();
|
|
this.tabIndex = 0;
|
|
this.textContent = "";
|
|
this.type = "";
|
|
this.readOnly = false;
|
|
this.elements = [];
|
|
}
|
|
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
|
dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); }
|
|
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
|
append(...items) { this.children.push(...items); }
|
|
replaceChildren(...items) { this.children = [...items]; }
|
|
showModal() { this.open = true; }
|
|
close() { this.open = false; }
|
|
reset() {}
|
|
checkValidity() { return true; }
|
|
}
|
|
|
|
const ids = [
|
|
"htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules",
|
|
"web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions",
|
|
"web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers",
|
|
"web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive",
|
|
"web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing",
|
|
"web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency",
|
|
"web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions",
|
|
"web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit",
|
|
"web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help",
|
|
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
|
|
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
|
|
"web-service-response-headers","web-service-rewrites","web-service-root","web-service-search",
|
|
"web-service-symlink-policy","web-service-tabs","web-service-try-files",
|
|
"web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog",
|
|
"php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help"
|
|
];
|
|
const elements = new Map(ids.map(id => [id, new Element(id)]));
|
|
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
|
|
const page = new Element(); page.dataset.webServicePage = name; return page;
|
|
});
|
|
|
|
elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock";
|
|
elements.get("web-service-php-connect-timeout").value = "1000";
|
|
elements.get("web-service-php-request-timeout").value = "30000";
|
|
elements.get("web-service-php-concurrency").value = "64";
|
|
elements.get("web-service-php-pass-authorization").checked = true;
|
|
elements.get("web-service-php-path-info").checked = false;
|
|
elements.get("web-service-php-body-limit").value = "0";
|
|
elements.get("web-service-php-header-limit").value = "65536";
|
|
elements.get("web-service-php-stderr-limit").value = "4096";
|
|
elements.get("web-service-compression").value = "";
|
|
for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-compression-${name}`).value = "";
|
|
|
|
const document = {
|
|
body: new Element("body"),
|
|
createElement: tag => { const node = new Element(); node.type = tag; return node; },
|
|
querySelectorAll(selector) {
|
|
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
|
|
if (selector === "[data-web-service-page]") return pages;
|
|
return [];
|
|
}
|
|
};
|
|
let savedSection = null;
|
|
const context = {
|
|
console,
|
|
structuredClone,
|
|
document,
|
|
window: {},
|
|
envelope: null,
|
|
busy: false,
|
|
$: id => {
|
|
const value = elements.get(id);
|
|
if (!value) throw new Error(`missing fake element ${id}`);
|
|
return value;
|
|
},
|
|
t: key => key,
|
|
el: (_tag, text = "", className = "") => {
|
|
const node = new Element();
|
|
node.textContent = text ?? "";
|
|
if (className) node.classList.values.add(className);
|
|
return node;
|
|
},
|
|
toast: () => {},
|
|
saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; },
|
|
appConfirm: async () => true,
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" });
|
|
vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" });
|
|
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
|
|
|
|
// Explicitly disabling scoped dynamic compression must hide and disable codec
|
|
// inheritance controls without erasing their saved selections.
|
|
const compressionMode = elements.get("web-service-compression");
|
|
const compressionOptions = elements.get("web-service-compression-options");
|
|
const compressionZstd = elements.get("web-service-compression-zstd");
|
|
compressionZstd.value = "off";
|
|
compressionMode.value = "off";
|
|
compressionMode.dispatch("change");
|
|
if (!compressionOptions.hidden) throw new Error("Disabled Web Service compression did not hide codec overrides");
|
|
for (const name of ["zstd", "br", "gzip", "deflate"]) {
|
|
if (!elements.get(`web-service-compression-${name}`).disabled) throw new Error(`Disabled Web Service compression left ${name} override active`);
|
|
}
|
|
compressionMode.value = "on";
|
|
compressionMode.dispatch("change");
|
|
if (compressionOptions.hidden) throw new Error("Re-enabled Web Service compression did not restore codec overrides");
|
|
if (compressionZstd.value !== "off") throw new Error("Compression visibility toggle erased the saved codec selection");
|
|
|
|
const preset = elements.get("web-service-preset");
|
|
preset.value = "wordpress";
|
|
preset.dispatch("change");
|
|
|
|
const expectedIndex = "index.php\nindex.html\nindex.htm";
|
|
const expectedTry = "$uri\n$uri/\n/index.php?$query_string";
|
|
if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files");
|
|
if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files");
|
|
if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM");
|
|
if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars");
|
|
if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules");
|
|
|
|
preset.value = "downloads";
|
|
preset.dispatch("change");
|
|
if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files");
|
|
if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing");
|
|
if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM");
|
|
if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing");
|
|
|
|
// The save path must serialize the values materialized by preset selection, not
|
|
// the previous/custom values that were present when the dialog opened.
|
|
context.envelope = { config: { web_services: [] } };
|
|
elements.get("web-service-id").value = "web1";
|
|
elements.get("web-service-name").value = "web1";
|
|
elements.get("web-service-root").value = "/srv/web1";
|
|
elements.get("web-service-php-pass-authorization").checked = false;
|
|
elements.get("web-service-php-path-info").checked = true;
|
|
elements.get("web-service-php-body-limit").value = "1048576";
|
|
elements.get("web-service-php-header-limit").value = "131072";
|
|
elements.get("web-service-php-stderr-limit").value = "8192";
|
|
elements.get("web-service-php-custom-params").value = "APP_ENV = production";
|
|
elements.get("web-service-php-values").value = "memory_limit = 256M";
|
|
preset.value = "wordpress";
|
|
preset.dispatch("change");
|
|
elements.get("web-service-compression").value = "on";
|
|
elements.get("web-service-compression-gzip").value = "off";
|
|
await elements.get("web-service-form").dispatch("submit");
|
|
if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service");
|
|
const saved = savedSection.value[0];
|
|
if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files");
|
|
if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files");
|
|
if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement");
|
|
if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement");
|
|
if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override");
|
|
if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice");
|
|
if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice");
|
|
if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits");
|
|
if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters");
|
|
if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings");
|
|
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
|
|
|
|
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
|
|
|
|
// The shared editor lists profiles, saves them under the new revisioned
|
|
// section, and Web Services can attach without losing local PHP settings.
|
|
context.envelope.config.php_fpm_profiles = [{
|
|
id: "wp-pool", name: "WordPress pool", php: {
|
|
enabled: true, path_info: true, pass_authorization: true,
|
|
extensions: ["php"], endpoint: "tcp:127.0.0.1:9000",
|
|
connect_timeout_ms: 1000, request_timeout_ms: 30000,
|
|
max_concurrent_requests: 64, max_request_body_bytes: 0,
|
|
max_response_header_bytes: 65536, stderr_log_bytes: 4096,
|
|
custom_params: {}, php_values: {}, php_admin_values: {}
|
|
}
|
|
}];
|
|
// Both entry points must share the existing modal; the toolbar shortcut
|
|
// belongs next to New Web Service rather than inside the service editor.
|
|
const html = fs.readFileSync("src/admin/web/index.html", "utf8");
|
|
const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('</dialog>')[0] ?? '';
|
|
const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"');
|
|
const addAt = profileDialog.indexOf('id="php-fpm-profile-add"');
|
|
const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"');
|
|
const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"');
|
|
if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) {
|
|
throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule");
|
|
}
|
|
const webPanel = html.split('id="panel-web-server"')[1]?.split("</section>")[0] ?? "";
|
|
const toolbar = webPanel.match(/<div class="vhost-list-tools">([\s\S]*?)<\/div>/)?.[1] ?? "";
|
|
const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"');
|
|
const newAt = toolbar.indexOf('id="web-service-new"');
|
|
if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) {
|
|
throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar");
|
|
}
|
|
elements.get("web-service-php-profiles-open").dispatch("click");
|
|
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal");
|
|
elements.get("php-fpm-profiles-close").dispatch("click");
|
|
if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close");
|
|
elements.get("web-service-php-profiles").dispatch("click");
|
|
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened");
|
|
const profileDetail = elements.get("php-fpm-profiles-list").children[0];
|
|
if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing");
|
|
const profileForm = profileDetail.children[1];
|
|
const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide"));
|
|
if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows");
|
|
for (const row of phpRows) {
|
|
if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" ||
|
|
row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) {
|
|
throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover");
|
|
}
|
|
}
|
|
if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) {
|
|
throw new Error("PHP execution and PATH_INFO saved values were not restored");
|
|
}
|
|
phpRows[1].children[0].children[0].checked = false;
|
|
if (!phpRows[0].children[0].children[0].checked) {
|
|
throw new Error("Changing PATH_INFO also disabled PHP execution");
|
|
}
|
|
phpRows[1].children[0].children[0].checked = true;
|
|
elements.get("php-fpm-profiles-help").dispatch("click");
|
|
if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation");
|
|
elements.get("php-help-close").dispatch("click");
|
|
if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close");
|
|
profileForm.children[1].children[1].value = "Updated WordPress pool";
|
|
await profileForm.dispatch("submit");
|
|
if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") {
|
|
throw new Error("Shared profile edit did not save to the profile configuration section");
|
|
}
|
|
const profileSelector = elements.get("web-service-php-profile");
|
|
const localEndpoint = elements.get("web-service-php-endpoint").value;
|
|
profileSelector.value = "wp-pool";
|
|
profileSelector.dispatch("change");
|
|
if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable");
|
|
elements.get("web-service-id").value = "wp-shared";
|
|
elements.get("web-service-name").value = "WordPress shared";
|
|
await elements.get("web-service-form").dispatch("submit");
|
|
if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") {
|
|
throw new Error("Web Service did not persist selected shared PHP-FPM profile");
|
|
}
|
|
if (savedSection.value[0].php.endpoint !== localEndpoint) {
|
|
throw new Error("Attaching a shared FPM profile changed local settings");
|
|
}
|
|
console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");
|