Root cause
Injection & unsafe execution introduced
add: [navbar] notification bell dropdown and convert search
How AI contributed: Direct introductionPublished May 28, 2026 in
misp/cti-transmuteAll 4 findings in this repository
A stored cross-site scripting (XSS) vulnerability exists in the notification panel of CTI Transmute in versions prior to the patched release. Notification messages containing user-controlled convert names were rendered in the notification bell dropdown using innerHTML without adequate sanitization. An attacker able to create or influence a convert name that is included in a notification could inject arbitrary JavaScript, which would execute in the browser of an authenticated user when they opened the notification panel. Successful exploitation could allow the attacker to perform actions in the victim's session or access information available to the application in the browser context. The issue was remediated by constructing notification elements through DOM methods and assigning notification message content via textContent instead of innerHTML. This vulnerability was only present on a development branch.
AI-generated code inserted unescaped user-controlled convert names into the notification dropdown via innerHTML, enabling stored XSS and arbitrary script execution for authenticated users.
Root cause
add: [navbar] notification bell dropdown and convert search
Fix
fix: [security] XSS in notification bell and unread-only
Case facts
diff --git a/website/web/templates/base.html b/website/web/templates/base.htmlindex a86e09d..7d5a144 100644--- a/website/web/templates/base.html+++ b/website/web/templates/base.html@@ -20,6 +20,7 @@ <link rel="stylesheet" href="{{ url_for('static', filename='fontawesome-6.3.0/css/solid.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='fontawesome-6.3.0/css/brands.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core.css') }}">+ <link rel="stylesheet" href="{{ url_for('static', filename='css/comments.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/select2.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/select2-bootstrap-5-theme.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/jquery-ui.css') }}">@@ -90,6 +91,21 @@ </div> </div> + <!-- Navbar search -->+ <form class="top-nav-search" id="top-nav-search-form" action="/convert/history" method="get">+ <div class="top-nav-search-wrap">+ <i class="fas fa-search top-nav-search-icon"></i>+ <input+ id="navbar-search-input"+ name="search"+ class="top-nav-search-input"+ type="text"+ placeholder="Search converts…"+ autocomplete="off">+ <div class="nav-search-dropdown" id="navbar-search-dropdown" style="display:none;"></div>+ </div>+ </form>+ <!-- Right controls --> <div class="top-nav-controls"> <!-- User dropdown -->@@ -111,6 +127,15 @@ <a href="/account/manage_user" class="top-nav-dropdown-item"> <i class="fas fa-users me-2"></i> Manage Users </a>+ <a href="/account/admin/comments" class="top-nav-dropdown-item">+ <i class="fas fa-comments me-2"></i> All Comments+ </a>+ <a href="/account/admin/reports" class="top-nav-dropdown-item">+ <i class="fas fa-flag me-2"></i> Reports+ </a>+ <a href="/account/admin/logs" class="top-nav-dropdown-item">+ <i class="fas fa-bell me-2"></i> Activity Logs+ </a> <div class="top-nav-dropdown-divider"></div> {% endif %} <a href="/account/" class="top-nav-dropdown-item">@@ -131,6 +156,26 @@ </div> </div> + {% if current_user.is_authenticated %}+ <!-- Notification bell dropdown -->+ <div class="notif-dropdown-wrapper" id="notif-wrapper">+ <button class="top-nav-icon-btn notif-bell-wrapper" id="notif-bell-btn" aria-label="Notifications">+ <i class="fas fa-bell"></i>+ <span class="notif-badge" id="notif-count" style="display:none;">0</span>+ </button>+ <div class="notif-dropdown-panel" id="notif-panel" style="display:none;">+ <div class="notif-panel-header">+ <span class="notif-panel-title">Notifications</span>+ <button class="notif-panel-mark-all" id="notif-mark-all">Mark all read</button>+ </div>+ <div class="notif-panel-list" id="notif-panel-list">+ <div class="notif-panel-loading"><div class="spinner-border spinner-border-sm" style="color:var(--accent);"></div></div>+ </div>+ <a href="/account/notifications" class="notif-panel-footer">See all notifications <i class="fas fa-arrow-right ms-1"></i></a>+ </div>+ </div>+ {% endif %}+ <!-- Theme toggle --> <button class="top-nav-icon-btn" id="theme-toggle" aria-label="Toggle theme"> <i id="theme-icon" class="fas fa-sun"></i>@@ -145,7 +190,7 @@ </nav> <!-- ── MAIN CONTENT ─────────────────────────────────────────── -->- <main id="main-containers" class="container my-5">+ <main id="main-containers" class="container"> {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}@@ -287,6 +332,78 @@ document.getElementById('topNavLinks').classList.toggle('mobile-open'); }); + // Navbar search dropdown+ ;(function () {+ const input = document.getElementById('navbar-search-input')+ const dropdown = document.getElementById('navbar-search-dropdown')+ const form = document.getElementById('top-nav-search-form')+ if (!input || !dropdown) return++ // Pre-fill when on history page+ const _sq = new URLSearchParams(window.location.search).get('search')+ if (_sq) input.value = _sq++ let debounce = null++ function esc(str) {+ return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')+ }++ function typeLabel(t) {+ return t === 'MISP_TO_STIX' ? 'MISP→STIX' : t === 'STIX_TO_MISP' ? 'STIX→MISP' : t || '—'+ }+ function typeClass(t) {+ return t === 'MISP_TO_STIX' ? 'nav-sd-misp' : t === 'STIX_TO_MISP' ? 'nav-sd-stix' : ''+ }++ async function search(q) {+ if (!q.trim()) { dropdown.style.display = 'none'; return }+ try {+ const res = await fetch('/convert/get_convert_page_history?searchQuery=' + encodeURIComponent(q) + '&page=1')+ if (!res.ok) return+ const data = await res.json()+ const items = (data.list || []).slice(0, 8)+ if (items.length === 0) {+ dropdown.innerHTML = '<div class="nav-sd-empty"><i class="fas fa-inbox me-1"></i>No results</div>'+ } else {+ dropdown.innerHTML =+ items.map(item => `+ <a class="nav-sd-item" href="/convert/detail/${item.id}">+ <span class="nav-sd-badge ${typeClass(item.conversion_type)}">${typeLabel(item.conversion_type)}</span>+ <span class="nav-sd-name">${esc(item.name)}</span>+ </a>+ `).join('') ++ `<a class="nav-sd-footer" href="/convert/history?search=${encodeURIComponent(q)}">+ <i class="fas fa-search me-1"></i>See all results+ </a>`+ }+ dropdown.style.display = 'block'+ } catch {}+ }++ input.addEventListener('input', () => {+ clearTimeout(debounce)+ const q = input.value+ if (!q.trim()) { dropdown.style.display = 'none'; return }+ debounce = setTimeout(() => search(q), 250)+ })++ input.addEventListener('focus', () => {+ if (input.value.trim()) search(input.value)+ })++ input.addEventListener('keydown', e => {+ if (e.key === 'Escape') dropdown.style.display = 'none'+ })++ // prevent form submit from closing dropdown items via navigation race+ form.addEventListener('submit', () => { dropdown.style.display = 'none' })++ document.addEventListener('click', e => {+ if (!form.contains(e.target)) dropdown.style.display = 'none'+ })+ })()+ // Back to top const backBtn = document.getElementById('backToTop'); window.addEventListener('scroll', () => {@@ -296,6 +413,128 @@ })(); </script> + {% if current_user.is_authenticated %}+ <script>+ (function () {+ const btn = document.getElementById('notif-bell-btn')+ const panel = document.getElementById('notif-panel')+ const list = document.getElementById('notif-panel-list')+ const badge = document.getElementById('notif-count')+ const markAll = document.getElementById('notif-mark-all')+ let panelOpen = false+ let loaded = false++ function updateBadge(count) {+ if (!badge) return+ if (count > 0) {+ badge.textContent = count > 99 ? '99+' : count+ badge.style.display = 'flex'+ } else {+ badge.style.display = 'none'+ }+ }++ async function refreshNotifCount() {+ try {+ const res = await fetch('/account/get_notification_count')+ if (!res.ok) return+ const d = await res.json()+ updateBadge(d.count || 0)+ } catch {}+ }++ function notifIcon(type) {+ if (type === 'comment_reply') return 'fa-reply'+ if (type === 'new_follow_convert') return 'fa-bolt'+ return 'fa-flag'+ }+ function notifColor(type) {+ if (type === 'comment_reply') return 'type-reply'+ if (type === 'new_follow_convert') return 'type-convert'+ return 'type-report'+ }++ async function loadPanelNotifs() {+ list.innerHTML = '<div class="notif-panel-loading"><div class="spinner-border spinner-border-sm" style="color:var(--accent);"></div></div>'+ try {+ const res = await fetch('/account/get_notifications?page=1')+ if (!res.ok) return+ const data = await res.json()+ const items = (data.list || []).slice(0, 6)+ if (items.length === 0) {+ list.innerHTML = '<div class="notif-panel-empty"><i class="fas fa-bell-slash"></i> No notifications yet.</div>'+ return+ }+ list.innerHTML = items.map(n => `+ <div class="notif-panel-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}" data-related="${n.related_id || ''}" data-type="${n.related_type || ''}">+ <div class="notif-icon ${notifColor(n.type)}"><i class="fas ${notifIcon(n.type)}"></i></div>+ <div class="notif-panel-item-body">+ <div class="notif-panel-item-msg">${n.message}</div>+ <div class="notif-panel-item-date">${n.created_at}</div>+ </div>+ ${!n.is_read ? '<div class="unread-dot"></div>' : ''}+ </div>+ `).join('')+ list.querySelectorAll('.notif-panel-item').forEach(el => {+ el.addEventListener('click', async () => {+ const id = el.dataset.id+ const related = el.dataset.related+ if (!el.classList.contains('unread')) {+ if (related) window.location.href = '/convert/detail/' + related+ return+ }+ await fetch(`/account/mark_notification_read?notification_id=${id}`)+ el.classList.remove('unread')+ el.querySelector('.unread-dot')?.remove()+ const currentCount = parseInt(badge?.textContent || '0') || 0+ updateBadge(Math.max(0, currentCount - 1))+ if (related) window.location.href = '/convert/detail/' + related+ })+ })+ loaded = true+ } catch (e) {+ list.innerHTML = '<div class="notif-panel-empty">Error loading notifications.</div>'+ }+ }++ function openPanel() {+ panel.style.display = 'block'+ panelOpen = true+ loadPanelNotifs()+ }++ function closePanel() {+ panel.style.display = 'none'+ panelOpen = false+ loaded = false+ }++ btn?.addEventListener('click', (e) => {+ e.stopPropagation()+ panelOpen ? closePanel() : openPanel()+ })++ document.addEventListener('click', (e) => {+ if (panelOpen && !document.getElementById('notif-wrapper')?.contains(e.target)) closePanel()+ })++ markAll?.addEventListener('click', async (e) => {+ e.stopPropagation()+ await fetch('/account/mark_all_read')+ list.querySelectorAll('.notif-panel-item.unread').forEach(el => {+ el.classList.remove('unread')+ el.querySelector('.unread-dot')?.remove()+ })+ updateBadge(0)+ })++ refreshNotifCount()+ setInterval(refreshNotifCount, 60000)+ window.__refreshNotifCount = refreshNotifCount+ })()+ </script>+ {% endif %}+ {% block script %} <script type="module"> // Empty vue used for toast to declar vue is used in the rest of the projectdiff --git a/website/web/templates/sidebar.html b/website/web/templates/sidebar.htmlindex b071cf9..900207d 100644--- a/website/web/templates/sidebar.html+++ b/website/web/templates/sidebar.html@@ -1,96 +1,87 @@-<div class="sidebar" id="sidebar">- <div class="sidebar-header d-flex align-items-center px-3 py-2">- <img src="{{ url_for('static', filename='image/logo.png') }}" alt="Logo"- style="height:30px; margin-right:8px;">- <span class="fs-5">CTI Transmutes</span>+<div class="conv-sidebar" id="conv-sidebar">++ <!-- Quick search -->+ <div class="conv-sidebar-search">+ <div class="conv-sidebar-search-label">Search Converts</div>+ <div class="conv-sidebar-search-box">+ <i class="fas fa-search conv-sidebar-search-icon"></i>+ <input+ id="sidebar-search-input"+ class="conv-sidebar-search-input"+ type="text"+ placeholder="Name, description, content…"+ @input="onSidebarSearch"+ v-model="sidebarQuery"+ @keyup.enter="onSidebarSearch">+ <button v-if="sidebarQuery" class="conv-sidebar-search-clear" @click="clearSidebarSearch" title="Clear">+ <i class="fas fa-times"></i>+ </button>+ </div>+ <div v-if="sidebarQuery" class="conv-sidebar-search-hint">+ <i class="fas fa-info-circle me-1"></i>+ Press <kbd>↵</kbd> or wait to search </div>+ </div> - <ul class="nav nav-pills flex-column mb-auto">- <li class="nav-item">- <a href="/" class="nav-link">- <i class="fas fa-home me-2"></i>Home- </a>- </li>+ <div class="conv-sidebar-divider"></div> - <li>- <a class="nav-link" data-bs-toggle="collapse" href="#userMenu" role="button" aria-expanded="false"- aria-controls="userMenu">- <i class="fa-solid fa-user me-2"></i>- {% if current_user.is_authenticated %}- {{ current_user.get_first_name() }}- {% else %}- User- {% endif %}- <i class="fa-solid fa-caret-down"></i>- </a>- <div class="collapse ps-3" id="userMenu">- <ul class="nav flex-column">- {% if current_user.is_authenticated %}- {% if current_user.is_admin() %}- <li>- <a href="/account/manage_user" class="nav-link">- <i class="fa-solid fa-users fa-fw me-2"></i> Manage users- </a>- </li>- {% endif %}- <li>- <a href="/account/" class="nav-link">- <i class="fa-solid fa-user fa-fw me-2"></i> Profil- </a>- </li>- <li>- <a href="/account/logout" class="nav-link">- <i class="fa-solid fa-right-from-bracket fa-fw me-2"></i> Logout- </a>- </li>- {% else %}- <li>- <a href="/account/register" class="nav-link">- <i class="fa-solid fa-user-plus fa-fw me-2"></i> Register- </a>- </li>- <li>- <a href="/account/login" class="nav-link">- <i class="fa-solid fa-right-to-bracket fa-fw me-2"></i> Login- </a>- </li>- {% endif %}- </ul>- </div>- </li>+ <!-- Active filters summary -->+ <div class="conv-sidebar-section">+ <div class="conv-sidebar-section-label">Active Filters</div>+ <div v-if="!filter_type && !searchQuery && !date_from && !date_to" class="conv-sidebar-empty">+ No filters applied+ </div>+ <div v-else class="conv-sidebar-chips">+ <span v-if="searchQuery" class="conv-sidebar-chip">+ <i class="fas fa-search me-1"></i>[[ searchQuery ]]+ <button @click="searchQuery=''; onSearchInput()"><i class="fas fa-times"></i></button>+ </span>+ <span v-if="filter_type" class="conv-sidebar-chip">+ <i class="fas fa-exchange-alt me-1"></i>[[ filter_type.replace('_',' → ') ]]+ <button @click="filter_type=''; onSearchInput()"><i class="fas fa-times"></i></button>+ </span>+ <span v-if="date_from" class="conv-sidebar-chip">+ <i class="fas fa-calendar me-1"></i>From [[ date_from ]]+ <button @click="date_from=''; onSearchInput()"><i class="fas fa-times"></i></button>+ </span>+ <span v-if="date_to" class="conv-sidebar-chip">+ <i class="fas fa-calendar me-1"></i>To [[ date_to ]]+ <button @click="date_to=''; onSearchInput()"><i class="fas fa-times"></i></button>+ </span>+ </div>+ </div> - <li>- <a href="/list" class="nav-link">- <i class="fas fa-file-upload me-2"></i>API endpoints- </a>- </li>- <li>- <a href="/convert/history" class="nav-link">- <i class="fas fa-solid fa-book me-2"></i>History- </a>- </li>- <li>- <a class="nav-link" data-bs-toggle="collapse" href="#convertMenu" role="button" aria-expanded="false"- aria-controls="convertMenu">- <i class="fas fa-exchange-alt me-2"></i>Convert <i class="fa-solid fa-caret-down"></i>- </a>- <div class="collapse ps-3" id="convertMenu">- <ul class="nav flex-column">- <li>- <a href="/convert/misp_to_stix" class="nav-link">- MISP to STIX- </a>- </li>- <li>- <a href="/convert/stix_to_misp" class="nav-link">- STIX to MISP- </a>- </li>- </ul>- </div>- </li>- </ul>+ <div class="conv-sidebar-divider"></div> + <!-- Type filter -->+ <div class="conv-sidebar-section">+ <div class="conv-sidebar-section-label">Type</div>+ <div class="conv-sidebar-options">+ <button class="conv-sidebar-opt" :class="{active: filter_type===''}" @click="filter_type=''; onSearchInput()">+ <i class="fas fa-layer-group me-2"></i>All+ </button>+ <button class="conv-sidebar-opt" :class="{active: filter_type==='MISP_TO_STIX'}" @click="filter_type='MISP_TO_STIX'; onSearchInput()">+ <i class="fas fa-arrow-right me-2"></i>MISP → STIX+ </button>+ <button class="conv-sidebar-opt" :class="{active: filter_type==='STIX_TO_MISP'}" @click="filter_type='STIX_TO_MISP'; onSearchInput()">+ <i class="fas fa-arrow-left me-2"></i>STIX → MISP+ </button>+ </div>+ </div>++ <div class="conv-sidebar-divider"></div> + <!-- Sort -->+ <div class="conv-sidebar-section">+ <div class="conv-sidebar-section-label">Sort</div>+ <div class="conv-sidebar-options">+ <button class="conv-sidebar-opt" :class="{active: sort_order==='desc'}" @click="sort_order='desc'; onSearchInput()">+ <i class="fas fa-sort-amount-down me-2"></i>Newest first+ </button>+ <button class="conv-sidebar-opt" :class="{active: sort_order==='asc'}" @click="sort_order='asc'; onSearchInput()">+ <i class="fas fa-sort-amount-up me-2"></i>Oldest first+ </button>+ </div>+ </div> </div>diff --git a/website/web/static/css/core.css b/website/web/static/css/core.cssindex 6f172c4..530952e 100644--- a/website/web/static/css/core.css+++ b/website/web/static/css/core.css@@ -131,17 +131,19 @@ td { color: var(--text); } #wrapper.toggled #sidebar { transform: translateX(-250px); } #wrapper.toggled #page-content { margin-left: 0; } + /* ═══════════════════════════════════════════════════════════════ TOP NAVBAR ═══════════════════════════════════════════════════════════════ */ .top-navbar {- position: absolute;- top: 0; left: 0; right: 0;+ position: sticky;+ top: 0;+ left: 0; right: 0; height: 56px; background: var(--surface); border-bottom: 1px solid var(--border); box-shadow: var(--shadow-xs);-+ z-index: 1100; transition: background var(--t), border-color var(--t); } @@ -207,12 +209,110 @@ td { color: var(--text); } font-weight: 600; } +/* Navbar search */+.top-nav-search {+ flex-shrink: 0;+ width: 220px;+ margin: 0 12px 0 8px;+}+.top-nav-search-wrap {+ position: relative;+ display: flex;+ align-items: center;+}+.top-nav-search-icon {+ position: absolute;+ left: 9px;+ color: var(--text-3);+ font-size: 0.72rem;+ pointer-events: none;+}+.top-nav-search-input {+ width: 100%;+ padding: 5px 10px 5px 28px;+ border: 1px solid var(--border);+ border-radius: var(--radius-sm);+ background: var(--bg-2);+ color: var(--text);+ font-size: 0.81rem;+ font-family: var(--font-ui);+ outline: none;+ transition: border-color var(--t), background var(--t), box-shadow var(--t);+}+.top-nav-search-input::placeholder { color: var(--text-3); }+.top-nav-search-input:focus {+ border-color: var(--accent);+ background: var(--surface);+ box-shadow: 0 0 0 2px rgba(37,99,235,0.10);+}++/* Navbar search dropdown */+.nav-search-dropdown {+ position: absolute;+ top: calc(100% + 5px);+ left: 0; right: 0;+ background: var(--surface);+ border: 1px solid var(--border);+ border-radius: var(--radius-sm);+ box-shadow: var(--shadow-md);+ z-index: 1200;+ overflow: hidden;+}+.nav-sd-item {+ display: flex;+ align-items: center;+ gap: 8px;+ padding: 8px 11px;+ text-decoration: none;+ color: var(--text);+ font-size: 0.81rem;+ transition: background var(--t);+ border-bottom: 1px solid var(--border);+}+.nav-sd-item:last-child { border-bottom: none; }+.nav-sd-item:hover { background: var(--bg-2); color: var(--text); }+.nav-sd-badge {+ font-size: 0.6rem;+ font-weight: 700;+ font-family: var(--font-mono);+ padding: 2px 6px;+ border-radius: 10px;+ white-space: nowrap;+ flex-shrink: 0;+}+.nav-sd-misp { background: rgba(59,130,246,0.12); color: #3b82f6; }+.nav-sd-stix { background: rgba(139,92,246,0.12); color: #8b5cf6; }+.nav-sd-name {+ flex: 1;+ overflow: hidden;+ text-overflow: ellipsis;+ white-space: nowrap;+ font-weight: 500;+}+.nav-sd-empty {+ padding: 12px;+ text-align: center;+ color: var(--text-3);+ font-size: 0.81rem;+}+.nav-sd-footer {+ display: block;+ padding: 8px 11px;+ font-size: 0.78rem;+ color: var(--accent);+ text-align: center;+ text-decoration: none;+ background: var(--surface-2);+ border-top: 1px solid var(--border);+ transition: background var(--t);+}+.nav-sd-footer:hover { background: var(--accent-light); }+ /* Right controls */ .top-nav-controls { display: flex; align-items: center; gap: 4px;- margin-left: auto; flex-shrink: 0; } @@ -300,6 +400,7 @@ td { color: var(--text); } .top-burger { display: none; } @media (max-width: 768px) {+ .top-nav-search { display: none; } .top-burger { display: flex; } .top-nav-links { display: none;@@ -334,12 +435,52 @@ body { } #main-content {- margin-top: 56px;- flex: 1; /* pousse le footer en bas */+ flex: 1; display: flex; flex-direction: column; } +#main-containers {+ padding-top: 1.5rem;+ padding-bottom: 3rem;+}++/* ── Shared page header classes (used across all templates) ── */+.list-header {+ text-align: center;+ padding: 2.5rem 1rem 1.75rem;+ max-width: 640px;+ margin: 0 auto;+}+.list-title {+ font-size: clamp(1.5rem, 4vw, 2rem);+ font-weight: 700;+ letter-spacing: -0.025em;+ color: var(--text);+ margin-bottom: 0.6rem;+}+.list-subtitle {+ font-size: 0.88rem;+ color: var(--text-2);+ line-height: 1.7;+ margin: 0;+}+.home-hero-badge {+ display: inline-flex;+ align-items: center;+ gap: 6px;+ padding: 4px 14px;+ border-radius: 999px;+ background: var(--accent-light);+ color: var(--accent);+ font-size: 0.72rem;+ font-weight: 700;+ letter-spacing: 0.06em;+ text-transform: uppercase;+ margin-bottom: 1rem;+ border: 1px solid rgba(37,99,235,0.15);+}+ /* ═══════════════════════════════════════════════════════════════diff --git a/website/web/templates/convert/history.html b/website/web/templates/convert/history.htmlindex f3c186d..5bb1448 100644--- a/website/web/templates/convert/history.html+++ b/website/web/templates/convert/history.html@@ -4,7 +4,7 @@ {% endblock %} {% block content %}-<div class="container my-5">+<div> <!-- Header --> <div class="d-flex align-items-center justify-content-between mb-4">@@ -369,6 +369,10 @@ <h5 class="modal-title"> } } + // Pre-fill search from navbar redirect (?search=...)+ const _urlSearch = new URLSearchParams(window.location.search).get('search')+ if (_urlSearch) searchQuery.value = _urlSearch+ fetchConvertHistory(1) async function deleteItem(id) {@@ -412,6 +416,7 @@ <h5 class="modal-title"> searchDebounce = setTimeout(() => fetchConvertHistory(1), 350) } + const visiblePages = computed(() => { const pages = [], total = total_pages.value, current = current_page.value if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i) }@@ -427,6 +432,7 @@ <h5 class="modal-title"> only_mine, seeDetails, searchQuery, search_scope, exact_match, date_from, date_to, onSearchInput, is_loading, snippets, scopeLabel, searchPlaceholder, highlight, highlightSnippet, resetFilters,+ } } }).mount('#main-containers')diff --git a/website/web/templates/base.html b/website/web/templates/base.htmlindex 7d5a144..11a2e16 100644--- a/website/web/templates/base.html+++ b/website/web/templates/base.html@@ -20,6 +20,7 @@ <link rel="stylesheet" href="{{ url_for('static', filename='fontawesome-6.3.0/css/solid.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='fontawesome-6.3.0/css/brands.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/core.css') }}">+ <link rel="stylesheet" href="{{ url_for('static', filename='css/sidebar.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/comments.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/select2.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/select2-bootstrap-5-theme.min.css') }}">@@ -46,148 +47,7 @@ <body> - <!-- ── TOP NAVBAR ───────────────────────────────────────────── -->- <nav class="top-navbar" id="topNavbar">- <div class="top-navbar-inner">-- <!-- Brand -->- <a href="/" class="top-brand">- <img src="{{ url_for('static', filename='image/logo.png') }}" alt="Logo">- <span>CTI Transmute</span>- </a>-- <!-- Nav links (desktop) -->- <div class="top-nav-links" id="topNavLinks">- <a href="/" class="top-nav-link {% if request.path == '/' %}active{% endif %}">- <i class="fas fa-home"></i> Home- </a>- <a href="/list" class="top-nav-link {% if request.path == '/list' %}active{% endif %}">- <i class="fas fa-plug"></i> API Endpoints- </a>- <a href="/convert/history"- class="top-nav-link {% if '/convert/history' in request.path %}active{% endif %}">- <i class="fas fa-book"></i> History- </a>-- <!-- Convert dropdown -->- <div class="top-nav-dropdown">- <button- class="top-nav-link top-nav-dropdown-btn {% if '/convert/' in request.path and 'history' not in request.path %}active{% endif %}">- <i class="fas fa-exchange-alt"></i> Convert- <i class="fas fa-chevron-down top-nav-caret"></i>- </button>- <div class="top-nav-dropdown-menu">- <a href="/convert/misp_to_stix"- class="top-nav-dropdown-item {% if request.path == '/convert/misp_to_stix' %}active{% endif %}">- <i class="fas fa-arrow-right-arrow-left me-2"></i>- MISP <span class="convert-arrow">→</span> STIX- </a>- <a href="/convert/stix_to_misp"- class="top-nav-dropdown-item {% if request.path == '/convert/stix_to_misp' %}active{% endif %}">- <i class="fas fa-arrow-right-arrow-left me-2"></i>- STIX <span class="convert-arrow">→</span> MISP- </a>- </div>- </div>- </div>-- <!-- Navbar search -->- <form class="top-nav-search" id="top-nav-search-form" action="/convert/history" method="get">- <div class="top-nav-search-wrap">- <i class="fas fa-search top-nav-search-icon"></i>- <input- id="navbar-search-input"- name="search"- class="top-nav-search-input"- type="text"- placeholder="Search converts…"- autocomplete="off">- <div class="nav-search-dropdown" id="navbar-search-dropdown" style="display:none;"></div>- </div>- </form>-- <!-- Right controls -->- <div class="top-nav-controls">- <!-- User dropdown -->- <div class="top-nav-dropdown">- <button class="top-nav-link top-nav-dropdown-btn top-user-btn">- <i class="fas fa-circle-user"></i>- <span class="top-user-name">- {% if current_user.is_authenticated %}- {{ current_user.get_first_name() }}- {% else %}- Account- {% endif %}- </span>- <i class="fas fa-chevron-down top-nav-caret"></i>- </button>- <div class="top-nav-dropdown-menu top-nav-dropdown-menu-right">- {% if current_user.is_authenticated %}- {% if current_user.is_admin() %}- <a href="/account/manage_user" class="top-nav-dropdown-item">- <i class="fas fa-users me-2"></i> Manage Users- </a>- <a href="/account/admin/comments" class="top-nav-dropdown-item">- <i class="fas fa-comments me-2"></i> All Comments- </a>- <a href="/account/admin/reports" class="top-nav-dropdown-item">- <i class="fas fa-flag me-2"></i> Reports- </a>- <a href="/account/admin/logs" class="top-nav-dropdown-item">- <i class="fas fa-bell me-2"></i> Activity Logs- </a>- <div class="top-nav-dropdown-divider"></div>- {% endif %}- <a href="/account/" class="top-nav-dropdown-item">- <i class="fas fa-user me-2"></i> Profile- </a>- <div class="top-nav-dropdown-divider"></div>- <a href="/account/logout" class="top-nav-dropdown-item top-nav-dropdown-danger">- <i class="fas fa-right-from-bracket me-2"></i> Logout- </a>- {% else %}- <a href="/account/register" class="top-nav-dropdown-item">- <i class="fas fa-user-plus me-2"></i> Register- </a>- <a href="/account/login" class="top-nav-dropdown-item">- <i class="fas fa-right-to-bracket me-2"></i> Login- </a>- {% endif %}- </div>- </div>-- {% if current_user.is_authenticated %}- <!-- Notification bell dropdown -->- <div class="notif-dropdown-wrapper" id="notif-wrapper">- <button class="top-nav-icon-btn notif-bell-wrapper" id="notif-bell-btn" aria-label="Notifications">- <i class="fas fa-bell"></i>- <span class="notif-badge" id="notif-count" style="display:none;">0</span>- </button>- <div class="notif-dropdown-panel" id="notif-panel" style="display:none;">- <div class="notif-panel-header">- <span class="notif-panel-title">Notifications</span>- <button class="notif-panel-mark-all" id="notif-mark-all">Mark all read</button>- </div>- <div class="notif-panel-list" id="notif-panel-list">- <div class="notif-panel-loading"><div class="spinner-border spinner-border-sm" style="color:var(--accent);"></div></div>- </div>- <a href="/account/notifications" class="notif-panel-footer">See all notifications <i class="fas fa-arrow-right ms-1"></i></a>- </div>- </div>- {% endif %}-- <!-- Theme toggle -->- <button class="top-nav-icon-btn" id="theme-toggle" aria-label="Toggle theme">- <i id="theme-icon" class="fas fa-sun"></i>- </button>-- <!-- Mobile burger -->- <button class="top-nav-icon-btn top-burger" id="mobileMenuBtn" aria-label="Menu">- <i class="fas fa-bars"></i>- </button>- </div>- </div>- </nav>+ {% include 'sidebar.html' %} <!-- ── MAIN CONTENT ─────────────────────────────────────────── --> <main id="main-containers" class="container">@@ -334,71 +194,142 @@ // Navbar search dropdown ;(function () {+ const IS_ADMIN = {{ 'true' if current_user.is_authenticated and current_user.is_admin() else 'false' }}+ const IS_AUTH = {{ 'true' if current_user.is_authenticated else 'false' }}+ const input = document.getElementById('navbar-search-input') const dropdown = document.getElementById('navbar-search-dropdown') const form = document.getElementById('top-nav-search-form') if (!input || !dropdown) return - // Pre-fill when on history page const _sq = new URLSearchParams(window.location.search).get('search') if (_sq) input.value = _sq let debounce = null-- function esc(str) {- return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')+ let activeIdx = -1++ const SECTIONS = [+ { icon: 'fa-home', label: 'Home', url: '/' },+ { icon: 'fa-book', label: 'History', url: '/convert/history' },+ { icon: 'fa-arrow-right-arrow-left', label: 'MISP → STIX', url: '/convert/misp_to_stix' },+ { icon: 'fa-arrow-right-arrow-left', label: 'STIX → MISP', url: '/convert/stix_to_misp' },+ { icon: 'fa-plug', label: 'API Endpoints', url: '/list' },+ ...(IS_AUTH ? [{ icon: 'fa-user', label: 'Profile', url: '/account/' }] : []),+ ...(IS_ADMIN ? [+ { icon: 'fa-users', label: 'Manage Users', url: '/account/manage_user', admin: true },+ { icon: 'fa-comments', label: 'All Comments', url: '/account/admin/comments', admin: true },+ { icon: 'fa-flag', label: 'Reports', url: '/account/admin/reports', admin: true },+ { icon: 'fa-trash-alt', label: 'Deleted Converts', url: '/account/admin/deleted_converts', admin: true },+ { icon: 'fa-stream', label: 'Activity Logs', url: '/account/admin/logs', admin: true },+ ] : []),+ ]++ function esc(s) {+ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }-- function typeLabel(t) {- return t === 'MISP_TO_STIX' ? 'MISP→STIX' : t === 'STIX_TO_MISP' ? 'STIX→MISP' : t || '—'+ function typeLabel(t) { return t==='MISP_TO_STIX'?'MISP→STIX':t==='STIX_TO_MISP'?'STIX→MISP':t||'—' }+ function typeClass(t) { return t==='MISP_TO_STIX'?'nav-sd-misp':t==='STIX_TO_MISP'?'nav-sd-stix':'' }++ function sectionHtml(sections) {+ return sections.map(s =>+ `<a class="nav-sd-section-item nav-sd-focusable" href="${s.url}">+ <span class="nav-sd-section-icon${s.admin?' nav-sd-admin-icon':''}"><i class="fas ${s.icon}"></i></span>+ <span class="nav-sd-name">${esc(s.label)}</span>+ ${s.admin ? '<span class="nav-sd-admin-tag">Admin</span>' : ''}+ </a>`+ ).join('') }- function typeClass(t) {- return t === 'MISP_TO_STIX' ? 'nav-sd-misp' : t === 'STIX_TO_MISP' ? 'nav-sd-stix' : ''++ function showQuickAccess() {+ const user = SECTIONS.filter(s => !s.admin)+ const admin = SECTIONS.filter(s => s.admin)+ let html = ''+ if (user.length) html += `<div class="nav-sd-group-header"><i class="fas fa-layer-group me-1"></i>Quick Access</div>` + sectionHtml(user)+ if (admin.length) html += `<div class="nav-sd-group-header"><i class="fas fa-shield-halved me-1"></i>Admin</div>` + sectionHtml(admin)+ dropdown.innerHTML = html+ dropdown.style.display = 'block'+ activeIdx = -1 } async function search(q) {- if (!q.trim()) { dropdown.style.display = 'none'; return }+ if (!q.trim()) { showQuickAccess(); return } try {- const res = await fetch('/convert/get_convert_page_history?searchQuery=' + encodeURIComponent(q) + '&page=1')- if (!res.ok) return- const data = await res.json()- const items = (data.list || []).slice(0, 8)- if (items.length === 0) {- dropdown.innerHTML = '<div class="nav-sd-empty"><i class="fas fa-inbox me-1"></i>No results</div>'- } else {- dropdown.innerHTML =- items.map(item => `- <a class="nav-sd-item" href="/convert/detail/${item.id}">- <span class="nav-sd-badge ${typeClass(item.conversion_type)}">${typeLabel(item.conversion_type)}</span>- <span class="nav-sd-name">${esc(item.name)}</span>- </a>- `).join('') +- `<a class="nav-sd-footer" href="/convert/history?search=${encodeURIComponent(q)}">- <i class="fas fa-search me-1"></i>See all results+ const matchUser = SECTIONS.filter(s => !s.admin && s.label.toLowerCase().includes(q.toLowerCase()))+ const matchAdmin = SECTIONS.filter(s => s.admin && s.label.toLowerCase().includes(q.toLowerCase()))++ let html = ''+ if (matchUser.length) html += `<div class="nav-sd-group-header"><i class="fas fa-layer-group me-1"></i>Sections</div>` + sectionHtml(matchUser)+ if (matchAdmin.length) html += `<div class="nav-sd-group-header"><i class="fas fa-shield-halved me-1"></i>Admin</div>` + sectionHtml(matchAdmin)++ const res = await fetch('/convert/get_convert_page_history?searchQuery=' + encodeURIComponent(q) + '&page=1')+ const data = res.ok ? await res.json() : { list: [] }+ const items = (data.list || []).slice(0, 5)++ if (items.length) {+ html += `<div class="nav-sd-group-header"><i class="fas fa-exchange-alt me-1"></i>Converts</div>`+ html += items.map(item =>+ `<a class="nav-sd-item nav-sd-focusable" href="/convert/detail/${item.id}">+ <span class="nav-sd-badge ${typeClass(item.conversion_type)}">${typeLabel(item.conversion_type)}</span>+ <span class="nav-sd-name">${esc(item.name)}</span> </a>`+ ).join('')+ html += `<a class="nav-sd-footer nav-sd-focusable" href="/convert/history?search=${encodeURIComponent(q)}">+ <i class="fas fa-search me-1"></i>See all results for “${esc(q)}”+ </a>`+ } else if (!matchUser.length && !matchAdmin.length) {+ html = `<div class="nav-sd-empty"><i class="fas fa-inbox me-1"></i>No results</div>` }++ dropdown.innerHTML = html dropdown.style.display = 'block'+ activeIdx = -1 } catch {} } + function getFocusable() { return Array.from(dropdown.querySelectorAll('.nav-sd-focusable')) }++ input.addEventListener('keydown', e => {+ const items = getFocusable()+ if (e.key === 'ArrowDown') {+ e.preventDefault()+ activeIdx = Math.min(activeIdx + 1, items.length - 1)+ items[activeIdx]?.focus()+ } else if (e.key === 'Escape') {+ dropdown.style.display = 'none'+ }+ })++ dropdown.addEventListener('keydown', e => {+ const items = getFocusable()+ if (e.key === 'ArrowDown') {+ e.preventDefault()+ activeIdx = Math.min(activeIdx + 1, items.length - 1)+ items[activeIdx]?.focus()+ } else if (e.key === 'ArrowUp') {+ e.preventDefault()+ if (activeIdx <= 0) { activeIdx = -1; input.focus(); return }+ activeIdx = Math.max(0, activeIdx - 1)+ items[activeIdx]?.focus()+ } else if (e.key === 'Escape') {+ dropdown.style.display = 'none'+ input.focus()+ }+ })+ input.addEventListener('input', () => { clearTimeout(debounce) const q = input.value- if (!q.trim()) { dropdown.style.display = 'none'; return }+ if (!q.trim()) { showQuickAccess(); return } debounce = setTimeout(() => search(q), 250) }) input.addEventListener('focus', () => {- if (input.value.trim()) search(input.value)- })-- input.addEventListener('keydown', e => {- if (e.key === 'Escape') dropdown.style.display = 'none'+ const q = input.value+ if (!q.trim()) showQuickAccess()+ else search(q) }) - // prevent form submit from closing dropdown items via navigation race form.addEventListener('submit', () => { dropdown.style.display = 'none' })- document.addEventListener('click', e => { if (!form.contains(e.target)) dropdown.style.display = 'none' })@@ -457,7 +388,7 @@ async function loadPanelNotifs() { list.innerHTML = '<div class="notif-panel-loading"><div class="spinner-border spinner-border-sm" style="color:var(--accent);"></div></div>' try {- const res = await fetch('/account/get_notifications?page=1')+ const res = await fetch('/account/get_notifications?page=1&only_unread=true') if (!res.ok) return const data = await res.json() const items = (data.list || []).slice(0, 6)@@ -465,31 +396,53 @@ list.innerHTML = '<div class="notif-panel-empty"><i class="fas fa-bell-slash"></i> No notifications yet.</div>' return }- list.innerHTML = items.map(n => `- <div class="notif-panel-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}" data-related="${n.related_id || ''}" data-type="${n.related_type || ''}">- <div class="notif-icon ${notifColor(n.type)}"><i class="fas ${notifIcon(n.type)}"></i></div>- <div class="notif-panel-item-body">- <div class="notif-panel-item-msg">${n.message}</div>- <div class="notif-panel-item-date">${n.created_at}</div>- </div>- ${!n.is_read ? '<div class="unread-dot"></div>' : ''}- </div>- `).join('')- list.querySelectorAll('.notif-panel-item').forEach(el => {- el.addEventListener('click', async () => {- const id = el.dataset.id- const related = el.dataset.related- if (!el.classList.contains('unread')) {- if (related) window.location.href = '/convert/detail/' + related- return+ list.innerHTML = ''+ items.forEach(n => {+ const item = document.createElement('div')+ item.className = 'notif-panel-item' + (n.is_read ? '' : ' unread')+ item.dataset.id = n.id+ item.dataset.related = n.related_id || ''+ item.dataset.type = n.related_type || ''++ const icon = document.createElement('div')+ icon.className = 'notif-icon ' + notifColor(n.type)+ icon.innerHTML = `<i class="fas ${notifIcon(n.type)}"></i>`++ const body = document.createElement('div')+ body.className = 'notif-panel-item-body'++ const msg = document.createElement('div')+ msg.className = 'notif-panel-item-msg'+ msg.textContent = n.message++ const date = document.createElement('div')+ date.className = 'notif-panel-item-date'+ date.textContent = n.created_at++ body.append(msg, date)+ item.append(icon, body)++ if (!n.is_read) {+ const dot = document.createElement('div')+ dot.className = 'unread-dot'+ item.appendChild(dot)+ }++ item.addEventListener('click', async () => {+ const related = item.dataset.related+ if (item.classList.contains('unread')) {+ await fetch(`/account/mark_notification_read?notification_id=${item.dataset.id}`)+ const currentCount = parseInt(badge?.textContent || '0') || 0+ updateBadge(Math.max(0, currentCount - 1))+ }+ item.remove()+ if (!list.querySelector('.notif-panel-item')) {+ list.innerHTML = '<div class="notif-panel-empty"><i class="fas fa-bell-slash"></i> No notifications yet.</div>' }- await fetch(`/account/mark_notification_read?notification_id=${id}`)- el.classList.remove('unread')- el.querySelector('.unread-dot')?.remove()- const currentCount = parseInt(badge?.textContent || '0') || 0- updateBadge(Math.max(0, currentCount - 1)) if (related) window.location.href = '/convert/detail/' + related })++ list.appendChild(item) }) loaded = true } catch (e) {AI-assisted change bd90da8acfc80ab7c8d62c70ab9253ede8ccc5a617f927b37222070a32f72b12 · Fix e53c910827c3f444e6aa047511acfd86cf4be91bd496aad9549e9172db168251