update 2026-10-09 04:58:55

This commit is contained in:
action committed 2026-10-09 04:58:55 +08:00
1 parent 9c17634d78
commit d6b7af2a48
636 files changed
+163 -121913

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 }) %}