From 1ac3c4e8c0225c07949038b1930bc08eeeb59999 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Wed, 11 Mar 2026 16:47:09 +0100 Subject: [PATCH] feat(metacatalog): Add LuCI dashboard (Phase 2) New luci-app-metacatalog package: - RPCD backend with 10 methods (list_entries, list_books, search, etc.) - ACL permissions for read/write access - Menu entry under SecuBox - KISS-themed dashboard with: - Stats chips (entries, metablogs, streamlits, books) - Virtual books shelf with color-coded cards - Entry links to published content - Sync button for manual refresh - Landing page link Deployed and tested on router. Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-metacatalog/Makefile | 29 +++ .../resources/view/metacatalog/overview.js | 167 +++++++++++++ .../root/usr/libexec/rpcd/luci.metacatalog | 236 ++++++++++++++++++ .../luci/menu.d/luci-app-metacatalog.json | 14 ++ .../rpcd/acl.d/luci-app-metacatalog.json | 27 ++ 5 files changed, 473 insertions(+) create mode 100644 package/secubox/luci-app-metacatalog/Makefile create mode 100644 package/secubox/luci-app-metacatalog/htdocs/luci-static/resources/view/metacatalog/overview.js create mode 100644 package/secubox/luci-app-metacatalog/root/usr/libexec/rpcd/luci.metacatalog create mode 100644 package/secubox/luci-app-metacatalog/root/usr/share/luci/menu.d/luci-app-metacatalog.json create mode 100644 package/secubox/luci-app-metacatalog/root/usr/share/rpcd/acl.d/luci-app-metacatalog.json diff --git a/package/secubox/luci-app-metacatalog/Makefile b/package/secubox/luci-app-metacatalog/Makefile new file mode 100644 index 00000000..26ed52df --- /dev/null +++ b/package/secubox/luci-app-metacatalog/Makefile @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (C) 2026 CyberMind.fr - Gandalf +# +# LuCI app for Meta Cataloger + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI Meta Cataloger +LUCI_DEPENDS:=+secubox-app-metacatalog +LUCI_PKGARCH:=all + +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=CyberMind + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-metacatalog/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* + /etc/init.d/rpcd restart +} +exit 0 +endef + +# call BuildPackage - OpenWrt buildance diff --git a/package/secubox/luci-app-metacatalog/htdocs/luci-static/resources/view/metacatalog/overview.js b/package/secubox/luci-app-metacatalog/htdocs/luci-static/resources/view/metacatalog/overview.js new file mode 100644 index 00000000..7dac4944 --- /dev/null +++ b/package/secubox/luci-app-metacatalog/htdocs/luci-static/resources/view/metacatalog/overview.js @@ -0,0 +1,167 @@ +'use strict'; +'require view'; +'require rpc'; +'require ui'; +'require poll'; + +var callGetStats = rpc.declare({ + object: 'luci.metacatalog', + method: 'get_stats', + expect: {} +}); + +var callListBooks = rpc.declare({ + object: 'luci.metacatalog', + method: 'list_books', + expect: {} +}); + +var callListEntries = rpc.declare({ + object: 'luci.metacatalog', + method: 'list_entries', + expect: {} +}); + +var callSync = rpc.declare({ + object: 'luci.metacatalog', + method: 'sync', + expect: {} +}); + +var callSearch = rpc.declare({ + object: 'luci.metacatalog', + method: 'search', + params: ['query'], + expect: {} +}); + +return view.extend({ + handleSync: function() { + return callSync().then(function(res) { + ui.addNotification(null, E('p', 'Sync started in background. Refresh in a few seconds.'), 'info'); + }); + }, + + load: function() { + return Promise.all([ + callGetStats(), + callListBooks(), + callListEntries() + ]); + }, + + render: function(data) { + var stats = data[0] || {}; + var booksData = data[1] || {}; + var entriesData = data[2] || {}; + + var books = (booksData.books || []); + var entries = (entriesData.entries || []); + + // Build entries lookup + var entriesMap = {}; + entries.forEach(function(e) { + entriesMap[e.id] = e; + }); + + var view = E('div', { 'class': 'cbi-map' }, [ + E('style', {}, [ + '.mc-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }', + '.mc-title { font-size: 1.5rem; font-weight: bold; }', + '.mc-chips { display: flex; gap: 0.5rem; flex-wrap: wrap; }', + '.mc-chip { padding: 0.3rem 0.8rem; border-radius: 4px; font-size: 0.75rem; background: #1a1a2e; color: #fff; }', + '.mc-chip.fire { background: #ff0066; }', + '.mc-chip.wood { background: #00ff88; color: #000; }', + '.mc-chip.water { background: #0066ff; }', + '.mc-chip.metal { background: #cc00ff; }', + '.mc-actions { margin-left: auto; display: flex; gap: 0.5rem; }', + '.mc-shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }', + '.mc-book { background: #0a0c1a; border: 1px solid #222; border-left: 4px solid var(--book-color, #cc00ff); padding: 1rem; border-radius: 4px; }', + '.mc-book-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }', + '.mc-book-icon { font-size: 1.5rem; }', + '.mc-book-title { font-weight: bold; }', + '.mc-book-count { margin-left: auto; font-size: 0.7rem; color: #888; }', + '.mc-entries { display: flex; flex-direction: column; gap: 0.3rem; }', + '.mc-entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem; background: rgba(255,255,255,0.02); border-radius: 2px; }', + '.mc-entry:hover { background: rgba(255,255,255,0.05); }', + '.mc-entry-type { font-size: 0.55rem; padding: 0.1rem 0.3rem; border-radius: 2px; background: #cc00ff; color: #000; }', + '.mc-entry-type.metablog { background: #ff0066; color: #fff; }', + '.mc-entry-type.streamlit { background: #00ff88; color: #000; }', + '.mc-entry-name { font-size: 0.8rem; flex: 1; }', + '.mc-entry-link { font-size: 0.65rem; color: #0066ff; text-decoration: none; }', + '.mc-empty { color: #666; font-style: italic; font-size: 0.75rem; }', + '.mc-landing { margin-top: 1.5rem; padding: 1rem; background: #0a0c1a; border-radius: 4px; }', + '.mc-landing a { color: #00ffff; }' + ].join('\n')), + + E('div', { 'class': 'mc-header' }, [ + E('span', { 'class': 'mc-title' }, 'Meta Cataloger'), + E('div', { 'class': 'mc-chips' }, [ + E('span', { 'class': 'mc-chip fire' }, (stats.total_entries || 0) + ' Entries'), + E('span', { 'class': 'mc-chip wood' }, (stats.metablogs || 0) + ' MetaBlogs'), + E('span', { 'class': 'mc-chip water' }, (stats.streamlits || 0) + ' Streamlits'), + E('span', { 'class': 'mc-chip metal' }, (stats.books || 0) + ' Books') + ]), + E('div', { 'class': 'mc-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': ui.createHandlerFn(this, 'handleSync') + }, 'Sync Now'), + E('a', { + 'class': 'cbi-button cbi-button-neutral', + 'href': '/metacatalog/', + 'target': '_blank' + }, 'Landing Page') + ]) + ]) + ]); + + // Virtual Books shelf + var shelf = E('div', { 'class': 'mc-shelf' }); + + books.forEach(function(book) { + var bookEntries = (book.entries || []).map(function(eid) { + return entriesMap[eid]; + }).filter(function(e) { return e; }); + + var bookDiv = E('div', { 'class': 'mc-book', 'style': '--book-color: ' + (book.color || '#cc00ff') }, [ + E('div', { 'class': 'mc-book-head' }, [ + E('span', { 'class': 'mc-book-icon' }, book.icon || ''), + E('span', { 'class': 'mc-book-title' }, book.name || book.id), + E('span', { 'class': 'mc-book-count' }, bookEntries.length + ' entries') + ]), + E('div', { 'class': 'mc-entries' }, + bookEntries.length > 0 ? + bookEntries.slice(0, 8).map(function(entry) { + return E('div', { 'class': 'mc-entry' }, [ + E('span', { 'class': 'mc-entry-type ' + entry.type }, entry.type), + E('span', { 'class': 'mc-entry-name' }, (entry.metadata && entry.metadata.title) || entry.name), + E('a', { + 'class': 'mc-entry-link', + 'href': entry.url, + 'target': '_blank' + }, entry.domain) + ]); + }).concat( + bookEntries.length > 8 ? + [E('div', { 'class': 'mc-empty' }, '+ ' + (bookEntries.length - 8) + ' more...')] : [] + ) : + [E('div', { 'class': 'mc-empty' }, 'No entries')] + ) + ]); + + shelf.appendChild(bookDiv); + }); + + view.appendChild(shelf); + + // Landing page link + view.appendChild(E('div', { 'class': 'mc-landing' }, [ + E('strong', {}, 'Public Landing Page: '), + E('a', { 'href': 'https://catalog.gk2.secubox.in/metacatalog/', 'target': '_blank' }, + 'https://catalog.gk2.secubox.in/metacatalog/') + ])); + + return view; + } +}); diff --git a/package/secubox/luci-app-metacatalog/root/usr/libexec/rpcd/luci.metacatalog b/package/secubox/luci-app-metacatalog/root/usr/libexec/rpcd/luci.metacatalog new file mode 100644 index 00000000..c9fb9273 --- /dev/null +++ b/package/secubox/luci-app-metacatalog/root/usr/libexec/rpcd/luci.metacatalog @@ -0,0 +1,236 @@ +#!/bin/sh +# RPCD handler for Meta Cataloger +# Copyright (C) 2026 CyberMind.fr + +. /lib/functions.sh + +DATA_DIR="/srv/metacatalog" +ENTRIES_DIR="$DATA_DIR/entries" +INDEX_FILE="$DATA_DIR/index.json" +BOOKS_FILE="$DATA_DIR/books.json" + +# ═══════════════════════════════════════════════════════════════ +# HELPERS +# ═══════════════════════════════════════════════════════════════ + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' | tr '\n' ' ' +} + +# ═══════════════════════════════════════════════════════════════ +# METHODS +# ═══════════════════════════════════════════════════════════════ + +list_entries() { + if [ -f "$INDEX_FILE" ]; then + cat "$INDEX_FILE" + else + echo '{"version":"1.0.0","entries":[]}' + fi +} + +list_books() { + if [ -f "$BOOKS_FILE" ]; then + cat "$BOOKS_FILE" + else + echo '{"version":"1.0.0","books":[]}' + fi +} + +get_entry() { + local id="$1" + local entry_file="$ENTRIES_DIR/$id.json" + + if [ -f "$entry_file" ]; then + cat "$entry_file" + else + echo '{"error":"Entry not found"}' + fi +} + +get_book() { + local book_id="$1" + + if [ -f "$BOOKS_FILE" ]; then + jsonfilter -i "$BOOKS_FILE" -e "@.books[@.id='$book_id']" 2>/dev/null || echo '{"error":"Book not found"}' + else + echo '{"error":"Books file not found"}' + fi +} + +search_catalog() { + local query="$1" + query=$(echo "$query" | tr '[:upper:]' '[:lower:]') + + echo '{"results":[' + local first=1 + + for f in "$ENTRIES_DIR"/*.json; do + [ -f "$f" ] || continue + local content=$(cat "$f" | tr '[:upper:]' '[:lower:]') + if echo "$content" | grep -q "$query"; then + [ $first -eq 0 ] && echo "," + cat "$f" + first=0 + fi + done + + echo ']}' +} + +get_stats() { + local total=$(ls -1 "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l) + local metablogs=$(grep -l '"type": "metablog"' "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l) + local streamlits=$(grep -l '"type": "streamlit"' "$ENTRIES_DIR"/*.json 2>/dev/null | wc -l) + local books_count=0 + + if [ -f "$BOOKS_FILE" ]; then + books_count=$(jsonfilter -i "$BOOKS_FILE" -e '@.books[*].id' 2>/dev/null | wc -l) + fi + + cat </dev/null 2>&1 & + echo '{"status":"started","message":"Sync started in background"}' +} + +trigger_scan() { + local source="$1" + if [ -n "$source" ]; then + /usr/sbin/metacatalogctl scan "$source" >/dev/null 2>&1 & + else + /usr/sbin/metacatalogctl scan >/dev/null 2>&1 & + fi + echo '{"status":"started","message":"Scan started in background"}' +} + +assign_entry_to_book() { + local entry_id="$1" + local book_id="$2" + local entry_file="$ENTRIES_DIR/$entry_id.json" + + if [ ! -f "$entry_file" ]; then + echo '{"error":"Entry not found"}' + return 1 + fi + + # Read current books + local current_books=$(jsonfilter -i "$entry_file" -e '@.books[*]' 2>/dev/null | tr '\n' ',' | sed 's/,$//') + + # Check if already assigned + if echo "$current_books" | grep -q "$book_id"; then + echo '{"status":"already_assigned"}' + return 0 + fi + + # Add book to entry + if [ -z "$current_books" ]; then + sed -i "s/\"books\": \[\]/\"books\": [\"$book_id\"]/" "$entry_file" + else + sed -i "s/\"books\": \[/\"books\": [\"$book_id\",/" "$entry_file" + fi + + echo '{"status":"assigned"}' +} + +remove_entry_from_book() { + local entry_id="$1" + local book_id="$2" + local entry_file="$ENTRIES_DIR/$entry_id.json" + + if [ ! -f "$entry_file" ]; then + echo '{"error":"Entry not found"}' + return 1 + fi + + # Remove book from array (simplified - just remove the string) + sed -i "s/\"$book_id\",//g; s/,\"$book_id\"//g; s/\"$book_id\"//g" "$entry_file" + + echo '{"status":"removed"}' +} + +# ═══════════════════════════════════════════════════════════════ +# MAIN +# ═══════════════════════════════════════════════════════════════ + +case "$1" in + list) + cat </dev/null) + trigger_scan "$source" + ;; + assign) + read -r input + entry_id=$(echo "$input" | jsonfilter -e '@.entry_id') + book_id=$(echo "$input" | jsonfilter -e '@.book_id') + assign_entry_to_book "$entry_id" "$book_id" + ;; + unassign) + read -r input + entry_id=$(echo "$input" | jsonfilter -e '@.entry_id') + book_id=$(echo "$input" | jsonfilter -e '@.book_id') + remove_entry_from_book "$entry_id" "$book_id" + ;; + *) + echo '{"error":"Unknown method"}' + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-metacatalog/root/usr/share/luci/menu.d/luci-app-metacatalog.json b/package/secubox/luci-app-metacatalog/root/usr/share/luci/menu.d/luci-app-metacatalog.json new file mode 100644 index 00000000..23372eef --- /dev/null +++ b/package/secubox/luci-app-metacatalog/root/usr/share/luci/menu.d/luci-app-metacatalog.json @@ -0,0 +1,14 @@ +{ + "admin/secubox/metacatalog": { + "title": "Meta Cataloger", + "order": 55, + "action": { + "type": "view", + "path": "metacatalog/overview" + }, + "depends": { + "acl": ["luci-app-metacatalog"], + "uci": {"metacatalog": true} + } + } +} diff --git a/package/secubox/luci-app-metacatalog/root/usr/share/rpcd/acl.d/luci-app-metacatalog.json b/package/secubox/luci-app-metacatalog/root/usr/share/rpcd/acl.d/luci-app-metacatalog.json new file mode 100644 index 00000000..cb99f3a0 --- /dev/null +++ b/package/secubox/luci-app-metacatalog/root/usr/share/rpcd/acl.d/luci-app-metacatalog.json @@ -0,0 +1,27 @@ +{ + "luci-app-metacatalog": { + "description": "Grant access to Meta Cataloger", + "read": { + "ubus": { + "luci.metacatalog": [ + "list_entries", + "list_books", + "get_entry", + "get_book", + "search", + "get_stats" + ] + } + }, + "write": { + "ubus": { + "luci.metacatalog": [ + "sync", + "scan", + "assign", + "unassign" + ] + } + } + } +}