diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/api.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/api.js index a703ed26..f2c99067 100644 --- a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/api.js +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/api.js @@ -139,6 +139,29 @@ var callEmancipateStatus = rpc.declare({ params: ['job_id'] }); +var callUploadAndCreateSite = rpc.declare({ + object: 'luci.metablogizer', + method: 'upload_and_create_site', + params: ['name', 'domain', 'content', 'is_zip'] +}); + +var callUnpublishSite = rpc.declare({ + object: 'luci.metablogizer', + method: 'unpublish_site', + params: ['id'] +}); + +var callSetAuthRequired = rpc.declare({ + object: 'luci.metablogizer', + method: 'set_auth_required', + params: ['id', 'auth_required'] +}); + +var callGetSitesExposureStatus = rpc.declare({ + object: 'luci.metablogizer', + method: 'get_sites_exposure_status' +}); + return baseclass.extend({ getStatus: function() { return callStatus(); @@ -269,16 +292,35 @@ return baseclass.extend({ return callEmancipateStatus(jobId); }, + uploadAndCreateSite: function(name, domain, content, isZip) { + return callUploadAndCreateSite(name, domain, content || '', isZip ? '1' : '0'); + }, + + unpublishSite: function(id) { + return callUnpublishSite(id); + }, + + setAuthRequired: function(id, authRequired) { + return callSetAuthRequired(id, authRequired ? '1' : '0'); + }, + + getSitesExposureStatus: function() { + return callGetSitesExposureStatus().then(function(res) { + return res.sites || []; + }); + }, + getDashboardData: function() { var self = this; return Promise.all([ self.getStatus(), - self.listSites() + self.listSites(), + self.getSitesExposureStatus() ]).then(function(results) { return { status: results[0] || {}, sites: results[1] || [], - hosting: {} + exposure: results[2] || [] }; }); } diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js index 19bd3ac5..6d028b49 100644 --- a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/dashboard.js @@ -1,7 +1,6 @@ 'use strict'; 'require view'; 'require ui'; -'require fs'; 'require metablogizer.api as api'; 'require metablogizer.qrcode as qrcode'; 'require secubox/kiss-theme'; @@ -9,732 +8,384 @@ return view.extend({ status: {}, sites: [], - hosting: {}, - uploadFiles: [], - currentSite: null, + exposure: [], load: function() { var self = this; return api.getDashboardData().then(function(data) { self.status = data.status || {}; self.sites = data.sites || []; - self.hosting = data.hosting || {}; + self.exposure = data.exposure || []; }); }, render: function() { var self = this; - var status = this.status; var sites = this.sites; - var hosting = this.hosting; + var exposure = this.exposure; - // Load hosting status asynchronously after render - api.getHostingStatus().then(function(h) { - self.hosting = h || {}; - var haproxyEl = document.getElementById('haproxy-status'); - var ipEl = document.getElementById('public-ip'); - if (haproxyEl) { - haproxyEl.innerHTML = ''; - haproxyEl.appendChild(h.haproxy_status === 'running' ? - E('span', { 'style': 'color:#0a0' }, _('Running')) : - E('span', { 'style': 'color:#a00' }, _('Stopped'))); - } - if (ipEl) { - ipEl.textContent = h.public_ip || '-'; - } - }).catch(function() {}); + // Merge exposure data into sites + var exposureMap = {}; + exposure.forEach(function(e) { + exposureMap[e.id] = e; + }); return KissTheme.wrap([ E('div', { 'class': 'cbi-map' }, [ E('h2', {}, _('MetaBlogizer')), E('div', { 'class': 'cbi-map-descr' }, _('Static site publisher with HAProxy vhosts and SSL')), - // Status Section + // One-Click Deploy Section E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, _('Status')), - E('table', { 'class': 'table' }, [ - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td', 'style': 'width:200px' }, _('Runtime')), - E('td', { 'class': 'td' }, status.detected_runtime || 'uhttpd') + E('h3', {}, _('One-Click Deploy')), + E('div', { 'class': 'cbi-section-descr' }, _('Upload HTML/ZIP to create a new static site with auto-configured SSL')), + E('div', { 'style': 'display:flex; gap:1em; flex-wrap:wrap; align-items:flex-end' }, [ + E('div', { 'style': 'flex:1; min-width:150px' }, [ + E('label', { 'style': 'display:block; margin-bottom:0.25em; font-weight:500' }, _('Site Name')), + E('input', { 'type': 'text', 'id': 'deploy-name', 'class': 'cbi-input-text', + 'placeholder': 'myblog', 'style': 'width:100%' }) ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, _('HAProxy')), - E('td', { 'class': 'td', 'id': 'haproxy-status' }, E('em', {}, _('Loading...'))) + E('div', { 'style': 'flex:2; min-width:200px' }, [ + E('label', { 'style': 'display:block; margin-bottom:0.25em; font-weight:500' }, _('Domain')), + E('input', { 'type': 'text', 'id': 'deploy-domain', 'class': 'cbi-input-text', + 'placeholder': 'blog.example.com', 'style': 'width:100%' }) ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, _('Public IP')), - E('td', { 'class': 'td', 'id': 'public-ip' }, _('Loading...')) + E('div', { 'style': 'flex:2; min-width:200px' }, [ + E('label', { 'style': 'display:block; margin-bottom:0.25em; font-weight:500' }, _('Content (HTML or ZIP)')), + E('input', { 'type': 'file', 'id': 'deploy-file', 'accept': '.html,.htm,.zip', + 'style': 'width:100%' }) ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, _('Sites')), - E('td', { 'class': 'td' }, String(sites.length)) - ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, _('Backends Running')), - E('td', { 'class': 'td' }, String(sites.filter(function(s) { return s.backend_running; }).length) + ' / ' + sites.length) - ]) - ]), - E('div', { 'style': 'margin-top:1em' }, [ E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(this, 'handleSyncConfig') - }, _('Sync Config')), - ' ', - E('span', { 'class': 'cbi-value-description' }, _('Update port/runtime info for all sites')) + 'class': 'cbi-button cbi-button-positive', + 'style': 'white-space:nowrap', + 'click': ui.createHandlerFn(this, 'handleOneClickDeploy') + }, _('Deploy')) ]) ]), - // Sites Section + // Sites Table E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('Sites')), - sites.length > 0 ? this.renderSitesTable(sites) : E('div', { 'class': 'cbi-section-descr' }, _('No sites configured')) - ]), - - // Create Site Section - E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, _('Create Site')), - E('div', { 'class': 'cbi-section-descr' }, _('Add a new static site with auto-configured HAProxy vhost and SSL')), - - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Site Name')), - E('div', { 'class': 'cbi-value-field' }, [ - E('input', { 'type': 'text', 'id': 'new-site-name', 'class': 'cbi-input-text', - 'placeholder': 'myblog' }), - E('div', { 'class': 'cbi-value-description' }, _('Lowercase letters, numbers, and hyphens only')) - ]) - ]), - - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Domain')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'new-site-domain', 'class': 'cbi-input-text', - 'placeholder': 'blog.example.com' })) - ]), - - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Gitea Repository')), - E('div', { 'class': 'cbi-value-field' }, [ - E('input', { 'type': 'text', 'id': 'new-site-gitea', 'class': 'cbi-input-text', - 'placeholder': 'user/repo' }), - E('div', { 'class': 'cbi-value-description' }, _('Optional: Sync content from Gitea')) - ]) - ]), - - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Description')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'new-site-desc', 'class': 'cbi-input-text', - 'placeholder': 'Short description (optional)' })) - ]), - - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('HTTPS')), - E('div', { 'class': 'cbi-value-field' }, - E('select', { 'id': 'new-site-ssl', 'class': 'cbi-input-select' }, [ - E('option', { 'value': '1', 'selected': true }, _('Enabled (ACME)')), - E('option', { 'value': '0' }, _('Disabled')) - ])) - ]), - - E('div', { 'class': 'cbi-page-actions' }, - E('button', { - 'class': 'cbi-button cbi-button-positive', - 'click': ui.createHandlerFn(this, 'handleCreateSite') - }, _('Create Site'))) - ]), - - // Hosting Status Section - this.renderHostingSection(hosting) + sites.length > 0 ? + this.renderSitesTable(sites, exposureMap) : + E('div', { 'class': 'cbi-section-descr' }, _('No sites configured')) + ]) ]) ], 'admin/services/metablogizer'); }, - renderSitesTable: function(sites) { + renderSitesTable: function(sites, exposureMap) { var self = this; + return E('table', { 'class': 'table' }, [ E('tr', { 'class': 'tr table-titles' }, [ - E('th', { 'class': 'th' }, _('Name')), - E('th', { 'class': 'th' }, _('Domain')), - E('th', { 'class': 'th' }, _('Port')), - E('th', { 'class': 'th' }, _('Backend')), - E('th', { 'class': 'th' }, _('Content')), - E('th', { 'class': 'th' }, _('Actions')) + E('th', { 'class': 'th' }, _('Site')), + E('th', { 'class': 'th' }, _('Status')), + E('th', { 'class': 'th' }, _('Exposure')), + E('th', { 'class': 'th', 'style': 'text-align:center' }, _('Actions')) ]) ].concat(sites.map(function(site) { - var backendStatus = site.backend_running ? - E('span', { 'style': 'color:#0a0' }, _('Running')) : - E('span', { 'style': 'color:#a00' }, _('Stopped')); - var contentStatus = site.has_content ? - E('span', { 'style': 'color:#0a0' }, _('OK')) : - E('span', { 'style': 'color:#888' }, _('Empty')); - - return E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, [ - E('strong', {}, site.name), - site.runtime ? E('br') : '', - site.runtime ? E('small', { 'style': 'color:#888' }, site.runtime) : '' - ]), - E('td', { 'class': 'td' }, site.domain ? - E('a', { 'href': site.url || ('https://' + site.domain), 'target': '_blank' }, site.domain) : - E('em', {}, '-')), - E('td', { 'class': 'td' }, site.port ? String(site.port) : '-'), - E('td', { 'class': 'td' }, backendStatus), - E('td', { 'class': 'td' }, contentStatus), - E('td', { 'class': 'td' }, [ - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(self, 'showShareModal', site), - 'title': _('Share') - }, _('Share')), - ' ', - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(self, 'showUploadModal', site), - 'title': _('Upload') - }, _('Upload')), - ' ', - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(self, 'showFilesModal', site), - 'title': _('Files') - }, _('Files')), - ' ', - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(self, 'showEditModal', site), - 'title': _('Edit') - }, _('Edit')), - ' ', - site.gitea_repo ? E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': ui.createHandlerFn(self, 'handleSync', site), - 'title': _('Sync') - }, _('Sync')) : '', - ' ', - E('button', { - 'class': 'cbi-button cbi-button-apply', - 'click': ui.createHandlerFn(self, 'handleEmancipate', site), - 'title': _('KISS ULTIME MODE: DNS + SSL + Mesh') - }, site.emancipated ? '✓' : _('Emancipate')), - ' ', - E('button', { - 'class': 'cbi-button cbi-button-remove', - 'click': ui.createHandlerFn(self, 'handleDelete', site), - 'title': _('Delete') - }, _('Delete')) - ]) - ]); + var exp = exposureMap[site.id] || {}; + return self.renderSiteRow(site, exp); }))); }, - renderHostingSection: function(hosting) { - var hostingSites = hosting.sites || []; - if (hostingSites.length === 0) return E('div'); + renderSiteRow: function(site, exp) { + var self = this; - return E('div', { 'class': 'cbi-section' }, [ - E('h3', {}, _('Hosting Status')), - E('div', { 'class': 'cbi-section-descr' }, _('DNS, SSL certificates, and publish status for each site')), - E('table', { 'class': 'table' }, [ - E('tr', { 'class': 'tr table-titles' }, [ - E('th', { 'class': 'th' }, _('Site')), - E('th', { 'class': 'th' }, _('DNS')), - E('th', { 'class': 'th' }, _('IP')), - E('th', { 'class': 'th' }, _('Certificate')), - E('th', { 'class': 'th' }, _('Status')) - ]) - ].concat(hostingSites.map(function(site) { - return E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, E('strong', {}, site.name)), - E('td', { 'class': 'td' }, site.dns_status === 'ok' ? - E('span', { 'style': 'color:#0a0' }, 'OK') : - E('span', { 'style': 'color:#a00' }, site.dns_status || 'unknown')), - E('td', { 'class': 'td' }, site.dns_ip || '-'), - E('td', { 'class': 'td' }, site.cert_status === 'ok' ? - E('span', { 'style': 'color:#0a0' }, (site.cert_days || 0) + 'd') : - E('span', { 'style': 'color:#a00' }, site.cert_status || 'missing')), - E('td', { 'class': 'td' }, site.publish_status === 'published' ? - E('span', { 'style': 'color:#0a0' }, _('Published')) : - E('span', { 'style': 'color:#888' }, site.publish_status || 'pending')) - ]); - }))) + // Backend status badge + var backendBadge; + if (exp.backend_running) { + backendBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#d4edda; color:#155724' + }, 'Running'); + } else { + backendBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#f8d7da; color:#721c24' + }, 'Stopped'); + } + + // Exposure badge + var exposureBadge; + if (exp.vhost_exists && exp.cert_status === 'valid') { + exposureBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#d4edda; color:#155724', + 'title': 'SSL certificate valid' + }, 'SSL OK'); + } else if (exp.vhost_exists && exp.cert_status === 'warning') { + exposureBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#fff3cd; color:#856404', + 'title': 'Certificate expiring soon' + }, 'SSL Warn'); + } else if (exp.vhost_exists) { + exposureBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#f8d7da; color:#721c24', + 'title': exp.cert_status || 'No certificate' + }, 'No SSL'); + } else { + exposureBadge = E('span', { + 'style': 'display:inline-block; padding:2px 8px; border-radius:4px; font-size:0.85em; background:#e2e3e5; color:#383d41' + }, 'Private'); + } + + // Auth badge + var authBadge = ''; + if (exp.auth_required) { + authBadge = E('span', { + 'style': 'display:inline-block; padding:2px 6px; border-radius:4px; font-size:0.85em; background:#cce5ff; color:#004085; margin-left:4px' + }, 'Auth'); + } + + // Domain link + var domainEl; + if (site.domain) { + domainEl = E('a', { + 'href': 'https://' + site.domain, + 'target': '_blank', + 'style': 'color:#0066cc' + }, site.domain); + } else { + domainEl = E('em', { 'style': 'color:#888' }, '-'); + } + + return E('tr', { 'class': 'tr' }, [ + // Site column + E('td', { 'class': 'td' }, [ + E('strong', {}, site.name), + E('br'), + domainEl, + site.port ? E('span', { 'style': 'color:#888; font-size:0.9em; margin-left:0.5em' }, ':' + site.port) : '' + ]), + // Status column + E('td', { 'class': 'td' }, [ + backendBadge, + exp.has_content ? '' : E('span', { + 'style': 'display:inline-block; padding:2px 6px; border-radius:4px; font-size:0.85em; background:#fff3cd; color:#856404; margin-left:4px' + }, 'Empty') + ]), + // Exposure column + E('td', { 'class': 'td' }, [ + exposureBadge, + authBadge + ]), + // Actions column + E('td', { 'class': 'td', 'style': 'text-align:center; white-space:nowrap' }, [ + // Share button + E('button', { + 'class': 'cbi-button', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Share / QR Code'), + 'click': ui.createHandlerFn(self, 'showShareModal', site) + }, _('Share')), + // Upload button + E('button', { + 'class': 'cbi-button', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Upload content'), + 'click': ui.createHandlerFn(self, 'showUploadModal', site) + }, _('Upload')), + // Expose/Unpublish button + exp.vhost_exists ? + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Unpublish site'), + 'click': ui.createHandlerFn(self, 'handleUnpublish', site) + }, _('Unpublish')) : + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Expose with SSL'), + 'click': ui.createHandlerFn(self, 'handleEmancipate', site) + }, _('Expose')), + // Auth toggle button + E('button', { + 'class': 'cbi-button', + 'style': 'padding:0.25em 0.5em; margin:2px; ' + (exp.auth_required ? 'background:#cce5ff' : ''), + 'title': exp.auth_required ? _('Authentication required - click to disable') : _('No authentication - click to enable'), + 'click': ui.createHandlerFn(self, 'handleToggleAuth', site, exp) + }, exp.auth_required ? _('Unlock') : _('Lock')), + // Delete button + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'style': 'padding:0.25em 0.5em; margin:2px', + 'title': _('Delete site'), + 'click': ui.createHandlerFn(self, 'handleDelete', site) + }, 'X') + ]) ]); }, - handleCreateSite: function() { + handleOneClickDeploy: function() { var self = this; - var name = document.getElementById('new-site-name').value.trim(); - var domain = document.getElementById('new-site-domain').value.trim(); - var gitea = document.getElementById('new-site-gitea').value.trim(); - var desc = document.getElementById('new-site-desc').value.trim(); - var ssl = document.getElementById('new-site-ssl').value; + var name = document.getElementById('deploy-name').value.trim(); + var domain = document.getElementById('deploy-domain').value.trim(); + var fileInput = document.getElementById('deploy-file'); + var file = fileInput.files[0]; if (!name) { ui.addNotification(null, E('p', _('Site name is required')), 'error'); return; } + if (!/^[a-z0-9-]+$/.test(name)) { + ui.addNotification(null, E('p', _('Name must be lowercase letters, numbers, and hyphens only')), 'error'); + return; + } if (!domain) { ui.addNotification(null, E('p', _('Domain is required')), 'error'); return; } - if (!/^[a-z0-9-]+$/.test(name)) { - ui.addNotification(null, E('p', _('Invalid name format: use lowercase letters, numbers, and hyphens')), 'error'); - return; + + ui.showModal(_('Deploying Site'), [ + E('p', { 'class': 'spinning' }, _('Creating site and configuring HAProxy...')) + ]); + + var deployFn = function(content, isZip) { + return api.uploadAndCreateSite(name, domain, content, isZip).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Site created: ') + r.url)); + window.location.reload(); + } 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'); + }); + }; + + if (file) { + var reader = new FileReader(); + reader.onload = function(e) { + var bytes = new Uint8Array(e.target.result); + var chunks = []; + for (var i = 0; i < bytes.length; i += 8192) { + chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); + } + var content = btoa(chunks.join('')); + var isZip = file.name.toLowerCase().endsWith('.zip'); + deployFn(content, isZip); + }; + reader.onerror = function() { + ui.hideModal(); + ui.addNotification(null, E('p', _('Failed to read file')), 'error'); + }; + reader.readAsArrayBuffer(file); + } else { + // No file - create site with default content + deployFn('', false); } - - ui.showModal(_('Creating Site'), [ - E('p', { 'class': 'spinning' }, _('Setting up site and HAProxy vhost...')) - ]); - - api.createSite(name, domain, gitea, ssl, desc).then(function(r) { - ui.hideModal(); - if (r.success) { - ui.addNotification(null, E('p', _('Site created successfully'))); - self.showShareModal({ name: r.name || name, domain: r.domain || domain, url: r.url }); - setTimeout(function() { window.location.reload(); }, 500); - } 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'); - }); - }, - - showEditModal: function(site) { - var self = this; - ui.showModal(_('Edit Site: ') + site.name, [ - E('div', { 'class': 'cbi-section' }, [ - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Domain')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'edit-site-domain', 'class': 'cbi-input-text', - 'value': site.domain || '' })) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Gitea Repository')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'edit-site-gitea', 'class': 'cbi-input-text', - 'value': site.gitea_repo || '', 'placeholder': 'user/repo' })) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Description')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'text', 'id': 'edit-site-desc', 'class': 'cbi-input-text', - 'value': site.description || '' })) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('HTTPS')), - E('div', { 'class': 'cbi-value-field' }, - E('select', { 'id': 'edit-site-ssl', 'class': 'cbi-input-select' }, [ - E('option', { 'value': '1', 'selected': site.ssl !== false }, _('Enabled')), - E('option', { 'value': '0', 'selected': site.ssl === false }, _('Disabled')) - ])) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Enabled')), - E('div', { 'class': 'cbi-value-field' }, - E('select', { 'id': 'edit-site-enabled', 'class': 'cbi-input-select' }, [ - E('option', { 'value': '1', 'selected': site.enabled !== false }, _('Yes')), - E('option', { 'value': '0', 'selected': site.enabled === false }, _('No')) - ])) - ]) - ]), - E('div', { 'class': 'right' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), - ' ', - E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { - var domain = document.getElementById('edit-site-domain').value.trim(); - var gitea = document.getElementById('edit-site-gitea').value.trim(); - var desc = document.getElementById('edit-site-desc').value.trim(); - var ssl = document.getElementById('edit-site-ssl').value; - var enabled = document.getElementById('edit-site-enabled').value; - - if (!domain) { - ui.addNotification(null, E('p', _('Domain required')), 'error'); - return; - } - - ui.hideModal(); - ui.showModal(_('Saving'), [E('p', { 'class': 'spinning' }, _('Updating site...'))]); - - api.updateSite(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 || 'Unknown')), 'error'); - } - }).catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); - }}, _('Save')) - ]) - ]); }, showUploadModal: function(site) { var self = this; - this.uploadFiles = []; - this.currentSite = site; - - var fileList = E('div', { 'id': 'upload-file-list', 'style': 'margin:1em 0; max-height:200px; overflow-y:auto' }); ui.showModal(_('Upload to: ') + site.name, [ E('div', { 'class': 'cbi-section' }, [ E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Files')), + E('label', { 'class': 'cbi-value-title' }, _('File')), E('div', { 'class': 'cbi-value-field' }, [ - E('input', { 'type': 'file', 'id': 'upload-file-input', 'multiple': true, - 'change': function(e) { self.handleFileSelect(e, fileList); } }), - E('div', { 'class': 'cbi-value-description' }, _('Select HTML, CSS, JS, images, etc.')) + E('input', { 'type': 'file', 'id': 'upload-file-input' }), + E('div', { 'class': 'cbi-value-description' }, _('HTML, CSS, JS, images, etc.')) ]) ]), - fileList, E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, _('Set first HTML as index')), - E('div', { 'class': 'cbi-value-field' }, - E('input', { 'type': 'checkbox', 'id': 'upload-as-index', 'checked': true })) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, ''), - E('div', { 'class': 'cbi-value-field cbi-value-description' }, - _('After upload, use Ctrl+Shift+R to refresh cached pages')) + E('label', { 'class': 'cbi-value-title' }, _('Destination')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { 'type': 'text', 'id': 'upload-dest', 'class': 'cbi-input-text', + 'placeholder': 'index.html' }), + E('div', { 'class': 'cbi-value-description' }, _('Leave empty to use original filename')) + ]) ]) ]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), ' ', - E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.createHandlerFn(this, 'handleUpload') }, _('Upload')) - ]) - ]); - }, + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + var fileInput = document.getElementById('upload-file-input'); + var destInput = document.getElementById('upload-dest'); + var file = fileInput.files[0]; - handleFileSelect: function(e, listEl) { - var files = e.target.files; - for (var i = 0; i < files.length; i++) { - this.uploadFiles.push(files[i]); - } - this.updateFileList(listEl); - }, - - updateFileList: function(listEl) { - var self = this; - listEl.innerHTML = ''; - if (this.uploadFiles.length === 0) return; - - this.uploadFiles.forEach(function(f, i) { - var row = E('div', { 'style': 'display:flex; align-items:center; gap:0.5em; margin:0.25em 0; padding:0.25em; background:#f8f8f8; border-radius:4px' }, [ - E('span', { 'style': 'flex:1' }, f.name), - E('span', { 'style': 'color:#888; font-size:0.9em' }, self.formatSize(f.size)), - E('button', { 'class': 'cbi-button cbi-button-remove', 'style': 'padding:0.25em 0.5em', - 'click': function() { self.uploadFiles.splice(i, 1); self.updateFileList(listEl); } }, 'X') - ]); - listEl.appendChild(row); - }); - }, - - handleUpload: function() { - var self = this; - if (!this.uploadFiles.length) { - ui.addNotification(null, E('p', _('No files selected')), 'error'); - return; - } - - var site = this.currentSite; - var asIndex = document.getElementById('upload-as-index').checked; - var 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; - } - } - } - - ui.hideModal(); - ui.showModal(_('Uploading'), [E('p', { 'class': 'spinning' }, _('Uploading files...'))]); - - // Process files sequentially to avoid RPC batch conflicts - var uploadSequential = function(files, idx, results) { - if (idx >= files.length) { - return Promise.resolve(results); - } - - var f = files[idx]; - return new Promise(function(resolve) { - var reader = new FileReader(); - reader.onload = function(e) { - // Convert ArrayBuffer to base64 - var bytes = new Uint8Array(e.target.result); - var chunks = []; - for (var i = 0; i < bytes.length; i += 8192) { - chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); - } - var content = btoa(chunks.join('')); - - var dest = (asIndex && f === firstHtml) ? 'index.html' : f.name; - - // Use chunked upload for files > 40KB (uhttpd has 64KB JSON body limit) - var uploadFn; - if (content.length > 40000) { - uploadFn = api.chunkedUpload(site.id, dest, content); - } else { - uploadFn = api.uploadFile(site.id, dest, content); - } - - uploadFn - .then(function(r) { - results.push({ ok: r && r.success, name: f.name }); - resolve(); - }) - .catch(function() { - results.push({ ok: false, name: f.name }); - resolve(); - }); - }; - reader.onerror = function() { - results.push({ ok: false, name: f.name }); - resolve(); - }; - reader.readAsArrayBuffer(f); - }).then(function() { - return uploadSequential(files, idx + 1, results); - }); - }; - - uploadSequential(this.uploadFiles, 0, []).then(function(results) { - ui.hideModal(); - var ok = results.filter(function(r) { return r.ok; }).length; - var failed = results.length - ok; - if (failed > 0) { - ui.addNotification(null, E('p', ok + _(' file(s) uploaded, ') + failed + _(' failed')), 'warning'); - } else { - ui.addNotification(null, E('p', ok + _(' file(s) uploaded successfully'))); - } - self.uploadFiles = []; - }); - }, - - showFilesModal: function(site) { - var self = this; - this.currentSite = site; - var sitesRoot = '/srv/metablogizer/sites'; - - ui.showModal(_('Files: ') + site.name, [ - E('div', { 'id': 'files-list' }, [ - E('p', { 'class': 'spinning' }, _('Loading files...')) - ]), - E('div', { 'class': 'right' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) - ]) - ]); - - fs.list(sitesRoot + '/' + site.name).then(function(files) { - var container = document.getElementById('files-list'); - container.innerHTML = ''; - - if (!files || !files.length) { - container.appendChild(E('p', { 'style': 'color:#888' }, _('No files'))); - return; - } - - var table = E('table', { 'class': 'table' }, [ - E('tr', { 'class': 'tr table-titles' }, [ - E('th', { 'class': 'th' }, _('File')), - E('th', { 'class': 'th' }, _('Size')), - E('th', { 'class': 'th' }, _('Actions')) - ]) - ]); - - files.forEach(function(f) { - if (f.type !== 'file') return; - var isIndex = f.name === 'index.html'; - table.appendChild(E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, [ - isIndex ? E('strong', {}, f.name + ' (homepage)') : f.name - ]), - E('td', { 'class': 'td' }, self.formatSize(f.size)), - E('td', { 'class': 'td' }, [ - (!isIndex && f.name.endsWith('.html')) ? E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': function() { self.setAsHomepage(site, f.name); } - }, _('Set as Homepage')) : '', - ' ', - E('button', { - 'class': 'cbi-button cbi-button-remove', - 'click': function() { self.deleteFile(site, f.name); } - }, _('Delete')) - ]) - ])); - }); - - container.appendChild(table); - }).catch(function(e) { - var container = document.getElementById('files-list'); - container.innerHTML = ''; - container.appendChild(E('p', { 'style': 'color:#a00' }, _('Error: ') + e.message)); - }); - }, - - setAsHomepage: function(site, filename) { - var sitesRoot = '/srv/metablogizer/sites'; - var path = sitesRoot + '/' + site.name; - - ui.showModal(_('Setting Homepage'), [E('p', { 'class': 'spinning' }, _('Renaming...'))]); - - fs.read(path + '/' + filename).then(function(content) { - return fs.write(path + '/index.html', content); - }).then(function() { - return fs.remove(path + '/' + filename); - }).then(function() { - ui.hideModal(); - ui.addNotification(null, E('p', filename + _(' set as homepage'))); - }).catch(function(e) { - ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); - }, - - deleteFile: function(site, filename) { - var self = this; - var sitesRoot = '/srv/metablogizer/sites'; - - if (!confirm(_('Delete ') + filename + '?')) return; - - fs.remove(sitesRoot + '/' + site.name + '/' + filename).then(function() { - ui.addNotification(null, E('p', _('File deleted'))); - self.showFilesModal(site); - }).catch(function(e) { - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); - }, - - showShareModal: function(site) { - var self = this; - var url = site.url || ('https://' + site.domain); - var title = site.name + ' - SecuBox'; - var enc = encodeURIComponent; - - var qrSvg = ''; - try { - qrSvg = qrcode.generateSVG(url, 180); - } catch (e) { - qrSvg = '

QR code unavailable

'; - } - - ui.showModal(_('Share: ') + site.name, [ - E('div', { 'style': 'text-align:center' }, [ - E('div', { 'class': 'cbi-value', 'style': 'display:flex; gap:0.5em; margin-bottom:1em' }, [ - E('input', { 'type': 'text', 'readonly': true, 'value': url, 'id': 'share-url', - 'class': 'cbi-input-text', 'style': 'flex:1' }), - E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { - self.copyToClipboard(url); - }}, _('Copy')) - ]), - E('div', { 'style': 'display:inline-block; padding:1em; background:#f8f8f8; border-radius:8px; margin:1em 0' }, [ - E('div', { 'innerHTML': qrSvg }) - ]), - E('div', { 'style': 'margin-top:1em' }, [ - E('p', { 'style': 'margin-bottom:0.5em' }, _('Share on:')), - E('div', { 'style': 'display:flex; gap:0.5em; justify-content:center; flex-wrap:wrap' }, [ - E('a', { 'href': 'https://twitter.com/intent/tweet?url=' + enc(url) + '&text=' + enc(title), - 'target': '_blank', 'class': 'cbi-button cbi-button-action' }, 'Twitter'), - E('a', { 'href': 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(url), - 'target': '_blank', 'class': 'cbi-button cbi-button-action' }, 'LinkedIn'), - E('a', { 'href': 'https://t.me/share/url?url=' + enc(url) + '&text=' + enc(title), - 'target': '_blank', 'class': 'cbi-button cbi-button-action' }, 'Telegram'), - E('a', { 'href': 'https://wa.me/?text=' + enc(title + ' ' + url), - 'target': '_blank', 'class': 'cbi-button cbi-button-action' }, 'WhatsApp'), - E('a', { 'href': 'mailto:?subject=' + enc(title) + '&body=' + enc(url), - 'class': 'cbi-button cbi-button-action' }, 'Email') - ]) - ]) - ]), - E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ - E('a', { 'href': url, 'target': '_blank', 'class': 'cbi-button cbi-button-positive', - 'style': 'text-decoration:none' }, _('Visit Site')), - ' ', - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) - ]) - ]); - }, - - handleSync: function(site) { - ui.showModal(_('Syncing'), [E('p', { 'class': 'spinning' }, _('Pulling from Gitea...'))]); - - api.syncSite(site.id).then(function(r) { - ui.hideModal(); - if (r.success) { - ui.addNotification(null, E('p', _('Site synced successfully'))); - } else { - ui.addNotification(null, E('p', _('Sync failed: ') + (r.error || 'Unknown')), '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 "') + site.name + '"?'), - E('p', { 'style': 'color:#a00' }, _('This will remove the site, HAProxy vhost, and all files.')), - E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), - ' ', - E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - ui.hideModal(); - ui.showModal(_('Deleting'), [E('p', { 'class': 'spinning' }, _('Removing site...'))]); - - api.deleteSite(site.id).then(function(r) { - ui.hideModal(); - if (r.success) { - ui.addNotification(null, E('p', _('Site deleted'))); - window.location.reload(); - } else { - ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown')), 'error'); + if (!file) { + ui.addNotification(null, E('p', _('Please select a file')), 'error'); + return; } - }).catch(function(e) { + + var dest = destInput.value.trim() || file.name; + ui.hideModal(); - ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); - }); - }}, _('Delete')) + ui.showModal(_('Uploading'), [E('p', { 'class': 'spinning' }, _('Uploading file...'))]); + + var reader = new FileReader(); + reader.onload = function(e) { + var bytes = new Uint8Array(e.target.result); + var chunks = []; + for (var i = 0; i < bytes.length; i += 8192) { + chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192))); + } + var content = btoa(chunks.join('')); + + var uploadFn = content.length > 40000 ? + api.chunkedUpload(site.id, dest, content) : + api.uploadFile(site.id, dest, content); + + uploadFn.then(function(r) { + ui.hideModal(); + if (r && r.success) { + ui.addNotification(null, E('p', _('File uploaded: ') + dest)); + } else { + ui.addNotification(null, E('p', _('Upload failed')), 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + err.message), 'error'); + }); + }; + reader.onerror = function() { + ui.hideModal(); + ui.addNotification(null, E('p', _('Failed to read file')), 'error'); + }; + reader.readAsArrayBuffer(file); + } + }, _('Upload')) ]) ]); }, handleEmancipate: function(site) { var self = this; - ui.showModal(_('Emancipate Site'), [ - E('p', {}, _('KISS ULTIME MODE will configure:')), + + ui.showModal(_('Expose Site'), [ + E('p', {}, _('This will configure:')), E('ul', {}, [ - E('li', {}, _('DNS registration (Gandi/OVH)')), - E('li', {}, _('Vortex DNS mesh publication')), - E('li', {}, _('HAProxy vhost with SSL')), - E('li', {}, _('ACME certificate issuance')) + E('li', {}, _('HAProxy vhost for ') + site.domain), + E('li', {}, _('ACME SSL certificate')), + E('li', {}, _('DNS + Vortex mesh publication')) ]), - E('p', { 'style': 'margin-top:1em' }, _('Emancipate "') + site.name + '" (' + site.domain + ')?'), E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), ' ', - E('button', { 'class': 'cbi-button cbi-button-apply', 'click': function() { - ui.hideModal(); - self.runEmancipateAsync(site); - }}, _('Emancipate')) + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'click': function() { + ui.hideModal(); + self.runEmancipateAsync(site); + } + }, _('Expose')) ]) ]); }, runEmancipateAsync: function(site) { var self = this; - var outputPre = E('pre', { 'style': 'max-height:300px;overflow:auto;background:#f5f5f5;padding:10px;font-size:11px;white-space:pre-wrap' }, _('Starting...')); + var outputPre = E('pre', { + 'style': 'max-height:300px; overflow:auto; background:#f5f5f5; padding:10px; font-size:11px; white-space:pre-wrap' + }, _('Starting...')); - ui.showModal(_('Emancipating'), [ + ui.showModal(_('Exposing Site'), [ E('p', { 'class': 'spinning' }, _('Running KISS ULTIME MODE workflow...')), outputPre ]); @@ -742,16 +393,10 @@ return view.extend({ api.emancipate(site.id).then(function(r) { if (!r.success) { ui.hideModal(); - ui.showModal(_('Emancipation Failed'), [ - E('p', { 'style': 'color:#a00' }, r.error || _('Failed to start')), - E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) - ]) - ]); + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown')), 'error'); return; } - // Poll for completion var jobId = r.job_id; var pollInterval = setInterval(function() { api.emancipateStatus(jobId).then(function(status) { @@ -765,24 +410,10 @@ return view.extend({ ui.hideModal(); if (status.status === 'success') { - ui.showModal(_('Emancipation Complete'), [ - E('p', { 'style': 'color:#0a0' }, _('Site emancipated successfully!')), - E('pre', { 'style': 'max-height:300px;overflow:auto;background:#f5f5f5;padding:10px;font-size:11px;white-space:pre-wrap' }, status.output || ''), - E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ - E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { - ui.hideModal(); - window.location.reload(); - }}, _('OK')) - ]) - ]); + ui.addNotification(null, E('p', _('Site exposed successfully!'))); + window.location.reload(); } else { - ui.showModal(_('Emancipation Failed'), [ - E('p', { 'style': 'color:#a00' }, _('Workflow failed')), - E('pre', { 'style': 'max-height:200px;overflow:auto;background:#fee;padding:10px;font-size:11px;white-space:pre-wrap' }, status.output || ''), - E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ - E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) - ]) - ]); + ui.addNotification(null, E('p', _('Exposure failed')), 'error'); } } }).catch(function(e) { @@ -790,44 +421,59 @@ return view.extend({ ui.hideModal(); ui.addNotification(null, E('p', _('Poll error: ') + e.message), 'error'); }); - }, 2000); // Poll every 2 seconds + }, 2000); }).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); }); }, - copyToClipboard: function(text) { - if (navigator.clipboard) { - navigator.clipboard.writeText(text).then(function() { - ui.addNotification(null, E('p', _('URL copied to clipboard'))); - }); - } else { - var input = document.getElementById('share-url'); - if (input) { - input.select(); - document.execCommand('copy'); - ui.addNotification(null, E('p', _('URL copied to clipboard'))); - } - } + handleUnpublish: function(site) { + var self = this; + + ui.showModal(_('Unpublish Site'), [ + E('p', {}, _('Remove public exposure for "') + site.name + '"?'), + E('p', { 'style': 'color:#666' }, _('The site content will be preserved but the HAProxy vhost will be removed.')), + E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + ui.hideModal(); + ui.showModal(_('Unpublishing'), [E('p', { 'class': 'spinning' }, _('Removing exposure...'))]); + + api.unpublishSite(site.id).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Site unpublished'))); + window.location.reload(); + } else { + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); + }); + } + }, _('Unpublish')) + ]) + ]); }, - handleSyncConfig: function() { - ui.showModal(_('Syncing Configuration'), [ - E('p', { 'class': 'spinning' }, _('Updating port and runtime info for all sites...')) - ]); + handleToggleAuth: function(site, exp) { + var self = this; + var newAuth = !exp.auth_required; - api.syncConfig().then(function(r) { + ui.showModal(_('Updating'), [E('p', { 'class': 'spinning' }, _('Setting authentication...'))]); + + api.setAuthRequired(site.id, newAuth).then(function(r) { ui.hideModal(); if (r.success) { - var msg = _('Configuration synced'); - if (r.fixed > 0) { - msg += ' (' + r.fixed + _(' entries updated)'); - } - ui.addNotification(null, E('p', msg)); + ui.addNotification(null, E('p', newAuth ? _('Authentication enabled') : _('Authentication disabled'))); window.location.reload(); } else { - ui.addNotification(null, E('p', _('Sync failed: ') + (r.error || 'Unknown')), 'error'); + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown')), 'error'); } }).catch(function(e) { ui.hideModal(); @@ -835,10 +481,85 @@ return view.extend({ }); }, - formatSize: function(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1048576).toFixed(1) + ' MB'; + handleDelete: function(site) { + var self = this; + + ui.showModal(_('Delete Site'), [ + E('p', {}, _('Are you sure you want to delete "') + site.name + '"?'), + E('p', { 'style': 'color:#a00' }, _('This will remove the site, HAProxy vhost, and all files.')), + E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + ui.hideModal(); + ui.showModal(_('Deleting'), [E('p', { 'class': 'spinning' }, _('Removing site...'))]); + + api.deleteSite(site.id).then(function(r) { + ui.hideModal(); + if (r.success) { + ui.addNotification(null, E('p', _('Site deleted'))); + window.location.reload(); + } else { + ui.addNotification(null, E('p', _('Failed: ') + (r.error || 'Unknown')), 'error'); + } + }).catch(function(e) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + e.message), 'error'); + }); + } + }, _('Delete')) + ]) + ]); + }, + + showShareModal: function(site) { + var self = this; + var url = 'https://' + site.domain; + var title = site.name + ' - SecuBox'; + var enc = encodeURIComponent; + + var qrSvg = ''; + try { + qrSvg = qrcode.generateSVG(url, 180); + } catch (e) { + qrSvg = '

QR code unavailable

'; + } + + ui.showModal(_('Share: ') + site.name, [ + E('div', { 'style': 'text-align:center' }, [ + E('div', { 'style': 'display:flex; gap:0.5em; margin-bottom:1em' }, [ + E('input', { 'type': 'text', 'readonly': true, 'value': url, 'id': 'share-url', + 'class': 'cbi-input-text', 'style': 'flex:1' }), + E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { + if (navigator.clipboard) { + navigator.clipboard.writeText(url).then(function() { + ui.addNotification(null, E('p', _('URL copied'))); + }); + } + }}, _('Copy')) + ]), + E('div', { 'style': 'display:inline-block; padding:1em; background:#f8f8f8; border-radius:8px' }, [ + E('div', { 'innerHTML': qrSvg }) + ]), + E('div', { 'style': 'margin-top:1em; display:flex; gap:0.5em; justify-content:center; flex-wrap:wrap' }, [ + E('a', { 'href': 'https://twitter.com/intent/tweet?url=' + enc(url) + '&text=' + enc(title), + 'target': '_blank', 'class': 'cbi-button' }, 'Twitter'), + E('a', { 'href': 'https://t.me/share/url?url=' + enc(url) + '&text=' + enc(title), + 'target': '_blank', 'class': 'cbi-button' }, 'Telegram'), + E('a', { 'href': 'https://wa.me/?text=' + enc(title + ' ' + url), + 'target': '_blank', 'class': 'cbi-button' }, 'WhatsApp'), + E('a', { 'href': 'mailto:?subject=' + enc(title) + '&body=' + enc(url), + 'class': 'cbi-button' }, 'Email') + ]) + ]), + E('div', { 'class': 'right', 'style': 'margin-top:1em' }, [ + E('a', { 'href': url, 'target': '_blank', 'class': 'cbi-button cbi-button-positive' }, _('Visit Site')), + ' ', + E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Close')) + ]) + ]); }, handleSaveApply: null, 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 743db4b9..2e583d20 100755 --- a/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer +++ b/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer @@ -1602,6 +1602,329 @@ EOF json_dump } +# One-click upload and create site +# Accepts: name, domain, content (base64), is_zip +method_upload_and_create_site() { + local tmpinput="/tmp/rpcd_mb_upload_create_$$.json" + cat > "$tmpinput" + + local name domain content is_zip + name=$(jsonfilter -i "$tmpinput" -e '@.name' 2>/dev/null) + domain=$(jsonfilter -i "$tmpinput" -e '@.domain' 2>/dev/null) + content=$(jsonfilter -i "$tmpinput" -e '@.content' 2>/dev/null) + is_zip=$(jsonfilter -i "$tmpinput" -e '@.is_zip' 2>/dev/null) + rm -f "$tmpinput" + + if [ -z "$name" ] || [ -z "$domain" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Name and domain are required" + json_dump + return + fi + + # Sanitize name + local section_id="site_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" + + # Check if site already exists + if uci -q get "$UCI_CONFIG.$section_id" >/dev/null 2>&1; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site with this name already exists" + json_dump + return + fi + + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + + # 1. Create site directory + mkdir -p "$SITES_ROOT/$name" + + # 2. Decode and save content + umask 022 + if [ "$is_zip" = "1" ] && [ -n "$content" ]; then + # Handle ZIP upload + local tmpzip="/tmp/metablog_upload_$$.zip" + echo "$content" | base64 -d > "$tmpzip" 2>/dev/null + unzip -o "$tmpzip" -d "$SITES_ROOT/$name" >/dev/null 2>&1 + rm -f "$tmpzip" + elif [ -n "$content" ]; then + # Single file - assume index.html + echo "$content" | base64 -d > "$SITES_ROOT/$name/index.html" 2>/dev/null + fi + + # 3. Fix permissions + fix_permissions "$SITES_ROOT/$name" + + # 4. Create default index if none exists + if [ ! -f "$SITES_ROOT/$name/index.html" ]; then + cat > "$SITES_ROOT/$name/index.html" < + + + + + $name + + + +
+

$name

+

Site published with MetaBlogizer

+
+ + +EOF + chmod 644 "$SITES_ROOT/$name/index.html" + fi + + # 5. Get next port and create uhttpd instance + local port=$(get_next_port) + local server_address=$(uci -q get network.lan.ipaddr || echo "192.168.255.1") + + 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 + + # 6. Create UCI site config + uci set "$UCI_CONFIG.$section_id=site" + uci set "$UCI_CONFIG.$section_id.name=$name" + uci set "$UCI_CONFIG.$section_id.domain=$domain" + uci set "$UCI_CONFIG.$section_id.ssl=1" + uci set "$UCI_CONFIG.$section_id.enabled=1" + uci set "$UCI_CONFIG.$section_id.port=$port" + uci set "$UCI_CONFIG.$section_id.runtime=uhttpd" + + # 7. Create HAProxy backend if available + if haproxy_available; then + local backend_name="metablog_$(echo "$name" | sed 's/[^a-zA-Z0-9]/_/g')" + + 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" + + 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=$port" + uci set "haproxy.$server_name.weight=100" + uci set "haproxy.$server_name.check=1" + uci set "haproxy.$server_name.enabled=1" + + uci commit haproxy + reload_haproxy + fi + + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_int "port" "$port" + json_add_string "url" "https://$domain" + json_dump +} + +# Unpublish/revoke site exposure (remove HAProxy vhost but keep site) +method_unpublish_site() { + 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 domain + name=$(get_uci "$id" name "") + domain=$(get_uci "$id" domain "") + + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + # Remove HAProxy vhost (keep backend for local access) + if uci -q get haproxy >/dev/null 2>&1; then + local vhost_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') + uci delete "haproxy.$vhost_id" 2>/dev/null + + # Remove cert entry if exists + uci delete "haproxy.cert_$vhost_id" 2>/dev/null + + uci commit haproxy + reload_haproxy + fi + + # Mark as unpublished in UCI + uci set "$UCI_CONFIG.$id.emancipated=0" + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_add_string "message" "Site unpublished" + json_dump +} + +# Set authentication requirement for a site +method_set_auth_required() { + local id auth_required + + read -r input + json_load "$input" + json_get_var id id + json_get_var auth_required auth_required + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing site id" + json_dump + return + fi + + local name domain + name=$(get_uci "$id" name "") + domain=$(get_uci "$id" domain "") + + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + # Update UCI config + uci set "$UCI_CONFIG.$id.auth_required=$auth_required" + uci commit "$UCI_CONFIG" + + # If site has HAProxy vhost, update it + if uci -q get haproxy >/dev/null 2>&1 && [ -n "$domain" ]; then + local vhost_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') + if uci -q get "haproxy.$vhost_id" >/dev/null 2>&1; then + uci set "haproxy.$vhost_id.auth_required=$auth_required" + uci commit haproxy + reload_haproxy + fi + fi + + json_init + json_add_boolean "success" 1 + json_add_string "auth_required" "$auth_required" + json_dump +} + +# Get exposure status for all sites (cert info, emancipation state) +method_get_sites_exposure_status() { + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + + json_init + json_add_array "sites" + + config_load "$UCI_CONFIG" + config_foreach _add_site_exposure_status site + + json_close_array + json_dump +} + +_add_site_exposure_status() { + local section="$1" + local name domain ssl enabled emancipated auth_required port + + config_get name "$section" name "" + config_get domain "$section" domain "" + config_get ssl "$section" ssl "1" + config_get enabled "$section" enabled "1" + config_get emancipated "$section" emancipated "0" + config_get auth_required "$section" auth_required "0" + config_get port "$section" port "" + + [ -z "$name" ] && return + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_boolean "enabled" "$enabled" + json_add_boolean "emancipated" "$emancipated" + json_add_boolean "auth_required" "$auth_required" + [ -n "$port" ] && json_add_int "port" "$port" + + # Check if HAProxy vhost exists + local vhost_exists=0 + if [ -n "$domain" ]; then + local vhost_id=$(echo "$domain" | sed 's/[^a-zA-Z0-9]/_/g') + if uci -q get "haproxy.$vhost_id" >/dev/null 2>&1; then + vhost_exists=1 + fi + fi + json_add_boolean "vhost_exists" "$vhost_exists" + + # Quick certificate check - just check if file exists + # Full expiry check is expensive, use get_hosting_status for that + if [ -n "$domain" ] && [ "$ssl" = "1" ]; then + local cert_file="" + if [ -f "/srv/lxc/haproxy/rootfs/srv/haproxy/certs/${domain}.pem" ]; then + cert_file="/srv/lxc/haproxy/rootfs/srv/haproxy/certs/${domain}.pem" + elif [ -f "/etc/acme/${domain}_ecc/fullchain.cer" ]; then + cert_file="/etc/acme/${domain}_ecc/fullchain.cer" + fi + if [ -n "$cert_file" ]; then + json_add_string "cert_status" "valid" + else + json_add_string "cert_status" "missing" + fi + else + json_add_string "cert_status" "none" + fi + + # Backend running check + local backend_running="0" + if [ -n "$port" ]; then + local hex_port=$(printf '%04X' "$port" 2>/dev/null) + if grep -qi ":${hex_port}" /proc/net/tcp 2>/dev/null; then + backend_running="1" + fi + fi + json_add_boolean "backend_running" "$backend_running" + + # Has content + local has_content="0" + if [ -d "$SITES_ROOT/$name" ] && [ -f "$SITES_ROOT/$name/index.html" ]; then + has_content="1" + fi + json_add_boolean "has_content" "$has_content" + + json_close_object +} + # Emancipate site - KISS ULTIME MODE (DNS + Vortex + HAProxy + SSL) # Runs asynchronously to avoid XHR timeout - use emancipate_status to poll method_emancipate() { @@ -2042,7 +2365,11 @@ case "$1" in "import_vhost": { "instance": "string", "name": "string", "domain": "string" }, "sync_config": {}, "emancipate": { "id": "string" }, - "emancipate_status": { "job_id": "string" } + "emancipate_status": { "job_id": "string" }, + "upload_and_create_site": { "name": "string", "domain": "string", "content": "string", "is_zip": "string" }, + "unpublish_site": { "id": "string" }, + "set_auth_required": { "id": "string", "auth_required": "string" }, + "get_sites_exposure_status": {} } EOF ;; @@ -2073,6 +2400,10 @@ EOF sync_config) method_sync_config ;; emancipate) method_emancipate ;; emancipate_status) method_emancipate_status ;; + upload_and_create_site) method_upload_and_create_site ;; + unpublish_site) method_unpublish_site ;; + set_auth_required) method_set_auth_required ;; + get_sites_exposure_status) method_get_sites_exposure_status ;; *) echo '{"error": "unknown method"}' ;; esac ;; 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 13b468bf..fd9da74a 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 @@ -12,7 +12,8 @@ "get_hosting_status", "check_site_health", "get_tor_status", - "discover_vhosts" + "discover_vhosts", + "get_sites_exposure_status" ], "file": ["read", "list", "stat"] }, @@ -39,7 +40,10 @@ "import_vhost", "sync_config", "emancipate", - "emancipate_status" + "emancipate_status", + "upload_and_create_site", + "unpublish_site", + "set_auth_required" ], "luci.haproxy": [ "create_backend",