From d200af95ff5bdb3f5dcbfd241bccf5432ad6ebdb Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 15:29:42 -0700 Subject: [PATCH] fix(events): make debug-bar reload link subpath-aware (#3344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The debug bar's reload link was built from raw cgi.script_name, so under a subfolder (subpath) deployment it emitted /myapp/public/index.cfm/... links that the user's rewrite rules do not route. Compose the base from the resolved webPath plus the front-controller filename instead — the same idiom urlFor() uses — extracted into the unit-tested $buildDebugReloadUrl() helper in Global.cfc (public $-prefixed per cross-engine invariant 7). The helper preserves the previous behavior exactly for root installs (rewriting on and off, pinned byte-for-byte in the spec), keeps the request.cgi.path_info vs cgi.path_info branch, the rewriteFile strip, and the reload-param scrub, and falls back to the raw script name when webPath is not resolved yet (early boot/error paths). The CFML error page's displayed URL had the same class of defect and now uses the same webPath composition (plus HTML-encoding of the base). Fixes #3344 Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- changelog.d/debug-bar-reload-subpath.fixed.md | 6 + vendor/wheels/Global.cfc | 74 ++++++++ vendor/wheels/events/onerror/cfmlerror.cfm | 12 +- vendor/wheels/events/onrequestend/debug.cfm | 36 ++-- .../specs/global/BuildDebugReloadUrlSpec.cfc | 179 ++++++++++++++++++ 5 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 changelog.d/debug-bar-reload-subpath.fixed.md create mode 100644 vendor/wheels/tests/specs/global/BuildDebugReloadUrlSpec.cfc diff --git a/changelog.d/debug-bar-reload-subpath.fixed.md b/changelog.d/debug-bar-reload-subpath.fixed.md new file mode 100644 index 0000000000..bb6002d433 --- /dev/null +++ b/changelog.d/debug-bar-reload-subpath.fixed.md @@ -0,0 +1,6 @@ +- Debug bar reload link (and the CFML error page's displayed URL) now honors the `subpath` + setting: the base URL is composed from the resolved `webPath` plus the front-controller + filename — the same idiom as `urlFor()` — instead of raw `cgi.script_name`, so subfolder + deployments emit `/myapp/posts?reload=` instead of the unroutable + `/myapp/public/index.cfm/posts?reload=`. Root installs render byte-identical to before. + Extracted into the unit-tested `$buildDebugReloadUrl()` helper in `Global.cfc` ([#3344](https://github.com/wheels-dev/wheels/issues/3344)) diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 118a096c90..6ed06af54f 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -2699,6 +2699,80 @@ return local.$wheels; return local.base & local.relative; } + /** + * Internal function. Builds the debug bar's base reload URL (issue #3344). + * The base is composed from the resolved `webPath` plus the front-controller + * filename — the same idiom `urlFor()` uses — instead of raw + * `cgi.script_name`, so subfolder (subpath) installs emit links like + * `/myapp/posts?reload=` rather than `/myapp/public/index.cfm/posts?reload=` + * (which the user's rewrite rules don't route). The caller selects which + * path_info to pass (`request.cgi.path_info` when available, `cgi.path_info` + * otherwise — engines report it differently). `webPath` and `rewriteFile` + * default from application scope; tests pass them explicitly, and early + * boot/error paths where they're missing fall back to the raw script name + * (the pre-#3344 behavior). Pure string logic so it can be unit-tested in + * isolation. + */ + public string function $buildDebugReloadUrl( + required string scriptName, + string pathInfo = "", + string queryString = "", + string webPath, + string rewriteFile + ) { + // Resolve webPath/rewriteFile from application scope unless overridden. + // No runtime default-arg expressions (some engines evaluate those + // eagerly) — same pattern as $resolveSubpathInclude. + if (StructKeyExists(arguments, "webPath")) { + local.resolvedWebPath = arguments.webPath; + } else if (IsDefined("application.wheels.webPath")) { + local.resolvedWebPath = application.wheels.webPath; + } else { + local.resolvedWebPath = ""; + } + if (StructKeyExists(arguments, "rewriteFile")) { + local.resolvedRewriteFile = arguments.rewriteFile; + } else if (IsDefined("application.wheels.rewriteFile")) { + local.resolvedRewriteFile = application.wheels.rewriteFile; + } else { + local.resolvedRewriteFile = ""; + } + + // Base: webPath + front-controller filename (matches urlFor()); fall + // back to the raw script name when webPath isn't resolved yet. + if (Len(local.resolvedWebPath)) { + local.rv = local.resolvedWebPath & ListLast(arguments.scriptName, "/"); + } else { + local.rv = arguments.scriptName; + } + if (arguments.pathInfo != arguments.scriptName) { + local.rv &= arguments.pathInfo; + } + if (Len(arguments.queryString)) { + local.rv &= "?" & arguments.queryString; + } + if (Len(local.resolvedRewriteFile)) { + local.rv = ReplaceNoCase(local.rv, "/" & local.resolvedRewriteFile, ""); + } + local.reloadTokens = "development,testing,maintenance,production,true"; + local.iEnd = ListLen(local.reloadTokens); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.token = ListGetAt(local.reloadTokens, local.i); + local.rv = ReplaceNoCase( + ReplaceNoCase(local.rv, "?reload=" & local.token, ""), + "&reload=" & local.token, + "" + ); + } + if (Find("?", local.rv)) { + local.rv &= "&"; + } else { + local.rv &= "?"; + } + local.rv &= "reload="; + return local.rv; + } + /** * Internal function. */ diff --git a/vendor/wheels/events/onerror/cfmlerror.cfm b/vendor/wheels/events/onerror/cfmlerror.cfm index d6509569ab..961588a704 100644 --- a/vendor/wheels/events/onerror/cfmlerror.cfm +++ b/vendor/wheels/events/onerror/cfmlerror.cfm @@ -65,9 +65,19 @@
+ + + + + +
URL
-
https://#EncodeForHTML(cgi.server_name)##Replace(cgi.script_name, "/#application.wheels.rewriteFile#", "")##EncodeForHTML(request.cgi.path_info)##EncodeForHTML(cgi.path_info)#?#EncodeForHTML(cgi.query_string)#
+
https://#EncodeForHTML(cgi.server_name)##EncodeForHTML(local.errorUrlBase)##EncodeForHTML(request.cgi.path_info)##EncodeForHTML(cgi.path_info)#?#EncodeForHTML(cgi.query_string)#
diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index ad7b2c944f..a72774cb84 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -7,33 +7,21 @@ OR (StructKeyExists(local.reqHeaders, "X-Fetch") AND local.reqHeaders["X-Fetch"] OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.format))> - + - - - - - - - - - - - - - - - - - + - + - + diff --git a/vendor/wheels/tests/specs/global/BuildDebugReloadUrlSpec.cfc b/vendor/wheels/tests/specs/global/BuildDebugReloadUrlSpec.cfc new file mode 100644 index 0000000000..9f2a60d3e7 --- /dev/null +++ b/vendor/wheels/tests/specs/global/BuildDebugReloadUrlSpec.cfc @@ -0,0 +1,179 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Tests that $buildDebugReloadUrl", () => { + + // --------------------------------------------------------------- + // Root installs (webPath = "/") — these expectations are pinned + // byte-for-byte to the output of the previous inline composition in + // vendor/wheels/events/onrequestend/debug.cfm (raw cgi.script_name + // + path_info + query string, then the rewriteFile strip and the + // reload-param scrub). They must never change. + // --------------------------------------------------------------- + + it("builds the reload URL for a root install with URL rewriting on", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/posts?reload=") + }) + + it("preserves the query string on a root install", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "page=2&sort=title", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/posts?page=2&sort=title&reload=") + }) + + it("scrubs a leading ?reload= parameter from the query string", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "reload=true", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/posts?reload=") + }) + + it("scrubs an &reload= parameter while keeping the rest of the query string", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "page=2&reload=development", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/posts?page=2&reload=") + }) + + it("keeps rewriting-off root installs byte-identical to the previous inline composition", () => { + // With URL rewriting off, path_info equals script_name (Lucee) so + // nothing is appended; the rewriteFile strip leaves the bare query. + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/index.cfm", + queryString = "controller=posts&action=index", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("?controller=posts&action=index&reload=") + }) + + it("handles an empty path_info (Adobe engines with rewriting off)", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "", + queryString = "controller=posts&action=index", + webPath = "/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("?controller=posts&action=index&reload=") + }) + + // --------------------------------------------------------------- + // Subfolder (subpath) installs — issue #3344. The base must come + // from webPath, not raw cgi.script_name, so the emitted link never + // contains the on-disk /public/ segment or the front controller. + // --------------------------------------------------------------- + + it("honors webPath on a subfolder install (no /public/, no index.cfm)", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/wheelsproject1/public/index.cfm", + pathInfo = "/posts", + queryString = "", + webPath = "/wheelsproject1/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/wheelsproject1/posts?reload=") + expect(rv).notToInclude("/public/") + expect(rv).notToInclude("index.cfm") + }) + + it("honors a nested subpath webPath", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/team/site/public/index.cfm", + pathInfo = "/posts/1", + queryString = "page=2", + webPath = "/team/site/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/team/site/posts/1?page=2&reload=") + }) + + it("scrubs reload params on a subfolder install", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/wheelsproject1/public/index.cfm", + pathInfo = "/posts", + queryString = "reload=true", + webPath = "/wheelsproject1/", + rewriteFile = "index.cfm" + ) + + expect(rv).toBe("/wheelsproject1/posts?reload=") + }) + + // --------------------------------------------------------------- + // Defensive fallbacks (early boot / error paths). + // --------------------------------------------------------------- + + it("falls back to the raw script name when webPath is empty", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/wheelsproject1/public/index.cfm", + pathInfo = "/posts", + queryString = "", + webPath = "", + rewriteFile = "index.cfm" + ) + + // Exactly what the previous inline composition produced. + expect(rv).toBe("/wheelsproject1/public/posts?reload=") + }) + + it("skips the rewriteFile strip when rewriteFile is empty", () => { + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "", + webPath = "/", + rewriteFile = "" + ) + + expect(rv).toBe("/index.cfm/posts?reload=") + }) + + it("defaults webPath and rewriteFile from application scope when omitted", () => { + // The running test app is a root install: webPath "/" and + // rewriteFile "index.cfm", so this must match the explicit + // root-install case above. + rv = g.$buildDebugReloadUrl( + scriptName = "/index.cfm", + pathInfo = "/posts", + queryString = "" + ) + + expect(rv).toBe("/posts?reload=") + }) + + }) + } +}