mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-04 22:23:56 +08:00
update 2026-10-23 00:28:40
This commit is contained in:
1 parent
3f38c69f75
commit
d64c67f0ed
320 files changed
+109777
-2309
No files matched your search
@@ -0,0 +1,33 @@
|
||||
{#
|
||||
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 under <body>,
|
||||
and this one stays inside <main> so both layouts order it within the flex column.
|
||||
The version strings are entityencode()d: an unescaped `&` in a distname would 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');L.require('fs-select')</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,217 @@
|
||||
{#
|
||||
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 and the first archive holding the hash wins, so the Appearance layout toggle rendered
|
||||
"Максимум" on a Russian router because another catalogue translates `Top` as "maximum". Every
|
||||
Appearance label and every string of the search overlay therefore carries the `footstrap` msgctxt;
|
||||
the chrome's strings (the Search trigger included), the login and notice sentences and
|
||||
System/Memory/Storage in fs-overview.js stay msgctxt-free, so a bare msgid
|
||||
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 chrome strings stay bare for it to adopt.
|
||||
-#}
|
||||
|
||||
{%
|
||||
import { getuid, getspnam } from 'luci.core';
|
||||
|
||||
const boardinfo = ubus.call('system', 'board') ?? {};
|
||||
/* 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.
|
||||
Handed over as the whole section: head.ut validates every key it reads, so no list of axes
|
||||
lives here to fall out of step. `uci` is the cursor dispatcher.uc builds per request; a
|
||||
second one here would allocate one per page render. */
|
||||
const fs_defaults = uci.get_all('footstrap', 'settings') ?? {};
|
||||
|
||||
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 and
|
||||
again on .fs-content below for page-scoped CSS: the modules load too late, and the
|
||||
Overview's stray <h2 name="content"> was painted for 65-138 ms before them.
|
||||
#}
|
||||
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
|
||||
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
|
||||
{% if (!blank_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 script in <body>, before
|
||||
* any script-built node; chrome pages only, the one place fs-router loads. Own property of
|
||||
* this <body> instance, never Node.prototype. */
|
||||
(() => {
|
||||
const body = document.body;
|
||||
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
|
||||
[ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
|
||||
'insertAdjacentElement', 'insertAdjacentHTML' ].forEach((name) => {
|
||||
const orig = body[name];
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{#
|
||||
The wallpaper pattern's paint surface: a mask on this element lets an uploaded SVG take the
|
||||
theme's ink, and a mask applies to the 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 shows it
|
||||
there too. Inert unless the axis is on (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. 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; <aside> gets
|
||||
role="complementary" and leaves the shell with no navigation landmark.
|
||||
data-fs-chrome marks a zone-1 ROOT: no third-party rule may match it (the fence in
|
||||
fs-sheets.js) or reach it by inheritance (the pin). Both key off the attribute, never
|
||||
a class list: the skip link and search overlay sit outside this <nav>. Roots only,
|
||||
never nested: a direct declaration beats an inherited one, and nesting cost
|
||||
.fs-label its nowrap and forced text-align left on 302 elements.
|
||||
#}
|
||||
<nav class="fs-sidebar" data-fs-chrome aria-label="{{ _('Menu') }}">
|
||||
<div class="fs-brandrow">
|
||||
{# Brand block: logo + hostname wordmark. #}
|
||||
<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.
|
||||
#}
|
||||
<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>
|
||||
{#
|
||||
The three chrome icons below are the same 24x24 outline differing only in path data;
|
||||
the stroke is `.fs-ico` in theme/20-shell.css, not attributes, and fs-widgets.js's
|
||||
svgIcon() emits the same markup client-side. aria-hidden: each sits beside its own label.
|
||||
#}
|
||||
<button type="button" class="fs-railtoggle" id="fs-rail-toggle" aria-label="{{ _('Collapse menu') }}" title="{{ _('Collapse menu') }}">
|
||||
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# LuCI's poll status ("Refreshing") and other indicators land here by id. #}
|
||||
<div id="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, from the theme's own
|
||||
menu.d entry; nothing of them mounts in this header.
|
||||
#}
|
||||
{#
|
||||
Search trigger (#fs-search-btn), wired by fs-search.js. A `.fs-themerow`, the chrome's
|
||||
shared row/button class: a square icon button in the bar and the rail, a full-width
|
||||
labelled row in the vertical sidebar. It shares `order: 2` with the poll pill and Log
|
||||
out, so DOM order is visual order. 'Search' carries no msgctxt (see the i18n note).
|
||||
#}
|
||||
<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') }}">
|
||||
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M16.5 16.5 21 21"/></svg>
|
||||
<span class="fs-label">{{ _('Search') }}</span>
|
||||
</button>
|
||||
{#
|
||||
Log out, guarded on the node being dispatchable. It is the only logout the theme
|
||||
renders: fs-chrome.js drops the menu tree's own admin/logout node (fs-search.js likewise).
|
||||
#}
|
||||
{% 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.
|
||||
#}
|
||||
<svg class="fs-ico" aria-hidden="true" viewBox="0 0 24 24"><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"/></svg>
|
||||
<span class="fs-label">{{ _('Log out') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</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.
|
||||
#}
|
||||
<h1 class="fs-title-main fs-sr" data-fs-chrome>{{ entityencode(striptags(dispatched?.title ? _(dispatched.title) : (boardinfo.hostname ?? 'OpenWrt')), true) }}</h1>
|
||||
|
||||
{#
|
||||
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 }}">
|
||||
{#
|
||||
Top-of-content notices: no-root-password, initramfs recovery, the <noscript> fallback
|
||||
and the #tabmenu container. The sentences carry no msgctxt on purpose (see the i18n
|
||||
note at the top). empty_password is read here, not above: /etc/shadow is only worth
|
||||
a read on a page that renders the shell.
|
||||
#}
|
||||
{% const empty_password = (getuid() == 0 && getspnam('root')?.pwdp === '') %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,411 @@
|
||||
{#
|
||||
<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), meaning "no router default".
|
||||
`fs_defaults` is a required include parameter: header.ut is the only caller, so a `?? {}`
|
||||
here would only hide that contract. */
|
||||
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 : '';
|
||||
/* Absent (null) or '' maps to -1, never 0: 0 is a real value for the four axes whose range
|
||||
starts at 0 (rounding, tint_strength, photo_dim, pattern_strength) and int(null) is 0. */
|
||||
function _sd_range(raw, min, max) {
|
||||
let n = int(raw);
|
||||
return (type(raw) == 'string' && n >= min && n <= max) ? n : -1;
|
||||
}
|
||||
/* A cache-bust TOKEN (the cgi-upload md5 of an uploaded sheet), not a path. Whitelisted to
|
||||
hex so a hand-edited config cannot inject markup into the inline script that prints 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 */
|
||||
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
|
||||
/* Hex or nothing: the four surface axes (cards, inset controls, the chrome bar, hairlines)
|
||||
* have no hue mode, where a hue such as '5' would pass _sd_color's number branch. */
|
||||
function _sd_hex(v) {
|
||||
return (type(v) == 'string' && match(lc(v), /^#[0-9a-f]{6}$/)) ? lc(v) : '';
|
||||
}
|
||||
/* 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) {
|
||||
let n = int(v);
|
||||
return _sd_hex(v) || ((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);
|
||||
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: font_sans / font_mono name a stack, and `fonts` is the cache-bust
|
||||
md5 of an @font-face sheet at /etc/footstrap/fonts/fonts.css. The package only serves that
|
||||
directory (kept across a sysupgrade); it installs and fetches no face.
|
||||
A stack is printed UNESCAPED, so the charset excludes < > ( ) { } ; and the backslash: no way
|
||||
to close the style element, start a second declaration or write a url(). Both quote kinds stay
|
||||
allowed and are scanned in order, never counted: the opener wins until its own kind closes
|
||||
it, and a quote still open at the end refuses the value (it would eat the next stack). */
|
||||
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 (a future `ar-EG` is covered); the list is every base subtag whose
|
||||
CLDR default script is right-to-left, not the four 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', 'he', 'khw', 'ks', 'ps', 'sd', 'ug', '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="{{ _sd_lay == '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>
|
||||
/* One IIFE pre-paints every Appearance axis before the first frame, duplicating the live
|
||||
applier in fs-prefs.js (no shared code: this runs before the module loader exists).
|
||||
It carries no interpolation, unlike the data blob above: an interpolated body is not
|
||||
JS until rendered, so it is exempt from the inline-script lint and anything merged
|
||||
into it would go unchecked.
|
||||
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
|
||||
Dark mode: a saved preference, else the router default, else the OS. */
|
||||
(() => {
|
||||
/* 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; } };
|
||||
const root = document.documentElement,
|
||||
sd = window.__fsSD || {};
|
||||
/* 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. */
|
||||
if (lsGet('fs-rail') === 'true') root.setAttribute('data-rail', 'true');
|
||||
/* The saved layout overrides the server stamp before paint. Only a known value counts, so
|
||||
a corrupt one leaves the stamp standing instead of blanking the attribute. */
|
||||
const lay = lsGet('fs-layout');
|
||||
if (lay === 'top' || lay === 'sidebar') root.setAttribute('data-layout', lay);
|
||||
try {
|
||||
let p = lsGet('fs-palette');
|
||||
/* A stray or retired palette name fails the list below and paints the bare :root, as
|
||||
listAxis() in fs-prefs.js does. The default palette is a bare :root, every other
|
||||
colourway opt-in; the list matches fs-prefs.js, or a name in one only would paint
|
||||
here and be 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
|
||||
(the slider is continuous). Written only when it differs from the default.
|
||||
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;
|
||||
if (cw >= 1280 && cw <= 3840 && cw !== 1280)
|
||||
root.style.setProperty('--fs-content-max', cw + 'px');
|
||||
|
||||
/* Rounding 0-20px (the control and chip radii derive from it). The default is a literal
|
||||
because this runs before the stylesheet; keep it equal to the token. -1 when unset. */
|
||||
const rRaw = lsGet('fs-radius');
|
||||
let r = parseInt(rRaw, 10);
|
||||
if (rRaw === null) r = sd.rounding;
|
||||
if (r >= 0 && r <= 20 && r !== 12)
|
||||
root.style.setProperty('--fs-radius-base', r + 'px');
|
||||
/* The five colour axes: Tint (page canvas), Accent and the three status colours. Each
|
||||
is off, a hue 1-360 rotating the palette colour, or #rrggbb replacing it; mirrors
|
||||
colorAxis()/normColor(). The property is set BEFORE the attribute that switches the
|
||||
rotation on, or a reload paints one frame in the previous colour. Each mode clears
|
||||
the other's property, as the live applier does. */
|
||||
const hex = /^#[0-9a-f]{6}$/i;
|
||||
/* inkFor() is fs-axes.js's function verbatim: black above OKLab L .62, else white;
|
||||
the two copies must give equal output. */
|
||||
const inkFor = (hx) => {
|
||||
const [ lr, lg, lb ] = [ 1, 3, 5 ].map((i) => {
|
||||
const u = parseInt(hx.slice(i, i + 2), 16) / 255;
|
||||
return (u <= 0.04045) ? (u / 12.92) : (((u + 0.055) / 1.055) ** 2.4);
|
||||
});
|
||||
const f = (x, y, z) => Math.cbrt((x * lr) + (y * lg) + (z * lb));
|
||||
return (((0.21045 * f(0.41222, 0.53633, 0.05145)) + (0.79362 * f(0.2119, 0.6807, 0.1074)) - (0.00407 * f(0.0883, 0.28172, 0.62998))) > 0.62) ? '#000' : '#fff';
|
||||
};
|
||||
const colour = (key, sdVal, attr, hueProp, colorProp, inkProp) => {
|
||||
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());
|
||||
if (inkProp) root.style.setProperty(inkProp, inkFor(v));
|
||||
root.setAttribute(attr, 'hex');
|
||||
return;
|
||||
}
|
||||
const h = parseInt(v, 10);
|
||||
if (h >= 1 && h <= 360) {
|
||||
root.style.removeProperty(colorProp);
|
||||
if (inkProp) root.style.removeProperty(inkProp);
|
||||
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', '--fs-on-accent');
|
||||
colour('fs-good', sd.good, 'data-good', '--fs-good-h', '--fs-good', '--fs-on-good');
|
||||
colour('fs-warn', sd.warn, 'data-warn', '--fs-warn-h', '--fs-warn', '--fs-on-warn');
|
||||
colour('fs-danger', sd.danger, 'data-danger', '--fs-danger-h', '--fs-danger', '--fs-on-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 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 to hex above. */
|
||||
if (sd.login_bg)
|
||||
root.style.setProperty('--fs-login-bg-url', 'url("/luci-static/footstrap/bg?v=' + sd.login_bg + '")');
|
||||
/* 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;
|
||||
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;
|
||||
if (pd >= 0 && pd <= 100 && pd !== 74)
|
||||
root.style.setProperty('--fs-photo-dim', pd + '%');
|
||||
} catch (e) {}
|
||||
|
||||
const saved = lsGet('fs-darkmode');
|
||||
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). Must stay
|
||||
identical to stampDark() in fs-prefs.js. */
|
||||
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.darkmode === 'dark') ? true
|
||||
: (sd.darkmode === '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) => {
|
||||
const s = lsGet('fs-darkmode');
|
||||
if (s === 'true' || s === 'false') return;
|
||||
if (s === null && (sd.darkmode === 'dark' || sd.darkmode === 'light')) return;
|
||||
set(m.matches);
|
||||
});
|
||||
})();
|
||||
</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,
|
||||
installed app 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: neither
|
||||
file can be rendered per request, so the two must stay equal. fs-prefs.js repaints this
|
||||
tag from the live body background. One tag, no `media` pair: a media-qualified tag would
|
||||
win for a viewer whose OS is dark but who chose light HERE.
|
||||
#}
|
||||
<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 leaves the preload behind and every page keeps asking the router
|
||||
for files that are gone: six 404s per page, measured.
|
||||
#}
|
||||
{#
|
||||
`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">
|
||||
{#
|
||||
Lets an admin install this page as an app (icon, own window, no address bar), worth more
|
||||
than a tab on the phone LuCI is opened from when the network is broken. Both files are
|
||||
static, a theme having no dispatcher node to render them per request: start_url is
|
||||
/cgi-bin/luci/, uhttpd's default (a moved LuCI opens the standard path), and
|
||||
background/theme_color is the default palette's page colour, read once at install time.
|
||||
.json, not .webmanifest: uhttpd's compiled-in MIME map has no entry for that extension,
|
||||
so it answers application/octet-stream.
|
||||
#}
|
||||
<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: a stylesheet the server links for the
|
||||
page instead of the view injecting one at module eval. `dispatched`, not `node`: the
|
||||
dispatcher exports the resolved node as `dispatched` and never binds `node`, so no ucode
|
||||
theme emitted this link until openwrt/luci#8920. That commit also adds `css: 'string'` to
|
||||
the node schema; without it the property is filtered out and this stays false, as on 24.10
|
||||
and 25.12. Versioned, as uhttpd sends no Cache-Control. Not `data-fs-shell`: the sheet
|
||||
belongs to one page, so the SPA router must judge and contain it like any 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) that delivers 13
|
||||
bytes at lang=en, where no en catalogue exists because the msgids already are English.
|
||||
The process is the cost, so emit those bytes inline when the language has no catalogue
|
||||
and keep the tag when it has. The probe mirrors the server: load_catalog() reads every
|
||||
`*.<lang>.lmo` in /usr/lib/lua/luci/i18n, so a router shipping an en catalogue still gets
|
||||
the tag; deciding by language NAME would drop it. Fails open: a throwing probe keeps the
|
||||
tag, as a missing catalogue renders English and reports 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>
|
||||
@@ -0,0 +1,16 @@
|
||||
{#
|
||||
One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The
|
||||
`dispatcher.lookup` guard must not drift: `path` is 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.
|
||||
include(…, { title, text }) for a plain notice; add { path, label } for the button.
|
||||
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>
|
||||
@@ -0,0 +1,186 @@
|
||||
{#
|
||||
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 whole chrome renders around the login form with every control dead.
|
||||
No departure here may drop a field the backend posts.
|
||||
Do not re-introduce a `<section hidden>` form that a view module reveals: the view bootstraps
|
||||
before a session exists, its RPCs answer "Access denied", render() never runs and the page is
|
||||
blank with no way to log in. The server renders the form, so it works with JS off.
|
||||
The strings carry no msgctxt: luci-base translates them in the ~40 languages without a catalogue.
|
||||
-#}
|
||||
|
||||
{%
|
||||
/* 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 an
|
||||
include cannot hand a local back from header.ut: one extra round trip on a page rendered
|
||||
once per session. It discloses nothing new: the 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 the stylesheet's hook: 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. The `?? 'OpenWrt'` fallback and the
|
||||
striptags/entityencode pair match head.ut and are load-bearing: the hostname is admin-controlled
|
||||
text printed to an unauthenticated page. It is the card's visual title but a <p>, not a heading: the h1
|
||||
`Authorization Required` below stays the one heading (clipped by .fs-sr), or a screen reader
|
||||
hears the hostname as a section title.
|
||||
-#}
|
||||
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
|
||||
{#
|
||||
Two independent alerts: neither variable alone says which dispatcher branch rendered this.
|
||||
`fuser` is set in both branches, including the OTP step after a correct password; and
|
||||
`auth_message` (copied from get_challenges() BEFORE the check, dispatcher.uc:956) is set on
|
||||
every pending result. `auth_plugin` is set only once session_setup() accepted the
|
||||
credentials: gate the credentials alert on `!auth_plugin`, print the message beside it.
|
||||
entityencode, where upstream prints raw: plugin markup travels in `auth_html`, so this is
|
||||
text on an open page.
|
||||
-#}
|
||||
{% 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 have no top-level
|
||||
heading for a screen reader. Clipped with .fs-sr, not shown: the hostname above is the
|
||||
card's visible title and the words repeat what the fields say.
|
||||
-#}
|
||||
<h1 class="fs-sr">{{ _('Authorization Required') }}</h1>
|
||||
<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: a pluggable backend puts its OTP input in `auth_fields`;
|
||||
a template that drops them never posts it and nobody can log in with 2FA, so this one
|
||||
stays a SUPERSET of the generic one's. The leading space in each conditional attribute
|
||||
is load-bearing: ucode's lexer eats whitespace before `{%` and the newline after `%}`;
|
||||
measured, the generic spelling renders
|
||||
`inputmode="numeric"pattern="[0-9]*"maxlength="6"required`.
|
||||
Every attribute value is entityencoded; maxlength goes through a template literal first, as
|
||||
entityencode(6) is null.
|
||||
-#}
|
||||
{% 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="{{ entityencode(field.type ?? 'text', true) }}"
|
||||
name="{{ entityencode(field.name, true) }}"
|
||||
id="{{ entityencode(field.name, true) }}"
|
||||
{% if (field.placeholder): %} placeholder="{{ entityencode(field.placeholder, true) }}"{% endif %}
|
||||
{% if (field.inputmode): %} inputmode="{{ entityencode(field.inputmode, true) }}"{% endif %}
|
||||
{% if (field.pattern): %} pattern="{{ entityencode(field.pattern, true) }}"{% endif %}
|
||||
{% if (field.maxlength): %} maxlength="{{ entityencode(`${field.maxlength}`, true) }}"{% endif %}
|
||||
{% if (field.autocomplete): %} autocomplete="{{ entityencode(field.autocomplete, true) }}"{% 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: Chromium treats http to https as cross-site and
|
||||
* withholds LuCI's `SameSite=strict` cookie, so a reader logged in over https lands back on
|
||||
* this form (403 to the hop, 200 to curl with the cookie; Firefox sends it). Where this form
|
||||
* is what the hop landed on, it is asked again once over the same scheme, which carries the
|
||||
* cookie. The retry is bounded by the REFERRER, not a URL marker: a `#` marker never runs
|
||||
* the retry on Firefox, 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