Files
mc-webui/app/templates/index.html
T
MarekWoandClaude Opus 5 be0929a3ae feat(demo): read-only demo mode for a publicly shared instance
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 <noreply@anthropic.com>
2026-08-21 18:20:53 +02:00

387 lines
19 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ t('chat.title') }} - mc-webui{% endblock %}
{% block extra_head %}
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
{% endblock %}
{% block content %}
<div class="container-fluid d-flex flex-column" style="height: 100%;">
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- Channel Sidebar (visible on lg+ screens) -->
<div id="channelSidebar" class="channel-sidebar">
<div class="channel-sidebar-header">
<i class="bi bi-broadcast-pin"></i> {{ t('chat.channels') }}
</div>
<div class="channel-sidebar-list" id="channelSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<div class="filter-input-wrapper">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="{{ t('chat.filter.ph') }}" autocomplete="off">
<!-- Filter mentions autocomplete popup -->
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
<div class="mentions-list" id="filterMentionsList"></div>
</div>
</div>
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="{{ t('chat.filter.me_title') }}">
<i class="bi bi-person-fill"></i>
</button>
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="{{ t('chat.filter.clear_title') }}">
<i class="bi bi-x"></i>
</button>
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="{{ t('common.close') }}">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ t('common.loading') }}</span>
</div>
<p class="mt-3">{{ t('chat.loading_messages') }}</p>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="{{ t('chat.scroll_bottom_title') }}">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="{{ t('chat.input_ph') }}"
rows="2"
maxlength="500"
required
></textarea>
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="{{ t('chat.emoji_title') }}">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
</div>
</div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 135</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex flex-wrap gap-2 justify-content-between align-items-center">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span id="statusText" class="text-nowrap">
<i class="bi bi-circle-fill text-secondary"></i> {{ t('common.connecting') }}
</span>
<span id="regionIndicator" class="badge bg-info text-dark d-none" role="button"
title="{{ t('chat.region_title') }}">
<i class="bi bi-pin-map"></i> <span id="regionIndicatorName"></span>
</span>
<span id="diagRecordingIndicator" class="badge bg-danger d-none" role="button"
title="{{ t('settings.diag.indicator_title') }}">
<i class="bi bi-record-circle"></i> {{ t('settings.diag.indicator') }}
</span>
</div>
<span id="lastRefresh" class="text-nowrap">{{ t('chat.updated', time=t('common.never')) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->
<div class="fab-container" id="fabContainer">
<button class="fab fab-toggle" id="fabToggle" title="{{ t('chat.fab.hide') }}">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="filterFab" title="{{ t('chat.filter.fab_title') }}">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-search" id="globalSearchBtn" data-bs-toggle="modal" data-bs-target="#searchModal" title="{{ t('chat.fab.search_title') }}">
<i class="bi bi-search"></i>
</button>
<button class="fab fab-dm" data-bs-toggle="modal" data-bs-target="#dmModal" title="{{ t('dm.title') }}">
<i class="bi bi-envelope-fill"></i>
</button>
<button class="fab fab-contacts" data-bs-toggle="modal" data-bs-target="#contactsModal" title="{{ t('contacts.title') }}">
<i class="bi bi-person-fill"></i>
</button>
<button class="fab fab-settings" data-bs-toggle="modal" data-bs-target="#settingsModal" title="{{ t('common.settings') }}">
<i class="bi bi-gear-fill"></i>
</button>
<button class="fab fab-advert d-none" id="fab-advert" data-demo-lock="hide" title="{{ t('menu.advert') }}">
<i class="bi bi-megaphone"></i>
</button>
<button class="fab fab-floodadvert d-none" id="fab-floodadvert" data-demo-lock="hide" title="{{ t('menu.flood_advert') }}">
<i class="bi bi-broadcast"></i>
</button>
<button class="fab fab-map d-none" id="fab-map" title="{{ t('menu.map') }}">
<i class="bi bi-map"></i>
</button>
<button class="fab fab-console d-none" id="fab-console" data-bs-toggle="modal" data-bs-target="#consoleModal" title="{{ t('console.title') }}">
<i class="bi bi-terminal"></i>
</button>
<button class="fab fab-repeaters d-none" id="fab-repeaters" data-bs-toggle="modal" data-bs-target="#repeatersModal" title="{{ t('repeaters.title') }}">
<i class="bi bi-diagram-3"></i>
</button>
<button class="fab fab-pathanalyzer d-none" id="fab-pathanalyzer" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" title="{{ t('pa.title') }}">
<i class="bi bi-signpost-split"></i>
</button>
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="{{ t('menu.device_info') }}">
<i class="bi bi-cpu"></i>
</button>
<button class="fab fab-syslog d-none" id="fab-syslog" data-demo-lock="hide" data-bs-toggle="modal" data-bs-target="#logsModal" title="{{ t('logs.title') }}">
<i class="bi bi-journal-text"></i>
</button>
</div>
<!-- DM Modal (Full Screen) -->
<div class="modal fade" id="dmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-envelope"></i> {{ t('dm.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="dmFrame" src="/dm" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- Contact Management Modal (Full Screen) -->
<div class="modal fade" id="contactsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-person-check"></i> {{ t('contacts.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="contactsFrame" src="/contacts/manage" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- Console Modal (Full Screen) -->
<div class="modal fade" id="consoleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content" style="background-color: #1a1a2e;">
<div class="modal-header" style="background-color: #16213e; border-bottom: 1px solid #0f3460;">
<h5 class="modal-title text-white"><i class="bi bi-terminal"></i> mc-webui {{ t('console.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="consoleFrame" src="/console" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- My Repeaters Modal (Full Screen) -->
<div class="modal fade" id="repeatersModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-diagram-3"></i> {{ t('repeaters.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="repeatersFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- Path Analyzer Modal (Full Screen) -->
<div class="modal fade" id="pathAnalyzerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header pathanalyzer-header text-white">
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> {{ t('pa.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="pathAnalyzerFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- System Log Modal (Full Screen) -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content" style="background-color: #1a1a2e;">
<div class="modal-header" style="background-color: #16213e; border-bottom: 1px solid #0f3460;">
<h5 class="modal-title text-white"><i class="bi bi-journal-text"></i> {{ t('logs.title') }}</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> {{ t('common.close') }}
</button>
</div>
<div class="modal-body p-0">
<iframe id="logsFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Pass configuration from Flask to JavaScript
window.MC_CONFIG = {
deviceName: "{{ device_name }}"
};
// Reload iframe content when modals are opened to ensure fresh data
document.addEventListener('DOMContentLoaded', function() {
const dmModal = document.getElementById('dmModal');
const contactsModal = document.getElementById('contactsModal');
if (dmModal) {
dmModal.addEventListener('show.bs.modal', function () {
const dmFrame = document.getElementById('dmFrame');
if (dmFrame) {
dmFrame.src = dmFrame.src;
}
});
// Update DM badges when modal is closed (without full page reload)
dmModal.addEventListener('hidden.bs.modal', async function () {
try {
// Reload DM read status from server to sync with app.js automatic updates
// This ensures dmLastSeenTimestamps in app.js is current
if (typeof loadDmLastSeenTimestampsFromServer === 'function') {
await loadDmLastSeenTimestampsFromServer();
}
// Trigger DM badge update (now handled by app.js on FAB button)
if (typeof checkDmUpdates === 'function') {
await checkDmUpdates();
}
} catch (error) {
console.error('Error updating DM badges:', error);
}
});
}
if (contactsModal) {
contactsModal.addEventListener('show.bs.modal', function () {
const contactsFrame = document.getElementById('contactsFrame');
if (contactsFrame) {
contactsFrame.src = contactsFrame.src;
}
});
// Update pending contacts badge when modal is closed
contactsModal.addEventListener('hidden.bs.modal', async function () {
try {
// Trigger pending contacts badge update (handled by app.js on FAB button)
if (typeof updatePendingContactsBadge === 'function') {
await updatePendingContactsBadge();
}
} catch (error) {
console.error('Error updating pending contacts badge:', error);
}
});
}
// Console modal - reload iframe when opened to reset WebSocket connection
const consoleModal = document.getElementById('consoleModal');
if (consoleModal) {
consoleModal.addEventListener('show.bs.modal', function () {
const consoleFrame = document.getElementById('consoleFrame');
if (consoleFrame) {
consoleFrame.src = '/console';
}
});
}
// My Repeaters modal - (re)load iframe on open for fresh data
const repeatersModal = document.getElementById('repeatersModal');
if (repeatersModal) {
repeatersModal.addEventListener('show.bs.modal', function () {
const repeatersFrame = document.getElementById('repeatersFrame');
if (repeatersFrame) {
repeatersFrame.src = '/repeaters';
}
});
}
// Path Analyzer modal - (re)load iframe on open for fresh data
const pathAnalyzerModal = document.getElementById('pathAnalyzerModal');
if (pathAnalyzerModal) {
pathAnalyzerModal.addEventListener('show.bs.modal', function () {
const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame');
if (pathAnalyzerFrame) {
// Deep link from a chat path popup (openPathInAnalyzer):
// preselect the message + echo on the map view
const dl = window.paDeepLink;
window.paDeepLink = null;
pathAnalyzerFrame.src = dl
? `/path-analyzer?hash=${encodeURIComponent(dl.hash)}&path=${encodeURIComponent(dl.path)}`
: '/path-analyzer';
}
});
}
// System Log modal - load iframe on open, clear on close
const logsModal = document.getElementById('logsModal');
if (logsModal) {
logsModal.addEventListener('show.bs.modal', function () {
const logsFrame = document.getElementById('logsFrame');
if (logsFrame) {
logsFrame.src = '/logs';
}
});
logsModal.addEventListener('hidden.bs.modal', function () {
const logsFrame = document.getElementById('logsFrame');
if (logsFrame) {
logsFrame.src = ''; // disconnect WebSocket when closed
}
});
}
});
</script>
{% endblock %}