luci-base: dispatcher; improve wildcard routing
authorPaul Donald <redacted>
Sat, 17 Jan 2026 19:18:37 +0000 (20:18 +0100)
committerPaul Donald <redacted>
Fri, 23 Jan 2026 03:27:52 +0000 (04:27 +0100)
When a menu JSON describes an endpoint like

 "admin/app/edit/*" : { ...

and the user navigates to

 admin/app/edit/

instead of the URI which supplies an ID to edit, like

 admin/app/edit/myfoobarthing

we now can use 'alias' and 'rewrite' to redirect
transparently for more generic endpoints.
Without this, it's possible to navigate to

 admin/app/edit/

and the corresponding view does not receive a suitable
path/ID to derive data from, when views use anything
derived via L.env.requestpath.

This menu JSON

  "admin/app/entry/*": {
    "action": {
      "type": "view",
      "path": "app/entry"
    }
  },

  "admin/app/entries": {
    "title": "entries",
    "order": 5,
    "action": {
      "type": "view",
      "path": "app/entries"
    }
  },

  "admin/app/entry": {
    "action": {
      "type": "alias",
      "path": "admin/app/entries"
    }
  },

Produces JSON with a wildcardaction element

  "entry":
  {
    "satisfied": true,
    "wildcard": true,
    "action":
    {
      "type": "alias",
      "path": "admin/app/entries"
    },
    "wildcardaction":
    {
      "type": "view",
      "path": "app/entry"
    }
  },
  "entries":
  {
    "satisfied": true,
    "action":
    {
      "type": "view",
      "path": "app/entries"
    },
    "order": 5,
    "title": "entries"
  },

Signed-off-by: Paul Donald <redacted>
modules/luci-base/ucode/dispatcher.uc

index 09e53b885a0f72483b0ceb330069a6022bce2f35..0b31a38a10e0a6642575a257aeb9430a4cd43e1d 100644 (file)
@@ -388,10 +388,12 @@ function build_pagetree() {
                        for (let path, spec in data) {
                                if (type(spec) == 'object') {
                                        let node = tree;
+                                       let has_wildcard = false;
 
                                        for (let s in match(path, /[^\/]+/g)) {
                                                if (s[0] == '*') {
                                                        node.wildcard = true;
+                                                       has_wildcard = true;
                                                        break;
                                                }
 
@@ -405,6 +407,12 @@ function build_pagetree() {
                                                        if (type(spec[k]) == t)
                                                                node[k] = spec[k];
 
+                                               /* Preserve distinct actions for wildcard vs. base path */
+                                               if (has_wildcard && type(spec.action) == 'object')
+                                                       node.wildcardaction = spec.action;
+                                               else if (type(spec.action) == 'object')
+                                                       node.action = spec.action;
+
                                                node.satisfied = check_depends(spec);
                                        }
                                }
@@ -635,16 +643,27 @@ function resolve_page(tree, request_path) {
                if (!login && node.auth?.login)
                        login = true;
 
+               /* If this node is marked as wildcard, check if the next segment
+                * matches a child node. Only apply wildcard behaviour (capturing
+                * remaining segments as args) if no child matches, allowing
+                * deeper routes like foo/bar/* to work alongside
+                * foo/*
+                */
                if (node.wildcard) {
-                       ctx.request_args = [];
-                       ctx.request_path = ctx.path ? [ ...ctx.path ] : [];
+                       let next_segment = request_path[i + 1];
+                       let has_matching_child = next_segment && node.children?.[next_segment]?.satisfied;
 
-                       while (++i < length(request_path)) {
-                               push(ctx.request_path, request_path[i]);
-                               push(ctx.request_args, request_path[i]);
-                       }
+                       if (!has_matching_child) {
+                               ctx.request_args = [];
+                               ctx.request_path = ctx.path ? [ ...ctx.path ] : [];
 
-                       break;
+                               while (++i < length(request_path)) {
+                                       push(ctx.request_path, request_path[i]);
+                                       push(ctx.request_args, request_path[i]);
+                               }
+
+                               break;
+                       }
                }
        }
 
@@ -986,6 +1005,11 @@ dispatch = function(_http, path) {
 
                let action = resolved.node.action;
 
+               /* If this node matched a wildcard and we have request args,
+                * prefer the wildcard-specific action when defined. */
+               if (length(resolved.ctx.request_args) && type(resolved.node.wildcardaction) == 'object')
+                       action = resolved.node.wildcardaction;
+
                if (action?.type == 'arcombine')
                        action = length(resolved.ctx.request_args) ? action.targets?.[1] : action.targets?.[0];
 
git clone https://git.99rst.org/PROJECT