mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-05 01:33:56 +08:00
update 2026-10-13 23:30:23
This commit is contained in:
1 parent
09818a2882
commit
1e69cad46b
710 files changed
+368
-159536
No files matched your search
@@ -1,35 +0,0 @@
|
||||
{#
|
||||
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
|
||||
markup, morphed by :root[data-layout].
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (!blank_page): %}
|
||||
</div>{# /.fs-content #}
|
||||
|
||||
{#
|
||||
role="contentinfo" is explicit because a <footer> gets it implicitly only when
|
||||
its nearest ancestor is <body>, and this one must stay inside <main>: both
|
||||
layouts lay .fs-main out as a flex column and order the footer within it.
|
||||
The version strings are entityencode()d — /etc/openwrt_release is root-owned,
|
||||
but an unescaped `&` in a distname would still break the markup.
|
||||
#}
|
||||
<footer class="fs-footer" role="contentinfo">
|
||||
<span>
|
||||
Powered by
|
||||
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
|
||||
/
|
||||
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
|
||||
{% if (lua_active): %}
|
||||
/ {{ _('Lua compatibility mode active') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</footer>
|
||||
</main>{# /.fs-main #}
|
||||
</div>{# /.fs-shell #}
|
||||
<script>L.require('menu-footstrap')</script>
|
||||
<script>L.require('fs-select')</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,209 +0,0 @@
|
||||
{#
|
||||
Copyright 2008 Steven Barth <steven@midlink.org>
|
||||
Copyright 2012 David Menting <david@nut-bolt.nl>
|
||||
Copyright 2008-2022 Jo-Philipp Wich <jo@mein.io>
|
||||
Copyright 2026 footstrap theme
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
|
||||
i18n, and the rule the other templates point at. A msgid is a GLOBAL name: LuCI serves one merged
|
||||
catalogue — load_catalog() loads every *.<lang>.lmo and a lookup returns the first archive holding
|
||||
the hash — so readdir order decides who owns a string, and the Appearance layout toggle rendered
|
||||
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum".
|
||||
Anything left bare is a name anyone may take, and contexting cannot be selective.
|
||||
|
||||
So every Appearance label carries the `footstrap` msgctxt, while the chrome's own strings — Skip
|
||||
to content, Menu, Collapse menu, Search, Log out — are deliberately msgctxt-free, because a bare
|
||||
msgid is how a string inherits a translation in the ~40 languages this theme ships no catalogue
|
||||
for. Skip to content and Log out are luci-base msgids verbatim; the other three are ours alone and
|
||||
stay bare so a future luci-base string can adopt them. Same for the login/notice sentences and for
|
||||
System/Memory/Storage in fs-overview.js, which match the stock headings.
|
||||
-#}
|
||||
|
||||
{%
|
||||
import { getuid, getspnam } from 'luci.core';
|
||||
|
||||
const boardinfo = ubus.call('system', 'board') ?? {};
|
||||
const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '');
|
||||
/* The router's saved Appearance defaults (/etc/config/footstrap): head.ut stamps them before
|
||||
paint and the client's localStorage overrides every one. Absent = the built-in defaults.
|
||||
|
||||
`uci` and `config` come from the render scope dispatcher.uc builds per request: `uci` is
|
||||
already a cursor and `config.main` is already `uci.get_all('luci','main')`. Opening a second
|
||||
cursor here would allocate one per page render and re-read a package the dispatcher has
|
||||
already loaded. */
|
||||
const fsd = uci.get_all('footstrap', 'settings') ?? {};
|
||||
/* Every option saveAsDefault() writes, listed once and read back in a loop. A hand-written
|
||||
* literal with one line per axis drops an axis silently: Save to router reports success, the
|
||||
* file on disk is correct, and "Reset to router" lands on the BUILT-IN look because the value
|
||||
* the server hands the client was never there. This list is checked against
|
||||
* snapshotAxes().
|
||||
*
|
||||
* '' rather than a raw undefined when unset, so head.ut's `!= ''` guards fire: int(undefined)
|
||||
* is 0, a valid value, and an axis whose 0 is a real choice would default to it. */
|
||||
const FS_AXES = [
|
||||
'layout', 'darkmode', 'palette', 'wallpaper', 'density',
|
||||
'tint', 'accent', 'good', 'warn', 'danger',
|
||||
'card', 'control', 'bar', 'line',
|
||||
'rounding', 'autocollapse', 'tint_strength', 'photo_dim',
|
||||
'pattern_size', 'pattern_strength', 'pattern_ink', 'content_width',
|
||||
/* the two uploaded images' cache-bust tokens (head.ut whitelists both to hex); the files
|
||||
themselves are served. Not axes — they have no browser layer — but they reach the client
|
||||
the same way. */
|
||||
'login_bg', 'pattern',
|
||||
/* the fonts, same shape: no browser layer, no Appearance control, no entry in
|
||||
snapshotAxes(). font_sans/font_mono are font-family stacks (head.ut whitelists the
|
||||
charset); `fonts` is the cache-bust token of the @font-face sheet the admin installs
|
||||
beside the faces. All three are written with `uci set`, never by Save as default. */
|
||||
'font_sans', 'font_mono', 'fonts'
|
||||
];
|
||||
const fs_defaults = {};
|
||||
for (let k in FS_AXES)
|
||||
fs_defaults[k] = fsd[k] || '';
|
||||
/* the one value with a fallback of its own: the legacy migration seed
|
||||
luci.main.footstrap_layout=top, which an admin may set to give the router a default top-nav
|
||||
theme (a shell script cannot write localStorage).
|
||||
|
||||
`config.main?.`, not `config.main.`: ucode raises a hard reference error on a property read
|
||||
through null, and this template renders every page, so a dispatcher that stopped defaulting
|
||||
that section would answer 500 everywhere. `ucode -T -c` compiles the template and never runs
|
||||
it, so the CI gate cannot see the difference. */
|
||||
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
|
||||
|
||||
http.prepare_content('text/html; charset=UTF-8');
|
||||
-%}
|
||||
|
||||
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
|
||||
|
||||
{#
|
||||
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
|
||||
/admin/status renders the overview while request_path is only ['admin','status'], so a
|
||||
`[data-page='admin-status-overview']` rule would silently not apply. The SPA router stamps the
|
||||
resolved leaf path, so both ways of arriving agree. Stamped on <body> for the JS modules that
|
||||
read the route there, and again on .fs-content below for the page-scoped CSS that keys off it
|
||||
(styles/pages/): fs-router's seed() copies it there too, but only once the modules have loaded,
|
||||
and the Overview's stray <h2 name="content"> was painted for 65-138 ms before that.
|
||||
#}
|
||||
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
|
||||
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
|
||||
<script>
|
||||
/* Records every node a script parks directly on <body>: a page that does so makes the next
|
||||
* navigation a full load, because the document is spent. fs-router.js's
|
||||
* strayBodyNode()/bodyLittered() read window.__fsBodyAdds. First byte of <body>, before
|
||||
* any chrome element or the dispatched page's own <div id="view">. Own property of this
|
||||
* <body> instance, never Node.prototype. */
|
||||
(() => {
|
||||
const body = document.body;
|
||||
if (!body || body.__fsBodyHooked) return;
|
||||
body.__fsBodyHooked = true;
|
||||
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
|
||||
const names = [ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
|
||||
'insertAdjacentElement', 'insertAdjacentHTML' ];
|
||||
names.forEach((name) => {
|
||||
const orig = body[name];
|
||||
if (typeof orig !== 'function') return;
|
||||
try {
|
||||
Object.defineProperty(body, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value() {
|
||||
const before = Array.prototype.slice.call(this.children);
|
||||
const ret = orig.apply(this, arguments);
|
||||
const after = this.children;
|
||||
for (let i = 0; i < after.length; i++) {
|
||||
if (before.indexOf(after[i]) === -1) adds.push(after[i]);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* a host that refuses to redefine this one name leaves it unwrapped; the rest
|
||||
* still are */
|
||||
catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{#
|
||||
The wallpaper pattern's paint surface: 15-wallpaper.css masks this element so an
|
||||
uploaded SVG takes the theme's ink. A mask applies to an element's whole rendering,
|
||||
children included, so it needs a layer of its own with nothing in it.
|
||||
|
||||
Outside the blank_page guard on purpose: the login page renders without the shell, and a
|
||||
router whose saved default is Pattern has to show it there too.
|
||||
|
||||
Inert unless the axis is on — .fs-pattern is display:none by default.
|
||||
|
||||
data-fs-chrome: zone 1, ours — see the <nav> below.
|
||||
#}
|
||||
<div class="fs-pattern" data-fs-chrome aria-hidden="true"></div>
|
||||
{% if (!blank_page): %}
|
||||
{#
|
||||
Skip link: jump the menu instead of tabbing through every section on every page.
|
||||
Visually hidden until focused (.fs-skip, theme/10-chrome.css).
|
||||
data-fs-chrome: zone 1 — see the <nav> below.
|
||||
#}
|
||||
<a class="fs-skip" data-fs-chrome href="#maincontent">{{ _('Skip to content') }}</a>
|
||||
<div class="fs-shell">
|
||||
{#
|
||||
<nav>, not <aside>: this column IS the site navigation, and an <aside> gets
|
||||
role="complementary", leaving the shell with no navigation landmark at all. CSS
|
||||
keys off .fs-sidebar.
|
||||
|
||||
data-fs-chrome marks a zone-1 ROOT: everything from here down is ours, and no
|
||||
third-party rule may match it (the fence, fs-sheets.js) or reach it from html/body
|
||||
by inheritance (the pin, theme/10-chrome.css). Both key off the attribute, never off
|
||||
a class list: the chrome is not one element — the skip link and the search overlay
|
||||
are outside this <nav> — so a fence naming `.fs-sidebar` protects the menu and
|
||||
leaves them exposed.
|
||||
|
||||
Roots only: never nest one marker inside another. The pin states inherited
|
||||
properties on the marked element, and a direct declaration beats an inherited one
|
||||
even when the inherited one is ours — nesting cost .fs-label its nowrap and forced
|
||||
text-align from start to left on 302 elements. `npm run chrome-fence` fails on a
|
||||
nested marker.
|
||||
#}
|
||||
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
|
||||
<div class="fs-brandrow">
|
||||
{% include('themes/footstrap/partials/brand', { boardinfo }) %}
|
||||
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
|
||||
{% include('themes/footstrap/partials/icon', { body: '<path d="M15 6l-6 6 6 6"/>' }) %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
|
||||
<div id="indicators" class="fs-indicators"></div>
|
||||
|
||||
<div class="fs-navlabel">{{ _('Menu') }}</div>
|
||||
<ul class="nav" id="topmenu"></ul>
|
||||
<ul id="modemenu" class="fs-modemenu"></ul>
|
||||
|
||||
<div class="fs-spacer"></div>
|
||||
|
||||
{# right cluster, in visual order: the poll pill above, then Search, Log out. The
|
||||
Appearance controls are their own page, System -> Footstrap, registered by the
|
||||
theme's own menu.d entry (docs/architecture.md) — no tab, no fs-appearance.js
|
||||
mounting of any kind reaches this header. #}
|
||||
{% include('themes/footstrap/partials/search') %}
|
||||
{% include('themes/footstrap/partials/logout') %}
|
||||
</nav>
|
||||
|
||||
<main class="fs-main" id="maincontent" tabindex="-1">
|
||||
{# The document's <h1>: LuCI views start at <h2>, so without it the heading outline
|
||||
begins mid-way down. It must exist for a screen reader without being seen, which
|
||||
`hidden` cannot do — display:none strips the element from the accessibility tree,
|
||||
so the SPA router's .fs-title-main sync would update a node no assistive tech can
|
||||
read. .fs-sr clips instead, and since a foreign `*` outranks that class with one
|
||||
`!important` it is marked zone 1 too. Inside .fs-main, which is not marked: that
|
||||
is where app pages render. #}
|
||||
<div class="fs-title fs-sr" data-fs-chrome>
|
||||
<h1 class="fs-title-main">{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
|
||||
</div>
|
||||
|
||||
{# The SPA router swaps the page with no reload, which a screen reader cannot
|
||||
notice: no load event, focus dies with the clicked <a>, the new <title> is not
|
||||
announced. The router writes the incoming title here after every nav; empty on a
|
||||
full load, which announces itself. #}
|
||||
<div id="fs-nav-status" class="fs-sr" data-fs-chrome role="status" aria-live="polite"></div>
|
||||
|
||||
<div class="fs-content" data-page="{{ fs_page }}">
|
||||
{% include('themes/footstrap/partials/notices', { boardinfo, empty_password }) %}
|
||||
{% endif %}
|
||||
@@ -1,14 +0,0 @@
|
||||
{#
|
||||
Shared brand block (logo + hostname wordmark). Included as
|
||||
include('themes/footstrap/partials/brand', { boardinfo }); the look lives in
|
||||
styles/theme/10-chrome.css.
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
<a class="fs-brand" href="{{ dispatcher.build_url() }}">
|
||||
<span class="fs-logo">
|
||||
{# The OpenWrt mark, same as the tab favicon (logo.svg): the cyan arcs are baked, the ring
|
||||
flips with the theme mode through .fs-logo-ring in styles/theme/10-chrome.css. #}
|
||||
<svg aria-hidden="true" viewBox="-9 -1 100 100"><path d="m40.5 50.8a6.6 6.6 0 1 0 0 13.2 6.6 6.6 0 0 0 0-13.2m-40.5-33.9 7 6.8a47.5 47.5 0 0 1 67.2 0l7-6.8a57.2 57.2 0 0 0-81.2 0" fill="#00b5e2"/><path d="m12.5 29.2 6.8 7a30 30 0 0 1 42.6 0l6.8-7a39.7 39.7 0 0 0-56.3 0" fill="#00b5e2"/><path d="m24.8 41.6 6.8 6.9a12.6 12.6 0 0 1 18 0l6.8-6.9a22.3 22.3 0 0 0-31.6 0" fill="#00b5e2"/><path class="fs-logo-ring" d="m64.9 39.7a30.2 30.2 0 1 1-48.7 0l-6.9-7a39.9 39.9 0 1 0 62.5 0z"/></svg>
|
||||
</span>
|
||||
<span class="fs-wordmark">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</span>
|
||||
</a>
|
||||
@@ -1,465 +0,0 @@
|
||||
{#
|
||||
<head> for the ONE template. include(…, { boardinfo, fs_defaults }); everything else comes from
|
||||
the render scope. fs_defaults = the ROUTER's saved Appearance defaults (luci-theme-footstrap
|
||||
/etc/config/footstrap), which the client's own localStorage overrides before paint.
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
<!DOCTYPE html>
|
||||
{%
|
||||
/* Sanitise the router's saved Appearance defaults into the window.__fsSD object the pre-paint
|
||||
below reads. Enums are whitelisted and numbers clamped, so a hand-edited config can neither
|
||||
break the inline script nor inject markup (defence in depth — writing that file needs root).
|
||||
Absent or invalid becomes '' (or -1 where 0 is a real value), which the JS reads as "no router
|
||||
default"; the client's localStorage overrides every one before paint.
|
||||
|
||||
`fs_defaults` is a required include parameter: header.ut is the only caller and builds it
|
||||
before the include, so a `?? {}` here would only hide that contract from a second caller. */
|
||||
let _sd_lay = index([ 'sidebar', 'top' ], fs_defaults.layout) >= 0 ? fs_defaults.layout : '';
|
||||
let _sd_dark = index([ 'auto', 'light', 'dark' ], fs_defaults.darkmode) >= 0 ? fs_defaults.darkmode : '';
|
||||
let _sd_pal = index([ 'footstrap', 'hicontrast', 'bootstrap', '2020', 'forum' ], fs_defaults.palette) >= 0 ? fs_defaults.palette : '';
|
||||
let _sd_wall = index([ 'off', 'pattern', 'file' ], fs_defaults.wallpaper) >= 0 ? fs_defaults.wallpaper : '';
|
||||
let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : '';
|
||||
let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : '';
|
||||
let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : '';
|
||||
/* Six numeric axes share this shape: '' (unset) must map to -1, never to 0, because 0 is a real
|
||||
value for every one of them (sharp corners, a transparent scrim, no tile). */
|
||||
function _sd_range(raw, min, max) {
|
||||
let n = int(raw);
|
||||
return (raw != '' && n >= min && n <= max) ? n : -1;
|
||||
}
|
||||
/* a cache-bust TOKEN (the cgi-upload md5 for an uploaded background/pattern/font sheet), not a
|
||||
path — the served path is fixed. Whitelisted to hex so a hand-edited config cannot inject
|
||||
markup into an inline script that interpolates it. */
|
||||
function _sd_token(v) {
|
||||
return (type(v) == 'string' && match(v, /^[a-f0-9]{6,64}$/)) ? v : '';
|
||||
}
|
||||
/* how far the content column may grow past --fs-content-max's 1280px default.
|
||||
header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */
|
||||
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
|
||||
/* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue,
|
||||
or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which
|
||||
it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes
|
||||
and turns everything else into ''. The hex charset is whitelisted because the value is
|
||||
interpolated into the inline data block below.
|
||||
(Do not write that block's tag name in a comment: the inline scripts are found by matching
|
||||
the opening tag, so a mention in prose would match too.) */
|
||||
function _sd_color(v) {
|
||||
if (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/))
|
||||
return lc(v);
|
||||
let n = int(v);
|
||||
return (n >= 1 && n <= 360) ? `${n}` : '';
|
||||
}
|
||||
let _sd_tint = _sd_color(fs_defaults.tint);
|
||||
let _sd_acc = _sd_color(fs_defaults.accent);
|
||||
let _sd_good = _sd_color(fs_defaults.good);
|
||||
let _sd_warn = _sd_color(fs_defaults.warn);
|
||||
let _sd_dang = _sd_color(fs_defaults.danger);
|
||||
/* the four surface axes (cards, inset controls, the chrome bar, hairlines). Hex or nothing:
|
||||
* they have no hue mode, so _sd_color's number branch would let a bare '0' through as a
|
||||
* value. */
|
||||
function _sd_hex(v) {
|
||||
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
|
||||
}
|
||||
let _sd_card = _sd_hex(fs_defaults.card);
|
||||
let _sd_ctrl = _sd_hex(fs_defaults.control);
|
||||
let _sd_bar = _sd_hex(fs_defaults.bar);
|
||||
let _sd_line = _sd_hex(fs_defaults.line);
|
||||
let _sd_round = _sd_range(fs_defaults.rounding, 0, 20);
|
||||
let _sd_lbg = _sd_token(fs_defaults.login_bg);
|
||||
let _sd_tstr = _sd_range(fs_defaults.tint_strength, 0, 200);
|
||||
let _sd_pdim = _sd_range(fs_defaults.photo_dim, 0, 100);
|
||||
let _sd_pat = _sd_token(fs_defaults.pattern);
|
||||
/* the pattern's tile edge in px and the layer's opacity 0-100 */
|
||||
let _sd_psize = _sd_range(fs_defaults.pattern_size, 40, 1600);
|
||||
let _sd_pstr = _sd_range(fs_defaults.pattern_strength, 0, 100);
|
||||
/* ---- the fonts ----
|
||||
The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets
|
||||
unless the router names something else here. Two independent options, either usable alone:
|
||||
font_sans / font_mono a font-family stack, printed into a style element further down
|
||||
fonts the cache-bust md5 of an @font-face sheet at
|
||||
/etc/footstrap/fonts/fonts.css, for when the admin installs the
|
||||
files too
|
||||
The package is the serving half only: it reads these options, serves whatever is in
|
||||
/etc/footstrap/fonts and keeps that directory across a sysupgrade, installing no faces and
|
||||
fetching nothing at run time. `fonts/set-font.sh` is a repository tool and not part of this
|
||||
package: it names third-party font hosts, which a theme in this tree must not carry.
|
||||
Neither is an axis — no browser layer, no Appearance control, no entry in snapshotAxes().
|
||||
|
||||
The charset is most of the defence: a family stack is free text printed UNESCAPED, so the
|
||||
whitelist excludes everything that could end the declaration or the element (< > ( ) { } ;
|
||||
and the backslash), leaving no way to close a style element, start a second declaration or
|
||||
write a url(). A non-Latin family name is refused too; those faces carry a Latin name as well.
|
||||
|
||||
The two quotes are what the charset cannot refuse, and neither ends anything — they EXTEND.
|
||||
Both must be allowed (the shipped default is `"Manrope",system-ui,…`), and one left open
|
||||
starts a CSS string that runs to the end of the sheet, so a typo in the sans stack silently
|
||||
costs the mono one.
|
||||
|
||||
Counting quotes is not the test, tokenizing being stateful: inside '…' a " is literal and vice
|
||||
versa, so `"O'Reilly Sans", 'Fira Sans` holds two of each while the second `'` is still open.
|
||||
Scan in order instead: the quote that opens wins until its own kind closes it, and one still
|
||||
open at the end refuses the value. */
|
||||
function _sd_family(v) {
|
||||
if (!(type(v) == 'string' && length(v) >= 1 && length(v) <= 120 &&
|
||||
match(v, /^[A-Za-z0-9 ,._"'-]+$/)))
|
||||
return '';
|
||||
let q = '';
|
||||
for (let c in split(v, '')) {
|
||||
if (q == '')
|
||||
q = (c == '"' || c == "'") ? c : '';
|
||||
else if (c == q)
|
||||
q = '';
|
||||
}
|
||||
return (q == '') ? v : '';
|
||||
}
|
||||
let _sd_fsans = _sd_family(fs_defaults.font_sans);
|
||||
let _sd_fmono = _sd_family(fs_defaults.font_mono);
|
||||
let _sd_ftok = _sd_token(fs_defaults.fonts);
|
||||
/* ---- writing direction ----
|
||||
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic,
|
||||
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
|
||||
align to the wrong edge and every mirrored affordance points the wrong way. The theme owns
|
||||
<html>, so this is the one place that can say it.
|
||||
Matched on the base subtag, so a future `ar-EG` is covered, and the list is the standard RTL
|
||||
set rather than the three LuCI ships catalogues for. `ltr` is written explicitly for the same
|
||||
reason data-layout is: every rule then matches positively. */
|
||||
let _lang = dispatcher.lang;
|
||||
let _lbase = lc(split(_lang, /[-_]/)[0]);
|
||||
let _rtl = index([ 'ar', 'arc', 'ckb', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ps', 'sd', 'ur', 'yi' ], _lbase) >= 0;
|
||||
%}
|
||||
{# The server always stamps data-layout with an EXPLICIT value (sidebar|top), so every layout rule
|
||||
matches positively and a future third layout must opt in rather than inherit another's rules by
|
||||
not being it — never write a :not([data-layout=…]) guard. The chrome is right with JS off; the
|
||||
script below only overrides it.
|
||||
Absent = TOP: the bar is what the theme is built around (every layout rule starts from it, with
|
||||
the vertical column as the one guarded override) and it is what a phone gets at any setting. #}
|
||||
<html lang="{{ dispatcher.lang }}" dir="{{ _rtl ? 'rtl' : 'ltr' }}" data-layout="{{ fs_defaults.layout == 'sidebar' ? 'sidebar' : 'top' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{# entityencode around striptags: striptags drops tags but escapes neither `&` nor quotes,
|
||||
so a hostname with `&` breaks entity parsing #}
|
||||
<title>{{ entityencode(striptags(`${boardinfo.hostname ?? 'OpenWrt'}${dispatched?.title ? ` | ${_(dispatched.title)}` : ''}`), true) }}</title>
|
||||
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
|
||||
current*(), so the Appearance controls show the effective default when this browser has no
|
||||
localStorage. See the sanitiser above for the unset encodings. #}
|
||||
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
|
||||
<script>
|
||||
/* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a
|
||||
live applier in fs-prefs.js. They cannot share code — this runs before the module
|
||||
loader exists — so the contract (keys, attributes, properties,
|
||||
ranges, default, order) is derived from the JS and this template is held to it.
|
||||
|
||||
ONE element for all of them, and exactly two in this file: the data blob above carries
|
||||
interpolations and this one carries none. That is the split the inline-script
|
||||
lint keys off — an interpolated body is not JS until the server renders it, so it is
|
||||
exempt from the lint and everything mergeable into it would go unchecked with it. Two
|
||||
is also what a Content-Security-Policy would need nonces for, should uhttpd ever send
|
||||
one.
|
||||
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
|
||||
Dark mode: a saved preference, else the router default, else the OS. */
|
||||
(() => {
|
||||
const root = document.documentElement,
|
||||
sd = (window.__fsSD || {}).darkmode;
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
/* data-darkmode is what this theme's CSS reads; data-theme and data-bs-theme are
|
||||
outbound compat for apps that sniff them (justclash keys 21 rules off
|
||||
data-theme) and are never read from styles/. Must stay identical to stampDark() —
|
||||
axes.mjs proves it. */
|
||||
function set(dark) {
|
||||
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
|
||||
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
/* localStorage stores 'true'/'false' or 'auto' (explicitly follow the OS); absent
|
||||
falls to the router default, then the OS */
|
||||
const explicit = (saved === 'true') ? true
|
||||
: (saved === 'false') ? false
|
||||
: (saved === 'auto') ? 'auto'
|
||||
: (sd === 'dark') ? true
|
||||
: (sd === 'light') ? false
|
||||
: 'auto';
|
||||
set(explicit === true || (explicit === 'auto' && mq.matches));
|
||||
/* always registered, and it re-checks storage: the user can switch to Auto after
|
||||
loading with a saved value, and OS flips must track live from then on */
|
||||
mq.addEventListener('change', (m) => {
|
||||
try {
|
||||
const s = localStorage.getItem('fs-darkmode');
|
||||
if (s === 'true' || s === 'false') return;
|
||||
if (s === null && (sd === 'dark' || sd === 'light')) return;
|
||||
} catch (e) {}
|
||||
set(m.matches);
|
||||
});
|
||||
})();
|
||||
|
||||
/* the remaining axes, in the precedence stated above. */
|
||||
(() => {
|
||||
/* Guarded per read, not once around the block: a browser that refuses storage throws
|
||||
on the FIRST getItem, and one try around everything aborts the whole pre-paint —
|
||||
every axis below skipped, so a router with saved defaults paints none of them.
|
||||
Storage failing must mean "this browser has no override", which is what the live
|
||||
applier's lsGet means. The outer try is the last resort for everything else. */
|
||||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||||
try {
|
||||
const root = document.documentElement,
|
||||
sd = window.__fsSD || {};
|
||||
let p = lsGet('fs-palette');
|
||||
/* A stray or retired palette name needs no migration here: it fails the list
|
||||
below and paints the bare :root, and listAxis() in fs-prefs.js ends the same
|
||||
way, so the live applier agrees. The three legacy names ('rvht', 'roman',
|
||||
'github') were rewritten here until this comment replaced them — the block
|
||||
was measured as changing nothing, and it ran on every page load. */
|
||||
/* the default palette is a bare :root; every other colourway is opt-in. The list
|
||||
is the same one fs-prefs.js validates against — a name in one and not the
|
||||
other paints here and is taken away by the first live change. */
|
||||
if (p === null) p = sd.palette || null;
|
||||
if (p === 'hicontrast' || p === 'bootstrap' || p === '2020' || p === 'forum') root.setAttribute('data-palette', p);
|
||||
else root.removeAttribute('data-palette');
|
||||
|
||||
/* Wallpaper: off | pattern (the uploaded SVG, tiled and recoloured) | file (the
|
||||
uploaded photo). The url() and the two pattern numbers are set BEFORE the
|
||||
attribute, or the frame paints with the tile at the previous size.
|
||||
sd.pattern is whitelisted to hex above, so nothing user-controlled reaches
|
||||
the url(). */
|
||||
const pu = sd.pattern;
|
||||
/* /cgi-bin, not a /www symlink: a plain static SVG is a same-origin document
|
||||
that runs script opened directly (OpenWrt forum thread 251930); this path
|
||||
is the CGI handler that answers with CSP 'none' + sandbox instead — see
|
||||
the serve path of fs-axes.js's PATTERN, which this string must keep matching. */
|
||||
if (pu) root.style.setProperty('--fs-pattern-url', 'url("/cgi-bin/luci-theme-footstrap-pattern?v=' + pu + '")');
|
||||
const psRaw = lsGet('fs-pattern-size');
|
||||
const ps = (psRaw !== null) ? parseInt(psRaw, 10) : sd.pattern_size;
|
||||
if (ps >= 40 && ps <= 1600 && ps !== 440) root.style.setProperty('--fs-pattern-size', ps + 'px');
|
||||
const pstRaw = lsGet('fs-pattern-strength');
|
||||
const pst = (pstRaw !== null) ? parseInt(pstRaw, 10) : sd.pattern_strength;
|
||||
if (pst >= 0 && pst <= 100 && pst !== 20) root.style.setProperty('--fs-pattern-strength', String(pst / 100));
|
||||
let pi = lsGet('fs-pattern-ink');
|
||||
if (pi === null) pi = sd.pattern_ink || null;
|
||||
if (pi === 'original') root.setAttribute('data-pattern-ink', 'original');
|
||||
else root.removeAttribute('data-pattern-ink');
|
||||
|
||||
let w = lsGet('fs-wallpaper');
|
||||
if (w === null) w = sd.wallpaper || null;
|
||||
if (w === 'pattern' || w === 'file') root.setAttribute('data-wallpaper', w);
|
||||
else root.removeAttribute('data-wallpaper');
|
||||
|
||||
/* Density: a pure token axis (02-tokens multiplies the type and space ladders).
|
||||
Normal is a bare :root, so the removeAttribute half matters as much as the
|
||||
set, or a browser switched back to Normal paints one frame compressed. */
|
||||
let dn = lsGet('fs-density');
|
||||
if (dn === null) dn = sd.density || null;
|
||||
if (dn === 'compact' || dn === 'large') root.setAttribute('data-density', dn);
|
||||
else root.removeAttribute('data-density');
|
||||
|
||||
/* Content width: how far the column may grow past the 1280px
|
||||
default, as a px length rather than a step name — the slider is continuous.
|
||||
Written only when it differs from the default, so an untouched theme paints
|
||||
from the bare :root exactly as it did before this axis existed.
|
||||
sd.content_width is -1 when unset. */
|
||||
const cwRaw = lsGet('fs-content-width');
|
||||
let cw = parseInt(cwRaw, 10);
|
||||
if (cwRaw === null) cw = (sd.content_width >= 1280 ? sd.content_width : 1280);
|
||||
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
|
||||
root.style.setProperty('--fs-content-max', cw + 'px');
|
||||
|
||||
/* Rounding 0-20px (02-tokens derives the control and chip radii from it). The
|
||||
default is a literal, this running before the stylesheet, so axes.mjs is the
|
||||
only thing checking the two agree. sd.rounding is -1 when unset. */
|
||||
const rRaw = lsGet('fs-radius');
|
||||
let r = parseInt(rRaw, 10);
|
||||
if (rRaw === null) r = (sd.rounding >= 0 ? sd.rounding : 12);
|
||||
if (r >= 0 && r <= 20 && r !== 12)
|
||||
root.style.setProperty('--fs-radius-base', r + 'px');
|
||||
/* The five colour axes: Tint (the page canvas), Accent (the UI colour) and the
|
||||
three status colours. Each is off, a hue 1-360 rotating the palette's own
|
||||
colour, or a '#rrggbb' replacing it — mirrors colorAxis()/normColor().
|
||||
Set the property BEFORE the attribute that switches the rotation on, or a
|
||||
reload paints one frame in the previous colour. The two modes clear each
|
||||
other's property: nothing is stale here, but the live applier calls the same
|
||||
sequence where the other mode was in effect, and axes.mjs holds the two
|
||||
copies to one shape. */
|
||||
const hex = /^#[0-9a-f]{6}$/i;
|
||||
const colour = (key, sdVal, attr, hueProp, colorProp) => {
|
||||
const raw = lsGet(key);
|
||||
const v = String((raw === null ? sdVal : raw) ?? '').trim();
|
||||
if (hex.test(v)) {
|
||||
root.style.removeProperty(hueProp);
|
||||
root.style.setProperty(colorProp, v.toLowerCase());
|
||||
root.setAttribute(attr, 'hex');
|
||||
return;
|
||||
}
|
||||
const h = parseInt(v, 10);
|
||||
if (h >= 1 && h <= 360) {
|
||||
root.style.removeProperty(colorProp);
|
||||
root.style.setProperty(hueProp, String(h));
|
||||
root.setAttribute(attr, 'hue');
|
||||
}
|
||||
};
|
||||
colour('fs-tint', sd.tint, 'data-tint', '--fs-tint-h', '--fs-bg');
|
||||
colour('fs-accent', sd.accent, 'data-accent', '--fs-accent-h', '--fs-accent');
|
||||
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good');
|
||||
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn');
|
||||
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger');
|
||||
/* The four surface axes — cards, inset controls, the chrome bar, hairlines. No
|
||||
hue mode and therefore no attribute, just the token or nothing. Mirrors
|
||||
surfaceAxis() in fs-prefs.js. */
|
||||
const surface = (key, sdVal, prop) => {
|
||||
const raw = lsGet(key);
|
||||
const v = String((raw === null ? sdVal : raw) ?? '').trim().toLowerCase();
|
||||
if (hex.test(v)) root.style.setProperty(prop, v);
|
||||
};
|
||||
surface('fs-card', sd.card, '--fs-panel-base');
|
||||
surface('fs-control', sd.control, '--fs-panel2-base');
|
||||
surface('fs-bar', sd.bar, '--fs-bar-bg');
|
||||
surface('fs-line', sd.line, '--fs-border-base');
|
||||
/* The uploaded photo's URL: router-side, the token coming purely from
|
||||
window.__fsSD. Set the property whenever a valid token exists; whether it
|
||||
PAINTS is the wallpaper axis above, so a router defaulting to 'file' shows it
|
||||
here, pre-login included. The token is whitelisted above; the test is repeated
|
||||
because this one interpolates into a url() inside a style property, where the
|
||||
failure would be silent. */
|
||||
const lb = sd.login_bg;
|
||||
if (lb && (/^[a-f0-9]{6,64}$/).test(lb))
|
||||
root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + lb + '")');
|
||||
/* the Tint's strength (default 100% = the designed chroma) */
|
||||
const tsRaw = lsGet('fs-tint-strength');
|
||||
let ts = parseInt(tsRaw, 10);
|
||||
if (tsRaw === null) ts = (sd.tint_strength >= 0 ? sd.tint_strength : 100);
|
||||
if (ts >= 0 && ts <= 200 && ts !== 100)
|
||||
root.style.setProperty('--fs-tint-strength', String(ts / 100));
|
||||
/* the File photo's scrim opacity, same shape as the tint strength above; the 74%
|
||||
default lives in the CSS token */
|
||||
const pdRaw = lsGet('fs-photo-dim');
|
||||
let pd = parseInt(pdRaw, 10);
|
||||
if (pdRaw === null) pd = (sd.photo_dim >= 0 ? sd.photo_dim : 74);
|
||||
if (pd >= 0 && pd <= 100 && pd !== 74)
|
||||
root.style.setProperty('--fs-photo-dim', pd + '%');
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
/* the icon-rail preference, so a reload does not flash the full sidebar first. Not a
|
||||
router-wide default: a transient chrome collapse, not an appearance choice. */
|
||||
(() => {
|
||||
try {
|
||||
if (localStorage.getItem('fs-rail') === 'true')
|
||||
document.documentElement.setAttribute('data-rail', 'true');
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
/* the client's saved layout overrides the server's stamp above, before paint. Only a
|
||||
known value is honoured: a corrupt one leaves the stamp standing rather than blanking
|
||||
the attribute every layout rule matches on. */
|
||||
(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('fs-layout');
|
||||
if (saved === 'top' || saved === 'sidebar')
|
||||
document.documentElement.setAttribute('data-layout', saved);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="darkreader-lock">
|
||||
{# The colour a mobile browser paints its own chrome with — address bar, task switcher, the
|
||||
installed app's title bar. Without it a dark page keeps a white bar above it.
|
||||
|
||||
The literal is the default palette's page colour and the SAME value manifest.json carries,
|
||||
for the same reason: neither file can be rendered per request. It is a starting value
|
||||
only — fs-prefs.js repaints this tag from the live body background, so it follows palette,
|
||||
mode and every tint axis. The two literals must stay equal.
|
||||
|
||||
One tag, no `media` pair: a media-qualified tag would win over this one for a viewer whose
|
||||
OS is dark but who chose light HERE, and the choice is the theme's to honour. #}
|
||||
<meta name="theme-color" content="#f6f8fa">
|
||||
{# The sheet link below is emitted only when the TOKEN says the sheet exists — set the option
|
||||
after the file, never before. A preload is not in the stylesheet, so dropping the
|
||||
@font-face rules alone left the pair behind and every page went on asking the router for
|
||||
files that were gone: six 404s per page, measured, and nothing in CSS could say so. #}
|
||||
{# `data-fs-shell` marks the shell's own CSS: on every nav the SPA router asks whether a VIEW
|
||||
put CSS in the document, and this is how it tells its own sheet from an app's. #}
|
||||
<link rel="stylesheet" data-fs-shell href="{{ media }}/cascade.css?v={{ pkgs_update_time }}">
|
||||
{% if (_sd_ftok != ''): %}
|
||||
{# The generated @font-face sheet, and only the faces: the families come from the element
|
||||
below, so setting font_sans by hand takes effect without regenerating anything. The token
|
||||
is the sheet's md5 — uhttpd sends no Cache-Control, so without it a browser applies
|
||||
heuristic freshness and keeps the previous face for days. #}
|
||||
<link rel="stylesheet" data-fs-shell href="{{ media }}/fonts/fonts.css?v={{ _sd_ftok }}">
|
||||
{% endif %}
|
||||
{% if (_sd_fsans != '' || _sd_fmono != ''): %}
|
||||
{# The router's own font stacks. Unlayered, so it beats the `tokens` layer declaring the
|
||||
defaults whatever order the two sheets arrive in. Values are whitelisted at the top of
|
||||
this file; nothing here escapes them. #}
|
||||
<style data-fs-shell>:root { {% if (_sd_fsans != ''): %}--fs-font-sans: {{ _sd_fsans }};{% endif %}{% if (_sd_fmono != ''): %} --fs-font-mono: {{ _sd_fmono }};{% endif %} }</style>
|
||||
{% endif %}
|
||||
<link rel="icon" href="{{ media }}/logo_48.png" sizes="48x48">
|
||||
<link rel="icon" href="{{ media }}/logo.svg" sizes="any">
|
||||
{# The manifest lets an admin install this page as an app: an icon, its own window and no
|
||||
address bar, which is worth more than a tab on the phone LuCI is opened from when the
|
||||
network is broken.
|
||||
|
||||
Both files are static and cannot be otherwise, a theme having no dispatcher node to render
|
||||
them per request. That fixes two values:
|
||||
|
||||
start_url/scope — `/cgi-bin/luci/`, uhttpd's default and the path this template is
|
||||
served from on a stock install. An admin who moved LuCI gets an installed app that
|
||||
opens the standard path; the browser tab is unaffected.
|
||||
background/theme_color — the default palette's page colour. The manifest is read once
|
||||
at install time and cannot follow a live Appearance change, and it only paints the
|
||||
splash and the window chrome.
|
||||
|
||||
Chrome offers its install prompt on a secure context only, so over plain HTTP this is
|
||||
iOS's Add to Home Screen plus the icons. Nothing here is on the critical path.
|
||||
|
||||
`.json`, not the conventional `.webmanifest`: uhttpd's built-in MIME map has no entry for
|
||||
that extension, so the same bytes come back `application/octet-stream` there and with a
|
||||
JSON type only as `.json`. The map is compiled into uhttpd. #}
|
||||
<link rel="manifest" href="{{ media }}/manifest.json?v={{ pkgs_update_time }}">
|
||||
{# iOS reads this link and never the manifest's icons array, and it scales the 192 square the
|
||||
manifest also uses down to its own 180. One raster serves both. #}
|
||||
<link rel="apple-touch-icon" href="{{ media }}/app-icon-192.png">
|
||||
{% if (dispatched?.css): %}
|
||||
{# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
|
||||
should link for its page, instead of the view injecting one at module eval.
|
||||
|
||||
`dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched` and
|
||||
never binds a `node` — every ucode theme carried that typo until openwrt/luci#8920, so the
|
||||
link had never once been emitted. It also requires `css: 'string'` in the dispatcher's node
|
||||
schema, added by the same commit; without it the property is filtered out of the tree and
|
||||
this stays false, as on 24.10 and 25.12.
|
||||
|
||||
uhttpd sends no Cache-Control, so without a version key a stale app stylesheet can be
|
||||
served for days after an upgrade.
|
||||
|
||||
Deliberately not `data-fs-shell`: this sheet belongs to one page, so the SPA router must
|
||||
judge and contain it like any other app sheet. #}
|
||||
<link rel="stylesheet" href="{{ resource }}/{{ dispatched.css }}?v={{ pkgs_update_time }}">
|
||||
{% endif %}
|
||||
{% if (css): %}
|
||||
{# no `title` attribute: on a <style> it names a style sheet SET, making this a "preferred"
|
||||
alternate sheet the user can switch off in Firefox's Page Style menu. `data-fs-shell` as
|
||||
above. #}
|
||||
<style data-fs-shell>{{ css }}</style>
|
||||
{% endif %}
|
||||
{# The catalogue is a CGI spawn on the critical path — 30 ms on the router — and at lang=en it
|
||||
spends them to deliver 13 bytes, there being no en catalogue because the msgids already
|
||||
are English. The process is the cost, not the data, so emit those bytes inline when this
|
||||
language has no catalogue and keep the tag when it has.
|
||||
The probe mirrors the server: determine_request_language() loads the catalogue with
|
||||
load_catalog(lang, '/usr/lib/lua/luci/i18n'), i.e. every `*.<lang>.lmo` in that directory,
|
||||
so a router that does ship an en catalogue still gets the tag. Deciding by language NAME
|
||||
would silently drop it.
|
||||
Fails open — a throwing probe keeps the tag — because a missing catalogue makes every
|
||||
`_()` render English and report nothing. #}
|
||||
{%- let has_catalog = true;
|
||||
try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %}
|
||||
{% if (has_catalog): %}
|
||||
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}"></script>
|
||||
{% else %}
|
||||
<script>window.TR={};</script>
|
||||
{% endif %}
|
||||
{# the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred:
|
||||
luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the
|
||||
catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the
|
||||
parser is still running, so a module's `_()` would race a deferred window.TR. #}
|
||||
<script src="{{ resource }}/cbi.js?v={{ pkgs_update_time }}"></script>
|
||||
</head>
|
||||
@@ -1,14 +0,0 @@
|
||||
{#
|
||||
The chrome's one inline-SVG wrapper, server side: every icon the templates draw is the same 24x24
|
||||
stroked outline differing only in path data, so stroke width and linecaps are stated once and
|
||||
cannot drift between call sites. fs-widgets.js's svgIcon() is the client half of the same look.
|
||||
|
||||
`body` is the shape and is emitted raw on purpose: it is author-written markup from the caller,
|
||||
never anything a request can influence, and escaping it would print the path data as text. `cls`
|
||||
defaults to the icon class every chrome control uses.
|
||||
|
||||
aria-hidden, because each icon sits beside its own label; an unlabelled <svg> would be announced
|
||||
as a graphic in its own right.
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
<svg class="{{ cls ?? 'fs-ico' }}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">{{ body }}</svg>
|
||||
@@ -1,17 +0,0 @@
|
||||
{#
|
||||
The logout control, guarded on the node being dispatchable. One markup for every layout, since a
|
||||
layout is a presentation choice and must not fork the markup. It is also the only logout the theme
|
||||
renders: menu-footstrap.js drops the menu tree's own admin/logout node.
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (dispatcher.lookup("admin/logout")): %}
|
||||
<a class="fs-logout" href="{{ dispatcher.build_url('admin/logout') }}" title="{{ _('Log out') }}" aria-label="{{ _('Log out') }}">
|
||||
{#
|
||||
Mirrored about x=12, so the glyph reads LEAVING rather than entering: each x becomes 24-x and
|
||||
each arc's sweep flag flips with it, leaving stroke widths, corner radii and the 24x24 box
|
||||
untouched.
|
||||
-#}
|
||||
{% include('themes/footstrap/partials/icon', { body: '<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M14 12h7m0 0-3-3m3 3-3 3"/>' }) %}
|
||||
<span class="fs-label">{{ _('Log out') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -1,20 +0,0 @@
|
||||
{#
|
||||
One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The
|
||||
`dispatcher.lookup` guard is the part that must not drift — it keeps the button from pointing at a
|
||||
page this user's ACLs do not grant.
|
||||
|
||||
include(…, { title, text }) for a plain notice; add { path, label } for the button. `path` is a
|
||||
dispatcher path, looked up before it is linked, so a build without that page — or a user without
|
||||
rights to it — gets the warning and no dead link.
|
||||
|
||||
title/text are emitted unescaped: they are `_()` results over strings this theme ships, not
|
||||
request data.
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
<div class="alert-message warning">
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ text }}</p>
|
||||
{% if (path && dispatcher.lookup(path)): %}
|
||||
<div class="right"><a class="btn" href="{{ dispatcher.build_url(path) }}">{{ label }}</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
{#
|
||||
Top-of-content notices: no-root-password, initramfs recovery, the <noscript> fallback and the
|
||||
#tabmenu container. include(…, { boardinfo, empty_password }); empty_password is computed in the
|
||||
header, which already imports luci.core. The markup lives in partials/notice.ut — this file
|
||||
decides only which notices there are.
|
||||
The sentences carry no msgctxt on purpose: inheriting luci-base's translation is a feature in the
|
||||
~40 languages this theme has no catalogue for (see header.ut).
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (empty_password): %}
|
||||
{% include('themes/footstrap/partials/notice', {
|
||||
title: _('No password set!'),
|
||||
text: _('There is no password set on this router. Please configure a root password to protect the web interface.'),
|
||||
path: "admin/system/admin",
|
||||
label: _('Go to password configuration...')
|
||||
}) %}
|
||||
{% endif %}
|
||||
|
||||
{% if (boardinfo.rootfs_type == "initramfs"): %}
|
||||
{% include('themes/footstrap/partials/notice', {
|
||||
title: _('System running in recovery (initramfs) mode.'),
|
||||
text: _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade'),
|
||||
path: "admin/system/flash",
|
||||
label: _('Go to firmware upgrade...')
|
||||
}) %}
|
||||
{% endif %}
|
||||
|
||||
<noscript>
|
||||
{% include('themes/footstrap/partials/notice', {
|
||||
title: _('JavaScript required!'),
|
||||
text: _('You must enable JavaScript in your browser or LuCI will not work properly.')
|
||||
}) %}
|
||||
</noscript>
|
||||
|
||||
<div id="tabmenu" style="display:none"></div>
|
||||
@@ -1,15 +0,0 @@
|
||||
{#
|
||||
The page-search trigger (#fs-search-btn), wired by fs-search.js. A `.fs-themerow`, the chrome's
|
||||
shared row/button class, so it renders as a square icon button in the bar and the rail and as a
|
||||
full-width labelled row in the vertical sidebar with no CSS of its own. It shares `order: 2` with
|
||||
the poll pill and Log out, so the DOM order here is the visual order.
|
||||
|
||||
i18n: 'Search' is chrome and deliberately carries no msgctxt — inheriting luci-base's translation
|
||||
is a feature in the ~40 languages this theme ships no catalogue for. The palette's own strings do
|
||||
carry the `footstrap` context, a msgid being a global name shared with every luci-app (header.ut).
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
<button type="button" class="fs-themerow" id="fs-search-btn" aria-haspopup="dialog" aria-expanded="false" aria-controls="fs-search-ov" title="{{ _('Search') }}" aria-label="{{ _('Search') }}">
|
||||
{% include('themes/footstrap/partials/icon', { body: '<circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/>' }) %}
|
||||
<span class="fs-label">{{ _('Search') }}</span>
|
||||
</button>
|
||||
@@ -1,225 +0,0 @@
|
||||
{#
|
||||
The LOGIN page.
|
||||
|
||||
Copyright 2008 Steven Barth <steven@midlink.org>
|
||||
Copyright 2008-2012 Jo-Philipp Wich <jow@openwrt.org>
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
|
||||
It exists because the generic template includes the header WITHOUT `blank_page: true`: with no
|
||||
theme-local sysauth the dispatcher falls back to it and the whole chrome renders around the login
|
||||
form with every control on it dead. Everything else here is a departure the notes below justify,
|
||||
and none of them may drop a field the backend posts.
|
||||
|
||||
Do not re-introduce the `<section hidden>` shape that needs a view module to reveal the form: the
|
||||
view bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
|
||||
render() never runs — a blank page with no way to log in. The server renders the form, so it works
|
||||
with JS off and cannot be broken by a rejected promise.
|
||||
|
||||
styles/pages/10-login.css makes it the centred card, keyed off `.fs-login` below. The strings
|
||||
carry no msgctxt on purpose — luci-base translates them in the ~40 languages this theme has no
|
||||
catalogue for.
|
||||
-#}
|
||||
|
||||
{%
|
||||
/* Which router this is (openwrt/luci#8961): the login page has no chrome, so the only place the
|
||||
name appeared was the browser tab.
|
||||
|
||||
A second `ubus.call('system', 'board')`, because header.ut makes its own inside its own scope
|
||||
and an include cannot hand a local back — one extra round trip on a page that renders once
|
||||
per session, against threading `boardinfo` through a global.
|
||||
|
||||
It discloses nothing new: the same hostname already reaches an unauthenticated browser
|
||||
through <title>, as in every stock theme. */
|
||||
const boardinfo = ubus.call('system', 'board') ?? {};
|
||||
-%}
|
||||
|
||||
{% include('header', { blank_page: true }) %}
|
||||
|
||||
{# the class is what 10-login.css hooks: the markup is ours, so the form is named rather than
|
||||
found by a 49-character `:has()` selector repeated 17 times, ~0.8 KB #}
|
||||
<form method="post" class="fs-login">
|
||||
{#
|
||||
The router's name, above everything else in the card: an admin with three of these open has no
|
||||
other way to tell the tabs apart once the form is focused. `?? 'OpenWrt'` is the fallback
|
||||
head.ut and brand.ut use, and the striptags/entityencode pair is theirs too and load-bearing —
|
||||
the hostname is admin-controlled text printed to an unauthenticated page.
|
||||
|
||||
A <p> and not a heading: the card's h1 is `Authorization Required` and stays the page's one
|
||||
heading, or a screen reader walking the headings hears the router's name as a section title
|
||||
with the form nested under it.
|
||||
-#}
|
||||
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
|
||||
{#
|
||||
Two independent alerts, because neither variable alone says which dispatcher branch rendered
|
||||
this page.
|
||||
|
||||
`fuser` is the USERNAME the form posted, set in both branches — on the second step of a
|
||||
pluggable login (the OTP prompt, dispatcher.uc:1018) the credentials were correct and it is
|
||||
still set, so `auth_message && !fuser` reports "Invalid username and/or password!" about a
|
||||
password that was right and swallows the backend's own messages.
|
||||
|
||||
`auth_message` does not mean the password was right either: the first branch copies it out of
|
||||
get_challenges() BEFORE the password is checked (dispatcher.uc:956), and that call answers with
|
||||
a message on every pending result. So with a 2FA plugin installed both are set whenever a
|
||||
password is rejected, and gating on `fuser` alone never tells that user their password was
|
||||
wrong.
|
||||
|
||||
`auth_plugin` separates the branches: set only in the second one, i.e. exactly when
|
||||
session_setup() accepted the credentials. Gating the credentials alert on `!auth_plugin` and
|
||||
rendering the message beside it covers all four states — wrong password, wrong password with a
|
||||
challenge pending, the OTP prompt, a failed OTP.
|
||||
|
||||
entityencode where upstream prints raw: without the `!fuser` gate the message renders on the
|
||||
request that posted the form too, and this page is unauthenticated. A plugin's markup travels
|
||||
in `auth_html` (dispatcher.uc:952), so this variable is text.
|
||||
-#}
|
||||
{% if (fuser && !auth_plugin): %}
|
||||
<div class="alert-message error">
|
||||
<p>{{ _('Invalid username and/or password! Please try again.') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (auth_message): %}
|
||||
<div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
|
||||
<p>{{ entityencode(auth_message, true) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cbi-map">
|
||||
{#
|
||||
An h1, where every other LuCI theme leaves this an h2: the login page has no chrome, so
|
||||
header.ut's `.fs-title-main` h1 never renders here and the document would go out with no
|
||||
top-level heading for a screen reader's `1` shortcut to reach.
|
||||
|
||||
`name="content"` is upstream's legacy anchor and travels with the element, not its level.
|
||||
10-login.css sets this h1 back to the h2's 20px: the level was wrong, not the size.
|
||||
-#}
|
||||
<h1 name="content">{{ _('Authorization Required') }}</h1>
|
||||
<div class="cbi-map-descr">
|
||||
{{ _('Please enter your username and password.') }}
|
||||
</div>
|
||||
<div class="cbi-section"><div class="cbi-section-node">
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title" for="luci_username">{{ _('Username') }}</label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="cbi-input-text" type="text" name="luci_username" id="luci_username" autocomplete="username" value="{{ entityencode(duser, true) }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value cbi-value-last">
|
||||
<label class="cbi-value-title" for="luci_password">{{ _('Password') }}</label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
{#
|
||||
The login backend's own fields. The dispatcher hands `auth_fields`, `auth_message`,
|
||||
`auth_html` and `auth_assets` to whichever sysauth template renders, and a pluggable
|
||||
backend puts its OTP input there. A theme-local sysauth that drops them never posts the
|
||||
field, so on a router with 2FA enabled nobody can log in while this theme is active.
|
||||
This template must stay a SUPERSET of the generic one's fields.
|
||||
|
||||
The leading space inside each conditional attribute is load-bearing: ucode's template
|
||||
lexer eats the whitespace immediately before a `{%` tag and the newline immediately
|
||||
after a `%}`, so the indentation never reaches the output and the attributes would run
|
||||
together into one token — measured, the generic template's spelling renders
|
||||
`inputmode="numeric"pattern="[0-9]*"maxlength="6"required`.
|
||||
-#}
|
||||
{% if (auth_fields): %}
|
||||
{% for (let field in auth_fields): %}
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title" for="{{ entityencode(field.name, true) }}">{{ _(field.label ?? field.name) }}</label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="cbi-input-text"
|
||||
type="{{ field.type ?? 'text' }}"
|
||||
name="{{ field.name }}"
|
||||
id="{{ entityencode(field.name, true) }}"
|
||||
{% if (field.placeholder): %} placeholder="{{ field.placeholder }}"{% endif %}
|
||||
{% if (field.inputmode): %} inputmode="{{ field.inputmode }}"{% endif %}
|
||||
{% if (field.pattern): %} pattern="{{ field.pattern }}"{% endif %}
|
||||
{% if (field.maxlength): %} maxlength="{{ field.maxlength }}"{% endif %}
|
||||
{% if (field.autocomplete): %} autocomplete="{{ field.autocomplete }}"{% endif %}
|
||||
{% if (field.required): %} required{% endif %}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if (auth_html): %}
|
||||
<div class="cbi-value">
|
||||
{{ auth_html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (auth_assets): %}
|
||||
{% for (let asset in auth_assets): %}
|
||||
{% if (asset.type == 'script'): %}
|
||||
<script src="{{ asset.src }}"></script>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-page-actions">
|
||||
<input type="submit" value="{{ _('Log in') }}" class="btn cbi-button cbi-button-apply" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{%
|
||||
let https_ports = uci.get('uhttpd', 'main', 'listen_https') ?? [];
|
||||
|
||||
https_ports = uniq(filter(
|
||||
map(
|
||||
(type(https_ports) == 'string') ? split(https_ports, /\s+/) : https_ports,
|
||||
e => +match(e, /\d+$/)?.[0]
|
||||
),
|
||||
p => (p >= 0 && p <= 65535)
|
||||
));
|
||||
%}
|
||||
|
||||
{# The two server values the script below needs, handed over as DATA: a `{{ … }}` block is not JS
|
||||
until the server renders it, so this line carries no logic at all and eslint's .ut processor
|
||||
enforces that. The script itself is plain JS and is linted. #}
|
||||
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
|
||||
<script>
|
||||
(() => {
|
||||
/* if the router also serves HTTPS, hop over to it before the password goes over this
|
||||
* connection; the <img> probe is upstream's trick for proving the TLS port answers first
|
||||
*
|
||||
* THE HOP CAN ARRIVE WITHOUT THE SESSION. On Chromium the jump from http to https is
|
||||
* cross-site — schemeful same-site puts the two schemes in different sites — so the
|
||||
* browser withholds LuCI's `SameSite=strict` cookie (dispatcher.uc) on exactly that
|
||||
* navigation and a reader already logged in over https lands back on this form: 403 to
|
||||
* the hop against 200 to curl carrying the cookie, measured on a stand serving both
|
||||
* schemes. Firefox sends it and answers 200 straight away.
|
||||
*
|
||||
* So where this form is what the hop landed on, it is asked again once, over the same
|
||||
* scheme: that navigation is same-site everywhere and carries the cookie. The retry is
|
||||
* bounded by the REFERRER rather than by a marker in the URL — after it, the referrer is
|
||||
* this https page instead of the http one, so a reader who genuinely has no session sees
|
||||
* this form once and stays on it. A `#…` marker was tried and is not what ships: on
|
||||
* Firefox the retry never runs, so nothing takes the fragment back off the address. */
|
||||
if (document.location.protocol === 'https:') {
|
||||
/* the host is compared as a PARSED origin, not as a prefix of the string:
|
||||
* `http://192.168.1.1.example.com` starts with `http://192.168.1.1` */
|
||||
const from = document.referrer ? new URL(document.referrer) : null;
|
||||
|
||||
if (from && from.protocol === 'http:' && from.hostname === window.location.hostname)
|
||||
window.location.replace(window.location.href);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
(window.__fsHttps.ports || []).forEach((port) => {
|
||||
const url = `https://${hostname}:${port}${pathname}`;
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => { window.location = url; };
|
||||
img.src = `https://${hostname}:${port}${window.__fsHttps.resource}/icons/loading.svg?${Math.random()}`;
|
||||
|
||||
setTimeout(() => { img.src = ''; }, 5000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% include('footer', { blank_page: true }) %}
|
||||
Reference in new issue
Block a user