summaryrefslogtreecommitdiffstats
path: root/themes/luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js
blob: c7e870c91fa6a7ed44f9e5c27d69ef746c43608a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
'use strict';
'require baseclass';

/* The menu tree as a routing table: path <-> node, with LuCI's alias/firstchild resolution. Pure
 * lookup — no DOM. The chrome and the SPA router both need it, and require() raises
 * DependencyError on a loop, so the shared half is its own module rather than reached across. */

/* the ACL-filtered tree from /admin/menu, handed over once by the chrome's init() */
let _tree = null;

function setTree(tree) {
	_tree = tree;
}

/* /cgi-bin/luci/admin/status/overview -> ['admin','status','overview'].
 * The bare base (what build_url() emits for the brand wordmark) yields an empty seg list, not
 * null: the dispatcher's root node is itself a `firstchild`, so resolveSegs([]) walks to the
 * overview as the server does — returning null made the wordmark full-reload instead. null is
 * reserved for a path outside LuCI's scriptname. */
function segsFromPath(pathname) {
	const base = L.env.scriptname || '';
	if (base && pathname.indexOf(base) !== 0)
		return null;
	const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
	return rest.length ? rest.split('/') : [];
}

/* walk the (scrubbed, ACL-filtered) menu tree to the node for a path */
function nodeForSegs(segs) {
	let node = _tree;
	for (let i = 0; i < segs.length; i++) {
		node = node && node.children && node.children[segs[i]];
		if (!node) return null;
	}
	return node;
}

/* ---- alias / firstchild resolution ----
 *
 * 7 of the 27 menu links are redirects rather than pages (4 `alias`, 3 `firstchild`). The server
 * does not redirect them: a full GET answers 200 at the requested URL and stamps the resolved leaf
 * into requestpath/dispatchpath/nodespec. The client must therefore resolve exactly as
 * dispatcher.uc does, or a click and an F5 on the same URL open different pages — nodeWeight() and
 * firstChildOf() are ports, not approximations. Only the ACL check is skipped, the tree from
 * /admin/menu already being ACL-filtered for this session.
 *
 * `rewrite` is deliberately not followed: the tree has none, and a wrong guess at its splice
 * semantics would open the wrong page — worse than the full load it falls back to. */

/* node_weight() from dispatcher.uc: lower wins; a login node sorts last. */
function nodeWeight(node) {
	return Math.min(node.order ?? 9999, 9999) + (node.auth && node.auth.login ? 10000 : 0);
}

/* resolve_firstchild() from dispatcher.uc: the eligible child of lowest weight. Ties go to tree
 * order (the comparison is strict, as upstream's is, and JSON.parse preserves key order). A
 * `firstchild` child is eligible only if it resolves to something itself — recursively. */
function firstChildOf(node) {
	let bestName = null, best = null;
	const kids = node.children || {};
	for (const name in kids) {
		const child = kids[name];
		if (!child.satisfied || !child.title || !child.action || typeof child.action !== 'object')
			continue;
		if (child.action.type === 'firstchild') {
			if ((!best || nodeWeight(best) > nodeWeight(child)) && firstChildOf(child)) {
				best = child; bestName = name;
			}
		} else if (!child.firstchild_ineligible) {
			if (!best || nodeWeight(best) > nodeWeight(child)) {
				best = child; bestName = name;
			}
		}
	}
	return best ? { name: bestName, node: best } : null;
}

/* Follow alias/firstchild to the real page: {segs, node} of the leaf the dispatcher would have
 * rendered, or null when nothing resolves (the server would 404 — let it). The hop cap is a cycle
 * guard: an alias loop in some app's menu.d must not hang the UI. */
function resolveSegs(segs) {
	let node = nodeForSegs(segs);
	for (let hops = 0; node && node.action && hops < 8; hops++) {
		const type = node.action.type;
		if (type === 'alias') {
			segs = String(node.action.path).split('/');
			node = nodeForSegs(segs);
		} else if (type === 'firstchild') {
			const pick = firstChildOf(node);
			if (!pick) return null;
			segs = segs.concat([ pick.name ]);
			node = pick.node;
		} else {
			return { segs, node };
		}
	}
	return null;
}

/* ---- readonly is a property of the PATH, not of the leaf ----
 *
 * apply_tree_acls() (dispatcher.uc:442) marks a NODE readonly from its own `depends.acl`, but a
 * request accumulates every ancestor's acls into ctx.acls and stamps the leaf from the
 * accumulation (`resolved.node.readonly = !perm`, :1003). Reading the leaf alone therefore
 * disagrees with a full load: luci.js derives hasViewPermission() from
 * `!env.nodespec.readonly`, which views and the Save/Apply footer key their disabled state off, so
 * every page whose SECTION is read-only would gain controls the server refuses.
 *
 * The operator is AND down the path, not OR: check_acl_depends() (:312) is handed the whole
 * concatenated list (:457-470) and grants write as soon as ANY group in it does, so a page is
 * read-only only when every acl-bearing node on the path is. A leaf with a writable acl of its own
 * re-opens the whole path.
 *
 * A node with no `depends.acl` contributes nothing to ctx.acls and is skipped. That is also why
 * the test reads `depends.acl` rather than the `readonly` flag alone: an acl-bearing node the
 * session may write carries no flag and would otherwise look ungated.
 *
 * Feed it the RESOLVED segments — that is the path the dispatcher accumulates over. */
function readonlyForSegs(segs) {
	let node = _tree;
	let gated = 0, locked = 0;
	const weigh = (n) => {
		const acl = n && n.depends && n.depends.acl;
		if (!acl || !acl.length)
			return;
		gated++;
		if (n.readonly === true)
			locked++;
	};
	weigh(node);
	for (let i = 0; i < segs.length; i++) {
		node = node && node.children && node.children[segs[i]];
		if (!node)
			break;
		weigh(node);
	}
	return gated > 0 && gated === locked;
}

/* The view class a menu node instantiates, or null when the node is not SPA-able. The
 * Status -> Overview `template` node maps to view.status.index, its server template doing nothing
 * else; the globals that template also defines come from ensureOverviewHelpers() in
 * menu-footstrap-common.js. Shared by navigate() and the hover prefetch. */
function viewClassFor(node) {
	if (!node || !node.action || node.satisfied === false)
		return null;
	if (node.action.type === 'view')
		return 'view.' + String(node.action.path).replace(/\//g, '.');
	if (node.action.type === 'template' && node.action.path === 'admin_status/index')
		return 'view.status.index';
	return null;
}

/* The node the current full load landed on, i.e. what L.env.dispatchpath points at. */
function currentNode() {
	return nodeForSegs(L.env.dispatchpath || []);
}

return baseclass.extend({
	setTree,
	tree: () => _tree,
	/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
	 * node whose `depends.acl` names the group that command needs, and a node that resolves
	 * elsewhere would answer for a permission the session may not hold */
	nodeForSegs,
	segsFromPath,
	currentNode,
	resolveSegs,
	readonlyForSegs,
	viewClassFor
});