Skip to content

Commit d200af9

Browse files
Peter Amiriclaude
andcommitted
fix(events): make debug-bar reload link subpath-aware (#3344)
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 <noreply@anthropic.com> Signed-off-by: Peter Amiri <petera@pai.com>
1 parent 8bc8304 commit d200af9

5 files changed

Lines changed: 282 additions & 25 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- Debug bar reload link (and the CFML error page's displayed URL) now honors the `subpath`
2+
setting: the base URL is composed from the resolved `webPath` plus the front-controller
3+
filename — the same idiom as `urlFor()` — instead of raw `cgi.script_name`, so subfolder
4+
deployments emit `/myapp/posts?reload=` instead of the unroutable
5+
`/myapp/public/index.cfm/posts?reload=`. Root installs render byte-identical to before.
6+
Extracted into the unit-tested `$buildDebugReloadUrl()` helper in `Global.cfc` ([#3344](https://github.com/wheels-dev/wheels/issues/3344))

vendor/wheels/Global.cfc

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,6 +2699,80 @@ return local.$wheels;
26992699
return local.base & local.relative;
27002700
}
27012701

2702+
/**
2703+
* Internal function. Builds the debug bar's base reload URL (issue #3344).
2704+
* The base is composed from the resolved `webPath` plus the front-controller
2705+
* filename — the same idiom `urlFor()` uses — instead of raw
2706+
* `cgi.script_name`, so subfolder (subpath) installs emit links like
2707+
* `/myapp/posts?reload=` rather than `/myapp/public/index.cfm/posts?reload=`
2708+
* (which the user's rewrite rules don't route). The caller selects which
2709+
* path_info to pass (`request.cgi.path_info` when available, `cgi.path_info`
2710+
* otherwise — engines report it differently). `webPath` and `rewriteFile`
2711+
* default from application scope; tests pass them explicitly, and early
2712+
* boot/error paths where they're missing fall back to the raw script name
2713+
* (the pre-#3344 behavior). Pure string logic so it can be unit-tested in
2714+
* isolation.
2715+
*/
2716+
public string function $buildDebugReloadUrl(
2717+
required string scriptName,
2718+
string pathInfo = "",
2719+
string queryString = "",
2720+
string webPath,
2721+
string rewriteFile
2722+
) {
2723+
// Resolve webPath/rewriteFile from application scope unless overridden.
2724+
// No runtime default-arg expressions (some engines evaluate those
2725+
// eagerly) — same pattern as $resolveSubpathInclude.
2726+
if (StructKeyExists(arguments, "webPath")) {
2727+
local.resolvedWebPath = arguments.webPath;
2728+
} else if (IsDefined("application.wheels.webPath")) {
2729+
local.resolvedWebPath = application.wheels.webPath;
2730+
} else {
2731+
local.resolvedWebPath = "";
2732+
}
2733+
if (StructKeyExists(arguments, "rewriteFile")) {
2734+
local.resolvedRewriteFile = arguments.rewriteFile;
2735+
} else if (IsDefined("application.wheels.rewriteFile")) {
2736+
local.resolvedRewriteFile = application.wheels.rewriteFile;
2737+
} else {
2738+
local.resolvedRewriteFile = "";
2739+
}
2740+
2741+
// Base: webPath + front-controller filename (matches urlFor()); fall
2742+
// back to the raw script name when webPath isn't resolved yet.
2743+
if (Len(local.resolvedWebPath)) {
2744+
local.rv = local.resolvedWebPath & ListLast(arguments.scriptName, "/");
2745+
} else {
2746+
local.rv = arguments.scriptName;
2747+
}
2748+
if (arguments.pathInfo != arguments.scriptName) {
2749+
local.rv &= arguments.pathInfo;
2750+
}
2751+
if (Len(arguments.queryString)) {
2752+
local.rv &= "?" & arguments.queryString;
2753+
}
2754+
if (Len(local.resolvedRewriteFile)) {
2755+
local.rv = ReplaceNoCase(local.rv, "/" & local.resolvedRewriteFile, "");
2756+
}
2757+
local.reloadTokens = "development,testing,maintenance,production,true";
2758+
local.iEnd = ListLen(local.reloadTokens);
2759+
for (local.i = 1; local.i <= local.iEnd; local.i++) {
2760+
local.token = ListGetAt(local.reloadTokens, local.i);
2761+
local.rv = ReplaceNoCase(
2762+
ReplaceNoCase(local.rv, "?reload=" & local.token, ""),
2763+
"&reload=" & local.token,
2764+
""
2765+
);
2766+
}
2767+
if (Find("?", local.rv)) {
2768+
local.rv &= "&";
2769+
} else {
2770+
local.rv &= "?";
2771+
}
2772+
local.rv &= "reload=";
2773+
return local.rv;
2774+
}
2775+
27022776
/**
27032777
* Internal function.
27042778
*/

vendor/wheels/events/onerror/cfmlerror.cfm

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,19 @@
6565
<!--- Request Info Grid --->
6666
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin:1.5em 0;">
6767
<cfif IsDefined("application.wheels.rewriteFile")>
68+
<!---
69+
Base composed from webPath (subpath-aware, issue #3344) so subfolder
70+
installs display /myapp/... instead of /myapp/public/... — same idiom
71+
as urlFor() and $buildDebugReloadUrl().
72+
--->
73+
<cfif IsDefined("application.wheels.webPath") AND Len(application.wheels.webPath)>
74+
<cfset local.errorUrlBase = Replace(application.wheels.webPath & ListLast(cgi.script_name, "/"), "/#application.wheels.rewriteFile#", "")>
75+
<cfelse>
76+
<cfset local.errorUrlBase = Replace(cgi.script_name, "/#application.wheels.rewriteFile#", "")>
77+
</cfif>
6878
<div style="background:##181825;border:1px solid ##45475a;border-radius:6px;padding:12px 16px;">
6979
<div style="font-size:10px;font-weight:700;color:##6c7086;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px;">URL</div>
70-
<div style="font-family:monospace;font-size:12px;color:##cdd6f4;word-break:break-all;">http<cfif cgi.http_x_forwarded_proto EQ "https" OR cgi.server_port_secure EQ "true">s</cfif>://#EncodeForHTML(cgi.server_name)##Replace(cgi.script_name, "/#application.wheels.rewriteFile#", "")#<cfif IsDefined("request.cgi.path_info")>#EncodeForHTML(request.cgi.path_info)#<cfelse>#EncodeForHTML(cgi.path_info)#</cfif><cfif cgi.query_string IS NOT "">?#EncodeForHTML(cgi.query_string)#</cfif></div>
80+
<div style="font-family:monospace;font-size:12px;color:##cdd6f4;word-break:break-all;">http<cfif cgi.http_x_forwarded_proto EQ "https" OR cgi.server_port_secure EQ "true">s</cfif>://#EncodeForHTML(cgi.server_name)##EncodeForHTML(local.errorUrlBase)#<cfif IsDefined("request.cgi.path_info")>#EncodeForHTML(request.cgi.path_info)#<cfelse>#EncodeForHTML(cgi.path_info)#</cfif><cfif cgi.query_string IS NOT "">?#EncodeForHTML(cgi.query_string)#</cfif></div>
7181
</div>
7282
</cfif>
7383
<cfif Len(cgi.http_referer)>

vendor/wheels/events/onrequestend/debug.cfm

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,21 @@ OR (StructKeyExists(local.reqHeaders, "X-Fetch") AND local.reqHeaders["X-Fetch"]
77
OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.format))>
88
<cfexit>
99
</cfif>
10-
<cfset local.baseReloadURL = cgi.script_name>
10+
<!---
11+
Base reload URL composed from webPath (subpath-aware, issue #3344) via
12+
$buildDebugReloadUrl() in Global.cfc. Engines report path_info differently,
13+
so prefer the normalized request.cgi copy when available.
14+
--->
1115
<cfif IsDefined("request.cgi.path_info")>
12-
<cfif request.cgi.path_info IS NOT cgi.script_name>
13-
<cfset local.baseReloadURL &= request.cgi.path_info>
14-
</cfif>
15-
<cfelse>
16-
<cfif cgi.path_info IS NOT cgi.script_name>
17-
<cfset local.baseReloadURL &= cgi.path_info>
18-
</cfif>
19-
</cfif>
20-
<cfif Len(cgi.query_string)>
21-
<cfset local.baseReloadURL &= "?" & cgi.query_string>
22-
</cfif>
23-
<cfset local.baseReloadURL = ReplaceNoCase(local.baseReloadURL, "/" & application.wheels.rewriteFile, "")>
24-
<cfloop list="development,testing,maintenance,production,true" index="local.i">
25-
<cfset local.baseReloadURL = ReplaceNoCase(
26-
ReplaceNoCase(local.baseReloadURL, "?reload=" & local.i, ""),
27-
"&reload=" & local.i,
28-
""
29-
)>
30-
</cfloop>
31-
<cfif local.baseReloadURL Contains "?">
32-
<cfset local.baseReloadURL &= "&">
16+
<cfset local.debugPathInfo = request.cgi.path_info>
3317
<cfelse>
34-
<cfset local.baseReloadURL &= "?">
18+
<cfset local.debugPathInfo = cgi.path_info>
3519
</cfif>
36-
<cfset local.baseReloadURL &= "reload=">
20+
<cfset local.baseReloadURL = $buildDebugReloadUrl(
21+
scriptName = cgi.script_name,
22+
pathInfo = local.debugPathInfo,
23+
queryString = cgi.query_string
24+
)>
3725
<cfset local.gitbranch = DirectoryExists(GetDirectoryFromPath(GetBaseTemplatePath()) & ".git") ? FileRead(
3826
GetDirectoryFromPath(GetBaseTemplatePath()) & ".git/HEAD"
3927
) : "">
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
g = application.wo
6+
7+
describe("Tests that $buildDebugReloadUrl", () => {
8+
9+
// ---------------------------------------------------------------
10+
// Root installs (webPath = "/") — these expectations are pinned
11+
// byte-for-byte to the output of the previous inline composition in
12+
// vendor/wheels/events/onrequestend/debug.cfm (raw cgi.script_name
13+
// + path_info + query string, then the rewriteFile strip and the
14+
// reload-param scrub). They must never change.
15+
// ---------------------------------------------------------------
16+
17+
it("builds the reload URL for a root install with URL rewriting on", () => {
18+
rv = g.$buildDebugReloadUrl(
19+
scriptName = "/index.cfm",
20+
pathInfo = "/posts",
21+
queryString = "",
22+
webPath = "/",
23+
rewriteFile = "index.cfm"
24+
)
25+
26+
expect(rv).toBe("/posts?reload=")
27+
})
28+
29+
it("preserves the query string on a root install", () => {
30+
rv = g.$buildDebugReloadUrl(
31+
scriptName = "/index.cfm",
32+
pathInfo = "/posts",
33+
queryString = "page=2&sort=title",
34+
webPath = "/",
35+
rewriteFile = "index.cfm"
36+
)
37+
38+
expect(rv).toBe("/posts?page=2&sort=title&reload=")
39+
})
40+
41+
it("scrubs a leading ?reload= parameter from the query string", () => {
42+
rv = g.$buildDebugReloadUrl(
43+
scriptName = "/index.cfm",
44+
pathInfo = "/posts",
45+
queryString = "reload=true",
46+
webPath = "/",
47+
rewriteFile = "index.cfm"
48+
)
49+
50+
expect(rv).toBe("/posts?reload=")
51+
})
52+
53+
it("scrubs an &reload=<environment> parameter while keeping the rest of the query string", () => {
54+
rv = g.$buildDebugReloadUrl(
55+
scriptName = "/index.cfm",
56+
pathInfo = "/posts",
57+
queryString = "page=2&reload=development",
58+
webPath = "/",
59+
rewriteFile = "index.cfm"
60+
)
61+
62+
expect(rv).toBe("/posts?page=2&reload=")
63+
})
64+
65+
it("keeps rewriting-off root installs byte-identical to the previous inline composition", () => {
66+
// With URL rewriting off, path_info equals script_name (Lucee) so
67+
// nothing is appended; the rewriteFile strip leaves the bare query.
68+
rv = g.$buildDebugReloadUrl(
69+
scriptName = "/index.cfm",
70+
pathInfo = "/index.cfm",
71+
queryString = "controller=posts&action=index",
72+
webPath = "/",
73+
rewriteFile = "index.cfm"
74+
)
75+
76+
expect(rv).toBe("?controller=posts&action=index&reload=")
77+
})
78+
79+
it("handles an empty path_info (Adobe engines with rewriting off)", () => {
80+
rv = g.$buildDebugReloadUrl(
81+
scriptName = "/index.cfm",
82+
pathInfo = "",
83+
queryString = "controller=posts&action=index",
84+
webPath = "/",
85+
rewriteFile = "index.cfm"
86+
)
87+
88+
expect(rv).toBe("?controller=posts&action=index&reload=")
89+
})
90+
91+
// ---------------------------------------------------------------
92+
// Subfolder (subpath) installs — issue #3344. The base must come
93+
// from webPath, not raw cgi.script_name, so the emitted link never
94+
// contains the on-disk /public/ segment or the front controller.
95+
// ---------------------------------------------------------------
96+
97+
it("honors webPath on a subfolder install (no /public/, no index.cfm)", () => {
98+
rv = g.$buildDebugReloadUrl(
99+
scriptName = "/wheelsproject1/public/index.cfm",
100+
pathInfo = "/posts",
101+
queryString = "",
102+
webPath = "/wheelsproject1/",
103+
rewriteFile = "index.cfm"
104+
)
105+
106+
expect(rv).toBe("/wheelsproject1/posts?reload=")
107+
expect(rv).notToInclude("/public/")
108+
expect(rv).notToInclude("index.cfm")
109+
})
110+
111+
it("honors a nested subpath webPath", () => {
112+
rv = g.$buildDebugReloadUrl(
113+
scriptName = "/team/site/public/index.cfm",
114+
pathInfo = "/posts/1",
115+
queryString = "page=2",
116+
webPath = "/team/site/",
117+
rewriteFile = "index.cfm"
118+
)
119+
120+
expect(rv).toBe("/team/site/posts/1?page=2&reload=")
121+
})
122+
123+
it("scrubs reload params on a subfolder install", () => {
124+
rv = g.$buildDebugReloadUrl(
125+
scriptName = "/wheelsproject1/public/index.cfm",
126+
pathInfo = "/posts",
127+
queryString = "reload=true",
128+
webPath = "/wheelsproject1/",
129+
rewriteFile = "index.cfm"
130+
)
131+
132+
expect(rv).toBe("/wheelsproject1/posts?reload=")
133+
})
134+
135+
// ---------------------------------------------------------------
136+
// Defensive fallbacks (early boot / error paths).
137+
// ---------------------------------------------------------------
138+
139+
it("falls back to the raw script name when webPath is empty", () => {
140+
rv = g.$buildDebugReloadUrl(
141+
scriptName = "/wheelsproject1/public/index.cfm",
142+
pathInfo = "/posts",
143+
queryString = "",
144+
webPath = "",
145+
rewriteFile = "index.cfm"
146+
)
147+
148+
// Exactly what the previous inline composition produced.
149+
expect(rv).toBe("/wheelsproject1/public/posts?reload=")
150+
})
151+
152+
it("skips the rewriteFile strip when rewriteFile is empty", () => {
153+
rv = g.$buildDebugReloadUrl(
154+
scriptName = "/index.cfm",
155+
pathInfo = "/posts",
156+
queryString = "",
157+
webPath = "/",
158+
rewriteFile = ""
159+
)
160+
161+
expect(rv).toBe("/index.cfm/posts?reload=")
162+
})
163+
164+
it("defaults webPath and rewriteFile from application scope when omitted", () => {
165+
// The running test app is a root install: webPath "/" and
166+
// rewriteFile "index.cfm", so this must match the explicit
167+
// root-install case above.
168+
rv = g.$buildDebugReloadUrl(
169+
scriptName = "/index.cfm",
170+
pathInfo = "/posts",
171+
queryString = ""
172+
)
173+
174+
expect(rv).toBe("/posts?reload=")
175+
})
176+
177+
})
178+
}
179+
}

0 commit comments

Comments
 (0)