shelving 1.267.4 → 1.267.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/ui/router/Router.js +1 -0
- package/ui/router/Router.tsx +1 -0
- package/util/template.d.ts +15 -4
- package/util/template.js +67 -9
package/package.json
CHANGED
package/ui/router/Router.js
CHANGED
|
@@ -37,6 +37,7 @@ function _matchRoute(routes, fallback, meta, path = meta.path, depth = 0) {
|
|
|
37
37
|
if (typeof Route === "string") {
|
|
38
38
|
if (depth > 10)
|
|
39
39
|
throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
|
|
40
|
+
// `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
|
|
40
41
|
return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
|
|
41
42
|
}
|
|
42
43
|
// React element — render as-is.
|
package/ui/router/Router.tsx
CHANGED
|
@@ -61,6 +61,7 @@ function _matchRoute(
|
|
|
61
61
|
// String value is a redirect; re-run matching with the new path. Guard against infinite redirect loops by limiting depth.
|
|
62
62
|
if (typeof Route === "string") {
|
|
63
63
|
if (depth > 10) throw new UnexpectedError("Infinite redirect loop", { received: route, expected: path, caller: _matchRoute });
|
|
64
|
+
// `placeholders` are already decoded by `matchPathTemplate`, so re-encode them into the redirect path with `renderPathTemplate` — the symmetric partner of the match above.
|
|
64
65
|
return _matchRoute(routes, fallback, meta, renderPathTemplate(Route, placeholders), depth + 1);
|
|
65
66
|
}
|
|
66
67
|
|
package/util/template.d.ts
CHANGED
|
@@ -53,11 +53,13 @@ export declare function matchTemplate(template: string, target: string, caller?:
|
|
|
53
53
|
* Match a path-shaped template against a target path.
|
|
54
54
|
* - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
|
|
55
55
|
* - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
|
|
56
|
+
* - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
|
|
57
|
+
* - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
|
|
56
58
|
*
|
|
57
59
|
* @param template The path-shaped template string, e.g. `/users/{name}`.
|
|
58
60
|
* @param target The path containing values, e.g. `/users/Dave`.
|
|
59
61
|
* @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
|
|
60
|
-
* @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
62
|
+
* @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
61
63
|
* @throws {ValueError} If two template placeholders are not separated by at least one character.
|
|
62
64
|
* @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
|
|
63
65
|
* @see https://shelving.cc/util/template/matchPathTemplate
|
|
@@ -96,14 +98,23 @@ export declare function matchPathTemplates(templates: Iterable<AbsolutePath> & N
|
|
|
96
98
|
*/
|
|
97
99
|
export declare function renderTemplate(template: string, values: TemplateValues, caller?: AnyCaller): string;
|
|
98
100
|
/**
|
|
99
|
-
* Render a path-shaped template
|
|
101
|
+
* Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
|
|
102
|
+
*
|
|
103
|
+
* Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
|
|
104
|
+
* This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
|
|
105
|
+
* altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
|
|
106
|
+
* the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
|
|
107
|
+
* `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
|
|
108
|
+
*
|
|
109
|
+
* Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
|
|
110
|
+
* `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
|
|
100
111
|
*
|
|
101
112
|
* @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
|
|
102
113
|
* @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
|
|
103
114
|
* @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
|
|
104
|
-
* @returns The rendered path, e.g. `/users/Dave`.
|
|
115
|
+
* @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
|
|
105
116
|
* @throws {RequiredError} If a placeholder in the template string is not specified in values.
|
|
106
|
-
* @example renderPathTemplate("/users/{name}", { name: "
|
|
117
|
+
* @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
|
|
107
118
|
* @see https://shelving.cc/util/template/renderPathTemplate
|
|
108
119
|
*/
|
|
109
120
|
export declare function renderPathTemplate(template: AbsolutePath, values: TemplateValues, caller?: AnyCaller): AbsolutePath;
|
package/util/template.js
CHANGED
|
@@ -83,25 +83,40 @@ function _getPlaceholder({ name }) {
|
|
|
83
83
|
* @see https://shelving.cc/util/template/matchTemplate
|
|
84
84
|
*/
|
|
85
85
|
export function matchTemplate(template, target, caller = matchTemplate) {
|
|
86
|
-
return _matchTemplate(template, target, "", caller);
|
|
86
|
+
return _matchTemplate(template, target, "", false, caller);
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
89
|
* Match a path-shaped template against a target path.
|
|
90
90
|
* - Like `matchTemplate()`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can.
|
|
91
91
|
* - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`.
|
|
92
|
+
* - Matched values are **percent-decoded** (the inverse of `renderPathTemplate()`'s encoding), so a round trip is lossless: `matchPathTemplate(t, renderPathTemplate(t, values))` returns the original values. Catchall values keep their `/` separators (each segment is decoded).
|
|
93
|
+
* - Returns `undefined` if `target` is not validly percent-encoded (e.g. a bare `%` or `%zz`), treating a malformed path as a non-match.
|
|
92
94
|
*
|
|
93
95
|
* @param template The path-shaped template string, e.g. `/users/{name}`.
|
|
94
96
|
* @param target The path containing values, e.g. `/users/Dave`.
|
|
95
97
|
* @param caller Function to attribute a thrown error to (defaults to `matchPathTemplate` itself).
|
|
96
|
-
* @returns An object containing values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
98
|
+
* @returns An object containing decoded values (e.g. `{ name: "Dave" }`), or `undefined` if no match.
|
|
97
99
|
* @throws {ValueError} If two template placeholders are not separated by at least one character.
|
|
98
100
|
* @example matchPathTemplate("/users/{name}", "/users/Dave") // { name: "Dave" }
|
|
99
101
|
* @see https://shelving.cc/util/template/matchPathTemplate
|
|
100
102
|
*/
|
|
101
103
|
export function matchPathTemplate(template, target, caller = matchPathTemplate) {
|
|
102
|
-
return _matchTemplate(template, target, "/", caller);
|
|
104
|
+
return _matchTemplate(template, target, "/", true, caller);
|
|
103
105
|
}
|
|
104
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Decode a matched path value — the inverse of `renderPathTemplate()`'s `encodeURIComponent`.
|
|
108
|
+
* - Catchall values keep their `/` separators (each segment is decoded independently, mirroring the per-segment encode).
|
|
109
|
+
* - Returns `undefined` for malformed percent-encoding (e.g. a bare `%` or `%zz`), so the caller can reject the whole match.
|
|
110
|
+
*/
|
|
111
|
+
function _decodePathValue(value, catchall) {
|
|
112
|
+
try {
|
|
113
|
+
return catchall ? value.split("/").map(decodeURIComponent).join("/") : decodeURIComponent(value);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function _matchTemplate(template, target, separator, decode, caller) {
|
|
105
120
|
// Get separators and placeholders from template.
|
|
106
121
|
const chunks = _splitTemplateCached(template, caller);
|
|
107
122
|
const firstChunk = chunks[0];
|
|
@@ -134,7 +149,18 @@ function _matchTemplate(template, target, separator, caller) {
|
|
|
134
149
|
if (separator && value.includes(separator))
|
|
135
150
|
return undefined; // Non-catchall placeholders can't span separators (when one is configured).
|
|
136
151
|
}
|
|
137
|
-
|
|
152
|
+
// In path mode, percent-decode the (still-encoded) value so it round-trips with `renderPathTemplate()`; a
|
|
153
|
+
// malformed encoding rejects the whole match. The separator check above runs on the encoded value first,
|
|
154
|
+
// so an encoded `%2F` can't smuggle a separator into a non-catchall segment.
|
|
155
|
+
if (decode) {
|
|
156
|
+
const decoded = _decodePathValue(value, catchall);
|
|
157
|
+
if (decoded === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
values[name] = decoded;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
values[name] = value;
|
|
163
|
+
}
|
|
138
164
|
startIndex = stopIndex + post.length;
|
|
139
165
|
}
|
|
140
166
|
if (startIndex < target.length)
|
|
@@ -219,16 +245,48 @@ export function renderTemplate(template, values, caller = renderTemplate) {
|
|
|
219
245
|
return output;
|
|
220
246
|
}
|
|
221
247
|
/**
|
|
222
|
-
* Render a path-shaped template
|
|
248
|
+
* Render a path-shaped template, percent-encoding each substituted value so it stays within its path segment.
|
|
249
|
+
*
|
|
250
|
+
* Unlike `renderTemplate()`, each placeholder value is run through `encodeURIComponent()` before substitution.
|
|
251
|
+
* This stops a value that contains `/`, `?`, `#`, `%`, or control characters from escaping its segment and
|
|
252
|
+
* altering the resolved URL — i.e. path traversal to a different endpoint, or query/fragment injection — when
|
|
253
|
+
* the result is resolved against a base URL (e.g. by `ClientAPIProvider`). Catchall placeholders (`**`,
|
|
254
|
+
* `{...path}`, …) legitimately span segments, so each `/`-separated part is encoded but the separators are kept.
|
|
255
|
+
*
|
|
256
|
+
* Note: because `encodeURIComponent()` does not encode `.`, a value that is exactly `.` or `..` still renders as
|
|
257
|
+
* `.`/`..`; callers that treat a placeholder as a trusted single segment should validate against those.
|
|
223
258
|
*
|
|
224
259
|
* @param template The path-shaped template including placeholders, e.g. `/users/{name}`.
|
|
225
260
|
* @param values An object containing values (functions are called, everything else converted to string), or a function or string to use for all placeholders.
|
|
226
261
|
* @param caller Function to attribute a thrown error to (defaults to `renderPathTemplate` itself).
|
|
227
|
-
* @returns The rendered path, e.g. `/users/Dave`.
|
|
262
|
+
* @returns The rendered path with each value percent-encoded, e.g. `/users/Dave`.
|
|
228
263
|
* @throws {RequiredError} If a placeholder in the template string is not specified in values.
|
|
229
|
-
* @example renderPathTemplate("/users/{name}", { name: "
|
|
264
|
+
* @example renderPathTemplate("/users/{name}", { name: "a/b" }) // "/users/a%2Fb"
|
|
230
265
|
* @see https://shelving.cc/util/template/renderPathTemplate
|
|
231
266
|
*/
|
|
232
267
|
export function renderPathTemplate(template, values, caller = renderPathTemplate) {
|
|
233
|
-
|
|
268
|
+
const chunks = _splitTemplateCached(template, caller);
|
|
269
|
+
if (!chunks.length)
|
|
270
|
+
return template;
|
|
271
|
+
let output = template;
|
|
272
|
+
for (const { name, placeholder, catchall } of chunks) {
|
|
273
|
+
const value = _getTemplateValue(values, name, caller);
|
|
274
|
+
const encoded = catchall ? value.split("/").map(encodeURIComponent).join("/") : encodeURIComponent(value);
|
|
275
|
+
// Use a replacer function so `$` sequences in the (already-encoded) value are never treated as special replacement patterns.
|
|
276
|
+
output = output.replace(placeholder, () => encoded);
|
|
277
|
+
}
|
|
278
|
+
return output;
|
|
279
|
+
}
|
|
280
|
+
/** Resolve the string value for a single template placeholder from `TemplateValues` (mirrors `renderTemplate()`'s per-name resolution). */
|
|
281
|
+
function _getTemplateValue(values, name, caller) {
|
|
282
|
+
const value = isFunction(values)
|
|
283
|
+
? values(name)
|
|
284
|
+
: isData(values)
|
|
285
|
+
? getString(getDataProp(values, name))
|
|
286
|
+
: isArray(values)
|
|
287
|
+
? getString(values[Number(name)])
|
|
288
|
+
: getString(values);
|
|
289
|
+
if (value === undefined)
|
|
290
|
+
throw new RequiredError(`Template placeholder "${name}" not found`, { received: values, name, caller });
|
|
291
|
+
return value;
|
|
234
292
|
}
|