diff --git a/package/secubox/luci-app-lyrion/Makefile b/package/secubox/luci-app-lyrion/Makefile new file mode 100644 index 00000000..86743b7d --- /dev/null +++ b/package/secubox/luci-app-lyrion/Makefile @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Copyright (C) 2025 CyberMind.fr + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Lyrion Music Server +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-lyrion +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_MAINTAINER:=CyberMind +PKG_LICENSE:=GPL-2.0 + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-lyrion/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.lyrion $(1)/usr/libexec/rpcd/luci.lyrion + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-lyrion.json $(1)/usr/share/luci/menu.d/luci-app-lyrion.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-lyrion.json $(1)/usr/share/rpcd/acl.d/luci-app-lyrion.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/lyrion + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/lyrion/*.js $(1)/www/luci-static/resources/view/lyrion/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js new file mode 100644 index 00000000..bd046a13 --- /dev/null +++ b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/overview.js @@ -0,0 +1,234 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; +'require poll'; + +var callStatus = rpc.declare({ object: 'luci.lyrion', method: 'status', expect: {} }); +var callInstall = rpc.declare({ object: 'luci.lyrion', method: 'install', expect: {} }); +var callStart = rpc.declare({ object: 'luci.lyrion', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.lyrion', method: 'stop', expect: {} }); +var callRestart = rpc.declare({ object: 'luci.lyrion', method: 'restart', expect: {} }); + +var css = '.ly-container{max-width:900px;margin:0 auto}.ly-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#ec4899 0%,#8b5cf6 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.ly-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.ly-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.ly-status.running{background:rgba(16,185,129,.2)}.ly-status.stopped{background:rgba(239,68,68,.2)}.ly-status.installing{background:rgba(245,158,11,.2)}.ly-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.ly-status.running .ly-dot{background:#10b981}.ly-status.stopped .ly-dot{background:#ef4444}.ly-status.installing .ly-dot{background:#f59e0b}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.ly-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.ly-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.ly-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem}.ly-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.ly-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.ly-info-value{font-size:1.1rem;font-weight:500}.ly-actions{display:flex;gap:.75rem;flex-wrap:wrap}.ly-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.ly-btn-primary{background:linear-gradient(135deg,#ec4899,#8b5cf6);color:#fff}.ly-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(139,92,246,.3)}.ly-btn-success{background:#10b981;color:#fff}.ly-btn-danger{background:#ef4444;color:#fff}.ly-btn-secondary{background:#6b7280;color:#fff}.ly-btn:disabled{opacity:.5;cursor:not-allowed}.ly-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(236,72,153,.1),rgba(139,92,246,.1));border-radius:12px;margin-top:1rem}.ly-webui-icon{font-size:2rem}.ly-webui-info{flex:1}.ly-webui-url{font-family:monospace;color:#8b5cf6}.ly-not-installed{text-align:center;padding:3rem}.ly-not-installed h3{margin-bottom:1rem;color:#333}.ly-not-installed p{color:#666;margin-bottom:1.5rem}'; + +return view.extend({ + pollActive: true, + + load: function() { + return callStatus(); + }, + + startPolling: function() { + var self = this; + this.pollActive = true; + poll.add(L.bind(function() { + if (!this.pollActive) return Promise.resolve(); + return callStatus().then(L.bind(function(status) { + this.updateStatus(status); + }, this)); + }, this), 5); + }, + + updateStatus: function(status) { + var badge = document.querySelector('.ly-status'); + var dot = document.querySelector('.ly-dot'); + var statusText = document.querySelector('.ly-status-text'); + + if (badge && statusText) { + badge.className = 'ly-status ' + (status.running ? 'running' : 'stopped'); + statusText.textContent = status.running ? _('Running') : _('Stopped'); + } + + // Update info values + var updates = { + '.ly-val-runtime': status.detected_runtime || 'none', + '.ly-val-port': status.port || '9000', + '.ly-val-memory': status.memory_limit || '256M' + }; + Object.keys(updates).forEach(function(sel) { + var el = document.querySelector(sel); + if (el) el.textContent = updates[sel]; + }); + }, + + handleInstall: function() { + var self = this; + ui.showModal(_('Installing Lyrion'), [ + E('p', { 'class': 'spinning' }, _('Installing Lyrion Music Server. This may take several minutes...')) + ]); + callInstall().then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message || _('Installation started'))); + self.startPolling(); + } else { + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); + }); + }, + + handleStart: function() { + ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting Lyrion...'))]); + callStart().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Lyrion started'))); + else ui.addNotification(null, E('p', _('Failed to start')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStop: function() { + ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping Lyrion...'))]); + callStop().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Lyrion stopped'))); + else ui.addNotification(null, E('p', _('Failed to stop')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleRestart: function() { + ui.showModal(_('Restarting...'), [E('p', { 'class': 'spinning' }, _('Restarting Lyrion...'))]); + callRestart().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Lyrion restarted'))); + else ui.addNotification(null, E('p', _('Failed to restart')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + render: function(status) { + var self = this; + + if (!document.getElementById('ly-styles')) { + var s = document.createElement('style'); + s.id = 'ly-styles'; + s.textContent = css; + document.head.appendChild(s); + } + + // Not installed view + if (!status.installed) { + return E('div', { 'class': 'ly-container' }, [ + E('div', { 'class': 'ly-header' }, [ + E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]), + E('div', { 'class': 'ly-status stopped' }, [ + E('span', { 'class': 'ly-dot' }), + E('span', { 'class': 'ly-status-text' }, _('Not Installed')) + ]) + ]), + E('div', { 'class': 'ly-card' }, [ + E('div', { 'class': 'ly-not-installed' }, [ + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\ud83c\udfb5'), + E('h3', {}, _('Lyrion Music Server')), + E('p', {}, _('Self-hosted music streaming with Squeezebox/Logitech Media Server compatibility. Stream your music library to any device.')), + E('div', { 'class': 'ly-info-grid', 'style': 'margin-bottom:1.5rem;text-align:left' }, [ + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Runtime')), + E('div', { 'class': 'ly-info-value' }, status.detected_runtime === 'lxc' ? 'LXC Container' : status.detected_runtime === 'docker' ? 'Docker' : _('None detected')) + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Data Path')), + E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion') + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Media Path')), + E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media') + ]) + ]), + E('button', { + 'class': 'ly-btn ly-btn-primary', + 'click': ui.createHandlerFn(this, 'handleInstall'), + 'disabled': status.detected_runtime === 'none' + }, _('Install Lyrion')) + ]) + ]) + ]); + } + + // Installed view + this.startPolling(); + + return E('div', { 'class': 'ly-container' }, [ + E('div', { 'class': 'ly-header' }, [ + E('h2', {}, ['\ud83c\udfb5 ', _('Lyrion Music Server')]), + E('div', { 'class': 'ly-status ' + (status.running ? 'running' : 'stopped') }, [ + E('span', { 'class': 'ly-dot' }), + E('span', { 'class': 'ly-status-text' }, status.running ? _('Running') : _('Stopped')) + ]) + ]), + + // Info Card + E('div', { 'class': 'ly-card' }, [ + E('div', { 'class': 'ly-card-title' }, ['\u2139\ufe0f ', _('Service Information')]), + E('div', { 'class': 'ly-info-grid' }, [ + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Runtime')), + E('div', { 'class': 'ly-info-value ly-val-runtime' }, status.detected_runtime || 'auto') + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Port')), + E('div', { 'class': 'ly-info-value ly-val-port' }, status.port || '9000') + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Memory Limit')), + E('div', { 'class': 'ly-info-value ly-val-memory' }, status.memory_limit || '256M') + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Data Path')), + E('div', { 'class': 'ly-info-value' }, status.data_path || '/srv/lyrion') + ]), + E('div', { 'class': 'ly-info-item' }, [ + E('div', { 'class': 'ly-info-label' }, _('Media Path')), + E('div', { 'class': 'ly-info-value' }, status.media_path || '/srv/media') + ]) + ]), + + // Web UI Link + status.running && status.web_accessible ? E('div', { 'class': 'ly-webui' }, [ + E('div', { 'class': 'ly-webui-icon' }, '\ud83c\udf10'), + E('div', { 'class': 'ly-webui-info' }, [ + E('div', { 'style': 'font-weight:600' }, _('Web Interface')), + E('div', { 'class': 'ly-webui-url' }, status.web_url) + ]), + E('a', { + 'href': status.web_url, + 'target': '_blank', + 'class': 'ly-btn ly-btn-primary' + }, _('Open')) + ]) : '' + ]), + + // Actions Card + E('div', { 'class': 'ly-card' }, [ + E('div', { 'class': 'ly-card-title' }, ['\u26a1 ', _('Actions')]), + E('div', { 'class': 'ly-actions' }, [ + E('button', { + 'class': 'ly-btn ly-btn-success', + 'click': ui.createHandlerFn(this, 'handleStart'), + 'disabled': status.running + }, _('Start')), + E('button', { + 'class': 'ly-btn ly-btn-danger', + 'click': ui.createHandlerFn(this, 'handleStop'), + 'disabled': !status.running + }, _('Stop')), + E('button', { + 'class': 'ly-btn ly-btn-secondary', + 'click': ui.createHandlerFn(this, 'handleRestart'), + 'disabled': !status.running + }, _('Restart')), + E('a', { + 'href': L.url('admin', 'secubox', 'services', 'lyrion', 'settings'), + 'class': 'ly-btn ly-btn-secondary' + }, _('Settings')) + ]) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/settings.js b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/settings.js new file mode 100644 index 00000000..2c06782a --- /dev/null +++ b/package/secubox/luci-app-lyrion/htdocs/luci-static/resources/view/lyrion/settings.js @@ -0,0 +1,66 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; + +return view.extend({ + load: function() { + return uci.load('lyrion'); + }, + + render: function() { + var m, s, o; + + m = new form.Map('lyrion', _('Lyrion Settings'), + _('Configure Lyrion Music Server settings. Changes require service restart to take effect.')); + + s = m.section(form.TypedSection, 'lyrion', _('General Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enabled'), + _('Enable Lyrion Music Server')); + o.default = '0'; + o.rmempty = false; + + o = s.option(form.ListValue, 'runtime', _('Container Runtime'), + _('Select the container runtime to use')); + o.value('auto', _('Auto-detect (LXC preferred)')); + o.value('lxc', _('LXC Container')); + o.value('docker', _('Docker')); + o.default = 'auto'; + + o = s.option(form.Value, 'port', _('Web UI Port'), + _('Port for the Lyrion web interface')); + o.datatype = 'port'; + o.default = '9000'; + o.placeholder = '9000'; + + o = s.option(form.Value, 'data_path', _('Data Path'), + _('Path to store Lyrion configuration and cache')); + o.default = '/srv/lyrion'; + o.placeholder = '/srv/lyrion'; + + o = s.option(form.Value, 'media_path', _('Media Path'), + _('Path to your music library')); + o.default = '/srv/media'; + o.placeholder = '/srv/media'; + + o = s.option(form.Value, 'memory_limit', _('Memory Limit'), + _('Maximum memory for the container (e.g., 256M, 512M, 1G)')); + o.default = '256M'; + o.placeholder = '256M'; + + o = s.option(form.Value, 'timezone', _('Timezone'), + _('Timezone for the container')); + o.default = 'UTC'; + o.placeholder = 'UTC'; + + o = s.option(form.Value, 'image', _('Docker Image'), + _('Docker image to use (only for Docker runtime)')); + o.default = 'ghcr.io/lms-community/lyrionmusicserver:stable'; + o.depends('runtime', 'docker'); + + return m.render(); + } +}); diff --git a/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion b/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion new file mode 100644 index 00000000..bc232108 --- /dev/null +++ b/package/secubox/luci-app-lyrion/root/usr/libexec/rpcd/luci.lyrion @@ -0,0 +1,235 @@ +#!/bin/sh +# RPCD backend for Lyrion Music Server LuCI app + +. /lib/functions.sh + +CONFIG="lyrion" + +json_init() { echo "{"; } +json_close() { echo "}"; } +json_add_string() { echo "\"$1\": \"$2\""; } +json_add_int() { echo "\"$1\": $2"; } +json_add_bool() { [ "$2" = "1" ] && echo "\"$1\": true" || echo "\"$1\": false"; } + +uci_get() { uci -q get ${CONFIG}.main.$1; } +uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; } + +# Get service status +get_status() { + local enabled=$(uci_get enabled) + local runtime=$(uci_get runtime) + local port=$(uci_get port) + local data_path=$(uci_get data_path) + local media_path=$(uci_get media_path) + local memory_limit=$(uci_get memory_limit) + local image=$(uci_get image) + + # Check if service is running + local running=0 + local container_status="stopped" + + if command -v lxc-info >/dev/null 2>&1; then + if lxc-info -n lyrion -s 2>/dev/null | grep -q "RUNNING"; then + running=1 + container_status="running" + fi + elif command -v docker >/dev/null 2>&1; then + if docker ps --filter "name=secbx-lyrion" --format "{{.Names}}" 2>/dev/null | grep -q "secbx-lyrion"; then + running=1 + container_status="running" + fi + fi + + # Check if installed (LXC rootfs or Docker image exists) + local installed=0 + if [ -d "/srv/lxc/lyrion/rootfs" ] && [ -f "/srv/lxc/lyrion/rootfs/opt/lyrion/slimserver.pl" ]; then + installed=1 + elif command -v docker >/dev/null 2>&1 && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "lyrionmusicserver"; then + installed=1 + fi + + # Detect runtime + local detected_runtime="none" + if command -v lxc-start >/dev/null 2>&1; then + detected_runtime="lxc" + elif command -v docker >/dev/null 2>&1; then + detected_runtime="docker" + fi + + # Check web UI accessibility + local web_accessible=0 + if [ "$running" = "1" ]; then + wget -q -O /dev/null --timeout=2 "http://127.0.0.1:${port:-9000}/" 2>/dev/null && web_accessible=1 + fi + + cat </dev/null) + local port=$(echo "$input" | jsonfilter -e '@.port' 2>/dev/null) + local data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null) + local media_path=$(echo "$input" | jsonfilter -e '@.media_path' 2>/dev/null) + local memory_limit=$(echo "$input" | jsonfilter -e '@.memory_limit' 2>/dev/null) + local timezone=$(echo "$input" | jsonfilter -e '@.timezone' 2>/dev/null) + + [ -n "$runtime" ] && uci_set runtime "$runtime" + [ -n "$port" ] && uci_set port "$port" + [ -n "$data_path" ] && uci_set data_path "$data_path" + [ -n "$media_path" ] && uci_set media_path "$media_path" + [ -n "$memory_limit" ] && uci_set memory_limit "$memory_limit" + [ -n "$timezone" ] && uci_set timezone "$timezone" + + echo '{"success": true}' +} + +# Install Lyrion +do_install() { + if command -v lyrionctl >/dev/null 2>&1; then + lyrionctl install >/tmp/lyrion-install.log 2>&1 & + echo '{"success": true, "message": "Installation started in background"}' + else + echo '{"success": false, "error": "lyrionctl not found"}' + fi +} + +# Start service +do_start() { + if [ -x /etc/init.d/lyrion ]; then + /etc/init.d/lyrion start >/dev/null 2>&1 + uci_set enabled '1' + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Stop service +do_stop() { + if [ -x /etc/init.d/lyrion ]; then + /etc/init.d/lyrion stop >/dev/null 2>&1 + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Restart service +do_restart() { + if [ -x /etc/init.d/lyrion ]; then + /etc/init.d/lyrion restart >/dev/null 2>&1 + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Update container +do_update() { + if command -v lyrionctl >/dev/null 2>&1; then + lyrionctl update >/tmp/lyrion-update.log 2>&1 & + echo '{"success": true, "message": "Update started in background"}' + else + echo '{"success": false, "error": "lyrionctl not found"}' + fi +} + +# Get logs +get_logs() { + local lines=50 + local log_content="" + + if [ -f /srv/lxc/lyrion/rootfs/var/log/lyrion/server.log ]; then + log_content=$(tail -n $lines /srv/lxc/lyrion/rootfs/var/log/lyrion/server.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|') + elif [ -f /tmp/lyrion-install.log ]; then + log_content=$(tail -n $lines /tmp/lyrion-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|') + fi + + echo "{\"logs\": \"$log_content\"}" +} + +# RPCD list method +list_methods() { + cat <<'EOF' +{ + "status": {}, + "get_config": {}, + "save_config": {"runtime": "string", "port": "string", "data_path": "string", "media_path": "string", "memory_limit": "string", "timezone": "string"}, + "install": {}, + "start": {}, + "stop": {}, + "restart": {}, + "update": {}, + "logs": {} +} +EOF +} + +# Main entry point +case "$1" in + list) + list_methods + ;; + call) + case "$2" in + status) get_status ;; + get_config) get_config ;; + save_config) save_config ;; + install) do_install ;; + start) do_start ;; + stop) do_stop ;; + restart) do_restart ;; + update) do_update ;; + logs) get_logs ;; + *) echo '{"error": "Unknown method"}' ;; + esac + ;; + *) + echo '{"error": "Unknown command"}' + ;; +esac diff --git a/package/secubox/luci-app-lyrion/root/usr/share/luci/menu.d/luci-app-lyrion.json b/package/secubox/luci-app-lyrion/root/usr/share/luci/menu.d/luci-app-lyrion.json new file mode 100644 index 00000000..3869808c --- /dev/null +++ b/package/secubox/luci-app-lyrion/root/usr/share/luci/menu.d/luci-app-lyrion.json @@ -0,0 +1,28 @@ +{ + "admin/secubox/services/lyrion": { + "title": "Lyrion", + "order": 50, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-lyrion"] + } + }, + "admin/secubox/services/lyrion/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "lyrion/overview" + } + }, + "admin/secubox/services/lyrion/settings": { + "title": "Settings", + "order": 90, + "action": { + "type": "view", + "path": "lyrion/settings" + } + } +} diff --git a/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json b/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json new file mode 100644 index 00000000..af73e077 --- /dev/null +++ b/package/secubox/luci-app-lyrion/root/usr/share/rpcd/acl.d/luci-app-lyrion.json @@ -0,0 +1,17 @@ +{ + "luci-app-lyrion": { + "description": "Grant access to Lyrion Music Server", + "read": { + "ubus": { + "luci.lyrion": ["status", "get_config", "logs"] + }, + "uci": ["lyrion"] + }, + "write": { + "ubus": { + "luci.lyrion": ["install", "start", "stop", "restart", "update", "save_config"] + }, + "uci": ["lyrion"] + } + } +} diff --git a/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/overview.js b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/overview.js new file mode 100644 index 00000000..2a0aa2cf --- /dev/null +++ b/package/secubox/luci-app-magicmirror2/htdocs/luci-static/resources/view/magicmirror2/overview.js @@ -0,0 +1,95 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; + +var callStatus = rpc.declare({ object: 'luci.magicmirror2', method: 'status', expect: {} }); +var callInstall = rpc.declare({ object: 'luci.magicmirror2', method: 'install', expect: {} }); +var callStart = rpc.declare({ object: 'luci.magicmirror2', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.magicmirror2', method: 'stop', expect: {} }); +var callRestart = rpc.declare({ object: 'luci.magicmirror2', method: 'restart', expect: {} }); + +var css = '.mm-container{max-width:900px;margin:0 auto}.mm-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#1a1a2e 0%,#16213e 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mm-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mm-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mm-status.running{background:rgba(16,185,129,.2)}.mm-status.stopped{background:rgba(239,68,68,.2)}.mm-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mm-status.running .mm-dot{background:#10b981}.mm-status.stopped .mm-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mm-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mm-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mm-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mm-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mm-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mm-info-value{font-size:1rem;font-weight:500}.mm-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mm-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mm-btn-primary{background:linear-gradient(135deg,#1a1a2e,#16213e);color:#fff}.mm-btn-success{background:#10b981;color:#fff}.mm-btn-danger{background:#ef4444;color:#fff}.mm-btn:disabled{opacity:.5;cursor:not-allowed}.mm-not-installed{text-align:center;padding:3rem}.mm-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0}.mm-feature{padding:.75rem;background:#e0e7ff;border-radius:8px;font-size:.9rem}'; + +return view.extend({ + load: function() { return callStatus(); }, + + handleInstall: function() { + ui.showModal(_('Installing MagicMirror'), [E('p', { 'class': 'spinning' }, _('Installing...'))]); + callInstall().then(function(r) { + ui.hideModal(); + ui.addNotification(null, E('p', r.message || _('Installation started'))); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStart: function() { + ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]); + callStart().then(function() { ui.hideModal(); location.reload(); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStop: function() { + ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]); + callStop().then(function() { ui.hideModal(); location.reload(); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + render: function(status) { + if (!document.getElementById('mm-styles')) { + var s = document.createElement('style'); s.id = 'mm-styles'; s.textContent = css; document.head.appendChild(s); + } + + if (!status.installed || !status.docker_available) { + return E('div', { 'class': 'mm-container' }, [ + E('div', { 'class': 'mm-header' }, [ + E('h2', {}, ['\uD83E\uDE9E ', _('MagicMirror')]), + E('div', { 'class': 'mm-status stopped' }, [E('span', { 'class': 'mm-dot' }), _('Not Installed')]) + ]), + E('div', { 'class': 'mm-card' }, [ + E('div', { 'class': 'mm-not-installed' }, [ + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\uD83E\uDE9E'), + E('h3', {}, _('MagicMirror\u00B2')), + E('p', {}, _('Open source modular smart mirror platform with customizable widgets.')), + E('div', { 'class': 'mm-features' }, [ + E('div', { 'class': 'mm-feature' }, '\u2600 Weather'), + E('div', { 'class': 'mm-feature' }, '\uD83D\uDCC5 Calendar'), + E('div', { 'class': 'mm-feature' }, '\uD83D\uDCF0 News'), + E('div', { 'class': 'mm-feature' }, '\u23F0 Clock'), + E('div', { 'class': 'mm-feature' }, '\uD83D\uDDE3 Compliments'), + E('div', { 'class': 'mm-feature' }, '\uD83D\uDD0C Modules') + ]), + !status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker required')) : '', + E('button', { 'class': 'mm-btn mm-btn-primary', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install MagicMirror')) + ]) + ]) + ]); + } + + return E('div', { 'class': 'mm-container' }, [ + E('div', { 'class': 'mm-header' }, [ + E('h2', {}, ['\uD83E\uDE9E ', _('MagicMirror')]), + E('div', { 'class': 'mm-status ' + (status.running ? 'running' : 'stopped') }, [ + E('span', { 'class': 'mm-dot' }), + status.running ? _('Running') : _('Stopped') + ]) + ]), + E('div', { 'class': 'mm-card' }, [ + E('div', { 'class': 'mm-card-title' }, ['\u2139\uFE0F ', _('Configuration')]), + E('div', { 'class': 'mm-info-grid' }, [ + E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Port')), E('div', { 'class': 'mm-info-value' }, String(status.port))]), + E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Data Path')), E('div', { 'class': 'mm-info-value' }, status.data_path)]), + E('div', { 'class': 'mm-info-item' }, [E('div', { 'class': 'mm-info-label' }, _('Access')), E('div', { 'class': 'mm-info-value' }, [E('a', { 'href': 'http://' + window.location.hostname + ':' + status.port, 'target': '_blank' }, _('Open Mirror'))])]) + ]) + ]), + E('div', { 'class': 'mm-card' }, [ + E('div', { 'class': 'mm-card-title' }, ['\u26A1 ', _('Actions')]), + E('div', { 'class': 'mm-actions' }, [ + E('button', { 'class': 'mm-btn mm-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')), + E('button', { 'class': 'mm-btn mm-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop')) + ]) + ]) + ]); + }, + + handleSaveApply: null, handleSave: null, handleReset: null +}); diff --git a/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 b/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 index efe2bfdd..371a3bbc 100755 --- a/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 +++ b/package/secubox/luci-app-magicmirror2/root/usr/libexec/rpcd/luci.magicmirror2 @@ -1,427 +1,54 @@ #!/bin/sh -# -# RPCD backend for MagicMirror2 LuCI interface -# Copyright (C) 2026 CyberMind.fr (SecuBox) -# +# RPCD backend for MagicMirror2 LuCI app -. /lib/functions.sh +CONFIG="magicmirror2" +CONTAINER="secbx-magicmirror" -DATA_DIR=$(uci -q get magicmirror2.main.data_path || echo "/srv/magicmirror2") -LXC_NAME="magicmirror2" +uci_get() { uci -q get ${CONFIG}.main.$1; } -# Get service status get_status() { + local enabled=$(uci_get enabled) + local port=$(uci_get port) + local data_path=$(uci_get data_path) + + local docker_available=0 + command -v docker >/dev/null 2>&1 && docker_available=1 + local running=0 - local pid="" - local lxc_state="" - local web_url="" - - # Check LXC container status - if command -v lxc-info >/dev/null 2>&1; then - lxc_state=$(lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -oE 'RUNNING|STOPPED' || echo "UNKNOWN") - if [ "$lxc_state" = "RUNNING" ]; then - running=1 - pid=$(lxc-info -n "$LXC_NAME" -p 2>/dev/null | grep -oE '[0-9]+' || echo "0") - fi + if [ "$docker_available" = "1" ]; then + docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER" && running=1 fi - local enabled=$(uci -q get magicmirror2.main.enabled || echo "0") - local port=$(uci -q get magicmirror2.main.port || echo "8085") - local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") + local installed=0 + [ "$docker_available" = "1" ] && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "magicmirror" && installed=1 - [ "$running" = "1" ] && web_url="http://${router_ip}:${port}" - - # Count installed modules - local module_count=0 - if [ -d "$DATA_DIR/modules" ]; then - module_count=$(ls -d "$DATA_DIR/modules"/MMM-* "$DATA_DIR/modules"/mm-* 2>/dev/null | wc -l) - fi - - cat </dev/null 2>&1 && { magicmirror2ctl install >/tmp/mm2-install.log 2>&1 & echo '{"success":true,"message":"Installing"}'; } || echo '{"success":false,"error":"magicmirror2ctl not found"}' } -# Get display configuration -get_display_config() { - local width=$(uci -q get magicmirror2.display.width || echo "1920") - local height=$(uci -q get magicmirror2.display.height || echo "1080") - local zoom=$(uci -q get magicmirror2.display.zoom || echo "1.0") - local brightness=$(uci -q get magicmirror2.display.brightness || echo "100") +do_start() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 start >/dev/null 2>&1; echo '{"success":true}'; } +do_stop() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 stop >/dev/null 2>&1; echo '{"success":true}'; } +do_restart() { [ -x /etc/init.d/magicmirror2 ] && /etc/init.d/magicmirror2 restart >/dev/null 2>&1; echo '{"success":true}'; } - cat </dev/null || echo "unknown") - local desc=$(jsonfilter -i "$module_dir/package.json" -e '@.description' 2>/dev/null | head -c 100 | sed 's/"/\\"/g') - local author=$(jsonfilter -i "$module_dir/package.json" -e '@.author' 2>/dev/null | sed 's/"/\\"/g') - local has_config="false" - [ -f "$DATA_DIR/config/${name}.json" ] && has_config="true" - - [ "$first" = "1" ] || echo "," - first=0 - - cat </dev/null || echo "unknown") - local desc=$(jsonfilter -i "$module_dir/package.json" -e '@.description' 2>/dev/null | head -c 100 | sed 's/"/\\"/g') - local author=$(jsonfilter -i "$module_dir/package.json" -e '@.author' 2>/dev/null | sed 's/"/\\"/g') - local has_config="false" - [ -f "$DATA_DIR/config/${name}.json" ] && has_config="true" - - [ "$first" = "1" ] || echo "," - first=0 - - cat </dev/null 2>&1 - sleep 3 - get_status -} - -service_stop() { - /etc/init.d/magicmirror2 stop >/dev/null 2>&1 - sleep 1 - get_status -} - -service_restart() { - /etc/init.d/magicmirror2 restart >/dev/null 2>&1 - sleep 3 - get_status -} - -# Install module -install_module() { - local module_name="$1" - - if [ -z "$module_name" ]; then - echo '{"success":false,"message":"Module name required"}' - return - fi - - local result=$(/usr/sbin/mm2ctl module install "$module_name" 2>&1) - local rc=$? - - if [ $rc -eq 0 ]; then - echo '{"success":true,"message":"Module installed successfully"}' - else - local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ') - echo "{\"success\":false,\"message\":\"$escaped\"}" - fi -} - -# Remove module -remove_module() { - local module_name="$1" - - if [ -z "$module_name" ]; then - echo '{"success":false,"message":"Module name required"}' - return - fi - - local result=$(/usr/sbin/mm2ctl module remove "$module_name" 2>&1) - local rc=$? - - if [ $rc -eq 0 ]; then - echo '{"success":true,"message":"Module removed successfully"}' - else - local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ') - echo "{\"success\":false,\"message\":\"$escaped\"}" - fi -} - -# Update module(s) -update_modules() { - local module_name="$1" - - local result=$(/usr/sbin/mm2ctl module update $module_name 2>&1) - local rc=$? - - if [ $rc -eq 0 ]; then - echo '{"success":true,"message":"Modules updated successfully"}' - else - local escaped=$(echo "$result" | sed 's/"/\\"/g' | tr '\n' ' ') - echo "{\"success\":false,\"message\":\"$escaped\"}" - fi -} - -# Regenerate config -regenerate_config() { - /usr/sbin/mm2ctl config >/dev/null 2>&1 - echo '{"success":true,"message":"Configuration regenerated"}' -} - -# Set configuration -set_config() { - local key="$1" - local value="$2" - - if [ -z "$key" ] || [ -z "$value" ]; then - echo '{"success":false,"message":"Key and value required"}' - return - fi - - # Handle boolean conversion - case "$value" in - true) value="1" ;; - false) value="0" ;; - esac - - # Determine section based on key - local section="main" - case "$key" in - width|height|zoom|brightness) - section="display" - ;; - provider|api_key|location|location_id) - section="weather" - ;; - display_seconds|show_date|show_week) - section="clock" - ;; - max_entries|fetch_interval) - section="calendar" - ;; - max_news_items|show_description|show_source_title) - section="newsfeed" - ;; - update_interval) - section="compliments" - ;; - esac - - uci set "magicmirror2.$section.$key=$value" - uci commit magicmirror2 - - echo '{"success":true}' -} - -# Get web URL for iframe -get_web_url() { - local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") - local port=$(uci -q get magicmirror2.main.port || echo "8085") - - cat </dev/null) - install_module "$name" - ;; - remove_module) - read -r input - name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) - remove_module "$name" - ;; - update_modules) - read -r input - name=$(echo "$input" | jsonfilter -e '@.name' 2>/dev/null) - update_modules "$name" - ;; - regenerate_config) - regenerate_config - ;; - set_config) - read -r input - key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null) - value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null) - set_config "$key" "$value" - ;; - *) - echo '{"error":"Unknown method"}' - ;; - esac - ;; - *) - echo '{"error":"Unknown command"}' - ;; + list) list_methods ;; + call) case "$2" in status) get_status ;; install) do_install ;; start) do_start ;; stop) do_stop ;; restart) do_restart ;; *) echo '{"error":"Unknown method"}' ;; esac ;; + *) echo '{"error":"Unknown command"}' ;; esac diff --git a/package/secubox/luci-app-magicmirror2/root/usr/share/luci/menu.d/luci-app-magicmirror2.json b/package/secubox/luci-app-magicmirror2/root/usr/share/luci/menu.d/luci-app-magicmirror2.json index 96c586d9..22c32418 100644 --- a/package/secubox/luci-app-magicmirror2/root/usr/share/luci/menu.d/luci-app-magicmirror2.json +++ b/package/secubox/luci-app-magicmirror2/root/usr/share/luci/menu.d/luci-app-magicmirror2.json @@ -1,45 +1,8 @@ { "admin/secubox/services/magicmirror2": { - "title": "MagicMirror2", - "order": 60, - "action": { - "type": "firstchild" - }, - "depends": { - "acl": ["luci-app-magicmirror2"], - "uci": {"magicmirror2": true} - } - }, - "admin/secubox/services/magicmirror2/dashboard": { - "title": "Dashboard", - "order": 10, - "action": { - "type": "view", - "path": "magicmirror2/dashboard" - } - }, - "admin/secubox/services/magicmirror2/webui": { - "title": "Display", - "order": 15, - "action": { - "type": "view", - "path": "magicmirror2/webui" - } - }, - "admin/secubox/services/magicmirror2/modules": { - "title": "Modules", - "order": 20, - "action": { - "type": "view", - "path": "magicmirror2/modules" - } - }, - "admin/secubox/services/magicmirror2/settings": { - "title": "Settings", - "order": 30, - "action": { - "type": "view", - "path": "magicmirror2/settings" - } + "title": "MagicMirror", + "action": { "type": "view", "path": "magicmirror2/overview" }, + "depends": { "acl": ["luci-app-magicmirror2"] }, + "order": 70 } } diff --git a/package/secubox/luci-app-magicmirror2/root/usr/share/rpcd/acl.d/luci-app-magicmirror2.json b/package/secubox/luci-app-magicmirror2/root/usr/share/rpcd/acl.d/luci-app-magicmirror2.json index 7e36b44b..eaeaea6e 100644 --- a/package/secubox/luci-app-magicmirror2/root/usr/share/rpcd/acl.d/luci-app-magicmirror2.json +++ b/package/secubox/luci-app-magicmirror2/root/usr/share/rpcd/acl.d/luci-app-magicmirror2.json @@ -1,34 +1,7 @@ { "luci-app-magicmirror2": { - "description": "Grant access to MagicMirror2 dashboard", - "read": { - "ubus": { - "luci.magicmirror2": [ - "get_status", - "get_config", - "get_display_config", - "get_weather_config", - "get_modules_config", - "get_installed_modules", - "get_web_url" - ] - }, - "uci": ["magicmirror2"] - }, - "write": { - "ubus": { - "luci.magicmirror2": [ - "service_start", - "service_stop", - "service_restart", - "install_module", - "remove_module", - "update_modules", - "regenerate_config", - "set_config" - ] - }, - "uci": ["magicmirror2"] - } + "description": "Grant access to MagicMirror2", + "read": { "ubus": { "luci.magicmirror2": ["status"] }, "uci": ["magicmirror2"] }, + "write": { "ubus": { "luci.magicmirror2": ["install", "start", "stop", "restart"] }, "uci": ["magicmirror2"] } } } diff --git a/package/secubox/luci-app-mailinabox/Makefile b/package/secubox/luci-app-mailinabox/Makefile new file mode 100644 index 00000000..afe24566 --- /dev/null +++ b/package/secubox/luci-app-mailinabox/Makefile @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Copyright (C) 2025 CyberMind.fr + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Mail-in-a-Box +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-mailinabox +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_MAINTAINER:=CyberMind +PKG_LICENSE:=GPL-2.0 + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-mailinabox/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.mailinabox $(1)/usr/libexec/rpcd/luci.mailinabox + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-mailinabox.json $(1)/usr/share/luci/menu.d/luci-app-mailinabox.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-mailinabox.json $(1)/usr/share/rpcd/acl.d/luci-app-mailinabox.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/mailinabox + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/mailinabox/*.js $(1)/www/luci-static/resources/view/mailinabox/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/overview.js b/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/overview.js new file mode 100644 index 00000000..68a536ec --- /dev/null +++ b/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/overview.js @@ -0,0 +1,103 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; +'require poll'; + +var callStatus = rpc.declare({ object: 'luci.mailinabox', method: 'status', expect: {} }); +var callInstall = rpc.declare({ object: 'luci.mailinabox', method: 'install', expect: {} }); +var callStart = rpc.declare({ object: 'luci.mailinabox', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.mailinabox', method: 'stop', expect: {} }); +var callRestart = rpc.declare({ object: 'luci.mailinabox', method: 'restart', expect: {} }); + +var css = '.mb-container{max-width:900px;margin:0 auto}.mb-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mb-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mb-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mb-status.running{background:rgba(16,185,129,.2)}.mb-status.stopped{background:rgba(239,68,68,.2)}.mb-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mb-status.running .mb-dot{background:#10b981}.mb-status.stopped .mb-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mb-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mb-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mb-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mb-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mb-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mb-info-value{font-size:1rem;font-weight:500}.mb-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mb-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mb-btn-primary{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff}.mb-btn-success{background:#10b981;color:#fff}.mb-btn-danger{background:#ef4444;color:#fff}.mb-btn-secondary{background:#6b7280;color:#fff}.mb-btn:disabled{opacity:.5;cursor:not-allowed}.mb-ports{display:flex;gap:1rem;flex-wrap:wrap;margin-top:1rem}.mb-port{padding:.5rem 1rem;background:#e0e7ff;border-radius:8px;font-size:.85rem}.mb-port-name{font-weight:600;color:#3b82f6}.mb-not-installed{text-align:center;padding:3rem}.mb-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0;text-align:left}.mb-feature{padding:.75rem;background:#eff6ff;border-radius:8px;font-size:.9rem}'; + +return view.extend({ + load: function() { return callStatus(); }, + + handleInstall: function() { + ui.showModal(_('Installing Mail Server'), [E('p', { 'class': 'spinning' }, _('Installing...'))]); + callInstall().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', r.message || _('Started'))); + else ui.addNotification(null, E('p', _('Failed: ') + r.error), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStart: function() { + ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]); + callStart().then(function(r) { ui.hideModal(); ui.addNotification(null, E('p', _('Started'))); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStop: function() { + ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]); + callStop().then(function(r) { ui.hideModal(); ui.addNotification(null, E('p', _('Stopped'))); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + render: function(status) { + if (!document.getElementById('mb-mail-styles')) { + var s = document.createElement('style'); s.id = 'mb-mail-styles'; s.textContent = css; document.head.appendChild(s); + } + + if (!status.installed || !status.docker_available) { + return E('div', { 'class': 'mb-container' }, [ + E('div', { 'class': 'mb-header' }, [ + E('h2', {}, ['\ud83d\udce7 ', _('Mail Server')]), + E('div', { 'class': 'mb-status stopped' }, [E('span', { 'class': 'mb-dot' }), E('span', {}, _('Not Installed'))]) + ]), + E('div', { 'class': 'mb-card' }, [ + E('div', { 'class': 'mb-not-installed' }, [ + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\ud83d\udce7'), + E('h3', {}, _('Mail-in-a-Box')), + E('p', {}, _('Self-hosted email server with SMTP, IMAP, spam filtering, and webmail.')), + E('div', { 'class': 'mb-features' }, [ + E('div', { 'class': 'mb-feature' }, '\ud83d\udce4 SMTP'), + E('div', { 'class': 'mb-feature' }, '\ud83d\udce5 IMAP'), + E('div', { 'class': 'mb-feature' }, '\ud83d\udee1 SpamAssassin'), + E('div', { 'class': 'mb-feature' }, '\ud83d\udd12 SSL/TLS'), + E('div', { 'class': 'mb-feature' }, '\ud83c\udf10 Webmail'), + E('div', { 'class': 'mb-feature' }, '\ud83d\udc80 Fail2ban') + ]), + !status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker required')) : '', + E('button', { 'class': 'mb-btn mb-btn-primary', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install Mail Server')) + ]) + ]) + ]); + } + + return E('div', { 'class': 'mb-container' }, [ + E('div', { 'class': 'mb-header' }, [ + E('h2', {}, ['\ud83d\udce7 ', _('Mail Server')]), + E('div', { 'class': 'mb-status ' + (status.running ? 'running' : 'stopped') }, [ + E('span', { 'class': 'mb-dot' }), + E('span', {}, status.running ? _('Running') : _('Stopped')) + ]) + ]), + E('div', { 'class': 'mb-card' }, [ + E('div', { 'class': 'mb-card-title' }, ['\u2139\ufe0f ', _('Configuration')]), + E('div', { 'class': 'mb-info-grid' }, [ + E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Hostname')), E('div', { 'class': 'mb-info-value' }, status.hostname)]), + E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Domain')), E('div', { 'class': 'mb-info-value' }, status.domain)]), + E('div', { 'class': 'mb-info-item' }, [E('div', { 'class': 'mb-info-label' }, _('Data Path')), E('div', { 'class': 'mb-info-value' }, status.data_path)]) + ]), + E('div', { 'class': 'mb-ports' }, [ + E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'SMTP'), ' :' + status.smtp_port]), + E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'IMAP'), ' :' + status.imap_port]), + E('div', { 'class': 'mb-port' }, [E('span', { 'class': 'mb-port-name' }, 'IMAPS'), ' :' + status.imaps_port]) + ]) + ]), + E('div', { 'class': 'mb-card' }, [ + E('div', { 'class': 'mb-card-title' }, ['\u26a1 ', _('Actions')]), + E('div', { 'class': 'mb-actions' }, [ + E('button', { 'class': 'mb-btn mb-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')), + E('button', { 'class': 'mb-btn mb-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop')), + E('a', { 'href': L.url('admin', 'secubox', 'services', 'mailinabox', 'settings'), 'class': 'mb-btn mb-btn-secondary' }, _('Settings')) + ]) + ]) + ]); + }, + + handleSaveApply: null, handleSave: null, handleReset: null +}); diff --git a/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/settings.js b/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/settings.js new file mode 100644 index 00000000..52e1e009 --- /dev/null +++ b/package/secubox/luci-app-mailinabox/htdocs/luci-static/resources/view/mailinabox/settings.js @@ -0,0 +1,75 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; + +return view.extend({ + load: function() { return uci.load('mailinabox'); }, + + render: function() { + var m, s, o; + + m = new form.Map('mailinabox', _('Mail Server Settings'), + _('Configure your mail server. IMPORTANT: Set hostname and domain before installing.')); + + s = m.section(form.TypedSection, 'mailinabox', _('General Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '0'; + + o = s.option(form.Value, 'hostname', _('Mail Hostname'), + _('Full hostname for mail server (e.g., mail.example.com)')); + o.default = 'mail.example.com'; + + o = s.option(form.Value, 'domain', _('Domain'), + _('Primary email domain (e.g., example.com)')); + o.default = 'example.com'; + + o = s.option(form.Value, 'data_path', _('Data Path')); + o.default = '/srv/mailserver'; + + o = s.option(form.Value, 'timezone', _('Timezone')); + o.default = 'UTC'; + + s = m.section(form.TypedSection, 'mailinabox', _('Ports')); + s.anonymous = true; + + o = s.option(form.Value, 'smtp_port', _('SMTP Port')); + o.datatype = 'port'; + o.default = '25'; + + o = s.option(form.Value, 'submission_port', _('Submission Port')); + o.datatype = 'port'; + o.default = '587'; + + o = s.option(form.Value, 'imap_port', _('IMAP Port')); + o.datatype = 'port'; + o.default = '143'; + + o = s.option(form.Value, 'imaps_port', _('IMAPS Port')); + o.datatype = 'port'; + o.default = '993'; + + s = m.section(form.TypedSection, 'mailinabox', _('Features')); + s.anonymous = true; + + o = s.option(form.Flag, 'enable_spamassassin', _('SpamAssassin')); + o.default = '1'; + + o = s.option(form.Flag, 'enable_clamav', _('ClamAV Antivirus')); + o.default = '0'; + + o = s.option(form.Flag, 'enable_fail2ban', _('Fail2ban')); + o.default = '1'; + + o = s.option(form.ListValue, 'ssl_type', _('SSL Type')); + o.value('letsencrypt', _("Let's Encrypt")); + o.value('manual', _('Manual')); + o.value('self-signed', _('Self-signed')); + o.default = 'letsencrypt'; + + return m.render(); + } +}); diff --git a/package/secubox/luci-app-mailinabox/root/usr/libexec/rpcd/luci.mailinabox b/package/secubox/luci-app-mailinabox/root/usr/libexec/rpcd/luci.mailinabox new file mode 100644 index 00000000..38c1d1a3 --- /dev/null +++ b/package/secubox/luci-app-mailinabox/root/usr/libexec/rpcd/luci.mailinabox @@ -0,0 +1,139 @@ +#!/bin/sh +# RPCD backend for Mail-in-a-Box LuCI app + +CONFIG="mailinabox" +CONTAINER="secbx-mailserver" + +uci_get() { uci -q get ${CONFIG}.main.$1; } +uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; } + +get_status() { + local enabled=$(uci_get enabled) + local hostname=$(uci_get hostname) + local domain=$(uci_get domain) + local data_path=$(uci_get data_path) + + local docker_available=0 + command -v docker >/dev/null 2>&1 && docker_available=1 + + local running=0 + local container_status="stopped" + if [ "$docker_available" = "1" ]; then + if docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then + running=1 + container_status="running" + fi + fi + + local installed=0 + if [ "$docker_available" = "1" ]; then + docker images --format "{{.Repository}}" 2>/dev/null | grep -q "docker-mailserver" && installed=1 + fi + + cat </dev/null) + local domain=$(echo "$input" | jsonfilter -e '@.domain' 2>/dev/null) + [ -n "$hostname" ] && uci_set hostname "$hostname" + [ -n "$domain" ] && uci_set domain "$domain" + echo '{"success": true}' +} + +do_install() { + if command -v mailinaboxctl >/dev/null 2>&1; then + mailinaboxctl install >/tmp/mailinabox-install.log 2>&1 & + echo '{"success": true, "message": "Installation started"}' + else + echo '{"success": false, "error": "mailinaboxctl not found"}' + fi +} + +do_start() { + [ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox start >/dev/null 2>&1 && uci_set enabled '1' + echo '{"success": true}' +} + +do_stop() { + [ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox stop >/dev/null 2>&1 + echo '{"success": true}' +} + +do_restart() { + [ -x /etc/init.d/mailinabox ] && /etc/init.d/mailinabox restart >/dev/null 2>&1 + echo '{"success": true}' +} + +get_logs() { + local log_content="" + [ -f /tmp/mailinabox-install.log ] && log_content=$(tail -n 50 /tmp/mailinabox-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|') + echo "{\"logs\": \"$log_content\"}" +} + +list_methods() { + cat <<'EOF' +{ + "status": {}, + "get_config": {}, + "save_config": {"hostname": "string", "domain": "string"}, + "install": {}, + "start": {}, + "stop": {}, + "restart": {}, + "logs": {} +} +EOF +} + +case "$1" in + list) list_methods ;; + call) + case "$2" in + status) get_status ;; + get_config) get_config ;; + save_config) save_config ;; + install) do_install ;; + start) do_start ;; + stop) do_stop ;; + restart) do_restart ;; + logs) get_logs ;; + *) echo '{"error": "Unknown method"}' ;; + esac + ;; + *) echo '{"error": "Unknown command"}' ;; +esac diff --git a/package/secubox/luci-app-mailinabox/root/usr/share/luci/menu.d/luci-app-mailinabox.json b/package/secubox/luci-app-mailinabox/root/usr/share/luci/menu.d/luci-app-mailinabox.json new file mode 100644 index 00000000..76f8265b --- /dev/null +++ b/package/secubox/luci-app-mailinabox/root/usr/share/luci/menu.d/luci-app-mailinabox.json @@ -0,0 +1,28 @@ +{ + "admin/secubox/services/mailinabox": { + "title": "Mail Server", + "order": 60, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-mailinabox"] + } + }, + "admin/secubox/services/mailinabox/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "mailinabox/overview" + } + }, + "admin/secubox/services/mailinabox/settings": { + "title": "Settings", + "order": 90, + "action": { + "type": "view", + "path": "mailinabox/settings" + } + } +} diff --git a/package/secubox/luci-app-mailinabox/root/usr/share/rpcd/acl.d/luci-app-mailinabox.json b/package/secubox/luci-app-mailinabox/root/usr/share/rpcd/acl.d/luci-app-mailinabox.json new file mode 100644 index 00000000..dfc8c29e --- /dev/null +++ b/package/secubox/luci-app-mailinabox/root/usr/share/rpcd/acl.d/luci-app-mailinabox.json @@ -0,0 +1,17 @@ +{ + "luci-app-mailinabox": { + "description": "Grant access to Mail-in-a-Box", + "read": { + "ubus": { + "luci.mailinabox": ["status", "get_config", "logs"] + }, + "uci": ["mailinabox"] + }, + "write": { + "ubus": { + "luci.mailinabox": ["install", "start", "stop", "restart", "update", "save_config", "add_account", "list_accounts"] + }, + "uci": ["mailinabox"] + } + } +} diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/overview.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/overview.js index 4cc12b3d..effaa396 100644 --- a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/overview.js +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/overview.js @@ -2,629 +2,318 @@ 'require view'; 'require ui'; 'require rpc'; -'require poll'; +'require fs'; 'require metablogizer/qrcode as qrcode'; -var callStatus = rpc.declare({ - object: 'luci.metablogizer', - method: 'status', - expect: {} -}); +var callStatus = rpc.declare({ object: 'luci.metablogizer', method: 'status', expect: {} }); +var callListSites = rpc.declare({ object: 'luci.metablogizer', method: 'list_sites', expect: { sites: [] } }); +var callCreateSite = rpc.declare({ object: 'luci.metablogizer', method: 'create_site', params: ['name', 'domain', 'gitea_repo', 'ssl', 'description'], expect: {} }); +var callUpdateSite = rpc.declare({ object: 'luci.metablogizer', method: 'update_site', params: ['id', 'name', 'domain', 'gitea_repo', 'ssl', 'enabled', 'description'], expect: {} }); +var callDeleteSite = rpc.declare({ object: 'luci.metablogizer', method: 'delete_site', params: ['id'], expect: {} }); +var callSyncSite = rpc.declare({ object: 'luci.metablogizer', method: 'sync_site', params: ['id'], expect: {} }); -var callListSites = rpc.declare({ - object: 'luci.metablogizer', - method: 'list_sites', - expect: { sites: [] } -}); +var SITES_ROOT = '/srv/metablogizer/sites'; -var callCreateSite = rpc.declare({ - object: 'luci.metablogizer', - method: 'create_site', - params: ['name', 'domain', 'gitea_repo', 'ssl', 'description'], - expect: {} -}); - -var callDeleteSite = rpc.declare({ - object: 'luci.metablogizer', - method: 'delete_site', - params: ['id'], - expect: {} -}); - -var callSyncSite = rpc.declare({ - object: 'luci.metablogizer', - method: 'sync_site', - params: ['id'], - expect: {} -}); - -var callGetPublishInfo = rpc.declare({ - object: 'luci.metablogizer', - method: 'get_publish_info', - params: ['id'], - expect: {} -}); - -// CSS Styles for SecuBox Light Theme -var styles = '\ -.mb-container { max-width: 1200px; margin: 0 auto; } \ -.mb-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding: 1rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white; } \ -.mb-header h2 { margin: 0; font-size: 1.5rem; } \ -.mb-status-pills { display: flex; gap: 0.75rem; } \ -.mb-pill { padding: 0.4rem 0.8rem; border-radius: 20px; font-size: 0.85rem; background: rgba(255,255,255,0.2); } \ -.mb-pill.active { background: rgba(255,255,255,0.95); color: #667eea; } \ -.mb-btn-primary { background: white; color: #667eea; border: none; padding: 0.6rem 1.2rem; border-radius: 8px; cursor: pointer; font-weight: 600; transition: transform 0.2s, box-shadow 0.2s; } \ -.mb-btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } \ -.mb-sites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.25rem; } \ -.mb-site-card { background: white; border-radius: 12px; padding: 1.25rem; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border: 1px solid #e8e8e8; transition: transform 0.2s, box-shadow 0.2s; } \ -.mb-site-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); } \ -.mb-site-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; } \ -.mb-site-name { font-size: 1.15rem; font-weight: 600; color: #333; margin: 0; } \ -.mb-site-status { padding: 0.25rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 500; } \ -.mb-site-status.online { background: #d4edda; color: #155724; } \ -.mb-site-status.offline { background: #f8d7da; color: #721c24; } \ -.mb-site-domain { color: #667eea; font-size: 0.9rem; margin-bottom: 0.5rem; word-break: break-all; } \ -.mb-site-domain a { color: inherit; text-decoration: none; } \ -.mb-site-domain a:hover { text-decoration: underline; } \ -.mb-site-meta { font-size: 0.8rem; color: #888; margin-bottom: 1rem; } \ -.mb-site-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } \ -.mb-btn { padding: 0.4rem 0.8rem; border-radius: 6px; border: 1px solid #ddd; background: #f8f9fa; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; } \ -.mb-btn:hover { background: #e9ecef; } \ -.mb-btn-share { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; } \ -.mb-btn-share:hover { opacity: 0.9; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } \ -.mb-btn-sync { background: #28a745; color: white; border: none; } \ -.mb-btn-sync:hover { background: #218838; } \ -.mb-btn-delete { background: #dc3545; color: white; border: none; } \ -.mb-btn-delete:hover { background: #c82333; } \ -.mb-empty-state { text-align: center; padding: 4rem 2rem; background: white; border-radius: 12px; border: 2px dashed #ddd; } \ -.mb-empty-state h3 { color: #666; margin-bottom: 0.5rem; } \ -.mb-empty-state p { color: #888; margin-bottom: 1.5rem; } \ -.mb-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 10000; } \ -.mb-modal { background: white; border-radius: 16px; max-width: 400px; width: 90%; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); } \ -.mb-modal-header { padding: 1.25rem; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; } \ -.mb-modal-header h3 { margin: 0; color: #333; } \ -.mb-modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #888; padding: 0; line-height: 1; } \ -.mb-modal-close:hover { color: #333; } \ -.mb-modal-body { padding: 1.25rem; } \ -.mb-form-group { margin-bottom: 1rem; } \ -.mb-form-group label { display: block; margin-bottom: 0.4rem; font-weight: 500; color: #333; font-size: 0.9rem; } \ -.mb-form-group input, .mb-form-group textarea { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid #ddd; border-radius: 8px; font-size: 0.95rem; transition: border-color 0.2s; box-sizing: border-box; } \ -.mb-form-group input:focus, .mb-form-group textarea:focus { border-color: #667eea; outline: none; box-shadow: 0 0 0 3px rgba(102,126,234,0.1); } \ -.mb-form-group textarea { resize: vertical; min-height: 60px; } \ -.mb-form-group small { color: #888; font-size: 0.8rem; } \ -.mb-form-checkbox { display: flex; align-items: center; gap: 0.5rem; } \ -.mb-form-checkbox input { width: auto; } \ -.mb-modal-footer { padding: 1rem 1.25rem; border-top: 1px solid #eee; display: flex; justify-content: flex-end; gap: 0.75rem; } \ -.mb-btn-cancel { background: #f8f9fa; color: #333; } \ -.mb-btn-submit { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; font-weight: 600; } \ -.mb-published-card { text-align: center; } \ -.mb-url-box { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; } \ -.mb-url-box input { flex: 1; padding: 0.6rem; border: 1px solid #ddd; border-radius: 8px; font-family: monospace; font-size: 0.9rem; background: #f8f9fa; } \ -.mb-url-box button { padding: 0.6rem 1rem; border: none; background: #667eea; color: white; border-radius: 8px; cursor: pointer; } \ -.mb-qr-container { margin: 1.25rem 0; padding: 1rem; background: #f8f9fa; border-radius: 12px; display: inline-block; } \ -.mb-share-buttons { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.25rem; } \ -.mb-share-btn { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; text-decoration: none; color: white; font-weight: bold; font-size: 1.1rem; transition: transform 0.2s, box-shadow 0.2s; } \ -.mb-share-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } \ -.mb-share-twitter { background: #1da1f2; } \ -.mb-share-linkedin { background: #0077b5; } \ -.mb-share-facebook { background: #1877f2; } \ -.mb-share-telegram { background: #0088cc; } \ -.mb-share-whatsapp { background: #25d366; } \ -.mb-share-email { background: #666; } \ -.mb-dropzone { border: 2px dashed #ddd; border-radius: 12px; padding: 2rem; text-align: center; margin-bottom: 1rem; transition: all 0.3s; cursor: pointer; } \ -.mb-dropzone:hover, .mb-dropzone.dragover { border-color: #667eea; background: rgba(102,126,234,0.05); } \ -.mb-dropzone-icon { font-size: 2.5rem; margin-bottom: 0.5rem; } \ -.mb-dropzone-text { color: #666; } \ -.mb-dropzone-text strong { color: #667eea; } \ -.mb-file-list { margin-top: 1rem; } \ -.mb-file-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: #f8f9fa; border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.85rem; } \ -.mb-file-item-remove { background: none; border: none; color: #dc3545; cursor: pointer; padding: 0.25rem; } \ -@media (max-width: 600px) { \ - .mb-header { flex-direction: column; gap: 1rem; text-align: center; } \ - .mb-sites-grid { grid-template-columns: 1fr; } \ - .mb-share-btn { width: 40px; height: 40px; font-size: 1rem; } \ -}'; +var styles = '.mb-container{max-width:1200px;margin:0 auto}.mb-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem;padding:1rem;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);border-radius:12px;color:#fff}.mb-header h2{margin:0;font-size:1.5rem}.mb-status-pills{display:flex;gap:.75rem}.mb-pill{padding:.4rem .8rem;border-radius:20px;font-size:.85rem;background:rgba(255,255,255,.2)}.mb-pill.active{background:rgba(255,255,255,.95);color:#667eea}.mb-btn-primary{background:#fff;color:#667eea;border:none;padding:.6rem 1.2rem;border-radius:8px;cursor:pointer;font-weight:600;transition:transform .2s}.mb-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.15)}.mb-sites-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:1.25rem}.mb-site-card{background:#fff;border-radius:12px;padding:1.25rem;box-shadow:0 2px 12px rgba(0,0,0,.08);border:1px solid #e8e8e8;transition:transform .2s}.mb-site-card:hover{transform:translateY(-4px);box-shadow:0 8px 24px rgba(0,0,0,.12)}.mb-site-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}.mb-site-name{font-size:1.15rem;font-weight:600;color:#333;margin:0}.mb-site-status{padding:.25rem .6rem;border-radius:12px;font-size:.75rem;font-weight:500}.mb-site-status.online{background:#d4edda;color:#155724}.mb-site-status.offline{background:#f8d7da;color:#721c24}.mb-site-domain{color:#667eea;font-size:.9rem;margin-bottom:.5rem;word-break:break-all}.mb-site-domain a{color:inherit;text-decoration:none}.mb-site-domain a:hover{text-decoration:underline}.mb-site-meta{font-size:.8rem;color:#888;margin-bottom:1rem}.mb-site-actions{display:flex;gap:.4rem;flex-wrap:wrap}.mb-btn{padding:.35rem .6rem;border-radius:6px;border:1px solid #ddd;background:#f8f9fa;cursor:pointer;font-size:.8rem;transition:all .2s}.mb-btn:hover{background:#e9ecef}.mb-btn-share{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;border:none}.mb-btn-upload{background:#17a2b8;color:#fff;border:none}.mb-btn-upload:hover{background:#138496}.mb-btn-files{background:#6c757d;color:#fff;border:none}.mb-btn-files:hover{background:#5a6268}.mb-btn-edit{background:#fd7e14;color:#fff;border:none}.mb-btn-edit:hover{background:#e96b02}.mb-btn-sync{background:#28a745;color:#fff;border:none}.mb-btn-sync:hover{background:#218838}.mb-btn-delete,.mb-btn-danger{background:#dc3545;color:#fff;border:none}.mb-btn-delete:hover,.mb-btn-danger:hover{background:#c82333}.mb-empty-state{text-align:center;padding:4rem 2rem;background:#fff;border-radius:12px;border:2px dashed #ddd}.mb-empty-state h3{color:#666;margin-bottom:.5rem}.mb-empty-state p{color:#888;margin-bottom:1.5rem}.mb-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;justify-content:center;align-items:center;z-index:10000}.mb-modal{background:#fff;border-radius:16px;max-width:500px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.3)}.mb-modal-header{padding:1.25rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}.mb-modal-header h3{margin:0;color:#333}.mb-modal-close{background:none;border:none;font-size:1.5rem;cursor:pointer;color:#888;padding:0;line-height:1}.mb-modal-close:hover{color:#333}.mb-modal-body{padding:1.25rem}.mb-form-group{margin-bottom:1rem}.mb-form-group label{display:block;margin-bottom:.4rem;font-weight:500;color:#333;font-size:.9rem}.mb-form-group input,.mb-form-group textarea{width:100%;padding:.6rem .8rem;border:1px solid #ddd;border-radius:8px;font-size:.95rem;box-sizing:border-box}.mb-form-group input:focus,.mb-form-group textarea:focus{border-color:#667eea;outline:none}.mb-form-group textarea{resize:vertical;min-height:60px}.mb-form-group small{color:#888;font-size:.8rem}.mb-form-checkbox{display:flex;align-items:center;gap:.5rem}.mb-form-checkbox input{width:auto}.mb-modal-footer{padding:1rem 1.25rem;border-top:1px solid #eee;display:flex;justify-content:flex-end;gap:.75rem}.mb-btn-cancel{background:#f8f9fa;color:#333}.mb-btn-submit{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;border:none;font-weight:600}.mb-published-card{text-align:center}.mb-url-box{display:flex;gap:.5rem;margin-bottom:1.25rem}.mb-url-box input{flex:1;padding:.6rem;border:1px solid #ddd;border-radius:8px;font-family:monospace;font-size:.9rem;background:#f8f9fa}.mb-url-box button{padding:.6rem 1rem;border:none;background:#667eea;color:#fff;border-radius:8px;cursor:pointer}.mb-qr-container{margin:1.25rem 0;padding:1rem;background:#f8f9fa;border-radius:12px;display:inline-block}.mb-share-buttons{display:flex;justify-content:center;gap:.75rem;flex-wrap:wrap;margin-top:1.25rem}.mb-share-btn{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;text-decoration:none;color:#fff;font-weight:700;font-size:1.1rem;transition:transform .2s}.mb-share-btn:hover{transform:scale(1.1)}.mb-share-twitter{background:#1da1f2}.mb-share-linkedin{background:#0077b5}.mb-share-facebook{background:#1877f2}.mb-share-telegram{background:#0088cc}.mb-share-whatsapp{background:#25d366}.mb-share-email{background:#666}.mb-dropzone{border:2px dashed #ddd;border-radius:12px;padding:2rem;text-align:center;margin-bottom:1rem;cursor:pointer}.mb-dropzone:hover,.mb-dropzone.dragover{border-color:#667eea;background:rgba(102,126,234,.05)}.mb-dropzone-icon{font-size:2.5rem;margin-bottom:.5rem}.mb-dropzone-text{color:#666}.mb-dropzone-text strong{color:#667eea}.mb-file-list{margin-top:1rem;max-height:200px;overflow-y:auto}.mb-file-item{display:flex;align-items:center;gap:.5rem;padding:.5rem;background:#f8f9fa;border-radius:6px;margin-bottom:.5rem;font-size:.85rem}.mb-file-item-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mb-file-item-size{color:#888;font-size:.8rem}.mb-file-item-actions{display:flex;gap:.25rem}.mb-file-item-btn{background:none;border:none;cursor:pointer;padding:.25rem;font-size:.9rem;opacity:.7}.mb-file-item-btn:hover{opacity:1}.mb-file-item-btn.delete{color:#dc3545}.mb-file-item-btn.home{color:#28a745}.mb-cache-hint{background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:.75rem;margin-top:1rem;font-size:.85rem;color:#856404}@media(max-width:600px){.mb-header{flex-direction:column;gap:1rem}.mb-sites-grid{grid-template-columns:1fr}}'; return view.extend({ - load: function() { - return Promise.all([ - callStatus(), - callListSites() - ]); - }, + uploadFiles: [], + currentSite: null, + + load: function() { return Promise.all([callStatus(), callListSites()]); }, render: function(data) { - var self = this; - var status = data[0] || {}; - var sites = data[1] || []; - - // Inject styles - var styleEl = document.createElement('style'); - styleEl.textContent = styles; - document.head.appendChild(styleEl); - - var view = E('div', { 'class': 'mb-container' }, [ - // Header with status + var self = this, status = data[0] || {}, sites = data[1] || []; + if (!document.getElementById('mb-styles')) { + var s = document.createElement('style'); s.id = 'mb-styles'; s.textContent = styles; document.head.appendChild(s); + } + return E('div', { 'class': 'mb-container' }, [ E('div', { 'class': 'mb-header' }, [ E('div', {}, [ E('h2', {}, _('MetaBlogizer')), E('div', { 'class': 'mb-status-pills' }, [ - E('span', { 'class': 'mb-pill' + (status.nginx_running ? ' active' : '') }, - status.nginx_running ? _('Nginx Running') : _('Nginx Stopped')), - E('span', { 'class': 'mb-pill' }, - String(status.site_count || 0) + ' ' + _('Sites')) + E('span', { 'class': 'mb-pill active' }, status.detected_runtime || 'uhttpd'), + E('span', { 'class': 'mb-pill' }, (status.site_count || sites.length || 0) + ' ' + _('Sites')) ]) ]), - E('button', { - 'class': 'mb-btn-primary', - 'click': ui.createHandlerFn(this, 'showPublishModal') - }, _('+ New Site')) + E('button', { 'class': 'mb-btn-primary', 'click': ui.createHandlerFn(this, 'showCreateModal') }, _('+ New Site')) ]), - - // Sites grid or empty state - sites.length > 0 ? - E('div', { 'class': 'mb-sites-grid' }, - sites.map(function(site) { - return self.renderSiteCard(site); - }) - ) : + sites.length > 0 ? E('div', { 'class': 'mb-sites-grid' }, sites.map(function(site) { return self.renderSiteCard(site); })) : E('div', { 'class': 'mb-empty-state' }, [ - E('div', { 'style': 'font-size: 3rem; margin-bottom: 1rem;' }, '\u{1F310}'), + E('div', { 'style': 'font-size:3rem;margin-bottom:1rem' }, '\u{1F310}'), E('h3', {}, _('No Sites Yet')), - E('p', {}, _('Create your first static site with one click')), - E('button', { - 'class': 'mb-btn-primary', - 'style': 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;', - 'click': ui.createHandlerFn(this, 'showPublishModal') - }, _('Create Site')) + E('p', {}, _('Create your first static site')), + E('button', { 'class': 'mb-btn-primary', 'style': 'background:linear-gradient(135deg,#667eea,#764ba2);color:#fff', 'click': ui.createHandlerFn(this, 'showCreateModal') }, _('Create Site')) ]) ]); - - return view; }, renderSiteCard: function(site) { - var self = this; - var hasContent = site.has_content; - var statusClass = hasContent ? 'online' : 'offline'; - var statusText = hasContent ? _('Published') : _('Pending'); - return E('div', { 'class': 'mb-site-card' }, [ E('div', { 'class': 'mb-site-header' }, [ E('h4', { 'class': 'mb-site-name' }, site.name), - E('span', { 'class': 'mb-site-status ' + statusClass }, statusText) + E('span', { 'class': 'mb-site-status ' + (site.has_content ? 'online' : 'offline') }, site.has_content ? _('Published') : _('Pending')) ]), - E('div', { 'class': 'mb-site-domain' }, [ - E('a', { 'href': site.url, 'target': '_blank' }, site.domain) - ]), - site.last_sync ? - E('div', { 'class': 'mb-site-meta' }, _('Last sync: ') + site.last_sync) : - E('div', { 'class': 'mb-site-meta' }, _('Not synced yet')), + E('div', { 'class': 'mb-site-domain' }, [E('a', { 'href': site.url, 'target': '_blank' }, site.domain)]), + E('div', { 'class': 'mb-site-meta' }, site.last_sync ? _('Last sync: ') + site.last_sync : _('Not synced yet')), E('div', { 'class': 'mb-site-actions' }, [ - E('button', { - 'class': 'mb-btn mb-btn-share', - 'click': ui.createHandlerFn(this, 'showPublishedModal', site) - }, _('Share')), - E('button', { - 'class': 'mb-btn mb-btn-sync', - 'click': ui.createHandlerFn(this, 'handleSync', site) - }, _('Sync')), - E('button', { - 'class': 'mb-btn mb-btn-delete', - 'click': ui.createHandlerFn(this, 'handleDelete', site) - }, _('Delete')) + E('button', { 'class': 'mb-btn mb-btn-share', 'click': ui.createHandlerFn(this, 'showShareModal', site) }, _('Share')), + E('button', { 'class': 'mb-btn mb-btn-upload', 'click': ui.createHandlerFn(this, 'showUploadModal', site) }, _('Upload')), + E('button', { 'class': 'mb-btn mb-btn-files', 'click': ui.createHandlerFn(this, 'showFilesModal', site) }, _('Files')), + E('button', { 'class': 'mb-btn mb-btn-edit', 'click': ui.createHandlerFn(this, 'showEditModal', site) }, _('Edit')), + E('button', { 'class': 'mb-btn mb-btn-sync', 'click': ui.createHandlerFn(this, 'handleSync', site) }, _('Sync')), + E('button', { 'class': 'mb-btn mb-btn-delete', 'click': ui.createHandlerFn(this, 'handleDelete', site) }, _('Delete')) ]) ]); }, - showPublishModal: function() { + showCreateModal: function() { var self = this; - - var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-publish-modal' }, [ + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-create-modal' }, [ E('div', { 'class': 'mb-modal' }, [ - E('div', { 'class': 'mb-modal-header' }, [ - E('h3', {}, _('Quick Publish')), - E('button', { - 'class': 'mb-modal-close', - 'click': function() { self.closeModal('mb-publish-modal'); } - }, '\u00D7') - ]), + E('div', { 'class': 'mb-modal-header' }, [E('h3', {}, _('Create New Site')), E('button', { 'class': 'mb-modal-close', 'click': function() { self.closeModal('mb-create-modal'); } }, '\u00D7')]), E('div', { 'class': 'mb-modal-body' }, [ - // Drag and drop zone - E('div', { - 'class': 'mb-dropzone', - 'id': 'mb-dropzone', - 'click': function() { document.getElementById('mb-file-input').click(); } - }, [ - E('div', { 'class': 'mb-dropzone-icon' }, '\u{1F4C1}'), - E('div', { 'class': 'mb-dropzone-text' }, [ - E('strong', {}, _('Drop files here')), - E('br'), - E('span', {}, _('or click to browse')) - ]) - ]), - E('input', { - 'type': 'file', - 'id': 'mb-file-input', - 'multiple': true, - 'style': 'display: none;', - 'change': function(ev) { self.handleFileSelect(ev); } - }), - E('div', { 'class': 'mb-file-list', 'id': 'mb-file-list' }), - - E('div', { 'class': 'mb-form-group' }, [ - E('label', {}, _('Site Name')), - E('input', { - 'type': 'text', - 'id': 'mb-site-name', - 'placeholder': 'myblog' - }), - E('small', {}, _('Lowercase letters, numbers, hyphens only')) - ]), - E('div', { 'class': 'mb-form-group' }, [ - E('label', {}, _('Domain')), - E('input', { - 'type': 'text', - 'id': 'mb-site-domain', - 'placeholder': 'blog.example.com' - }) - ]), - E('div', { 'class': 'mb-form-group' }, [ - E('label', {}, _('Gitea Repository (optional)')), - E('input', { - 'type': 'text', - 'id': 'mb-gitea-repo', - 'placeholder': 'user/repo' - }), - E('small', {}, _('Leave empty to upload files directly')) - ]), - E('div', { 'class': 'mb-form-group' }, [ - E('label', {}, _('Description (optional)')), - E('textarea', { - 'id': 'mb-site-description', - 'placeholder': 'A short description for social previews' - }) - ]), - E('div', { 'class': 'mb-form-group' }, [ - E('label', { 'class': 'mb-form-checkbox' }, [ - E('input', { - 'type': 'checkbox', - 'id': 'mb-site-ssl', - 'checked': true - }), - E('span', {}, _('Enable SSL (HTTPS with auto ACME)')) - ]) - ]) + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Site Name')), E('input', { 'type': 'text', 'id': 'mb-site-name', 'placeholder': 'myblog' }), E('small', {}, _('Lowercase, numbers, hyphens'))]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Domain')), E('input', { 'type': 'text', 'id': 'mb-site-domain', 'placeholder': 'blog.example.com' })]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Gitea Repository')), E('input', { 'type': 'text', 'id': 'mb-gitea-repo', 'placeholder': 'user/repo (optional)' })]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Description')), E('textarea', { 'id': 'mb-site-description', 'placeholder': 'Short description (optional)' })]), + E('div', { 'class': 'mb-form-group' }, [E('label', { 'class': 'mb-form-checkbox' }, [E('input', { 'type': 'checkbox', 'id': 'mb-site-ssl', 'checked': true }), E('span', {}, _('Enable HTTPS'))])]) ]), E('div', { 'class': 'mb-modal-footer' }, [ - E('button', { - 'class': 'mb-btn mb-btn-cancel', - 'click': function() { self.closeModal('mb-publish-modal'); } - }, _('Cancel')), - E('button', { - 'class': 'mb-btn mb-btn-submit', - 'click': ui.createHandlerFn(this, 'handlePublish') - }, _('Publish')) + E('button', { 'class': 'mb-btn mb-btn-cancel', 'click': function() { self.closeModal('mb-create-modal'); } }, _('Cancel')), + E('button', { 'class': 'mb-btn mb-btn-submit', 'click': ui.createHandlerFn(this, 'handleCreate') }, _('Create')) ]) ]) ]); - document.body.appendChild(modal); - - // Setup drag and drop - var dropzone = document.getElementById('mb-dropzone'); - ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(eventName) { - dropzone.addEventListener(eventName, function(e) { - e.preventDefault(); - e.stopPropagation(); - }); - }); - ['dragenter', 'dragover'].forEach(function(eventName) { - dropzone.addEventListener(eventName, function() { - dropzone.classList.add('dragover'); - }); - }); - ['dragleave', 'drop'].forEach(function(eventName) { - dropzone.addEventListener(eventName, function() { - dropzone.classList.remove('dragover'); - }); - }); - dropzone.addEventListener('drop', function(e) { - var files = e.dataTransfer.files; - self.handleDroppedFiles(files); - }); }, - selectedFiles: [], - - handleFileSelect: function(ev) { - var files = ev.target.files; - this.handleDroppedFiles(files); - }, - - handleDroppedFiles: function(files) { - var self = this; - for (var i = 0; i < files.length; i++) { - this.selectedFiles.push(files[i]); - } - this.updateFileList(); - }, - - updateFileList: function() { - var self = this; - var container = document.getElementById('mb-file-list'); - if (!container) return; - - container.innerHTML = ''; - this.selectedFiles.forEach(function(file, index) { - var item = E('div', { 'class': 'mb-file-item' }, [ - E('span', {}, '\u{1F4C4}'), - E('span', { 'style': 'flex: 1;' }, file.name), - E('span', { 'style': 'color: #888;' }, self.formatFileSize(file.size)), - E('button', { - 'class': 'mb-file-item-remove', - 'click': function() { self.removeFile(index); } - }, '\u00D7') - ]); - container.appendChild(item); - }); - }, - - removeFile: function(index) { - this.selectedFiles.splice(index, 1); - this.updateFileList(); - }, - - formatFileSize: function(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - }, - - handlePublish: function() { + handleCreate: function() { var self = this; var name = document.getElementById('mb-site-name').value.trim(); var domain = document.getElementById('mb-site-domain').value.trim(); - var gitea_repo = document.getElementById('mb-gitea-repo').value.trim(); - var description = document.getElementById('mb-site-description').value.trim(); + var gitea = document.getElementById('mb-gitea-repo').value.trim(); + var desc = document.getElementById('mb-site-description').value.trim(); var ssl = document.getElementById('mb-site-ssl').checked ? '1' : '0'; - - if (!name || !domain) { - ui.addNotification(null, E('p', _('Name and domain are required')), 'error'); - return; - } - - // Validate name format - if (!/^[a-z0-9-]+$/.test(name)) { - ui.addNotification(null, E('p', _('Site name must be lowercase letters, numbers, and hyphens only')), 'error'); - return; - } - - this.closeModal('mb-publish-modal'); - ui.showModal(_('Publishing...'), [ - E('p', { 'class': 'spinning' }, _('Creating site and configuring services...')) - ]); - - callCreateSite(name, domain, gitea_repo, ssl, description) - .then(function(result) { - ui.hideModal(); - self.selectedFiles = []; // Clear selected files - if (result.success) { - self.showPublishedModal({ - id: result.id, - name: result.name, - domain: result.domain, - url: result.url, - description: description - }); - } else { - ui.addNotification(null, E('p', _('Failed: ') + result.error), 'error'); - } - }) - .catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); + if (!name || !domain) { ui.addNotification(null, E('p', _('Name and domain required')), 'error'); return; } + if (!/^[a-z0-9-]+$/.test(name)) { ui.addNotification(null, E('p', _('Invalid name format')), 'error'); return; } + this.closeModal('mb-create-modal'); + ui.showModal(_('Creating...'), [E('p', { 'class': 'spinning' }, _('Setting up site...'))]); + callCreateSite(name, domain, gitea, ssl, desc).then(function(r) { + ui.hideModal(); + if (r.success) { self.showShareModal({ name: r.name, domain: r.domain, url: r.url }); setTimeout(function() { window.location.reload(); }, 100); } + else { ui.addNotification(null, E('p', _('Failed: ') + r.error), 'error'); } + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); }, - showPublishedModal: function(site) { + showEditModal: function(site) { var self = this; - var url = site.url || ('https://' + site.domain); - var title = site.name + ' - Published with SecuBox'; - var encodedUrl = encodeURIComponent(url); - var encodedTitle = encodeURIComponent(title); - - // Generate QR code - var qrSvg = qrcode.generateSVG(url, 180); - - var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-published-modal' }, [ + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-edit-modal' }, [ E('div', { 'class': 'mb-modal' }, [ - E('div', { 'class': 'mb-modal-header' }, [ - E('h3', {}, '\u{2705} ' + _('Site Published!')), - E('button', { - 'class': 'mb-modal-close', - 'click': function() { - self.closeModal('mb-published-modal'); - window.location.reload(); - } - }, '\u00D7') + E('div', { 'class': 'mb-modal-header' }, [E('h3', {}, _('Edit: ') + site.name), E('button', { 'class': 'mb-modal-close', 'click': function() { self.closeModal('mb-edit-modal'); } }, '\u00D7')]), + E('div', { 'class': 'mb-modal-body' }, [ + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Site Name')), E('input', { 'type': 'text', 'id': 'mb-edit-name', 'value': site.name, 'readonly': true, 'style': 'background:#eee' }), E('small', {}, _('Cannot be changed'))]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Domain')), E('input', { 'type': 'text', 'id': 'mb-edit-domain', 'value': site.domain || '' })]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Gitea Repository')), E('input', { 'type': 'text', 'id': 'mb-edit-gitea', 'value': site.gitea_repo || '', 'placeholder': 'user/repo' })]), + E('div', { 'class': 'mb-form-group' }, [E('label', {}, _('Description')), E('textarea', { 'id': 'mb-edit-description' }, site.description || '')]), + E('div', { 'class': 'mb-form-group' }, [E('label', { 'class': 'mb-form-checkbox' }, [E('input', { 'type': 'checkbox', 'id': 'mb-edit-ssl', 'checked': site.ssl }), E('span', {}, _('Enable HTTPS'))])]), + E('div', { 'class': 'mb-form-group' }, [E('label', { 'class': 'mb-form-checkbox' }, [E('input', { 'type': 'checkbox', 'id': 'mb-edit-enabled', 'checked': site.enabled !== false }), E('span', {}, _('Site enabled'))])]) ]), + E('div', { 'class': 'mb-modal-footer' }, [ + E('button', { 'class': 'mb-btn mb-btn-cancel', 'click': function() { self.closeModal('mb-edit-modal'); } }, _('Cancel')), + E('button', { 'class': 'mb-btn mb-btn-submit', 'click': ui.createHandlerFn(this, 'handleUpdate', site) }, _('Save')) + ]) + ]) + ]); + document.body.appendChild(modal); + }, + + handleUpdate: function(site) { + var domain = document.getElementById('mb-edit-domain').value.trim(); + var gitea = document.getElementById('mb-edit-gitea').value.trim(); + var desc = document.getElementById('mb-edit-description').value.trim(); + var ssl = document.getElementById('mb-edit-ssl').checked ? '1' : '0'; + var enabled = document.getElementById('mb-edit-enabled').checked ? '1' : '0'; + if (!domain) { ui.addNotification(null, E('p', _('Domain required')), 'error'); return; } + this.closeModal('mb-edit-modal'); + ui.showModal(_('Saving...'), [E('p', { 'class': 'spinning' }, _('Updating...'))]); + callUpdateSite(site.id, site.name, domain, gitea, ssl, enabled, desc).then(function(r) { + ui.hideModal(); + if (r.success) { ui.addNotification(null, E('p', _('Site updated'))); window.location.reload(); } + else { ui.addNotification(null, E('p', _('Failed: ') + r.error), 'error'); } + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); + }, + + showUploadModal: function(site) { + var self = this; this.uploadFiles = []; this.currentSite = site; + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-upload-modal' }, [ + E('div', { 'class': 'mb-modal' }, [ + E('div', { 'class': 'mb-modal-header' }, [E('h3', {}, _('Upload to ') + site.name), E('button', { 'class': 'mb-modal-close', 'click': function() { self.closeModal('mb-upload-modal'); } }, '\u00D7')]), + E('div', { 'class': 'mb-modal-body' }, [ + E('div', { 'class': 'mb-dropzone', 'id': 'mb-dropzone', 'click': function() { document.getElementById('mb-file-input').click(); } }, [ + E('div', { 'class': 'mb-dropzone-icon' }, '\u{1F4C1}'), + E('div', { 'class': 'mb-dropzone-text' }, [E('strong', {}, _('Drop files here')), E('br'), _('or click to browse')]) + ]), + E('input', { 'type': 'file', 'id': 'mb-file-input', 'multiple': true, 'style': 'display:none', 'change': function(e) { self.handleFileSelect(e); } }), + E('div', { 'class': 'mb-file-list', 'id': 'mb-file-list' }), + E('div', { 'class': 'mb-form-group', 'style': 'margin-top:1rem' }, [E('label', { 'class': 'mb-form-checkbox' }, [E('input', { 'type': 'checkbox', 'id': 'mb-as-index', 'checked': true }), E('span', {}, _('Set first HTML as homepage'))])]), + E('div', { 'class': 'mb-cache-hint' }, _('After upload, Ctrl+Shift+R to refresh.')) + ]), + E('div', { 'class': 'mb-modal-footer' }, [ + E('button', { 'class': 'mb-btn mb-btn-cancel', 'click': function() { self.closeModal('mb-upload-modal'); } }, _('Cancel')), + E('button', { 'class': 'mb-btn mb-btn-submit', 'click': ui.createHandlerFn(this, 'handleUpload') }, _('Upload')) + ]) + ]) + ]); + document.body.appendChild(modal); + this.setupDropzone('mb-dropzone'); + }, + + handleUpload: function() { + var self = this; + if (!this.uploadFiles.length) { ui.addNotification(null, E('p', _('No files')), 'error'); return; } + var name = this.currentSite.name, asIndex = document.getElementById('mb-as-index').checked, firstHtml = null; + if (asIndex) { for (var i = 0; i < this.uploadFiles.length; i++) { if (this.uploadFiles[i].name.endsWith('.html')) { firstHtml = this.uploadFiles[i]; break; } } } + this.closeModal('mb-upload-modal'); + ui.showModal(_('Uploading...'), [E('p', { 'class': 'spinning' }, _('Uploading...'))]); + Promise.all(this.uploadFiles.map(function(f) { + return new Promise(function(resolve) { + var reader = new FileReader(); + reader.onload = function(e) { + var dest = (asIndex && f === firstHtml) ? 'index.html' : f.name; + fs.write(SITES_ROOT + '/' + name + '/' + dest, e.target.result).then(function() { resolve({ ok: true }); }).catch(function() { resolve({ ok: false }); }); + }; + reader.onerror = function() { resolve({ ok: false }); }; + reader.readAsText(f); + }); + })).then(function(r) { + ui.hideModal(); + var ok = r.filter(function(x) { return x.ok; }).length; + ui.addNotification(null, E('p', ok + ' file(s) uploaded')); + self.uploadFiles = []; + }); + }, + + showFilesModal: function(site) { + var self = this; this.currentSite = site; + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-files-modal' }, [ + E('div', { 'class': 'mb-modal' }, [ + E('div', { 'class': 'mb-modal-header' }, [E('h3', {}, _('Files: ') + site.name), E('button', { 'class': 'mb-modal-close', 'click': function() { self.closeModal('mb-files-modal'); } }, '\u00D7')]), + E('div', { 'class': 'mb-modal-body' }, [E('div', { 'id': 'mb-files-list', 'class': 'mb-file-list' }, [E('p', { 'class': 'spinning' }, _('Loading...'))])]), + E('div', { 'class': 'mb-modal-footer' }, [E('button', { 'class': 'mb-btn', 'click': function() { self.closeModal('mb-files-modal'); } }, _('Close'))]) + ]) + ]); + document.body.appendChild(modal); + fs.list(SITES_ROOT + '/' + site.name).then(function(files) { + var c = document.getElementById('mb-files-list'); c.innerHTML = ''; + if (!files || !files.length) { c.appendChild(E('p', { 'style': 'color:#888;text-align:center' }, _('No files'))); return; } + files.forEach(function(f) { + if (f.type === 'file') { + var isIdx = f.name === 'index.html'; + c.appendChild(E('div', { 'class': 'mb-file-item' }, [ + E('span', {}, isIdx ? '\u{1F3E0}' : '\u{1F4C4}'), + E('span', { 'class': 'mb-file-item-name' }, f.name + (isIdx ? ' (homepage)' : '')), + E('span', { 'class': 'mb-file-item-size' }, self.formatFileSize(f.size)), + E('span', { 'class': 'mb-file-item-actions' }, [ + (!isIdx && f.name.endsWith('.html')) ? E('button', { 'class': 'mb-file-item-btn home', 'title': _('Set as homepage'), 'click': function() { self.setAsHomepage(site, f.name); } }, '\u{1F3E0}') : '', + E('button', { 'class': 'mb-file-item-btn delete', 'title': _('Delete'), 'click': function() { self.deleteFile(site, f.name); } }, '\u{1F5D1}') + ]) + ])); + } + }); + }).catch(function(e) { document.getElementById('mb-files-list').innerHTML = '

Error: ' + e.message + '

'; }); + }, + + setAsHomepage: function(site, fname) { + var self = this, path = SITES_ROOT + '/' + site.name; + ui.showModal(_('Setting...'), [E('p', { 'class': 'spinning' }, _('Renaming...'))]); + fs.read(path + '/' + fname).then(function(c) { return fs.write(path + '/index.html', c); }).then(function() { return fs.remove(path + '/' + fname); }).then(function() { + ui.hideModal(); ui.addNotification(null, E('p', fname + ' set as homepage')); self.closeModal('mb-files-modal'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); + }, + + deleteFile: function(site, fname) { + var self = this; + if (!confirm(_('Delete ') + fname + '?')) return; + fs.remove(SITES_ROOT + '/' + site.name + '/' + fname).then(function() { ui.addNotification(null, E('p', _('Deleted'))); self.showFilesModal(site); }).catch(function(e) { ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); + }, + + showShareModal: function(site) { + var self = this, url = site.url || ('https://' + site.domain), title = site.name + ' - SecuBox', enc = encodeURIComponent, qr = qrcode.generateSVG(url, 180); + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-share-modal' }, [ + E('div', { 'class': 'mb-modal' }, [ + E('div', { 'class': 'mb-modal-header' }, [E('h3', {}, '\u{2705} ' + site.name), E('button', { 'class': 'mb-modal-close', 'click': function() { self.closeModal('mb-share-modal'); } }, '\u00D7')]), E('div', { 'class': 'mb-modal-body' }, [ E('div', { 'class': 'mb-published-card' }, [ - // URL with copy button - E('div', { 'class': 'mb-url-box' }, [ - E('input', { - 'type': 'text', - 'readonly': true, - 'value': url, - 'id': 'mb-pub-url' - }), - E('button', { - 'click': function() { self.copyUrl(url); } - }, '\u{1F4CB}') - ]), - - // QR Code - E('div', { 'class': 'mb-qr-container' }, [ - E('div', { 'innerHTML': qrSvg || '

QR unavailable

' }) - ]), - - // Social Share Buttons + E('div', { 'class': 'mb-url-box' }, [E('input', { 'type': 'text', 'readonly': true, 'value': url, 'id': 'mb-url' }), E('button', { 'click': function() { self.copyUrl(url); } }, '\u{1F4CB}')]), + E('div', { 'class': 'mb-qr-container' }, [E('div', { 'innerHTML': qr || 'QR unavailable' })]), E('div', { 'class': 'mb-share-buttons' }, [ - // Twitter/X - E('a', { - 'href': 'https://twitter.com/intent/tweet?url=' + encodedUrl + '&text=' + encodedTitle, - 'target': '_blank', - 'class': 'mb-share-btn mb-share-twitter', - 'title': 'Share on Twitter' - }, '\u{1D54F}'), - - // LinkedIn - E('a', { - 'href': 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodedUrl, - 'target': '_blank', - 'class': 'mb-share-btn mb-share-linkedin', - 'title': 'Share on LinkedIn' - }, 'in'), - - // Facebook - E('a', { - 'href': 'https://www.facebook.com/sharer/sharer.php?u=' + encodedUrl, - 'target': '_blank', - 'class': 'mb-share-btn mb-share-facebook', - 'title': 'Share on Facebook' - }, 'f'), - - // Telegram - E('a', { - 'href': 'https://t.me/share/url?url=' + encodedUrl + '&text=' + encodedTitle, - 'target': '_blank', - 'class': 'mb-share-btn mb-share-telegram', - 'title': 'Share on Telegram' - }, '\u{2708}'), - - // WhatsApp - E('a', { - 'href': 'https://wa.me/?text=' + encodeURIComponent(title + ' ' + url), - 'target': '_blank', - 'class': 'mb-share-btn mb-share-whatsapp', - 'title': 'Share on WhatsApp' - }, '\u{260E}'), - - // Email - E('a', { - 'href': 'mailto:?subject=' + encodedTitle + '&body=' + encodedUrl, - 'class': 'mb-share-btn mb-share-email', - 'title': 'Share via Email' - }, '\u{2709}') + E('a', { 'href': 'https://twitter.com/intent/tweet?url=' + enc(url) + '&text=' + enc(title), 'target': '_blank', 'class': 'mb-share-btn mb-share-twitter' }, '\u{1D54F}'), + E('a', { 'href': 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(url), 'target': '_blank', 'class': 'mb-share-btn mb-share-linkedin' }, 'in'), + E('a', { 'href': 'https://www.facebook.com/sharer/sharer.php?u=' + enc(url), 'target': '_blank', 'class': 'mb-share-btn mb-share-facebook' }, 'f'), + E('a', { 'href': 'https://t.me/share/url?url=' + enc(url) + '&text=' + enc(title), 'target': '_blank', 'class': 'mb-share-btn mb-share-telegram' }, '\u{2708}'), + E('a', { 'href': 'https://wa.me/?text=' + enc(title + ' ' + url), 'target': '_blank', 'class': 'mb-share-btn mb-share-whatsapp' }, '\u{260E}'), + E('a', { 'href': 'mailto:?subject=' + enc(title) + '&body=' + enc(url), 'class': 'mb-share-btn mb-share-email' }, '\u{2709}') ]) ]) ]), E('div', { 'class': 'mb-modal-footer' }, [ - E('a', { - 'href': url, - 'target': '_blank', - 'class': 'mb-btn mb-btn-submit', - 'style': 'text-decoration: none; display: inline-block;' - }, _('Visit Site')), - E('button', { - 'class': 'mb-btn', - 'click': function() { - self.closeModal('mb-published-modal'); - window.location.reload(); - } - }, _('Done')) + E('a', { 'href': url, 'target': '_blank', 'class': 'mb-btn mb-btn-submit', 'style': 'text-decoration:none' }, _('Visit')), + E('button', { 'class': 'mb-btn', 'click': function() { self.closeModal('mb-share-modal'); } }, _('Close')) ]) ]) ]); - document.body.appendChild(modal); }, - copyUrl: function(url) { - if (navigator.clipboard) { - navigator.clipboard.writeText(url).then(function() { - ui.addNotification(null, E('p', _('URL copied to clipboard!'))); - }); - } else { - var input = document.getElementById('mb-pub-url'); - input.select(); - document.execCommand('copy'); - ui.addNotification(null, E('p', _('URL copied to clipboard!'))); - } - }, - - closeModal: function(id) { - var modal = document.getElementById(id); - if (modal) { - modal.remove(); - } - }, - handleSync: function(site) { - ui.showModal(_('Syncing...'), [ - E('p', { 'class': 'spinning' }, _('Pulling latest changes from repository...')) - ]); - - callSyncSite(site.id) - .then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Site synced: ') + (result.message || 'OK'))); - } else { - ui.addNotification(null, E('p', _('Sync failed: ') + result.error), 'error'); - } - }) - .catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); + ui.showModal(_('Syncing...'), [E('p', { 'class': 'spinning' }, _('Pulling...'))]); + callSyncSite(site.id).then(function(r) { ui.hideModal(); ui.addNotification(null, E('p', r.success ? _('Synced') : _('Failed: ') + r.error), r.success ? null : 'error'); }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); }, handleDelete: function(site) { - var self = this; - - ui.showModal(_('Delete Site'), [ - E('p', {}, _('Are you sure you want to delete "%s"?').format(site.name)), - E('p', { 'style': 'color: #dc3545;' }, _('This will remove the site, HAProxy vhost, and all files.')), - E('div', { 'style': 'display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1rem;' }, [ - E('button', { - 'class': 'mb-btn', - 'click': function() { ui.hideModal(); } - }, _('Cancel')), - E('button', { - 'class': 'mb-btn mb-btn-delete', - 'click': function() { - ui.hideModal(); - self.doDelete(site); - } - }, _('Delete')) + ui.showModal(_('Delete?'), [ + E('p', {}, _('Delete "') + site.name + '"?'), + E('p', { 'style': 'color:#dc3545' }, _('This removes site, vhost, and files.')), + E('div', { 'style': 'display:flex;gap:1rem;justify-content:flex-end;margin-top:1rem' }, [ + E('button', { 'class': 'mb-btn', 'click': ui.hideModal }, _('Cancel')), + E('button', { 'class': 'mb-btn mb-btn-danger', 'click': function() { + ui.hideModal(); ui.showModal(_('Deleting...'), [E('p', { 'class': 'spinning' }, _('Removing...'))]); + callDeleteSite(site.id).then(function(r) { ui.hideModal(); if (r.success) { ui.addNotification(null, E('p', _('Deleted'))); window.location.reload(); } else { ui.addNotification(null, E('p', _('Failed: ') + r.error), 'error'); } }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); + }}, _('Delete')) ]) ]); }, - doDelete: function(site) { - ui.showModal(_('Deleting...'), [ - E('p', { 'class': 'spinning' }, _('Removing site and cleaning up...')) - ]); - - callDeleteSite(site.id) - .then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Site deleted successfully'))); - window.location.reload(); - } else { - ui.addNotification(null, E('p', _('Delete failed: ') + result.error), 'error'); - } - }) - .catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); + setupDropzone: function(id) { + var self = this, dz = document.getElementById(id); if (!dz) return; + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(e) { dz.addEventListener(e, function(ev) { ev.preventDefault(); ev.stopPropagation(); }); }); + ['dragenter', 'dragover'].forEach(function(e) { dz.addEventListener(e, function() { dz.classList.add('dragover'); }); }); + ['dragleave', 'drop'].forEach(function(e) { dz.addEventListener(e, function() { dz.classList.remove('dragover'); }); }); + dz.addEventListener('drop', function(e) { self.handleDroppedFiles(e.dataTransfer.files); }); }, - handleSaveApply: null, - handleSave: null, - handleReset: null + handleFileSelect: function(e) { this.handleDroppedFiles(e.target.files); }, + handleDroppedFiles: function(files) { for (var i = 0; i < files.length; i++) this.uploadFiles.push(files[i]); this.updateFileList(); }, + + updateFileList: function() { + var self = this, c = document.getElementById('mb-file-list'); if (!c) return; c.innerHTML = ''; + this.uploadFiles.forEach(function(f, i) { + c.appendChild(E('div', { 'class': 'mb-file-item' }, [ + E('span', {}, '\u{1F4C4}'), E('span', { 'class': 'mb-file-item-name' }, f.name), E('span', { 'class': 'mb-file-item-size' }, self.formatFileSize(f.size)), + E('button', { 'class': 'mb-file-item-btn delete', 'click': function() { self.uploadFiles.splice(i, 1); self.updateFileList(); } }, '\u00D7') + ])); + }); + }, + + formatFileSize: function(b) { if (b < 1024) return b + ' B'; if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'; return (b / 1048576).toFixed(1) + ' MB'; }, + + copyUrl: function(url) { + if (navigator.clipboard) navigator.clipboard.writeText(url).then(function() { ui.addNotification(null, E('p', _('Copied!'))); }); + else { var i = document.getElementById('mb-url'); if (i) { i.select(); document.execCommand('copy'); } ui.addNotification(null, E('p', _('Copied!'))); } + }, + + closeModal: function(id) { var m = document.getElementById(id); if (m) m.remove(); }, + + handleSaveApply: null, handleSave: null, handleReset: null }); diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/settings.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/settings.js index 0817dddd..46c7ef9f 100644 --- a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/settings.js +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/settings.js @@ -24,11 +24,12 @@ return view.extend({ o.default = '1'; o.rmempty = false; - o = s.option(form.Value, 'nginx_container', _('Nginx Container'), - _('Name of the LXC container running nginx')); - o.placeholder = 'nginx'; - o.default = 'nginx'; - o.rmempty = false; + o = s.option(form.ListValue, 'runtime', _('Runtime'), + _('Web server runtime for serving static sites')); + o.value('auto', _('Auto-detect (Recommended)')); + o.value('uhttpd', _('uhttpd (Lightweight)')); + o.value('nginx', _('nginx LXC (Full-featured)')); + o.default = 'auto'; o = s.option(form.Value, 'sites_root', _('Sites Root Path'), _('Directory where site files are stored')); @@ -36,6 +37,17 @@ return view.extend({ o.default = '/srv/metablogizer/sites'; o.rmempty = false; + o = s.option(form.Value, 'gitea_url', _('Gitea URL'), + _('URL of Gitea server for cloning repositories')); + o.placeholder = 'http://localhost:3000'; + o.default = 'http://localhost:3000'; + + o = s.option(form.Value, 'nginx_container', _('Nginx Container'), + _('Name of the LXC container running nginx (only for nginx runtime)')); + o.placeholder = 'nginx'; + o.default = 'nginx'; + o.depends('runtime', 'nginx'); + // Info section s = m.section(form.TypedSection, 'metablogizer', _('Information')); s.anonymous = true; diff --git a/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer b/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer index 609cb341..a9a84da2 100644 --- a/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer +++ b/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer @@ -9,6 +9,7 @@ UCI_CONFIG="metablogizer" SITES_ROOT="/srv/metablogizer/sites" NGINX_CONTAINER="nginx" +PORT_BASE=8900 # Helper: Get UCI value with default get_uci() { @@ -20,19 +21,42 @@ get_uci() { echo "${value:-$default}" } +# Runtime detection (uhttpd preferred, nginx fallback) +detect_runtime() { + local configured=$(get_uci main runtime "auto") + case "$configured" in + uhttpd) [ -x /etc/init.d/uhttpd ] && echo "uhttpd" || echo "none" ;; + nginx) lxc-info -n "$NGINX_CONTAINER" >/dev/null 2>&1 && echo "nginx" || echo "none" ;; + auto|*) [ -x /etc/init.d/uhttpd ] && echo "uhttpd" || \ + (lxc-info -n "$NGINX_CONTAINER" >/dev/null 2>&1 && echo "nginx" || echo "none") ;; + esac +} + +# Get next available port for uhttpd +get_next_port() { + local port=$PORT_BASE + while uci show uhttpd 2>/dev/null | grep -q "listen_http='0.0.0.0:$port'"; do + port=$((port + 1)) + done + echo $port +} + # Status method - get overall status and list all sites method_status() { - local enabled nginx_running site_count + local enabled runtime detected_runtime nginx_running site_count enabled=$(get_uci main enabled 0) + runtime=$(get_uci main runtime "auto") SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER") - # Check nginx container + # Detect runtime + detected_runtime=$(detect_runtime) + + # Check nginx container if using nginx + nginx_running="0" if lxc-info -n "$NGINX_CONTAINER" -s 2>/dev/null | grep -q "RUNNING"; then nginx_running="1" - else - nginx_running="0" fi # Count sites @@ -43,6 +67,8 @@ method_status() { json_init json_add_boolean "enabled" "$enabled" + json_add_string "runtime" "$runtime" + json_add_string "detected_runtime" "$detected_runtime" json_add_boolean "nginx_running" "$nginx_running" json_add_int "site_count" "$site_count" json_add_string "sites_root" "$SITES_ROOT" @@ -197,29 +223,72 @@ method_create_site() { EOF fi - # 5. Create HAProxy backend + # 5. Detect runtime and configure accordingly + local current_runtime=$(detect_runtime) + local port="" + local server_address="192.168.255.1" + local server_port="80" + + if [ "$current_runtime" = "uhttpd" ]; then + # Create uhttpd instance + port=$(get_next_port) + uci set "uhttpd.metablog_${section_id}=uhttpd" + uci set "uhttpd.metablog_${section_id}.listen_http=0.0.0.0:$port" + uci set "uhttpd.metablog_${section_id}.home=$SITES_ROOT/$name" + uci set "uhttpd.metablog_${section_id}.index_page=index.html" + uci set "uhttpd.metablog_${section_id}.error_page=/index.html" + uci commit uhttpd + /etc/init.d/uhttpd reload 2>/dev/null + server_port="$port" + else + # Configure nginx location in container + _configure_nginx "$name" + local nginx_ip + nginx_ip=$(lxc-info -n "$NGINX_CONTAINER" -iH 2>/dev/null | head -1) + [ -n "$nginx_ip" ] && server_address="$nginx_ip" + fi + + # Save port to site config + [ -n "$port" ] && uci set "$UCI_CONFIG.$section_id.port=$port" + uci set "$UCI_CONFIG.$section_id.runtime=$current_runtime" + + # 6. Create HAProxy backend local backend_name="metablog_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" - # Create backend via HAProxy RPCD - echo "{\"name\":\"$backend_name\",\"mode\":\"http\",\"enabled\":\"1\"}" | \ - /usr/libexec/rpcd/luci.haproxy call create_backend >/dev/null 2>&1 + uci set "haproxy.$backend_name=backend" + uci set "haproxy.$backend_name.name=$backend_name" + uci set "haproxy.$backend_name.mode=http" + uci set "haproxy.$backend_name.balance=roundrobin" + uci set "haproxy.$backend_name.enabled=1" - # Create server pointing to nginx container - local nginx_ip - nginx_ip=$(lxc-info -n "$NGINX_CONTAINER" -iH 2>/dev/null | head -1) - [ -z "$nginx_ip" ] && nginx_ip="nginx" + # Create server + local server_name="${backend_name}_srv" + uci set "haproxy.$server_name=server" + uci set "haproxy.$server_name.backend=$backend_name" + uci set "haproxy.$server_name.name=srv" + uci set "haproxy.$server_name.address=$server_address" + uci set "haproxy.$server_name.port=$server_port" + uci set "haproxy.$server_name.weight=100" + uci set "haproxy.$server_name.check=1" + uci set "haproxy.$server_name.enabled=1" - echo "{\"backend\":\"$backend_name\",\"name\":\"nginx\",\"address\":\"$nginx_ip\",\"port\":\"80\",\"check\":\"1\"}" | \ - /usr/libexec/rpcd/luci.haproxy call create_server >/dev/null 2>&1 - - # 6. Create HAProxy vhost + # 7. Create HAProxy vhost + local vhost_name=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') local acme_val="0" [ "$ssl" = "1" ] && acme_val="1" - echo "{\"domain\":\"$domain\",\"backend\":\"$backend_name\",\"ssl\":\"$ssl\",\"ssl_redirect\":\"$ssl\",\"acme\":\"$acme_val\",\"enabled\":\"1\"}" | \ - /usr/libexec/rpcd/luci.haproxy call create_vhost >/dev/null 2>&1 - # 7. Configure nginx location in container - _configure_nginx "$name" + uci set "haproxy.$vhost_name=vhost" + uci set "haproxy.$vhost_name.domain=$domain" + uci set "haproxy.$vhost_name.backend=$backend_name" + uci set "haproxy.$vhost_name.ssl=$ssl" + uci set "haproxy.$vhost_name.ssl_redirect=$ssl" + uci set "haproxy.$vhost_name.acme=$acme_val" + uci set "haproxy.$vhost_name.enabled=1" + uci commit haproxy + + # Regenerate HAProxy config + /usr/sbin/haproxyctl generate >/dev/null 2>&1 + /etc/init.d/haproxy reload >/dev/null 2>&1 uci commit "$UCI_CONFIG" @@ -283,19 +352,32 @@ method_delete_site() { SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER") + # Get site runtime + local site_runtime=$(get_uci "$id" runtime "") + # 1. Delete HAProxy vhost local vhost_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') - echo "{\"id\":\"$vhost_id\"}" | \ - /usr/libexec/rpcd/luci.haproxy call delete_vhost >/dev/null 2>&1 + uci delete "haproxy.$vhost_id" 2>/dev/null - # 2. Delete HAProxy backend + # 2. Delete HAProxy backend and server local backend_name="metablog_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" - echo "{\"id\":\"$backend_name\"}" | \ - /usr/libexec/rpcd/luci.haproxy call delete_backend >/dev/null 2>&1 + uci delete "haproxy.$backend_name" 2>/dev/null + uci delete "haproxy.${backend_name}_srv" 2>/dev/null + uci commit haproxy + /usr/sbin/haproxyctl generate >/dev/null 2>&1 + /etc/init.d/haproxy reload >/dev/null 2>&1 - # 3. Remove nginx config - rm -f "/var/lib/lxc/$NGINX_CONTAINER/rootfs/etc/nginx/sites.d/metablog-$name.conf" - lxc-attach -n "$NGINX_CONTAINER" -- nginx -s reload 2>/dev/null || true + # 3. Remove runtime config + if [ "$site_runtime" = "uhttpd" ]; then + # Remove uhttpd instance + uci delete "uhttpd.metablog_$id" 2>/dev/null + uci commit uhttpd + /etc/init.d/uhttpd reload 2>/dev/null + else + # Remove nginx config + rm -f "/var/lib/lxc/$NGINX_CONTAINER/rootfs/etc/nginx/sites.d/metablog-$name.conf" + lxc-attach -n "$NGINX_CONTAINER" -- nginx -s reload 2>/dev/null || true + fi # 4. Remove site directory rm -rf "$SITES_ROOT/$name" @@ -537,31 +619,141 @@ method_update_site() { json_dump } +# Upload file to site +method_upload_file() { + local id filename content + + read -r input + json_load "$input" + json_get_var id id + json_get_var filename filename + json_get_var content content + + if [ -z "$id" ] || [ -z "$filename" ] || [ -z "$content" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing required fields (id, filename, content)" + json_dump + return + fi + + local name + name=$(get_uci "$id" name "") + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + local site_path="$SITES_ROOT/$name" + local file_path="$site_path/$filename" + + # Create directory structure if needed + local dir_path=$(dirname "$file_path") + mkdir -p "$dir_path" + + # Decode base64 content and write file + echo "$content" | base64 -d > "$file_path" 2>/dev/null + local rc=$? + + if [ $rc -eq 0 ]; then + chmod 644 "$file_path" + json_init + json_add_boolean "success" 1 + json_add_string "filename" "$filename" + json_add_string "path" "$file_path" + json_dump + else + json_init + json_add_boolean "success" 0 + json_add_string "error" "Failed to write file" + json_dump + fi +} + +# List files in a site +method_list_files() { + local id + + read -r input + json_load "$input" + json_get_var id id + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing site id" + json_dump + return + fi + + local name + name=$(get_uci "$id" name "") + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + local site_path="$SITES_ROOT/$name" + + json_init + json_add_boolean "success" 1 + json_add_array "files" + + if [ -d "$site_path" ]; then + find "$site_path" -type f 2>/dev/null | while read -r file; do + local rel_path="${file#$site_path/}" + local size=$(stat -c%s "$file" 2>/dev/null || echo "0") + json_add_object "" + json_add_string "name" "$rel_path" + json_add_int "size" "$size" + json_close_object + done + fi + + json_close_array + json_dump +} + # Get global settings method_get_settings() { json_init json_add_boolean "enabled" "$(get_uci main enabled 0)" + json_add_string "runtime" "$(get_uci main runtime auto)" + json_add_string "detected_runtime" "$(detect_runtime)" json_add_string "nginx_container" "$(get_uci main nginx_container nginx)" json_add_string "sites_root" "$(get_uci main sites_root /srv/metablogizer/sites)" + json_add_string "gitea_url" "$(get_uci main gitea_url http://localhost:3000)" json_dump } # Save global settings method_save_settings() { - local enabled nginx_container sites_root + local enabled runtime nginx_container sites_root gitea_url read -r input json_load "$input" json_get_var enabled enabled + json_get_var runtime runtime json_get_var nginx_container nginx_container json_get_var sites_root sites_root + json_get_var gitea_url gitea_url # Ensure main section exists uci -q get "$UCI_CONFIG.main" >/dev/null 2>&1 || uci set "$UCI_CONFIG.main=metablogizer" [ -n "$enabled" ] && uci set "$UCI_CONFIG.main.enabled=$enabled" + [ -n "$runtime" ] && uci set "$UCI_CONFIG.main.runtime=$runtime" [ -n "$nginx_container" ] && uci set "$UCI_CONFIG.main.nginx_container=$nginx_container" [ -n "$sites_root" ] && uci set "$UCI_CONFIG.main.sites_root=$sites_root" + [ -n "$gitea_url" ] && uci set "$UCI_CONFIG.main.gitea_url=$gitea_url" uci commit "$UCI_CONFIG" json_init @@ -582,6 +774,8 @@ case "$1" in "delete_site": { "id": "string" }, "sync_site": { "id": "string" }, "get_publish_info": { "id": "string" }, + "upload_file": { "id": "string", "filename": "string", "content": "string" }, + "list_files": { "id": "string" }, "get_settings": {}, "save_settings": { "enabled": "boolean", "nginx_container": "string", "sites_root": "string" } } @@ -597,6 +791,8 @@ EOF delete_site) method_delete_site ;; sync_site) method_sync_site ;; get_publish_info) method_get_publish_info ;; + upload_file) method_upload_file ;; + list_files) method_list_files ;; get_settings) method_get_settings ;; save_settings) method_save_settings ;; *) echo '{"error": "unknown method"}' ;; diff --git a/package/secubox/luci-app-metablogizer/root/usr/share/rpcd/acl.d/luci-app-metablogizer.json b/package/secubox/luci-app-metablogizer/root/usr/share/rpcd/acl.d/luci-app-metablogizer.json index 9e17becb..357a54ba 100644 --- a/package/secubox/luci-app-metablogizer/root/usr/share/rpcd/acl.d/luci-app-metablogizer.json +++ b/package/secubox/luci-app-metablogizer/root/usr/share/rpcd/acl.d/luci-app-metablogizer.json @@ -9,9 +9,13 @@ "get_site", "get_publish_info", "get_settings" - ] + ], + "file": ["read", "list", "stat"] }, - "uci": ["metablogizer"] + "uci": ["metablogizer"], + "file": { + "/srv/metablogizer/sites/*": ["read", "list"] + } }, "write": { "ubus": { @@ -20,7 +24,9 @@ "update_site", "delete_site", "sync_site", - "save_settings" + "save_settings", + "upload_file", + "list_files" ], "luci.haproxy": [ "create_backend", @@ -28,9 +34,13 @@ "create_vhost", "delete_backend", "delete_vhost" - ] + ], + "file": ["write", "remove"] }, - "uci": ["metablogizer"] + "uci": ["metablogizer"], + "file": { + "/srv/metablogizer/sites/*": ["write"] + } } } } diff --git a/package/secubox/luci-app-mitmproxy/htdocs/luci-static/resources/view/mitmproxy/overview.js b/package/secubox/luci-app-mitmproxy/htdocs/luci-static/resources/view/mitmproxy/overview.js new file mode 100644 index 00000000..9145377e --- /dev/null +++ b/package/secubox/luci-app-mitmproxy/htdocs/luci-static/resources/view/mitmproxy/overview.js @@ -0,0 +1,96 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; + +var callStatus = rpc.declare({ object: 'luci.mitmproxy', method: 'status', expect: {} }); +var callInstall = rpc.declare({ object: 'luci.mitmproxy', method: 'install', expect: {} }); +var callStart = rpc.declare({ object: 'luci.mitmproxy', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.mitmproxy', method: 'stop', expect: {} }); +var callRestart = rpc.declare({ object: 'luci.mitmproxy', method: 'restart', expect: {} }); + +var css = '.mp-container{max-width:900px;margin:0 auto}.mp-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#f97316 0%,#ea580c 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.mp-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.mp-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.mp-status.running{background:rgba(16,185,129,.2)}.mp-status.stopped{background:rgba(239,68,68,.2)}.mp-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.mp-status.running .mp-dot{background:#10b981}.mp-status.stopped .mp-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.mp-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.mp-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.mp-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem}.mp-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.mp-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.mp-info-value{font-size:1rem;font-weight:500}.mp-actions{display:flex;gap:.75rem;flex-wrap:wrap}.mp-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.mp-btn-primary{background:linear-gradient(135deg,#f97316,#ea580c);color:#fff}.mp-btn-success{background:#10b981;color:#fff}.mp-btn-danger{background:#ef4444;color:#fff}.mp-btn:disabled{opacity:.5;cursor:not-allowed}.mp-not-installed{text-align:center;padding:3rem}.mp-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1rem;margin:1.5rem 0}.mp-feature{padding:.75rem;background:#fff7ed;border-radius:8px;font-size:.9rem}.mp-warning{background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:1rem;margin-top:1rem;font-size:.9rem;color:#92400e}'; + +return view.extend({ + load: function() { return callStatus(); }, + + handleInstall: function() { + ui.showModal(_('Installing mitmproxy'), [E('p', { 'class': 'spinning' }, _('Installing...'))]); + callInstall().then(function(r) { + ui.hideModal(); + ui.addNotification(null, E('p', r.message || _('Installation started'))); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStart: function() { + ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting...'))]); + callStart().then(function() { ui.hideModal(); location.reload(); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStop: function() { + ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping...'))]); + callStop().then(function() { ui.hideModal(); location.reload(); }) + .catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + render: function(status) { + if (!document.getElementById('mp-styles')) { + var s = document.createElement('style'); s.id = 'mp-styles'; s.textContent = css; document.head.appendChild(s); + } + + if (!status.installed || !status.docker_available) { + return E('div', { 'class': 'mp-container' }, [ + E('div', { 'class': 'mp-header' }, [ + E('h2', {}, ['\uD83D\uDD0D ', _('mitmproxy')]), + E('div', { 'class': 'mp-status stopped' }, [E('span', { 'class': 'mp-dot' }), _('Not Installed')]) + ]), + E('div', { 'class': 'mp-card' }, [ + E('div', { 'class': 'mp-not-installed' }, [ + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\uD83D\uDD0D'), + E('h3', {}, _('mitmproxy')), + E('p', {}, _('Interactive HTTPS proxy for debugging, testing, and security analysis.')), + E('div', { 'class': 'mp-features' }, [ + E('div', { 'class': 'mp-feature' }, '\uD83D\uDCCA Web UI'), + E('div', { 'class': 'mp-feature' }, '\uD83D\uDD12 HTTPS'), + E('div', { 'class': 'mp-feature' }, '\uD83D\uDCDD Logging'), + E('div', { 'class': 'mp-feature' }, '\uD83D\uDD04 Replay'), + E('div', { 'class': 'mp-feature' }, '\u2699 Scripting'), + E('div', { 'class': 'mp-feature' }, '\uD83D\uDCE6 Export') + ]), + E('div', { 'class': 'mp-warning' }, _('Note: This is a security analysis tool. Only use for legitimate debugging and testing purposes.')), + !status.docker_available ? E('div', { 'style': 'color:#ef4444;margin:1rem 0' }, _('Docker required')) : '', + E('button', { 'class': 'mp-btn mp-btn-primary', 'style': 'margin-top:1rem', 'click': ui.createHandlerFn(this, 'handleInstall'), 'disabled': !status.docker_available }, _('Install mitmproxy')) + ]) + ]) + ]); + } + + return E('div', { 'class': 'mp-container' }, [ + E('div', { 'class': 'mp-header' }, [ + E('h2', {}, ['\uD83D\uDD0D ', _('mitmproxy')]), + E('div', { 'class': 'mp-status ' + (status.running ? 'running' : 'stopped') }, [ + E('span', { 'class': 'mp-dot' }), + status.running ? _('Running') : _('Stopped') + ]) + ]), + E('div', { 'class': 'mp-card' }, [ + E('div', { 'class': 'mp-card-title' }, ['\u2139\uFE0F ', _('Configuration')]), + E('div', { 'class': 'mp-info-grid' }, [ + E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Proxy Port')), E('div', { 'class': 'mp-info-value' }, String(status.proxy_port))]), + E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Web UI Port')), E('div', { 'class': 'mp-info-value' }, String(status.web_port))]), + E('div', { 'class': 'mp-info-item' }, [E('div', { 'class': 'mp-info-label' }, _('Web UI')), E('div', { 'class': 'mp-info-value' }, [E('a', { 'href': 'http://' + window.location.hostname + ':' + status.web_port, 'target': '_blank' }, _('Open UI'))])]) + ]) + ]), + E('div', { 'class': 'mp-card' }, [ + E('div', { 'class': 'mp-card-title' }, ['\u26A1 ', _('Actions')]), + E('div', { 'class': 'mp-actions' }, [ + E('button', { 'class': 'mp-btn mp-btn-success', 'click': ui.createHandlerFn(this, 'handleStart'), 'disabled': status.running }, _('Start')), + E('button', { 'class': 'mp-btn mp-btn-danger', 'click': ui.createHandlerFn(this, 'handleStop'), 'disabled': !status.running }, _('Stop')) + ]) + ]) + ]); + }, + + handleSaveApply: null, handleSave: null, handleReset: null +}); diff --git a/package/secubox/luci-app-mitmproxy/root/usr/libexec/rpcd/luci.mitmproxy b/package/secubox/luci-app-mitmproxy/root/usr/libexec/rpcd/luci.mitmproxy index 73250c19..6a9e8005 100755 --- a/package/secubox/luci-app-mitmproxy/root/usr/libexec/rpcd/luci.mitmproxy +++ b/package/secubox/luci-app-mitmproxy/root/usr/libexec/rpcd/luci.mitmproxy @@ -1,558 +1,56 @@ #!/bin/sh -# -# RPCD backend for mitmproxy LuCI interface -# Copyright (C) 2025 CyberMind.fr (SecuBox) -# +# RPCD backend for mitmproxy LuCI app -. /lib/functions.sh +CONFIG="mitmproxy" +CONTAINER="secbx-mitmproxy" -DATA_DIR=$(uci -q get mitmproxy.main.data_path || echo "/srv/mitmproxy") -LXC_NAME="mitmproxy" -CONF_DIR="$DATA_DIR" -LOG_FILE="$DATA_DIR/requests.log" -FLOW_FILE="$DATA_DIR/flows.bin" +uci_get() { uci -q get ${CONFIG}.main.$1; } -# Get service status get_status() { + local enabled=$(uci_get enabled) + local web_port=$(uci_get web_port) + local proxy_port=$(uci_get proxy_port) + local data_path=$(uci_get data_path) + + local docker_available=0 + command -v docker >/dev/null 2>&1 && docker_available=1 + local running=0 - local pid="" - local mode="unknown" - local web_url="" - local lxc_state="" - local nft_active="false" - - # Check LXC container status - if command -v lxc-info >/dev/null 2>&1; then - lxc_state=$(lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -oE 'RUNNING|STOPPED' || echo "UNKNOWN") - if [ "$lxc_state" = "RUNNING" ]; then - running=1 - mode="mitmweb" - pid=$(lxc-info -n "$LXC_NAME" -p 2>/dev/null | grep -oE '[0-9]+' || echo "0") - fi + if [ "$docker_available" = "1" ]; then + docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER" && running=1 fi - # Fallback: check for direct process - if [ "$running" = "0" ]; then - if pgrep mitmweb >/dev/null 2>&1; then - running=1 - pid=$(pgrep mitmweb | head -1) - mode="mitmweb" - elif pgrep mitmdump >/dev/null 2>&1; then - running=1 - pid=$(pgrep mitmdump | head -1) - mode="mitmdump" - fi - fi + local installed=0 + [ "$docker_available" = "1" ] && docker images --format "{{.Repository}}" 2>/dev/null | grep -q "mitmproxy" && installed=1 - # Check nftables rules - if command -v nft >/dev/null 2>&1; then - nft list table inet mitmproxy >/dev/null 2>&1 && nft_active="true" - fi - - local enabled=$(uci -q get mitmproxy.main.enabled || echo "0") - local proxy_port=$(uci -q get mitmproxy.main.proxy_port || echo "8080") - local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081") - local proxy_mode=$(uci -q get mitmproxy.main.mode || echo "regular") - local filtering_enabled=$(uci -q get mitmproxy.filtering.enabled || echo "0") - local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") - - [ "$running" = "1" ] && [ "$mode" = "mitmweb" ] && web_url="http://${router_ip}:${web_port}" - - cat </dev/null 2>&1 && { mitmproxyctl install >/tmp/mitmproxy-install.log 2>&1 & echo '{"success":true,"message":"Installing"}'; } || echo '{"success":false,"error":"mitmproxyctl not found"}' } -# Get transparent mode configuration -get_transparent_config() { - local enabled=$(uci -q get mitmproxy.transparent.enabled || echo "0") - local interface=$(uci -q get mitmproxy.transparent.interface || echo "br-lan") - local redirect_http=$(uci -q get mitmproxy.transparent.redirect_http || echo "1") - local redirect_https=$(uci -q get mitmproxy.transparent.redirect_https || echo "1") - local http_port=$(uci -q get mitmproxy.transparent.http_port || echo "80") - local https_port=$(uci -q get mitmproxy.transparent.https_port || echo "443") +do_start() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy start >/dev/null 2>&1; echo '{"success":true}'; } +do_stop() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy stop >/dev/null 2>&1; echo '{"success":true}'; } +do_restart() { [ -x /etc/init.d/mitmproxy ] && /etc/init.d/mitmproxy restart >/dev/null 2>&1; echo '{"success":true}'; } - cat </dev/null | tr ' ' '\n' | while read ip; do - [ -n "$ip" ] && printf '"%s",' "$ip" - done | sed 's/,$//') - - # Get bypass_domain list - local bypass_domains=$(uci -q get mitmproxy.whitelist.bypass_domain 2>/dev/null | tr ' ' '\n' | while read domain; do - [ -n "$domain" ] && printf '"%s",' "$domain" - done | sed 's/,$//') - - cat </dev/null || echo "0") - # Use jsonfilter for parsing (OpenWrt native) - if command -v jsonfilter >/dev/null 2>&1; then - unique_hosts=$(cat "$LOG_FILE" 2>/dev/null | while read line; do - echo "$line" | jsonfilter -e '@.request.host' 2>/dev/null - done | sort -u | wc -l) - cdn_requests=$(grep -c '"category":"cdn"' "$LOG_FILE" 2>/dev/null || echo "0") - media_requests=$(grep -c '"category":"media"' "$LOG_FILE" 2>/dev/null || echo "0") - blocked_ads=$(grep -c '"category":"blocked_ad"' "$LOG_FILE" 2>/dev/null || echo "0") - fi - fi - - if [ -f "$FLOW_FILE" ]; then - flow_size=$(ls -l "$FLOW_FILE" 2>/dev/null | awk '{print $5}' || echo "0") - fi - - cat </dev/null | tail -"$limit" | \ - awk 'BEGIN{first=1}{if(!first)printf ",";first=0;print}' 2>/dev/null || echo "" - echo ']}' - else - echo '{"requests":[' - tail -"$limit" "$LOG_FILE" 2>/dev/null | \ - awk 'BEGIN{first=1}{if(!first)printf ",";first=0;print}' 2>/dev/null || echo "" - echo ']}' - fi -} - -# Get top hosts -get_top_hosts() { - local limit="${1:-20}" - - if [ ! -f "$LOG_FILE" ]; then - echo '{"hosts":[]}' - return - fi - - echo '{"hosts":[' - # Parse JSON using grep/sed for compatibility - grep -o '"host":"[^"]*"' "$LOG_FILE" 2>/dev/null | \ - sed 's/"host":"//;s/"$//' | \ - sort | uniq -c | sort -rn | head -"$limit" | \ - awk 'BEGIN{first=1} { - if(!first) printf ","; - first=0; - gsub(/"/, "\\\"", $2); - printf "{\"host\":\"%s\",\"count\":%d}", $2, $1 - }' - echo ']}' -} - -# Service control -service_start() { - /etc/init.d/mitmproxy start >/dev/null 2>&1 - sleep 2 - get_status -} - -service_stop() { - /etc/init.d/mitmproxy stop >/dev/null 2>&1 - sleep 1 - get_status -} - -service_restart() { - /etc/init.d/mitmproxy restart >/dev/null 2>&1 - sleep 2 - get_status -} - -# Setup firewall rules -firewall_setup() { - /usr/sbin/mitmproxyctl firewall-setup 2>&1 - local result=$? - if [ $result -eq 0 ]; then - echo '{"success":true,"message":"Firewall rules applied"}' - else - echo '{"success":false,"message":"Failed to apply firewall rules"}' - fi -} - -# Clear firewall rules -firewall_clear() { - /usr/sbin/mitmproxyctl firewall-clear 2>&1 - local result=$? - if [ $result -eq 0 ]; then - echo '{"success":true,"message":"Firewall rules cleared"}' - else - echo '{"success":false,"message":"Failed to clear firewall rules"}' - fi -} - -# Set configuration -set_config() { - local key="$1" - local value="$2" - local section="main" - - case "$key" in - save_flows|capture_*) - section="capture" - ;; - redirect_*|interface|http_port|https_port) - section="transparent" - ;; - bypass_ip|bypass_domain) - section="whitelist" - ;; - filter_*|log_requests|block_ads|addon_script) - section="filtering" - ;; - esac - - # Handle boolean conversion - case "$value" in - true) value="1" ;; - false) value="0" ;; - esac - - uci set "mitmproxy.$section.$key=$value" - uci commit mitmproxy - echo '{"success":true}' -} - -# Add to list (for bypass_ip, bypass_domain) -add_to_list() { - local key="$1" - local value="$2" - local section="whitelist" - - uci add_list "mitmproxy.$section.$key=$value" - uci commit mitmproxy - echo '{"success":true}' -} - -# Remove from list -remove_from_list() { - local key="$1" - local value="$2" - local section="whitelist" - - uci del_list "mitmproxy.$section.$key=$value" - uci commit mitmproxy - echo '{"success":true}' -} - -# Clear captured data -clear_data() { - rm -f "$DATA_DIR"/*.log "$DATA_DIR"/*.bin 2>/dev/null - echo '{"success":true,"message":"Captured data cleared"}' -} - -# Get CA certificate info -get_ca_info() { - local cert="$CONF_DIR/mitmproxy-ca-cert.pem" - local router_ip=$(uci -q get network.lan.ipaddr || echo "192.168.1.1") - local web_port=$(uci -q get mitmproxy.main.web_port || echo "8081") - - if [ -f "$cert" ]; then - local subject=$(openssl x509 -in "$cert" -noout -subject 2>/dev/null | sed 's/subject=//') - local expires=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | sed 's/notAfter=//') - - cat </dev/null | tr -d '\n\r') - fi - - # Fallback: read token directly from container if host file is missing/empty - if [ -z "$token" ] && command -v lxc-attach >/dev/null 2>&1; then - if lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; then - token=$(lxc-attach -n "$LXC_NAME" -- cat /data/.mitmproxy_token 2>/dev/null | tr -d '\n\r') - fi - fi - - # Second fallback: parse token from mitmweb log inside container - if [ -z "$token" ] && command -v lxc-attach >/dev/null 2>&1; then - if lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; then - token=$(lxc-attach -n "$LXC_NAME" -- grep -o 'token=[a-zA-Z0-9_-]*' /tmp/mitmweb.log 2>/dev/null | head -1 | cut -d= -f2) - fi - fi - - # Construct URL - only add token parameter if token exists - local web_url="http://$router_ip:$web_port" - local web_url_with_token="$web_url" - if [ -n "$token" ]; then - web_url_with_token="$web_url/?token=$token" - fi - - cat </dev/null || echo "50") - category=$(echo "$input" | jsonfilter -e '@.category' 2>/dev/null || echo "all") - get_requests "$limit" "$category" - ;; - get_top_hosts) - read -r input - limit=$(echo "$input" | jsonfilter -e '@.limit' 2>/dev/null || echo "20") - get_top_hosts "$limit" - ;; - get_ca_info) - get_ca_info - ;; - get_web_token) - get_web_token - ;; - service_start) - service_start - ;; - service_stop) - service_stop - ;; - service_restart) - service_restart - ;; - firewall_setup) - firewall_setup - ;; - firewall_clear) - firewall_clear - ;; - set_config) - read -r input - key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null) - value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null) - set_config "$key" "$value" - ;; - add_to_list) - read -r input - key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null) - value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null) - add_to_list "$key" "$value" - ;; - remove_from_list) - read -r input - key=$(echo "$input" | jsonfilter -e '@.key' 2>/dev/null) - value=$(echo "$input" | jsonfilter -e '@.value' 2>/dev/null) - remove_from_list "$key" "$value" - ;; - clear_data) - clear_data - ;; - *) - echo '{"error":"Unknown method"}' - ;; - esac - ;; - *) - echo '{"error":"Unknown command"}' - ;; + list) list_methods ;; + call) case "$2" in status) get_status ;; install) do_install ;; start) do_start ;; stop) do_stop ;; restart) do_restart ;; *) echo '{"error":"Unknown method"}' ;; esac ;; + *) echo '{"error":"Unknown command"}' ;; esac diff --git a/package/secubox/luci-app-mitmproxy/root/usr/share/luci/menu.d/luci-app-mitmproxy.json b/package/secubox/luci-app-mitmproxy/root/usr/share/luci/menu.d/luci-app-mitmproxy.json index f008c481..63700360 100644 --- a/package/secubox/luci-app-mitmproxy/root/usr/share/luci/menu.d/luci-app-mitmproxy.json +++ b/package/secubox/luci-app-mitmproxy/root/usr/share/luci/menu.d/luci-app-mitmproxy.json @@ -1,45 +1,8 @@ { "admin/secubox/security/mitmproxy": { "title": "mitmproxy", - "order": 50, - "action": { - "type": "firstchild" - }, - "depends": { - "acl": ["luci-app-mitmproxy"], - "uci": {"mitmproxy": true} - } - }, - "admin/secubox/security/mitmproxy/dashboard": { - "title": "Dashboard", - "order": 10, - "action": { - "type": "view", - "path": "mitmproxy/dashboard" - } - }, - "admin/secubox/security/mitmproxy/webui": { - "title": "Web UI", - "order": 15, - "action": { - "type": "view", - "path": "mitmproxy/webui" - } - }, - "admin/secubox/security/mitmproxy/requests": { - "title": "Requests", - "order": 20, - "action": { - "type": "view", - "path": "mitmproxy/requests" - } - }, - "admin/secubox/security/mitmproxy/settings": { - "title": "Settings", - "order": 30, - "action": { - "type": "view", - "path": "mitmproxy/settings" - } + "action": { "type": "view", "path": "mitmproxy/overview" }, + "depends": { "acl": ["luci-app-mitmproxy"] }, + "order": 60 } } diff --git a/package/secubox/luci-app-mitmproxy/root/usr/share/rpcd/acl.d/luci-app-mitmproxy.json b/package/secubox/luci-app-mitmproxy/root/usr/share/rpcd/acl.d/luci-app-mitmproxy.json index 9ebabceb..3bf19a5b 100644 --- a/package/secubox/luci-app-mitmproxy/root/usr/share/rpcd/acl.d/luci-app-mitmproxy.json +++ b/package/secubox/luci-app-mitmproxy/root/usr/share/rpcd/acl.d/luci-app-mitmproxy.json @@ -1,43 +1,7 @@ { "luci-app-mitmproxy": { - "description": "Grant access to mitmproxy LuCI app", - "read": { - "ubus": { - "luci.mitmproxy": [ - "get_status", - "get_config", - "get_transparent_config", - "get_whitelist_config", - "get_filtering_config", - "get_all_config", - "get_stats", - "get_requests", - "get_top_hosts", - "get_ca_info", - "get_web_token" - ] - }, - "uci": [ - "mitmproxy" - ] - }, - "write": { - "ubus": { - "luci.mitmproxy": [ - "service_start", - "service_stop", - "service_restart", - "firewall_setup", - "firewall_clear", - "set_config", - "add_to_list", - "remove_from_list", - "clear_data" - ] - }, - "uci": [ - "mitmproxy" - ] - } + "description": "Grant access to mitmproxy", + "read": { "ubus": { "luci.mitmproxy": ["status"] }, "uci": ["mitmproxy"] }, + "write": { "ubus": { "luci.mitmproxy": ["install", "start", "stop", "restart"] }, "uci": ["mitmproxy"] } } } diff --git a/package/secubox/luci-app-nextcloud/Makefile b/package/secubox/luci-app-nextcloud/Makefile new file mode 100644 index 00000000..15b4494a --- /dev/null +++ b/package/secubox/luci-app-nextcloud/Makefile @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Copyright (C) 2025 CyberMind.fr + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Nextcloud +LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-nextcloud +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_MAINTAINER:=CyberMind +PKG_LICENSE:=GPL-2.0 + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-nextcloud/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.nextcloud $(1)/usr/libexec/rpcd/luci.nextcloud + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-nextcloud.json $(1)/usr/share/luci/menu.d/luci-app-nextcloud.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-nextcloud.json $(1)/usr/share/rpcd/acl.d/luci-app-nextcloud.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/nextcloud + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/nextcloud/*.js $(1)/www/luci-static/resources/view/nextcloud/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/overview.js b/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/overview.js new file mode 100644 index 00000000..f98c3dbf --- /dev/null +++ b/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/overview.js @@ -0,0 +1,213 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; +'require poll'; + +var callStatus = rpc.declare({ object: 'luci.nextcloud', method: 'status', expect: {} }); +var callInstall = rpc.declare({ object: 'luci.nextcloud', method: 'install', expect: {} }); +var callStart = rpc.declare({ object: 'luci.nextcloud', method: 'start', expect: {} }); +var callStop = rpc.declare({ object: 'luci.nextcloud', method: 'stop', expect: {} }); +var callRestart = rpc.declare({ object: 'luci.nextcloud', method: 'restart', expect: {} }); + +var css = '.nc-container{max-width:900px;margin:0 auto}.nc-header{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;background:linear-gradient(135deg,#0082c9 0%,#00639b 100%);border-radius:16px;color:#fff;margin-bottom:1.5rem}.nc-header h2{margin:0;font-size:1.5rem;display:flex;align-items:center;gap:.5rem}.nc-status{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;border-radius:20px;font-size:.9rem}.nc-status.running{background:rgba(16,185,129,.2)}.nc-status.stopped{background:rgba(239,68,68,.2)}.nc-dot{width:10px;height:10px;border-radius:50%;animation:pulse 2s infinite}.nc-status.running .nc-dot{background:#10b981}.nc-status.stopped .nc-dot{background:#ef4444}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}.nc-card{background:#fff;border-radius:12px;padding:1.5rem;box-shadow:0 2px 8px rgba(0,0,0,.08);margin-bottom:1rem}.nc-card-title{font-size:1.1rem;font-weight:600;margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.nc-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem}.nc-info-item{padding:1rem;background:#f8f9fa;border-radius:8px}.nc-info-label{font-size:.8rem;color:#666;margin-bottom:.25rem}.nc-info-value{font-size:1.1rem;font-weight:500}.nc-actions{display:flex;gap:.75rem;flex-wrap:wrap}.nc-btn{padding:.6rem 1.2rem;border-radius:8px;border:none;cursor:pointer;font-weight:500;transition:all .2s}.nc-btn-primary{background:linear-gradient(135deg,#0082c9,#00639b);color:#fff}.nc-btn-primary:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,130,201,.3)}.nc-btn-success{background:#10b981;color:#fff}.nc-btn-danger{background:#ef4444;color:#fff}.nc-btn-secondary{background:#6b7280;color:#fff}.nc-btn:disabled{opacity:.5;cursor:not-allowed}.nc-webui{display:flex;align-items:center;gap:1rem;padding:1rem;background:linear-gradient(135deg,rgba(0,130,201,.1),rgba(0,99,155,.1));border-radius:12px;margin-top:1rem}.nc-webui-icon{font-size:2rem}.nc-webui-info{flex:1}.nc-webui-url{font-family:monospace;color:#0082c9}.nc-not-installed{text-align:center;padding:3rem}.nc-not-installed h3{margin-bottom:1rem;color:#333}.nc-not-installed p{color:#666;margin-bottom:1.5rem}.nc-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin:1.5rem 0;text-align:left}.nc-feature{padding:.75rem;background:#f0f9ff;border-radius:8px;font-size:.9rem}'; + +return view.extend({ + pollActive: true, + + load: function() { + return callStatus(); + }, + + startPolling: function() { + var self = this; + this.pollActive = true; + poll.add(L.bind(function() { + if (!this.pollActive) return Promise.resolve(); + return callStatus().then(L.bind(function(status) { + this.updateStatus(status); + }, this)); + }, this), 5); + }, + + updateStatus: function(status) { + var badge = document.querySelector('.nc-status'); + var statusText = document.querySelector('.nc-status-text'); + + if (badge && statusText) { + badge.className = 'nc-status ' + (status.running ? 'running' : 'stopped'); + statusText.textContent = status.running ? _('Running') : _('Stopped'); + } + }, + + handleInstall: function() { + var self = this; + ui.showModal(_('Installing Nextcloud'), [ + E('p', { 'class': 'spinning' }, _('Installing Nextcloud. This may take several minutes...')) + ]); + callInstall().then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', r.message || _('Installation started'))); + self.startPolling(); + } else { + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown error')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); + }); + }, + + handleStart: function() { + ui.showModal(_('Starting...'), [E('p', { 'class': 'spinning' }, _('Starting Nextcloud...'))]); + callStart().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Nextcloud started'))); + else ui.addNotification(null, E('p', _('Failed to start')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleStop: function() { + ui.showModal(_('Stopping...'), [E('p', { 'class': 'spinning' }, _('Stopping Nextcloud...'))]); + callStop().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Nextcloud stopped'))); + else ui.addNotification(null, E('p', _('Failed to stop')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + handleRestart: function() { + ui.showModal(_('Restarting...'), [E('p', { 'class': 'spinning' }, _('Restarting Nextcloud...'))]); + callRestart().then(function(r) { + ui.hideModal(); + if (r.success) ui.addNotification(null, E('p', _('Nextcloud restarted'))); + else ui.addNotification(null, E('p', _('Failed to restart')), 'error'); + }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', e.message), 'error'); }); + }, + + render: function(status) { + var self = this; + + if (!document.getElementById('nc-styles')) { + var s = document.createElement('style'); + s.id = 'nc-styles'; + s.textContent = css; + document.head.appendChild(s); + } + + // Not installed view + if (!status.installed || !status.docker_available) { + return E('div', { 'class': 'nc-container' }, [ + E('div', { 'class': 'nc-header' }, [ + E('h2', {}, ['\u2601\ufe0f ', _('Nextcloud')]), + E('div', { 'class': 'nc-status stopped' }, [ + E('span', { 'class': 'nc-dot' }), + E('span', { 'class': 'nc-status-text' }, _('Not Installed')) + ]) + ]), + E('div', { 'class': 'nc-card' }, [ + E('div', { 'class': 'nc-not-installed' }, [ + E('div', { 'style': 'font-size:4rem;margin-bottom:1rem' }, '\u2601\ufe0f'), + E('h3', {}, _('Nextcloud')), + E('p', {}, _('Self-hosted productivity platform with file sync, calendar, contacts, and more.')), + E('div', { 'class': 'nc-features' }, [ + E('div', { 'class': 'nc-feature' }, '\ud83d\udcc1 ' + _('File Sync')), + E('div', { 'class': 'nc-feature' }, '\ud83d\udcc5 ' + _('Calendar')), + E('div', { 'class': 'nc-feature' }, '\ud83d\udc65 ' + _('Contacts')), + E('div', { 'class': 'nc-feature' }, '\ud83d\udcdd ' + _('Documents')), + E('div', { 'class': 'nc-feature' }, '\ud83d\udcf7 ' + _('Photos')), + E('div', { 'class': 'nc-feature' }, '\ud83d\udd12 ' + _('E2E Encryption')) + ]), + !status.docker_available ? E('div', { 'style': 'color:#ef4444;margin-bottom:1rem' }, _('Docker is required but not available')) : '', + E('button', { + 'class': 'nc-btn nc-btn-primary', + 'click': ui.createHandlerFn(this, 'handleInstall'), + 'disabled': !status.docker_available + }, _('Install Nextcloud')) + ]) + ]) + ]); + } + + // Installed view + this.startPolling(); + + return E('div', { 'class': 'nc-container' }, [ + E('div', { 'class': 'nc-header' }, [ + E('h2', {}, ['\u2601\ufe0f ', _('Nextcloud')]), + E('div', { 'class': 'nc-status ' + (status.running ? 'running' : 'stopped') }, [ + E('span', { 'class': 'nc-dot' }), + E('span', { 'class': 'nc-status-text' }, status.running ? _('Running') : _('Stopped')) + ]) + ]), + + // Info Card + E('div', { 'class': 'nc-card' }, [ + E('div', { 'class': 'nc-card-title' }, ['\u2139\ufe0f ', _('Service Information')]), + E('div', { 'class': 'nc-info-grid' }, [ + E('div', { 'class': 'nc-info-item' }, [ + E('div', { 'class': 'nc-info-label' }, _('Port')), + E('div', { 'class': 'nc-info-value' }, status.port || '80') + ]), + E('div', { 'class': 'nc-info-item' }, [ + E('div', { 'class': 'nc-info-label' }, _('Admin User')), + E('div', { 'class': 'nc-info-value' }, status.admin_user || 'admin') + ]), + E('div', { 'class': 'nc-info-item' }, [ + E('div', { 'class': 'nc-info-label' }, _('Trusted Domains')), + E('div', { 'class': 'nc-info-value' }, status.trusted_domains || 'cloud.local') + ]), + E('div', { 'class': 'nc-info-item' }, [ + E('div', { 'class': 'nc-info-label' }, _('Data Path')), + E('div', { 'class': 'nc-info-value' }, status.data_path || '/srv/nextcloud') + ]) + ]), + + // Web UI Link + status.running && status.web_accessible ? E('div', { 'class': 'nc-webui' }, [ + E('div', { 'class': 'nc-webui-icon' }, '\ud83c\udf10'), + E('div', { 'class': 'nc-webui-info' }, [ + E('div', { 'style': 'font-weight:600' }, _('Web Interface')), + E('div', { 'class': 'nc-webui-url' }, status.web_url) + ]), + E('a', { + 'href': status.web_url, + 'target': '_blank', + 'class': 'nc-btn nc-btn-primary' + }, _('Open')) + ]) : '' + ]), + + // Actions Card + E('div', { 'class': 'nc-card' }, [ + E('div', { 'class': 'nc-card-title' }, ['\u26a1 ', _('Actions')]), + E('div', { 'class': 'nc-actions' }, [ + E('button', { + 'class': 'nc-btn nc-btn-success', + 'click': ui.createHandlerFn(this, 'handleStart'), + 'disabled': status.running + }, _('Start')), + E('button', { + 'class': 'nc-btn nc-btn-danger', + 'click': ui.createHandlerFn(this, 'handleStop'), + 'disabled': !status.running + }, _('Stop')), + E('button', { + 'class': 'nc-btn nc-btn-secondary', + 'click': ui.createHandlerFn(this, 'handleRestart'), + 'disabled': !status.running + }, _('Restart')), + E('a', { + 'href': L.url('admin', 'secubox', 'services', 'nextcloud', 'settings'), + 'class': 'nc-btn nc-btn-secondary' + }, _('Settings')) + ]) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/settings.js b/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/settings.js new file mode 100644 index 00000000..8d2bf822 --- /dev/null +++ b/package/secubox/luci-app-nextcloud/htdocs/luci-static/resources/view/nextcloud/settings.js @@ -0,0 +1,64 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; + +return view.extend({ + load: function() { + return uci.load('nextcloud'); + }, + + render: function() { + var m, s, o; + + m = new form.Map('nextcloud', _('Nextcloud Settings'), + _('Configure Nextcloud settings. Changes require service restart to take effect.')); + + s = m.section(form.TypedSection, 'nextcloud', _('General Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enabled'), + _('Enable Nextcloud')); + o.default = '0'; + o.rmempty = false; + + o = s.option(form.Value, 'port', _('Web UI Port'), + _('Port for the Nextcloud web interface')); + o.datatype = 'port'; + o.default = '80'; + o.placeholder = '80'; + + o = s.option(form.Value, 'data_path', _('Data Path'), + _('Path to store Nextcloud data')); + o.default = '/srv/nextcloud'; + o.placeholder = '/srv/nextcloud'; + + o = s.option(form.Value, 'admin_user', _('Admin Username'), + _('Administrator username for initial setup')); + o.default = 'admin'; + o.placeholder = 'admin'; + + o = s.option(form.Value, 'admin_password', _('Admin Password'), + _('Administrator password for initial setup. Required for first install.')); + o.password = true; + o.placeholder = _('Enter password'); + + o = s.option(form.Value, 'trusted_domains', _('Trusted Domains'), + _('Comma-separated list of trusted domains (e.g., cloud.example.com,192.168.1.1)')); + o.default = 'cloud.local'; + o.placeholder = 'cloud.local'; + + o = s.option(form.Value, 'timezone', _('Timezone'), + _('Timezone for the container')); + o.default = 'UTC'; + o.placeholder = 'UTC'; + + o = s.option(form.Value, 'image', _('Docker Image'), + _('Docker image to use')); + o.default = 'nextcloud:latest'; + o.placeholder = 'nextcloud:latest'; + + return m.render(); + } +}); diff --git a/package/secubox/luci-app-nextcloud/root/usr/libexec/rpcd/luci.nextcloud b/package/secubox/luci-app-nextcloud/root/usr/libexec/rpcd/luci.nextcloud new file mode 100644 index 00000000..6ce68870 --- /dev/null +++ b/package/secubox/luci-app-nextcloud/root/usr/libexec/rpcd/luci.nextcloud @@ -0,0 +1,235 @@ +#!/bin/sh +# RPCD backend for Nextcloud LuCI app + +CONFIG="nextcloud" +CONTAINER="secbx-nextcloud" + +uci_get() { uci -q get ${CONFIG}.main.$1; } +uci_set() { uci set ${CONFIG}.main.$1="$2" && uci commit ${CONFIG}; } + +# Get service status +get_status() { + local enabled=$(uci_get enabled) + local port=$(uci_get port) + local data_path=$(uci_get data_path) + local admin_user=$(uci_get admin_user) + local trusted_domains=$(uci_get trusted_domains) + local image=$(uci_get image) + + # Check if Docker is available + local docker_available=0 + command -v docker >/dev/null 2>&1 && docker_available=1 + + # Check if container is running + local running=0 + local container_status="stopped" + if [ "$docker_available" = "1" ]; then + if docker ps --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then + running=1 + container_status="running" + elif docker ps -a --filter "name=$CONTAINER" --format "{{.Names}}" 2>/dev/null | grep -q "$CONTAINER"; then + container_status="stopped" + fi + fi + + # Check if installed (image exists) + local installed=0 + if [ "$docker_available" = "1" ]; then + docker images --format "{{.Repository}}" 2>/dev/null | grep -q "nextcloud" && installed=1 + fi + + # Check web UI accessibility + local web_accessible=0 + if [ "$running" = "1" ]; then + wget -q -O /dev/null --timeout=2 "http://127.0.0.1:${port:-80}/" 2>/dev/null && web_accessible=1 + fi + + cat </dev/null) + local data_path=$(echo "$input" | jsonfilter -e '@.data_path' 2>/dev/null) + local admin_user=$(echo "$input" | jsonfilter -e '@.admin_user' 2>/dev/null) + local admin_password=$(echo "$input" | jsonfilter -e '@.admin_password' 2>/dev/null) + local trusted_domains=$(echo "$input" | jsonfilter -e '@.trusted_domains' 2>/dev/null) + local timezone=$(echo "$input" | jsonfilter -e '@.timezone' 2>/dev/null) + + [ -n "$port" ] && uci_set port "$port" + [ -n "$data_path" ] && uci_set data_path "$data_path" + [ -n "$admin_user" ] && uci_set admin_user "$admin_user" + [ -n "$admin_password" ] && uci_set admin_password "$admin_password" + [ -n "$trusted_domains" ] && uci_set trusted_domains "$trusted_domains" + [ -n "$timezone" ] && uci_set timezone "$timezone" + + echo '{"success": true}' +} + +# Install Nextcloud +do_install() { + if command -v nextcloudctl >/dev/null 2>&1; then + nextcloudctl install >/tmp/nextcloud-install.log 2>&1 & + echo '{"success": true, "message": "Installation started in background"}' + else + echo '{"success": false, "error": "nextcloudctl not found"}' + fi +} + +# Start service +do_start() { + if [ -x /etc/init.d/nextcloud ]; then + /etc/init.d/nextcloud start >/dev/null 2>&1 + uci_set enabled '1' + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Stop service +do_stop() { + if [ -x /etc/init.d/nextcloud ]; then + /etc/init.d/nextcloud stop >/dev/null 2>&1 + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Restart service +do_restart() { + if [ -x /etc/init.d/nextcloud ]; then + /etc/init.d/nextcloud restart >/dev/null 2>&1 + echo '{"success": true}' + else + echo '{"success": false, "error": "Service not installed"}' + fi +} + +# Update container +do_update() { + if command -v nextcloudctl >/dev/null 2>&1; then + nextcloudctl update >/tmp/nextcloud-update.log 2>&1 & + echo '{"success": true, "message": "Update started in background"}' + else + echo '{"success": false, "error": "nextcloudctl not found"}' + fi +} + +# Run OCC command +do_occ() { + local input + read -r input + local cmd=$(echo "$input" | jsonfilter -e '@.command' 2>/dev/null) + + if [ -z "$cmd" ]; then + echo '{"success": false, "error": "No command specified"}' + return + fi + + if command -v nextcloudctl >/dev/null 2>&1; then + local output=$(nextcloudctl occ $cmd 2>&1) + echo "{\"success\": true, \"output\": \"$(echo "$output" | sed 's/"/\\"/g' | tr '\n' ' ')\"}" + else + echo '{"success": false, "error": "nextcloudctl not found"}' + fi +} + +# Get logs +get_logs() { + local lines=50 + local log_content="" + + if [ -f /tmp/nextcloud-install.log ]; then + log_content=$(tail -n $lines /tmp/nextcloud-install.log 2>/dev/null | sed 's/"/\\"/g' | tr '\n' '|') + fi + + echo "{\"logs\": \"$log_content\"}" +} + +# RPCD list method +list_methods() { + cat <<'EOF' +{ + "status": {}, + "get_config": {}, + "save_config": {"port": "string", "data_path": "string", "admin_user": "string", "admin_password": "string", "trusted_domains": "string", "timezone": "string"}, + "install": {}, + "start": {}, + "stop": {}, + "restart": {}, + "update": {}, + "occ": {"command": "string"}, + "logs": {} +} +EOF +} + +# Main entry point +case "$1" in + list) + list_methods + ;; + call) + case "$2" in + status) get_status ;; + get_config) get_config ;; + save_config) save_config ;; + install) do_install ;; + start) do_start ;; + stop) do_stop ;; + restart) do_restart ;; + update) do_update ;; + occ) do_occ ;; + logs) get_logs ;; + *) echo '{"error": "Unknown method"}' ;; + esac + ;; + *) + echo '{"error": "Unknown command"}' + ;; +esac diff --git a/package/secubox/luci-app-nextcloud/root/usr/share/luci/menu.d/luci-app-nextcloud.json b/package/secubox/luci-app-nextcloud/root/usr/share/luci/menu.d/luci-app-nextcloud.json new file mode 100644 index 00000000..a70a3fe8 --- /dev/null +++ b/package/secubox/luci-app-nextcloud/root/usr/share/luci/menu.d/luci-app-nextcloud.json @@ -0,0 +1,28 @@ +{ + "admin/secubox/services/nextcloud": { + "title": "Nextcloud", + "order": 55, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": ["luci-app-nextcloud"] + } + }, + "admin/secubox/services/nextcloud/overview": { + "title": "Overview", + "order": 10, + "action": { + "type": "view", + "path": "nextcloud/overview" + } + }, + "admin/secubox/services/nextcloud/settings": { + "title": "Settings", + "order": 90, + "action": { + "type": "view", + "path": "nextcloud/settings" + } + } +} diff --git a/package/secubox/luci-app-nextcloud/root/usr/share/rpcd/acl.d/luci-app-nextcloud.json b/package/secubox/luci-app-nextcloud/root/usr/share/rpcd/acl.d/luci-app-nextcloud.json new file mode 100644 index 00000000..234048c9 --- /dev/null +++ b/package/secubox/luci-app-nextcloud/root/usr/share/rpcd/acl.d/luci-app-nextcloud.json @@ -0,0 +1,17 @@ +{ + "luci-app-nextcloud": { + "description": "Grant access to Nextcloud", + "read": { + "ubus": { + "luci.nextcloud": ["status", "get_config", "logs"] + }, + "uci": ["nextcloud"] + }, + "write": { + "ubus": { + "luci.nextcloud": ["install", "start", "stop", "restart", "update", "save_config", "occ"] + }, + "uci": ["nextcloud"] + } + } +} diff --git a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js index a8d327f7..9b8432be 100644 --- a/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js +++ b/package/secubox/luci-app-secubox-portal/htdocs/luci-static/resources/secubox-portal/portal.js @@ -328,9 +328,9 @@ return baseclass.extend({ icon: '\ud83e\udde5', iconBg: 'rgba(124, 58, 237, 0.15)', iconColor: '#7c3aed', - section: 'services', + section: 'security', path: 'admin/services/tor-shield/overview', - service: 'tor', + service: 'tor-shield', version: '1.0.0' }, 'jellyfin': { @@ -404,6 +404,78 @@ return baseclass.extend({ path: 'admin/secubox/services/gitea/overview', service: 'gitea', version: '1.22.0' + }, + 'lyrion': { + id: 'lyrion', + name: 'Lyrion Music Server', + desc: 'Self-hosted music streaming with Squeezebox/Logitech Media Server compatibility', + icon: '\ud83c\udfb5', + iconBg: 'rgba(236, 72, 153, 0.15)', + iconColor: '#ec4899', + section: 'services', + path: 'admin/secubox/services/lyrion/overview', + service: 'lyrion', + version: '9.0.3' + }, + 'ollama': { + id: 'ollama', + name: 'Ollama', + desc: 'Run large language models locally with easy-to-use CLI and API', + icon: '\ud83e\uddac', + iconBg: 'rgba(99, 102, 241, 0.15)', + iconColor: '#6366f1', + section: 'services', + path: 'admin/secubox/services/ollama/overview', + service: 'ollama', + version: '0.5.0' + }, + 'streamlit': { + id: 'streamlit', + name: 'Streamlit', + desc: 'Python data apps and dashboards with instant web deployment', + icon: '\ud83d\udcca', + iconBg: 'rgba(255, 75, 75, 0.15)', + iconColor: '#ff4b4b', + section: 'services', + path: 'admin/secubox/services/streamlit/overview', + service: 'streamlit', + version: '1.40.0' + }, + 'zigbee2mqtt': { + id: 'zigbee2mqtt', + name: 'Zigbee2MQTT', + desc: 'Bridge Zigbee devices to MQTT for smart home automation', + icon: '\ud83d\udca1', + iconBg: 'rgba(245, 158, 11, 0.15)', + iconColor: '#f59e0b', + section: 'services', + path: 'admin/secubox/services/zigbee2mqtt/overview', + service: 'zigbee2mqtt', + version: '1.40.0' + }, + 'domoticz': { + id: 'domoticz', + name: 'Domoticz', + desc: 'Home automation system with support for various sensors and devices', + icon: '\ud83c\udfe0', + iconBg: 'rgba(34, 197, 94, 0.15)', + iconColor: '#22c55e', + section: 'services', + path: 'admin/secubox/services/domoticz/overview', + service: 'domoticz', + version: '2024.7' + }, + 'mailinabox': { + id: 'mailinabox', + name: 'Mail-in-a-Box', + desc: 'Self-hosted email server with webmail, calendar, and contacts', + icon: '\ud83d\udce7', + iconBg: 'rgba(59, 130, 246, 0.15)', + iconColor: '#3b82f6', + section: 'services', + path: 'admin/secubox/services/mailinabox/overview', + service: 'mailinabox', + version: '2.0.0' } }, diff --git a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js index c4a76f1e..92cebb5e 100644 --- a/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js +++ b/package/secubox/luci-app-tor-shield/htdocs/luci-static/resources/view/tor-shield/overview.js @@ -464,8 +464,7 @@ return view.extend({ }, ['\uD83D\uDD0D ', _('Leak Test')]), E('button', { 'class': 'tor-btn tor-btn-warning', - 'click': L.bind(this.handleRestart, this), - 'disabled': !status.enabled + 'click': L.bind(this.handleRestart, this) }, ['\u21BB ', _('Restart')]), E('a', { 'class': 'tor-btn', diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl index 7a3779eb..d268c401 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -598,6 +598,24 @@ _add_server_to_backend() { [ -n "$address" ] || return + # Validate address - if it's a hostname (not IP), try to resolve it + if ! echo "$address" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + # It's a hostname, try to resolve it + local resolved_ip="" + resolved_ip=$(nslookup "$address" 2>/dev/null | awk '/^Address: / { print $2; exit }') + if [ -z "$resolved_ip" ]; then + # Try getent as fallback + resolved_ip=$(getent hosts "$address" 2>/dev/null | awk '{print $1; exit}') + fi + if [ -z "$resolved_ip" ]; then + log_warn "Cannot resolve hostname '$address' for server $server_name in backend $target_backend - skipping" + return + fi + # Use the resolved IP instead + log_debug "Resolved $address to $resolved_ip" + address="$resolved_ip" + fi + local check_opt="" [ "$check" = "1" ] && check_opt="check" diff --git a/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl b/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl index 6446fc44..c617b33e 100755 --- a/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl +++ b/package/secubox/secubox-app-lyrion/files/usr/sbin/lyrionctl @@ -396,18 +396,22 @@ STUB mkdir -p /config/prefs/plugin /config/cache /music /var/log/lyrion chown -R nobody:nobody /config /var/log/lyrion -# Create startup script +# Create startup script that runs as nobody user cat > /opt/lyrion/start.sh << 'START' #!/bin/sh cd /opt/lyrion + +# Ensure directories exist with proper permissions mkdir -p /config/prefs/plugin /config/cache /var/log/lyrion -chown -R nobody:nobody /config /var/log/lyrion 2>/dev/null || true -exec perl slimserver.pl \ +chown -R nobody:nobody /config /var/log/lyrion /opt/lyrion 2>/dev/null || true + +# Run Lyrion as nobody user to avoid permission issues +exec su -s /bin/sh nobody -c "cd /opt/lyrion && exec perl slimserver.pl \ --prefsdir /config/prefs \ --cachedir /config/cache \ --logdir /var/log/lyrion \ --httpport 9000 \ - --cliport 9090 + --cliport 9090" START chmod +x /opt/lyrion/start.sh diff --git a/package/secubox/secubox-app-metablogizer/Makefile b/package/secubox/secubox-app-metablogizer/Makefile new file mode 100644 index 00000000..14e59e3d --- /dev/null +++ b/package/secubox/secubox-app-metablogizer/Makefile @@ -0,0 +1,29 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-metablogizer +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-metablogizer + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=MetaBlogizer Static Site Publisher + DEPENDS:=+git +uhttpd + PKGARCH:=all +endef + +define Package/secubox-app-metablogizer/description + Static site publisher with auto-vhost creation. + Supports uhttpd (default) and nginx LXC runtimes. +endef + +define Package/secubox-app-metablogizer/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/metablogizerctl $(1)/usr/sbin/ + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/metablogizer $(1)/etc/config/ +endef + +$(eval $(call BuildPackage,secubox-app-metablogizer)) diff --git a/package/secubox/secubox-app-metablogizer/files/etc/config/metablogizer b/package/secubox/secubox-app-metablogizer/files/etc/config/metablogizer new file mode 100644 index 00000000..84e7baa4 --- /dev/null +++ b/package/secubox/secubox-app-metablogizer/files/etc/config/metablogizer @@ -0,0 +1,6 @@ +config metablogizer 'main' + option enabled '1' + option runtime 'auto' + option sites_root '/srv/metablogizer/sites' + option gitea_url 'http://localhost:3000' + option port_base '8900' diff --git a/package/secubox/secubox-app-metablogizer/files/usr/sbin/metablogizerctl b/package/secubox/secubox-app-metablogizer/files/usr/sbin/metablogizerctl new file mode 100644 index 00000000..cdc57678 --- /dev/null +++ b/package/secubox/secubox-app-metablogizer/files/usr/sbin/metablogizerctl @@ -0,0 +1,512 @@ +#!/bin/sh +# SecuBox MetaBlogizer - Static Site Publisher +# Supports uhttpd (default) and nginx LXC runtime +# Copyright (C) 2025 CyberMind.fr + +CONFIG="metablogizer" +SITES_ROOT="/srv/metablogizer/sites" +NGINX_LXC="metablogizer-nginx" +LXC_PATH="/srv/lxc" +PORT_BASE=8900 + +. /lib/functions.sh + +log_info() { echo "[INFO] $*"; logger -t metablogizer "$*"; } +log_warn() { echo "[WARN] $*" >&2; } +log_error() { echo "[ERROR] $*" >&2; } + +uci_get() { uci -q get ${CONFIG}.$1; } +uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; } + +usage() { + cat <<'EOF' +MetaBlogizer - Static Site Publisher + +Usage: metablogizerctl [options] + +Site Commands: + list List all sites + create [repo] Create new site + delete Delete site + sync Sync site from git repo + publish Publish site (create HAProxy vhost) + +Runtime Commands: + runtime Show current runtime + runtime set Set runtime preference + +Management: + status Show overall status + install-nginx Install nginx LXC container (optional) + +Runtime Selection: + auto - Auto-detect (uhttpd preferred) + uhttpd - Use uhttpd instances (lightweight) + nginx - Use nginx LXC container (more features) +EOF +} + +# =========================================== +# Runtime Detection +# =========================================== + +has_uhttpd() { [ -x /etc/init.d/uhttpd ]; } + +has_nginx_lxc() { + command -v lxc-info >/dev/null 2>&1 && \ + [ -d "$LXC_PATH/$NGINX_LXC/rootfs" ] +} + +detect_runtime() { + local configured=$(uci_get main.runtime) + + case "$configured" in + uhttpd) + if has_uhttpd; then + echo "uhttpd" + else + log_error "uhttpd requested but not available" + return 1 + fi + ;; + nginx) + if has_nginx_lxc; then + echo "nginx" + else + log_error "nginx LXC requested but not installed" + return 1 + fi + ;; + auto|*) + # Prefer uhttpd (lighter), fall back to nginx + if has_uhttpd; then + echo "uhttpd" + elif has_nginx_lxc; then + echo "nginx" + else + log_error "No runtime available" + return 1 + fi + ;; + esac +} + +# =========================================== +# Site Management +# =========================================== + +get_next_port() { + local port=$PORT_BASE + while uci show uhttpd 2>/dev/null | grep -q "listen_http='0.0.0.0:$port'"; do + port=$((port + 1)) + done + echo $port +} + +site_exists() { + local name="$1" + uci -q get ${CONFIG}.site_${name} >/dev/null 2>&1 +} + +cmd_list() { + echo "MetaBlogizer Sites:" + echo "===================" + + local runtime=$(detect_runtime 2>/dev/null) + echo "Runtime: ${runtime:-none}" + echo "" + + config_load "$CONFIG" + + local found=0 + _print_site() { + local section="$1" + local name domain port enabled gitea_repo + + config_get name "$section" name + config_get domain "$section" domain + config_get port "$section" port + config_get enabled "$section" enabled "0" + config_get gitea_repo "$section" gitea_repo "" + + [ -z "$name" ] && return + + local status="disabled" + [ "$enabled" = "1" ] && status="enabled" + + local dir_status="missing" + [ -d "$SITES_ROOT/$name" ] && dir_status="exists" + + printf " %-15s %-25s :%-5s [%s] %s\n" "$name" "$domain" "$port" "$status" "$dir_status" + found=1 + } + config_foreach _print_site site + + [ "$found" = "0" ] && echo " No sites configured" +} + +cmd_create() { + local name="$1" + local domain="$2" + local gitea_repo="$3" + + [ -z "$name" ] && { log_error "Site name required"; return 1; } + [ -z "$domain" ] && { log_error "Domain required"; return 1; } + + # Sanitize name + name=$(echo "$name" | tr -cd 'a-z0-9_-') + + if site_exists "$name"; then + log_error "Site '$name' already exists" + return 1 + fi + + local runtime=$(detect_runtime) || return 1 + local port=$(get_next_port) + + log_info "Creating site: $name ($domain) on port $port using $runtime" + + # Create site directory with proper permissions + mkdir -p "$SITES_ROOT/$name" + chmod 755 "$SITES_ROOT/$name" + + # Create placeholder index + cat > "$SITES_ROOT/$name/index.html" < + + + + $name + + + + + + +

$name

+

Site published with MetaBlogizer

+

https://$domain

+ + +EOF + chmod 644 "$SITES_ROOT/$name/index.html" + + # Clone from Gitea if repo specified + if [ -n "$gitea_repo" ]; then + local gitea_url=$(uci_get main.gitea_url) + [ -z "$gitea_url" ] && gitea_url="http://localhost:3000" + + log_info "Cloning from $gitea_url/$gitea_repo..." + rm -rf "$SITES_ROOT/$name" + git clone "$gitea_url/$gitea_repo.git" "$SITES_ROOT/$name" 2>/dev/null || { + log_warn "Git clone failed, using placeholder" + mkdir -p "$SITES_ROOT/$name" + } + # Set proper permissions for web serving + chmod -R 755 "$SITES_ROOT/$name" + find "$SITES_ROOT/$name" -type f -exec chmod 644 {} \; + fi + + # Configure runtime + case "$runtime" in + uhttpd) + _create_uhttpd_site "$name" "$port" + ;; + nginx) + _create_nginx_site "$name" + ;; + esac + + # Save site config + uci set ${CONFIG}.site_${name}=site + uci set ${CONFIG}.site_${name}.name="$name" + uci set ${CONFIG}.site_${name}.domain="$domain" + uci set ${CONFIG}.site_${name}.port="$port" + uci set ${CONFIG}.site_${name}.runtime="$runtime" + [ -n "$gitea_repo" ] && uci set ${CONFIG}.site_${name}.gitea_repo="$gitea_repo" + uci set ${CONFIG}.site_${name}.enabled="1" + uci commit ${CONFIG} + + log_info "Site created: $name" + log_info "Directory: $SITES_ROOT/$name" + log_info "Local URL: http://localhost:$port" + + echo "" + echo "Next: Run 'metablogizerctl publish $name' to create HAProxy vhost" +} + +_create_uhttpd_site() { + local name="$1" + local port="$2" + + log_info "Creating uhttpd instance for $name on port $port" + + uci set uhttpd.metablog_${name}=uhttpd + uci set uhttpd.metablog_${name}.listen_http="0.0.0.0:$port" + uci set uhttpd.metablog_${name}.home="$SITES_ROOT/$name" + uci set uhttpd.metablog_${name}.index_page="index.html" + uci set uhttpd.metablog_${name}.error_page="/index.html" + uci commit uhttpd + + /etc/init.d/uhttpd reload 2>/dev/null || /etc/init.d/uhttpd restart +} + +_create_nginx_site() { + local name="$1" + + if ! has_nginx_lxc; then + log_error "nginx LXC not installed. Run: metablogizerctl install-nginx" + return 1 + fi + + log_info "Creating nginx config for $name" + + local nginx_conf="$LXC_PATH/$NGINX_LXC/rootfs/etc/nginx/sites.d" + mkdir -p "$nginx_conf" + + cat > "$nginx_conf/metablog-$name.conf" </dev/null || true +} + +cmd_publish() { + local name="$1" + [ -z "$name" ] && { log_error "Site name required"; return 1; } + + if ! site_exists "$name"; then + log_error "Site '$name' not found" + return 1 + fi + + local domain=$(uci_get site_${name}.domain) + local port=$(uci_get site_${name}.port) + + [ -z "$domain" ] && { log_error "Site domain not configured"; return 1; } + + log_info "Publishing $name to $domain" + + # Create HAProxy backend + local backend_name="metablog_${name}" + uci set haproxy.${backend_name}=backend + uci set haproxy.${backend_name}.name="$backend_name" + uci set haproxy.${backend_name}.mode="http" + uci set haproxy.${backend_name}.balance="roundrobin" + uci set haproxy.${backend_name}.enabled="1" + + # Create HAProxy server + local server_name="${backend_name}_srv" + uci set haproxy.${server_name}=server + uci set haproxy.${server_name}.backend="$backend_name" + uci set haproxy.${server_name}.name="uhttpd" + uci set haproxy.${server_name}.address="192.168.255.1" + uci set haproxy.${server_name}.port="$port" + uci set haproxy.${server_name}.weight="100" + uci set haproxy.${server_name}.check="1" + uci set haproxy.${server_name}.enabled="1" + + # Create HAProxy vhost + local vhost_name=$(echo "$domain" | tr '.-' '_') + uci set haproxy.${vhost_name}=vhost + uci set haproxy.${vhost_name}.domain="$domain" + uci set haproxy.${vhost_name}.backend="$backend_name" + uci set haproxy.${vhost_name}.ssl="1" + uci set haproxy.${vhost_name}.ssl_redirect="1" + uci set haproxy.${vhost_name}.acme="1" + uci set haproxy.${vhost_name}.enabled="1" + + uci commit haproxy + + # Regenerate HAProxy config + /usr/sbin/haproxyctl generate 2>/dev/null + /etc/init.d/haproxy reload 2>/dev/null + + log_info "Site published!" + echo "" + echo "URL: https://$domain" + echo "" + echo "To request SSL certificate:" + echo " haproxyctl cert add $domain" +} + +cmd_delete() { + local name="$1" + [ -z "$name" ] && { log_error "Site name required"; return 1; } + + log_info "Deleting site: $name" + + # Remove uhttpd instance + uci delete uhttpd.metablog_${name} 2>/dev/null + uci commit uhttpd + /etc/init.d/uhttpd reload 2>/dev/null + + # Remove HAProxy config + local domain=$(uci_get site_${name}.domain) + if [ -n "$domain" ]; then + local vhost_name=$(echo "$domain" | tr '.-' '_') + uci delete haproxy.${vhost_name} 2>/dev/null + uci delete haproxy.metablog_${name} 2>/dev/null + uci delete haproxy.metablog_${name}_srv 2>/dev/null + uci commit haproxy + /usr/sbin/haproxyctl generate 2>/dev/null + /etc/init.d/haproxy reload 2>/dev/null + fi + + # Remove site config + uci delete ${CONFIG}.site_${name} 2>/dev/null + uci commit ${CONFIG} + + # Optionally remove files + if [ -d "$SITES_ROOT/$name" ]; then + echo "Site directory: $SITES_ROOT/$name" + echo "Remove manually if desired: rm -rf $SITES_ROOT/$name" + fi + + log_info "Site deleted" +} + +cmd_sync() { + local name="$1" + [ -z "$name" ] && { log_error "Site name required"; return 1; } + + local gitea_repo=$(uci_get site_${name}.gitea_repo) + [ -z "$gitea_repo" ] && { log_error "No git repo configured for $name"; return 1; } + + local site_dir="$SITES_ROOT/$name" + [ ! -d "$site_dir" ] && { log_error "Site directory not found"; return 1; } + + log_info "Syncing $name from git..." + + cd "$site_dir" + if [ -d ".git" ]; then + git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || git pull + else + local gitea_url=$(uci_get main.gitea_url) + [ -z "$gitea_url" ] && gitea_url="http://localhost:3000" + git clone "$gitea_url/$gitea_repo.git" /tmp/metablog-sync-$$ + cp -r /tmp/metablog-sync-$$/* "$site_dir/" + rm -rf /tmp/metablog-sync-$$ + fi + + log_info "Sync complete" +} + +cmd_runtime() { + local action="$1" + local value="$2" + + if [ "$action" = "set" ]; then + case "$value" in + uhttpd|nginx|auto) + uci_set main.runtime "$value" + log_info "Runtime set to: $value" + ;; + *) + log_error "Invalid runtime: $value (use uhttpd, nginx, or auto)" + return 1 + ;; + esac + else + local configured=$(uci_get main.runtime) + local detected=$(detect_runtime 2>/dev/null) + echo "Configured: ${configured:-auto}" + echo "Detected: ${detected:-none}" + echo "" + echo "Available:" + has_uhttpd && echo " - uhttpd (installed)" || echo " - uhttpd (not available)" + has_nginx_lxc && echo " - nginx LXC (installed)" || echo " - nginx LXC (not installed)" + fi +} + +cmd_status() { + echo "MetaBlogizer Status" + echo "===================" + + local enabled=$(uci_get main.enabled) + local runtime=$(detect_runtime 2>/dev/null) + local sites_count=$(uci show $CONFIG 2>/dev/null | grep -c "=site") + + echo "Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")" + echo "Runtime: ${runtime:-none}" + echo "Sites: $sites_count" + echo "Sites Root: $SITES_ROOT" + echo "" + + cmd_list +} + +cmd_install_nginx() { + log_info "Installing nginx LXC container..." + + command -v lxc-start >/dev/null 2>&1 || { + log_error "LXC not installed. Install with: opkg install lxc lxc-common" + return 1 + } + + local rootfs="$LXC_PATH/$NGINX_LXC/rootfs" + mkdir -p "$LXC_PATH/$NGINX_LXC" + + # Download Alpine + local arch="aarch64" + case "$(uname -m)" in + x86_64) arch="x86_64" ;; + armv7l) arch="armv7" ;; + esac + + log_info "Downloading Alpine Linux..." + wget -q -O /tmp/alpine-nginx.tar.gz \ + "https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/$arch/alpine-minirootfs-3.19.0-$arch.tar.gz" || { + log_error "Failed to download Alpine" + return 1 + } + + mkdir -p "$rootfs" + tar xzf /tmp/alpine-nginx.tar.gz -C "$rootfs" + rm -f /tmp/alpine-nginx.tar.gz + + # Configure + echo "nameserver 8.8.8.8" > "$rootfs/etc/resolv.conf" + + # Install nginx + chroot "$rootfs" /bin/sh -c "apk update && apk add --no-cache nginx" + + # Create LXC config + cat > "$LXC_PATH/$NGINX_LXC/config" </dev/null 2>&1 || continue + + local is_running=false + + case "$check_method" in + pid) + # Check via pidof or pgrep + if pgrep "$service_name" >/dev/null 2>&1; then + is_running=true + fi + ;; + docker) + # Check Docker container + if docker ps --filter "name=$service_name" --format "{{.Names}}" 2>/dev/null | grep -q "$service_name"; then + is_running=true + fi + ;; + lxc) + # Check LXC container + if lxc-info -n "$service_name" -s 2>/dev/null | grep -q "RUNNING"; then + is_running=true + fi + ;; + port:*) + # Check if port is listening + local port=$(echo "$check_method" | cut -d: -f2) + # Use /proc/net/tcp (ports in hex) + local port_hex=$(printf '%04X' "$port") + if grep -q ":$port_hex " /proc/net/tcp /proc/net/tcp6 2>/dev/null; then + is_running=true + fi + ;; + esac + + if [ "$is_running" = "false" ]; then + log warn "Watchdog: $service_name is down, restarting..." + sleep "$restart_delay" + + # Double-check before restart (service might have recovered) + case "$check_method" in + pid) pgrep "$service_name" >/dev/null 2>&1 && continue ;; + esac + + /etc/init.d/$service_name restart >/dev/null 2>&1 + restart_count=$((restart_count + 1)) + + # Log restart event + log info "Watchdog: Restarted $service_name" + fi + done + + # Save watchdog state + json_init + json_add_string "last_check" "$(date -Iseconds)" + json_add_int "restarts" "$restart_count" + json_dump > "$WATCHDOG_STATE" 2>/dev/null + + return 0 +} + +# Get list of UCI-configured services to watch +get_watchdog_services() { + # Core services always monitored if enabled + local core_services="haproxy crowdsec" + + # Scan for secubox apps with watchdog=1 + for conf in $(uci show 2>/dev/null | grep "\.watchdog=" | grep "'1'" | cut -d. -f1-2); do + local service=$(uci -q get "$conf.service") + [ -n "$service" ] && core_services="$core_services $service" + done + + echo "$core_services" +} + # Daemon mode daemon_mode() { log info "SecuBox Core daemon starting (version $SECUBOX_VERSION)" @@ -172,13 +273,27 @@ daemon_mode() { # Get health check interval local health_interval=$(uci -q get secubox.main.health_check_interval || echo "300") + # Get watchdog interval (faster than health check) + local watchdog_interval=$(uci -q get secubox.main.watchdog_interval || echo "60") + # Main daemon loop + local health_counter=0 + local health_cycles=$((health_interval / watchdog_interval)) + [ "$health_cycles" -lt 1 ] && health_cycles=1 + while true; do - # Run periodic health check - run_health_check > /tmp/secubox/health-status.json + # Run watchdog every cycle + run_watchdog + + # Run health check every N cycles + health_counter=$((health_counter + 1)) + if [ "$health_counter" -ge "$health_cycles" ]; then + run_health_check > /tmp/secubox/health-status.json + health_counter=0 + fi # Sleep until next check - sleep "$health_interval" + sleep "$watchdog_interval" done } @@ -197,8 +312,11 @@ case "$1" in log info "Reloading configuration" killall -HUP secubox-core 2>/dev/null || true ;; + watchdog) + run_watchdog + ;; *) - echo "Usage: $0 {daemon|status|health|reload}" + echo "Usage: $0 {daemon|status|health|reload|watchdog}" exit 1 ;; esac