From be0929a3aeebfaf5fbfa81c0b5bd19aae5b27569 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 21 Aug 2026 18:20:53 +0200 Subject: [PATCH] feat(demo): read-only demo mode for a publicly shared instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mc-webui has no login, which is fine on a LAN and not fine once the address is handed to testers: they assume the node is theirs and rename it, and the console is right there for anyone curious enough to type into it. MC_DEMO=true refuses everything that reconfigures the radio, destroys data, reaches other people's repeaters, rebuilds the containers, publishes to an external broker, or hands out a secret. Reading and sending messages keep working, so the instance is still worth visiting. The guard is default-deny: an api_bp.before_request hook refuses every non-GET unless it is on a short allowlist, so an endpoint added later is locked without touching this module. That mattered — six mutating endpoints already had no frontend caller and would never have made a hand-written blacklist. A small read denylist covers the GETs that leak (repeater passwords, backup download, capture download, /api/logs), which the write guard never sees. Two paths bypass the HTTP layer and are closed separately: the console is a Socket.IO event, not a route, so it is checked against a read-only command set; the log viewer is refused at the connect handshake. Access is kept two ways. MC_DEMO_TRUSTED_NETS unlocks by CIDR, and MC_DEMO_UNLOCK_CODE unlocks by a code typed once in Settings and held in an HMAC cookie for 30 days — stateless, and deliberately not Flask's session, whose SECRET_KEY is a hardcoded public constant. The trap the network list walks into: published through a tunnel, every visitor arrives from the tunnel's address, and if the tunnel runs on the LAN then trusting the LAN unlocks the instance for the whole internet. An address arriving with a proxy header is therefore not matched unless MC_TRUST_PROXY is on, and startup logs report the resulting shape. Frontend is advisory only: demo-lock.js loads from _head_i18n.html so it reaches all eight entry points including the six iframes, greys out [data-demo-lock] rather than hiding it (the instance exists to show the app off), re-applies via MutationObserver for JS-rendered buttons, and turns any 403 demo_locked into a toast. Verified on the local container: the rename, reboot, updater, vacuum, cleanup and the leaky GETs all 403; sending, read markers and ordinary reads pass; `set name` is refused over the console socket while `infos` still answers; unlock/lock round-trips. 86 new unit tests, en/pl at 100%. Co-Authored-By: Claude Opus 5 --- .env.example | 27 +++ README.md | 8 + app/config.py | 20 ++ app/demo_guard.py | 289 +++++++++++++++++++++++++++++ app/main.py | 32 ++++ app/routes/api.py | 69 +++++++ app/static/css/style.css | 62 +++++++ app/static/js/app.js | 2 +- app/static/js/demo-lock.js | 260 ++++++++++++++++++++++++++ app/templates/_head_i18n.html | 12 ++ app/templates/base.html | 69 +++++-- app/templates/console.html | 7 + app/templates/index.html | 6 +- app/translations/en.json | 12 ++ app/translations/pl.json | 12 ++ docker-compose.yml | 3 + docs/architecture.md | 13 ++ docs/demo-mode.md | 174 +++++++++++++++++ docs/whatsnew.md | 6 + tests/test_demo_guard.py | 338 ++++++++++++++++++++++++++++++++++ 20 files changed, 1399 insertions(+), 22 deletions(-) create mode 100644 app/demo_guard.py create mode 100644 app/static/js/demo-lock.js create mode 100644 docs/demo-mode.md create mode 100644 tests/test_demo_guard.py diff --git a/.env.example b/.env.example index 5e52ecb..7189ae7 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,33 @@ FLASK_DEBUG=false # yourself out. Leave unset (0.0.0.0) to keep HTTP available as before. # MC_BIND_ADDRESS=127.0.0.1 +# ============================================ +# Demo mode (optional) - see docs/demo-mode.md +# ============================================ +# For an instance you hand to people you do not know: everything that changes +# the radio, deletes data, or reconfigures this server is refused, and the +# controls for it are greyed out. Reading and sending messages still work, so +# the instance is still worth visiting. Off by default. +# MC_DEMO=true + +# How YOU keep full access. Two independent ways in - set either, or both. +# +# 1) By network. Comma-separated CIDRs whose visitors are never restricted. +# Empty by default, and think before filling it in: if the app is published +# through a tunnel (cloudflared, ngrok) or a reverse proxy, every visitor +# arrives from that one address, and listing the network it sits in would +# unlock the instance for everyone at once. mc-webui refuses to match on +# address alone when it sees a proxy header and MC_TRUST_PROXY is off - but +# the safe setup is still to leave this empty unless the port is reached +# directly from your LAN. +# MC_DEMO_TRUSTED_NETS=192.168.0.0/16,10.0.0.0/8 +# +# 2) By code. Typed once under Settings, then remembered in a cookie for 30 +# days. Works from any network, which is what you want if the app is behind +# a tunnel. Make it long - it is the only thing between a visitor and your +# radio, and it is not rate-limited beyond a one-second delay per attempt. +# MC_DEMO_UNLOCK_CODE=choose-something-long-and-unguessable + # ============================================ # System Configuration # ============================================ diff --git a/README.md b/README.md index fb38c79..0ee2921 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall | [User Guide](docs/user-guide.md) | Complete feature documentation | | [Android App](docs/android-app.md) | Installing the Android companion app and connecting it to your instance | | [HTTPS Setup](docs/https-setup.md) | Optional encrypted access via Nginx Proxy Manager (Let's Encrypt, self-signed, IP address) | +| [Demo Mode](docs/demo-mode.md) | Share an instance publicly with the device settings, the console and your data locked | | [Architecture](docs/architecture.md) | Technical details, API reference | | [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | | [Docker Installation](docs/docker-install.md) | How to install Docker on Debian/Ubuntu | @@ -518,6 +519,13 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall **Important**: This application is designed for **trusted local networks only** and has **no authentication**. Do not expose it to the internet without implementing proper security measures. +If you want to share an instance anyway — a public demo, a group of testers — turn on +[demo mode](docs/demo-mode.md) (`MC_DEMO=true`). Everything that reconfigures the radio, +deletes data or reaches the server is refused, while reading and chatting keep working; +you keep full access from your own network or with an unlock code. It restricts what +visitors can *change*, not what they can *see*, so pair it with a login (NPM's Access +Lists, see [HTTPS Setup](docs/https-setup.md)) if the content itself is private. + --- ## Contributing diff --git a/app/config.py b/app/config.py index 1f6ef8b..0b0350d 100644 --- a/app/config.py +++ b/app/config.py @@ -50,6 +50,26 @@ class Config: # talking to the port directly can forge those headers. MC_TRUST_PROXY = os.getenv('MC_TRUST_PROXY', 'false').lower() == 'true' + # Demo mode: the instance is shared with people who should be able to look + # around and chat, but not reconfigure the radio, wipe data or reach the + # console. Everything that writes is refused unless the caller is unlocked + # (see app/demo_guard.py). Off by default — a private install is unaffected. + MC_DEMO = os.getenv('MC_DEMO', 'false').lower() == 'true' + + # Networks that get full access without typing anything, as comma-separated + # CIDRs (e.g. "192.168.0.0/16,10.0.0.0/8"). Empty by default, and DELIBERATELY + # so: when the app is published through a tunnel or a proxy, every visitor + # arrives from that one address, and a network listed here would unlock the + # instance for all of them. demo_guard refuses to match on address alone when + # a forwarded header is present and MC_TRUST_PROXY is off, but the safe setup + # is still to leave this empty unless the port is reached directly. + MC_DEMO_TRUSTED_NETS = os.getenv('MC_DEMO_TRUSTED_NETS', '') + + # Shared secret that unlocks the UI from anywhere — typed once in Settings, + # remembered in a signed cookie. Empty = no unlock code, only the networks + # above (or nothing at all) can write. + MC_DEMO_UNLOCK_CODE = os.getenv('MC_DEMO_UNLOCK_CODE', '') + # Flask server configuration FLASK_HOST = os.getenv('FLASK_HOST', '0.0.0.0') FLASK_PORT = int(os.getenv('FLASK_PORT', '5000')) diff --git a/app/demo_guard.py b/app/demo_guard.py new file mode 100644 index 0000000..a973a7b --- /dev/null +++ b/app/demo_guard.py @@ -0,0 +1,289 @@ +""" +Demo mode — read-only guard for a publicly shared instance. + +mc-webui has no authentication: whoever reaches the port can rename the radio, +rewrite its frequency, wipe the address book or type into the console. That is +fine on a home LAN and not fine once the instance is handed to strangers, which +is what MC_DEMO=true is for. + +The rules, in the order they are applied: + +1. MC_DEMO off → nothing changes, every request passes. Existing installs are + untouched by this module. +2. Caller unlocked → every request passes. A caller is unlocked by coming from + a network in MC_DEMO_TRUSTED_NETS, or by holding the cookie handed out for + MC_DEMO_UNLOCK_CODE. +3. Otherwise → writes are refused (default deny, see WRITE_ALLOWLIST for the + handful of things a visitor may still do), a few reads that hand out secrets + are refused too (READ_DENYLIST), and the console accepts only the commands + in CONSOLE_READONLY. + +Default deny is the point. New endpoints are locked the day they are written +rather than the day someone remembers to add them to a list. +""" + +import hmac +import ipaddress +import logging +import re +from hashlib import sha256 +from typing import List, Optional + +from app.config import config + +logger = logging.getLogger(__name__) + +# Cookie carrying the unlock proof. HttpOnly — the UI learns whether it is +# unlocked from /api/status, never by reading this back. +UNLOCK_COOKIE = 'mc_demo_unlock' +UNLOCK_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days + +# Domain separation, so the token cannot be replayed as some other HMAC. +_UNLOCK_MESSAGE = b'mc-webui-demo-unlock-v1' + +# Error payload for a refused call. The frontend greys these controls out, so +# seeing this means either a stale page or a direct API call. +DEMO_ERROR = 'demo_locked' + + +# ================================================================ +# What stays reachable while locked +# ================================================================ + +# Writes a visitor may still perform. Anything not listed here is refused. +# Chatting is the whole point of the demo, so sending stays open; every form +# of configuration does not. +WRITE_ALLOWLIST = [ + re.compile(r'^/api/messages$'), + re.compile(r'^/api/messages/\d+/resend$'), + re.compile(r'^/api/dm/messages$'), + re.compile(r'^/api/read_status/mark_read$'), + re.compile(r'^/api/read_status/mark_all_read$'), + # The unlock endpoints themselves, or there is no way back in. + re.compile(r'^/api/demo/unlock$'), + re.compile(r'^/api/demo/lock$'), +] + +# Reads refused while locked. These are GETs, so the write guard never sees +# them, and each one hands out something a visitor has no business holding: +# stored repeater admin passwords, a copy of the whole database, captured +# message text, or the server log. +READ_DENYLIST = [ + re.compile(r'^/api/repeaters/[^/]+/password$'), + re.compile(r'^/api/backup/list$'), + re.compile(r'^/api/backup/download$'), + re.compile(r'^/api/diagnostics/captures/[^/]+/download$'), + re.compile(r'^/api/logs$'), +] + +# Console commands that only read. Everything else — every `set`, every form of +# transmit, every delete — is refused. `get_channel` is absent on purpose: it +# prints a channel's shared key. `clock` is absent because the same branch also +# sets the device clock. +CONSOLE_READONLY = frozenset({ + 'infos', 'status', 'stats', 'bat', 'ver', 'help', + 'contacts', 'contacts_all', 'contact_info', 'pending_contacts', + 'channels', 'path', 'get', +}) + + +# ================================================================ +# Trusted networks +# ================================================================ + +def _parse_nets(raw: str) -> List[ipaddress._BaseNetwork]: + """Parse the comma-separated CIDR list, skipping (and logging) bad entries.""" + nets = [] + for chunk in raw.split(','): + chunk = chunk.strip() + if not chunk: + continue + try: + nets.append(ipaddress.ip_network(chunk, strict=False)) + except ValueError: + logger.warning("MC_DEMO_TRUSTED_NETS: ignoring unparseable entry %r", chunk) + return nets + + +# Parsed once per distinct value of the setting rather than once per import, so +# the parse stays off the request path without freezing the config at import +# time — which is what lets the tests drive it. +_nets_cache: tuple = (None, []) # (raw value it was parsed from, parsed nets) + + +def trusted_nets() -> List[ipaddress._BaseNetwork]: + """The configured trusted networks, parsed.""" + global _nets_cache + raw = config.MC_DEMO_TRUSTED_NETS or '' + if _nets_cache[0] != raw: + _nets_cache = (raw, _parse_nets(raw)) + return _nets_cache[1] + + +def _came_through_a_proxy(req) -> bool: + """ + True when the request carries a hop header, i.e. the peer address belongs to + a proxy or tunnel rather than to the client. + """ + return any( + h in req.headers + for h in ('X-Forwarded-For', 'X-Real-IP', 'Forwarded', 'CF-Connecting-IP') + ) + + +def ip_is_trusted(req) -> bool: + """ + True when the caller's address falls inside MC_DEMO_TRUSTED_NETS. + + The subtlety worth keeping: when the instance is published through a tunnel + (cloudflared, ngrok, a VPN box) every visitor arrives from that tunnel's + address. If the tunnel runs on the LAN, that address sits inside the very + network the operator listed as trusted — and matching on it would unlock the + instance for the whole internet at once. So an address is only accepted when + it is genuinely the client's: either no hop header is present, or + MC_TRUST_PROXY is on and ProxyFix has already replaced remote_addr with the + real client address. + """ + nets = trusted_nets() + if not nets: + return False + if not config.MC_TRUST_PROXY and _came_through_a_proxy(req): + return False + + addr = req.remote_addr + if not addr: + return False + try: + ip = ipaddress.ip_address(addr.split('%')[0]) # strip any zone id + except ValueError: + return False + return any(ip in net for net in nets) + + +# ================================================================ +# Unlock code +# ================================================================ + +def unlock_token() -> str: + """The cookie value proving knowledge of MC_DEMO_UNLOCK_CODE.""" + return hmac.new( + config.MC_DEMO_UNLOCK_CODE.encode('utf-8'), _UNLOCK_MESSAGE, sha256 + ).hexdigest() + + +def code_is_valid(code: Optional[str]) -> bool: + """Constant-time check of a code typed by the user.""" + if not config.MC_DEMO_UNLOCK_CODE or not code: + return False + return hmac.compare_digest(code, config.MC_DEMO_UNLOCK_CODE) + + +def cookie_is_valid(req) -> bool: + """Constant-time check of the unlock cookie.""" + if not config.MC_DEMO_UNLOCK_CODE: + return False + presented = req.cookies.get(UNLOCK_COOKIE) + if not presented: + return False + return hmac.compare_digest(presented, unlock_token()) + + +# ================================================================ +# The decision +# ================================================================ + +def is_demo() -> bool: + """True when this instance runs in demo mode at all.""" + return bool(config.MC_DEMO) + + +def is_unlocked(req) -> bool: + """ + True when the caller may do anything — because demo mode is off, or because + they proved they are the operator. + """ + if not config.MC_DEMO: + return True + return ip_is_trusted(req) or cookie_is_valid(req) + + +def _matches(path: str, patterns) -> bool: + return any(p.match(path) for p in patterns) + + +def request_is_blocked(req) -> bool: + """ + True when this request must be refused. Call it from a before_request hook. + """ + if is_unlocked(req): + return False + + path = req.path.rstrip('/') or '/' + + if req.method in ('GET', 'HEAD', 'OPTIONS'): + return _matches(path, READ_DENYLIST) + + return not _matches(path, WRITE_ALLOWLIST) + + +def console_command_is_blocked(command: str) -> bool: + """ + True when a console command must be refused. `command` is the raw line; only + its first word decides, which is the same thing the router dispatches on. + """ + if not config.MC_DEMO: + return False + head = (command or '').strip().split() + if not head: + return False + return head[0].lower() not in CONSOLE_READONLY + + +# ================================================================ +# Startup reporting +# ================================================================ + +def log_startup_state() -> None: + """ + Say plainly what the guard will do, once, at boot. A demo instance that + quietly failed to lock looks exactly like one that locked correctly, so the + log is the only place the difference shows up before a tester finds it. + """ + if not config.MC_DEMO: + return + + logger.info("Demo mode ON — writes are refused unless the caller is unlocked") + + nets = trusted_nets() + if nets: + logger.info( + "Demo mode: trusted networks %s", + ', '.join(str(n) for n in nets), + ) + if not config.MC_TRUST_PROXY: + logger.warning( + "Demo mode: MC_DEMO_TRUSTED_NETS is set but MC_TRUST_PROXY is off. " + "Requests arriving with a proxy/tunnel header will NOT be trusted by " + "address, because that address would be the proxy's and not the " + "visitor's. Set MC_TRUST_PROXY=true only if the app is reached " + "exclusively through your proxy." + ) + else: + logger.info("Demo mode: no trusted networks configured") + + if config.MC_DEMO_UNLOCK_CODE: + if len(config.MC_DEMO_UNLOCK_CODE) < 8: + logger.warning( + "Demo mode: MC_DEMO_UNLOCK_CODE is shorter than 8 characters — " + "it is the only thing standing between a visitor and the radio" + ) + else: + logger.info("Demo mode: unlock code is set") + else: + logger.info("Demo mode: no unlock code set") + + if not nets and not config.MC_DEMO_UNLOCK_CODE: + logger.warning( + "Demo mode: neither MC_DEMO_TRUSTED_NETS nor MC_DEMO_UNLOCK_CODE is " + "set — this instance is locked for everyone, including you" + ) diff --git a/app/main.py b/app/main.py index 4889f03..be09783 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from flask_socketio import SocketIO, emit from werkzeug.middleware.proxy_fix import ProxyFix from app import i18n from app.config import config, runtime_config +from app import demo_guard from app.database import Database from app.device_manager import DeviceManager, parse_meshcore_uri from app.diagnostics import DiagnosticsManager @@ -238,6 +239,8 @@ def create_app(): app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) logger.info("Trusting X-Forwarded-* headers from one proxy hop (MC_TRUST_PROXY=true)") + demo_guard.log_startup_state() + # Inject version, branch, transport type, and UI language into all templates. # This is the single injection point for i18n — it covers every render_template() # in the app, including the six standalone pages loaded as fullscreen iframes. @@ -249,6 +252,13 @@ def create_app(): 'version': VERSION_STRING, 'git_branch': GIT_BRANCH, 'transport_type': config.transport_type, + # Demo mode reaches the templates here rather than through MC_CONFIG, + # because the fullscreen iframe pages (console, repeaters, contacts, + # dm, path-analyzer) never define MC_CONFIG and never load app.js — + # and the console is precisely one of the things being locked. + 'demo_mode': demo_guard.is_demo(), + 'demo_unlocked': demo_guard.is_unlocked(flask_request), + 'demo_has_unlock_code': bool(config.MC_DEMO_UNLOCK_CODE), 'lang': lang, 'i18n_catalog_url': i18n.catalog_url(lang), 'available_languages': i18n.available_languages(), @@ -436,6 +446,22 @@ def handle_send_command(data): emit('command_response', {'success': False, 'error': 'Empty command'}) return + # The console reaches `set name`, `set radio`, `reboot`, `remove_channel` and + # every other thing demo mode exists to prevent, and it arrives over a socket + # rather than through the /api blueprint — so the HTTP guard never sees it. + # Read-only commands still run, so the console remains worth demonstrating. + if demo_guard.console_command_is_blocked(command) and not demo_guard.is_unlocked(flask_request): + logger.info(f"Demo mode: refused console command: {command}") + emit('command_response', { + 'success': False, + 'command': command, + 'error': ( + 'This instance runs in demo mode — only read-only commands are ' + 'available. Allowed: ' + ', '.join(sorted(demo_guard.CONSOLE_READONLY)) + ), + }) + return + logger.info(f"Console command received: {command}") def execute_and_respond(): @@ -477,6 +503,12 @@ def handle_send_command(data): @socketio.on('connect', namespace='/logs') def handle_logs_connect(): """Handle log viewer WebSocket connection.""" + # The server log carries device internals, addresses and message text. On a + # public demo it is refused at the handshake, which also stops the stream the + # HTTP guard on /api/logs cannot see. + if not demo_guard.is_unlocked(flask_request): + logger.info("Demo mode: refused log viewer connection") + return False logger.debug("Log viewer WebSocket client connected") diff --git a/app/routes/api.py b/app/routes/api.py index a97c52a..3ce0c0d 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -24,6 +24,7 @@ from app.config import config, runtime_config from app.device_manager import decode_path_len from app.archiver import manager as archive_manager from app.contacts_cache import get_all_names, get_all_contacts +from app import demo_guard logger = logging.getLogger(__name__) @@ -39,6 +40,67 @@ def _get_dm(): api_bp = Blueprint('api', __name__, url_prefix='/api') + +@api_bp.before_request +def _demo_guard(): + """ + Refuse anything that writes when this instance runs as a public demo and the + caller has not unlocked it. Sits in front of every /api route, so an endpoint + added later is locked by default rather than by remembering to lock it — + including the several that have no button in the UI and are only reachable + with curl. See app/demo_guard.py for what stays open. + """ + if demo_guard.request_is_blocked(request): + logger.info( + "Demo mode: refused %s %s from %s", request.method, request.path, request.remote_addr + ) + return jsonify({ + 'success': False, + 'error': demo_guard.DEMO_ERROR, + 'message': 'This instance runs in demo mode — the action is disabled.', + }), 403 + + +@api_bp.route('/demo/unlock', methods=['POST']) +def demo_unlock(): + """ + Trade the unlock code for the cookie that lifts the demo restrictions. + + Deliberately quiet about why a code failed, and deliberately slow to answer + — the code is the only thing protecting the radio from anyone who can reach + the page, so a wrong guess should cost something. + """ + data = request.get_json(silent=True) or {} + code = str(data.get('code', '')) + + if not demo_guard.is_demo(): + return jsonify({'success': False, 'error': 'Demo mode is not enabled'}), 400 + + if not demo_guard.code_is_valid(code): + time.sleep(1.0) + logger.warning("Demo mode: failed unlock attempt from %s", request.remote_addr) + return jsonify({'success': False, 'error': 'invalid_code'}), 403 + + logger.info("Demo mode: unlocked from %s", request.remote_addr) + response = jsonify({'success': True, 'unlocked': True}) + response.set_cookie( + demo_guard.UNLOCK_COOKIE, + demo_guard.unlock_token(), + max_age=demo_guard.UNLOCK_COOKIE_MAX_AGE, + httponly=True, + samesite='Lax', + secure=request.is_secure, + ) + return response + + +@api_bp.route('/demo/lock', methods=['POST']) +def demo_lock(): + """Drop the unlock cookie — used by the "lock again" button in Settings.""" + response = jsonify({'success': True, 'unlocked': False}) + response.delete_cookie(demo_guard.UNLOCK_COOKIE, samesite='Lax') + return response + # Simple cache for get_channels() to reduce USB/meshcli calls # Channels don't change frequently, so caching for 30s is safe _channels_cache = None @@ -936,6 +998,13 @@ def get_status(): except Exception: pass + # Demo mode, so the UI knows whether to grey the controls out and whether + # to offer the unlock box. Both flags, not one: a locked and an unlocked + # visitor need different UI, and a normal install needs neither. + status_data['demo_mode'] = demo_guard.is_demo() + status_data['demo_unlocked'] = demo_guard.is_unlocked(request) + status_data['demo_has_unlock_code'] = bool(config.MC_DEMO_UNLOCK_CODE) + return jsonify(status_data), 200 except Exception as e: diff --git a/app/static/css/style.css b/app/static/css/style.css index 9917f36..002e210 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2643,3 +2643,65 @@ emoji-picker { .share-preview-body .share-card { margin: 0; } + +/* ============================================================ + Demo mode (MC_DEMO) + ------------------------------------------------------------ + Locked controls stay visible and stay laid out — the instance is shared to + show the interface off, so a control that vanished would just look like a + feature mc-webui does not have. Dimmed and struck out of reach instead. + ============================================================ */ + +.demo-locked { + opacity: 0.5; + cursor: not-allowed !important; + filter: grayscale(1); +} + +/* Bootstrap paints its own disabled state on form controls; keep ours from + fighting it, and keep the pointer honest over the whole locked block. */ +.demo-locked-region { + cursor: not-allowed; +} + +.demo-locked-region .form-check-input, +.demo-locked-region .form-control, +.demo-locked-region .form-select { + background-color: var(--bg-surface-alt); +} + +/* The notice that explains the greying out. Sits at the top of a locked pane. */ +.demo-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + border: 1px solid var(--border-color); + border-left: 3px solid #ffc107; + border-radius: 0.375rem; + background: var(--bg-surface); + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.35; +} + +.demo-notice i { + color: #ffc107; + margin-top: 0.1rem; +} + +/* "Demo" marker in the navbar, so it is obvious which instance this is. */ +.demo-badge { + font-size: 0.65rem; + letter-spacing: 0.05em; + text-transform: uppercase; + vertical-align: middle; +} + +/* Operator unlock box at the foot of the Settings modal. */ +.demo-unlock-box { + border-top: 1px solid var(--border-color); + margin-top: 1rem; + padding-top: 0.75rem; +} diff --git a/app/static/js/app.js b/app/static/js/app.js index e223e14..9902388 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2284,7 +2284,7 @@ async function loadDeviceInfo() { html += `
${tHtml('device.reboot.hint')}
-
diff --git a/app/static/js/demo-lock.js b/app/static/js/demo-lock.js new file mode 100644 index 0000000..de70778 --- /dev/null +++ b/app/static/js/demo-lock.js @@ -0,0 +1,260 @@ +/** + * Demo mode — the visible half of the guard. + * + * The server already refuses these actions (app/demo_guard.py); this file exists + * so a visitor sees *why* a control does nothing instead of clicking it and + * getting an error. Greying out rather than hiding is deliberate: the instance + * is shared to show mc-webui off, and a feature nobody can see is a feature + * nobody is impressed by. + * + * Loaded from _head_i18n.html, so it runs on all eight entry points including + * the six fullscreen iframes — the console among them, which is the one page + * where hiding the controls would matter most. + * + * Marking things up: + * data-demo-lock disable every control inside (or the element + * itself, when it is a control) + * data-demo-lock="hide" remove it from the page instead + * data-demo-lock-exempt leave this subtree alone + * + * Anything rendered later by JS is caught by a MutationObserver, because most of + * the dangerous buttons in this app (channel delete, region delete, repeater + * actions) are written into a list long after DOMContentLoaded. + */ + +(function () { + 'use strict'; + + const CFG = window.MC_DEMO || {}; + const LOCK_CLASS = 'demo-locked'; + const CONTROLS = 'input, select, textarea, button, a.btn, [contenteditable="true"]'; + + /** Locked = demo is on and this visitor has not proved they are the operator. */ + function isLocked() { + return !!CFG.enabled && !CFG.unlocked; + } + + function tr(key, fallback) { + const value = typeof window.t === 'function' ? window.t(key) : key; + // A missing key resolves to the key itself; prefer readable English to that. + return value === key ? fallback : value; + } + + // ============================================================ + // Applying the lock + // ============================================================ + + function lockControl(el) { + if (el.dataset.demoLocked === '1') return; + el.dataset.demoLocked = '1'; + + if ('disabled' in el) { + el.disabled = true; + } + el.classList.add(LOCK_CLASS); + el.setAttribute('aria-disabled', 'true'); + // Keep the original tooltip recoverable, in case the page is unlocked + // later without a reload. + if (el.title) el.dataset.demoTitle = el.title; + el.title = tr('demo.locked_title', 'Disabled in demo mode'); + + // A disabled still navigates, and a disabled element still fires + // click handlers bound to an ancestor — so refuse the event outright. + if (!el.dataset.demoBound) { + el.dataset.demoBound = '1'; + el.addEventListener('click', swallow, true); + } + } + + function swallow(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + notifyBlocked(); + } + + function applyTo(container) { + if (container.dataset.demoLock === 'hide') { + container.remove(); + return; + } + + if (container.matches(CONTROLS)) { + lockControl(container); + } + container.querySelectorAll(CONTROLS).forEach(function (el) { + if (el.closest('[data-demo-lock-exempt]')) return; + lockControl(el); + }); + + container.classList.add('demo-locked-region'); + } + + function apply(root) { + (root || document).querySelectorAll('[data-demo-lock]').forEach(applyTo); + } + + // ============================================================ + // Telling the visitor why + // ============================================================ + + let lastNotice = 0; + + function notifyBlocked() { + // Several handlers can fire for one click; one message per second is plenty. + const now = Date.now(); + if (now - lastNotice < 1000) return; + lastNotice = now; + + const message = tr('demo.blocked_toast', + 'This instance runs in demo mode — the action is disabled.'); + + if (typeof window.showNotification === 'function') { + window.showNotification(message, 'warning'); + } else if (typeof window.showToast === 'function') { + window.showToast(message, 'warning'); + } else { + console.info('[demo] ' + message); + } + } + + // ============================================================ + // The server is the real guard — report what it refuses + // ============================================================ + + /** + * A stale page, an un-marked control or a direct call can still reach a + * locked endpoint. The server answers 403 demo_locked; turn that into the + * same message rather than whatever "failed" text the caller would show. + */ + function interceptFetch() { + const original = window.fetch; + if (!original || original.__demoWrapped) return; + + const wrapped = function (input, init) { + return original.call(this, input, init).then(function (response) { + if (response.status === 403) { + // Peek without consuming: the caller still needs the body. + response.clone().json().then(function (body) { + if (body && body.error === 'demo_locked') notifyBlocked(); + }).catch(function () { /* not JSON — not ours */ }); + } + return response; + }); + }; + wrapped.__demoWrapped = true; + window.fetch = wrapped; + } + + // ============================================================ + // Unlocking + // ============================================================ + + /** Trade the code for the cookie. Resolves true when the page should reload. */ + window.demoUnlock = function (code) { + return fetch('/api/demo/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code }), + }).then(function (r) { return r.json().catch(function () { return {}; }); }) + .then(function (data) { return !!(data && data.success); }); + }; + + /** Drop the cookie and go back to being a visitor. */ + window.demoLock = function () { + return fetch('/api/demo/lock', { method: 'POST' }) + .then(function () { return true; }) + .catch(function () { return false; }); + }; + + window.demoIsLocked = isLocked; + + // ============================================================ + // The unlock box in Settings + // ============================================================ + + /** + * Wired whenever demo mode is on at all — not only while locked — because + * the "lock again" button exists precisely in the unlocked state, which the + * lock pass below returns early from. + */ + function wireUnlockBox() { + const input = document.getElementById('demoUnlockInput'); + const unlockBtn = document.getElementById('demoUnlockBtn'); + const lockBtn = document.getElementById('demoLockBtn'); + const hint = document.getElementById('demoUnlockHint'); + + if (unlockBtn && input) { + const submit = function () { + const code = input.value.trim(); + if (!code) return; + + unlockBtn.disabled = true; + window.demoUnlock(code).then(function (ok) { + if (ok) { + // Every locked control on every open page was rendered + // for a visitor; a reload is the honest way to redraw. + window.location.reload(); + return; + } + unlockBtn.disabled = false; + input.value = ''; + input.classList.add('is-invalid'); + if (hint) { + hint.textContent = tr('demo.unlock_bad', 'That code was not accepted.'); + hint.classList.add('text-danger'); + } + }); + }; + + unlockBtn.addEventListener('click', submit); + input.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }); + } + + if (lockBtn) { + lockBtn.addEventListener('click', function () { + lockBtn.disabled = true; + window.demoLock().then(function () { window.location.reload(); }); + }); + } + } + + // ============================================================ + // Start + // ============================================================ + + function start() { + if (!CFG.enabled) return; + + wireUnlockBox(); + + if (!isLocked()) return; + + document.documentElement.classList.add('demo-mode-locked'); + apply(document); + + // Dangerous controls are routinely rendered into a list after load. + const observer = new MutationObserver(function (mutations) { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.matches && node.matches('[data-demo-lock]')) applyTo(node); + if (node.querySelectorAll) apply(node); + } + } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + + interceptFetch(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/app/templates/_head_i18n.html b/app/templates/_head_i18n.html index 165fc51..bc3638d 100644 --- a/app/templates/_head_i18n.html +++ b/app/templates/_head_i18n.html @@ -10,3 +10,15 @@ + +{# Demo mode. Injected here rather than through MC_CONFIG because only index.html + and dm.html define that object, and the console — the page most in need of + locking — is not one of them. Must be set before demo-lock.js runs. #} + + diff --git a/app/templates/base.html b/app/templates/base.html index 4f3769a..62b692d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -101,12 +101,12 @@
{{ release }} - {{ git_branch }} + {{ git_branch }}{% if demo_mode %}{{ t('demo.badge') }}{% endif %} {{ version }} -
@@ -181,14 +181,14 @@
{{ t('menu.section.network') }}
- - -