diff --git a/package/secubox/luci-app-metablogizer/Makefile b/package/secubox/luci-app-metablogizer/Makefile new file mode 100644 index 00000000..8da57bc3 --- /dev/null +++ b/package/secubox/luci-app-metablogizer/Makefile @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2025 CyberMind.fr +# +# LuCI App for MetaBlogizer Static Site Publisher + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for MetaBlogizer Static Site Publisher +LUCI_DEPENDS:=+luci-base +git +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-metablogizer +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-metablogizer/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.metablogizer $(1)/usr/libexec/rpcd/luci.metablogizer + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-metablogizer.json $(1)/usr/share/luci/menu.d/luci-app-metablogizer.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-metablogizer.json $(1)/usr/share/rpcd/acl.d/luci-app-metablogizer.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/metablogizer + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/metablogizer/*.js $(1)/www/luci-static/resources/view/metablogizer/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/metablogizer + $(INSTALL_DATA) ./htdocs/luci-static/resources/metablogizer/*.js $(1)/www/luci-static/resources/metablogizer/ +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/qrcode.js b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/qrcode.js new file mode 100644 index 00000000..c70fbab2 --- /dev/null +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/metablogizer/qrcode.js @@ -0,0 +1,492 @@ +'use strict'; +'require baseclass'; + +/** + * QR Code Generator for WireGuard Dashboard + * Supports up to ~300 bytes (sufficient for WireGuard configs) + * Based on QR Code specification ISO/IEC 18004 + */ + +// QR Code version capacities (byte mode, EC level L) +var VERSION_CAPACITIES = { + 1: 17, 2: 32, 3: 53, 4: 78, 5: 106, 6: 134, 7: 154, 8: 192, 9: 230, 10: 271, + 11: 321, 12: 367, 13: 425, 14: 458, 15: 520, 16: 586, 17: 644, 18: 718, 19: 792, 20: 858 +}; + +// QR Code sizes (modules per side) +var VERSION_SIZES = { + 1: 21, 2: 25, 3: 29, 4: 33, 5: 37, 6: 41, 7: 45, 8: 49, 9: 53, 10: 57, + 11: 61, 12: 65, 13: 69, 14: 73, 15: 77, 16: 81, 17: 85, 18: 89, 19: 93, 20: 97 +}; + +// Data codewords per version (EC level L) +var DATA_CODEWORDS = { + 1: 19, 2: 34, 3: 55, 4: 80, 5: 108, 6: 136, 7: 156, 8: 194, 9: 232, 10: 274, + 11: 324, 12: 370, 13: 428, 14: 461, 15: 523, 16: 589, 17: 647, 18: 721, 19: 795, 20: 861 +}; + +// EC codewords per block for level L +var EC_CODEWORDS = { + 1: 7, 2: 10, 3: 15, 4: 20, 5: 26, 6: 18, 7: 20, 8: 24, 9: 30, 10: 18, + 11: 20, 12: 24, 13: 26, 14: 30, 15: 22, 16: 24, 17: 28, 18: 30, 19: 28, 20: 28 +}; + +// Number of EC blocks +var EC_BLOCKS = { + 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 2, 10: 4, + 11: 4, 12: 4, 13: 4, 14: 4, 15: 6, 16: 6, 17: 6, 18: 6, 19: 7, 20: 8 +}; + +// Alignment pattern positions +var ALIGNMENT_POSITIONS = { + 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], + 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50], + 11: [6, 30, 54], 12: [6, 32, 58], 13: [6, 34, 62], 14: [6, 26, 46, 66], + 15: [6, 26, 48, 70], 16: [6, 26, 50, 74], 17: [6, 30, 54, 78], + 18: [6, 30, 56, 82], 19: [6, 30, 58, 86], 20: [6, 34, 62, 90] +}; + +// Galois Field 256 tables +var GF_EXP = new Array(512); +var GF_LOG = new Array(256); + +// Initialize Galois Field tables +(function() { + var x = 1; + for (var i = 0; i < 255; i++) { + GF_EXP[i] = x; + GF_LOG[x] = i; + x = x << 1; + if (x >= 256) x ^= 0x11d; + } + for (var i = 255; i < 512; i++) { + GF_EXP[i] = GF_EXP[i - 255]; + } +})(); + +function gfMul(a, b) { + if (a === 0 || b === 0) return 0; + return GF_EXP[GF_LOG[a] + GF_LOG[b]]; +} + +function gfPow(x, power) { + return GF_EXP[(GF_LOG[x] * power) % 255]; +} + +// Generate Reed-Solomon generator polynomial +function rsGeneratorPoly(nsym) { + var g = [1]; + for (var i = 0; i < nsym; i++) { + var newG = new Array(g.length + 1).fill(0); + for (var j = 0; j < g.length; j++) { + newG[j] ^= g[j]; + newG[j + 1] ^= gfMul(g[j], GF_EXP[i]); + } + g = newG; + } + return g; +} + +// Reed-Solomon encoding +function rsEncode(data, nsym) { + var gen = rsGeneratorPoly(nsym); + var res = new Array(data.length + nsym).fill(0); + for (var i = 0; i < data.length; i++) { + res[i] = data[i]; + } + for (var i = 0; i < data.length; i++) { + var coef = res[i]; + if (coef !== 0) { + for (var j = 0; j < gen.length; j++) { + res[i + j] ^= gfMul(gen[j], coef); + } + } + } + return res.slice(data.length); +} + +// Select optimal QR version for data length +function selectVersion(dataLength) { + for (var v = 1; v <= 20; v++) { + if (VERSION_CAPACITIES[v] >= dataLength) { + return v; + } + } + return 20; // Max supported +} + +// Encode text to UTF-8 bytes +function textToBytes(text) { + var bytes = []; + for (var i = 0; i < text.length; i++) { + var c = text.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else if (c < 2048) { + bytes.push((c >> 6) | 192); + bytes.push((c & 63) | 128); + } else if (c < 65536) { + bytes.push((c >> 12) | 224); + bytes.push(((c >> 6) & 63) | 128); + bytes.push((c & 63) | 128); + } + } + return bytes; +} + +// Create data codewords +function createDataCodewords(text, version) { + var bytes = textToBytes(text); + var totalCodewords = DATA_CODEWORDS[version]; + var bits = []; + + // Mode indicator: 0100 (byte mode) + bits.push(0, 1, 0, 0); + + // Character count indicator (8 bits for v1-9, 16 bits for v10+) + var countBits = version <= 9 ? 8 : 16; + for (var i = countBits - 1; i >= 0; i--) { + bits.push((bytes.length >> i) & 1); + } + + // Data bits + for (var i = 0; i < bytes.length; i++) { + for (var j = 7; j >= 0; j--) { + bits.push((bytes[i] >> j) & 1); + } + } + + // Terminator (up to 4 zeros) + var terminatorLength = Math.min(4, totalCodewords * 8 - bits.length); + for (var i = 0; i < terminatorLength; i++) { + bits.push(0); + } + + // Pad to byte boundary + while (bits.length % 8 !== 0) { + bits.push(0); + } + + // Pad codewords + var padBytes = [236, 17]; + var padIdx = 0; + while (bits.length < totalCodewords * 8) { + for (var j = 7; j >= 0; j--) { + bits.push((padBytes[padIdx] >> j) & 1); + } + padIdx = (padIdx + 1) % 2; + } + + // Convert bits to bytes + var data = []; + for (var i = 0; i < bits.length; i += 8) { + var byte = 0; + for (var j = 0; j < 8; j++) { + byte = (byte << 1) | (bits[i + j] || 0); + } + data.push(byte); + } + + return data.slice(0, totalCodewords); +} + +// Interleave data and EC blocks +function interleaveBlocks(data, version) { + var numBlocks = EC_BLOCKS[version]; + var ecPerBlock = EC_CODEWORDS[version]; + var totalData = DATA_CODEWORDS[version]; + var dataPerBlock = Math.floor(totalData / numBlocks); + var extraBlocks = totalData % numBlocks; + + var blocks = []; + var ecBlocks = []; + var offset = 0; + + for (var i = 0; i < numBlocks; i++) { + var blockSize = dataPerBlock + (i >= numBlocks - extraBlocks ? 1 : 0); + var blockData = data.slice(offset, offset + blockSize); + blocks.push(blockData); + ecBlocks.push(rsEncode(blockData, ecPerBlock)); + offset += blockSize; + } + + // Interleave + var result = []; + var maxDataLen = dataPerBlock + (extraBlocks > 0 ? 1 : 0); + for (var i = 0; i < maxDataLen; i++) { + for (var j = 0; j < numBlocks; j++) { + if (i < blocks[j].length) { + result.push(blocks[j][i]); + } + } + } + for (var i = 0; i < ecPerBlock; i++) { + for (var j = 0; j < numBlocks; j++) { + result.push(ecBlocks[j][i]); + } + } + + return result; +} + +// Create QR matrix +function createMatrix(text) { + var bytes = textToBytes(text); + var version = selectVersion(bytes.length); + var size = VERSION_SIZES[version]; + + var matrix = []; + var reserved = []; + for (var i = 0; i < size; i++) { + matrix[i] = new Array(size).fill(0); + reserved[i] = new Array(size).fill(false); + } + + // Add finder patterns + addFinderPattern(matrix, reserved, 0, 0, size); + addFinderPattern(matrix, reserved, size - 7, 0, size); + addFinderPattern(matrix, reserved, 0, size - 7, size); + + // Add alignment patterns + if (version >= 2) { + var positions = ALIGNMENT_POSITIONS[version]; + for (var i = 0; i < positions.length; i++) { + for (var j = 0; j < positions.length; j++) { + var row = positions[i]; + var col = positions[j]; + // Skip if overlapping with finder patterns + if ((row < 9 && col < 9) || (row < 9 && col > size - 10) || (row > size - 10 && col < 9)) { + continue; + } + addAlignmentPattern(matrix, reserved, row, col); + } + } + } + + // Add timing patterns + for (var i = 8; i < size - 8; i++) { + var bit = i % 2 === 0 ? 1 : 0; + if (!reserved[6][i]) { + matrix[6][i] = bit; + reserved[6][i] = true; + } + if (!reserved[i][6]) { + matrix[i][6] = bit; + reserved[i][6] = true; + } + } + + // Add dark module + matrix[size - 8][8] = 1; + reserved[size - 8][8] = true; + + // Reserve format info areas + for (var i = 0; i < 9; i++) { + reserved[8][i] = true; + reserved[i][8] = true; + if (i < 8) { + reserved[8][size - 1 - i] = true; + reserved[size - 1 - i][8] = true; + } + } + + // Reserve version info areas (version >= 7) + if (version >= 7) { + for (var i = 0; i < 6; i++) { + for (var j = 0; j < 3; j++) { + reserved[i][size - 11 + j] = true; + reserved[size - 11 + j][i] = true; + } + } + } + + // Create and interleave data + var data = createDataCodewords(text, version); + var allData = interleaveBlocks(data, version); + + // Convert to bits + var bits = []; + for (var i = 0; i < allData.length; i++) { + for (var j = 7; j >= 0; j--) { + bits.push((allData[i] >> j) & 1); + } + } + + // Place data bits + var bitIdx = 0; + var up = true; + for (var col = size - 1; col >= 0; col -= 2) { + if (col === 6) col = 5; + for (var row = up ? size - 1 : 0; up ? row >= 0 : row < size; row += up ? -1 : 1) { + for (var c = 0; c < 2; c++) { + var x = col - c; + if (x >= 0 && !reserved[row][x] && bitIdx < bits.length) { + matrix[row][x] = bits[bitIdx++]; + } + } + } + up = !up; + } + + // Apply mask pattern 0 and add format info + applyMaskAndFormat(matrix, reserved, size, version); + + return { matrix: matrix, size: size, version: version }; +} + +function addFinderPattern(matrix, reserved, row, col, size) { + for (var r = -1; r <= 7; r++) { + for (var c = -1; c <= 7; c++) { + var rr = row + r; + var cc = col + c; + if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; + + var bit = 0; + if (r >= 0 && r <= 6 && c >= 0 && c <= 6) { + if (r === 0 || r === 6 || c === 0 || c === 6 || + (r >= 2 && r <= 4 && c >= 2 && c <= 4)) { + bit = 1; + } + } + matrix[rr][cc] = bit; + reserved[rr][cc] = true; + } + } +} + +function addAlignmentPattern(matrix, reserved, row, col) { + for (var r = -2; r <= 2; r++) { + for (var c = -2; c <= 2; c++) { + var bit = (Math.abs(r) === 2 || Math.abs(c) === 2 || (r === 0 && c === 0)) ? 1 : 0; + matrix[row + r][col + c] = bit; + reserved[row + r][col + c] = true; + } + } +} + +function applyMaskAndFormat(matrix, reserved, size, version) { + // Apply mask pattern 0: (row + col) % 2 === 0 + for (var row = 0; row < size; row++) { + for (var col = 0; col < size; col++) { + if (!reserved[row][col]) { + if ((row + col) % 2 === 0) { + matrix[row][col] ^= 1; + } + } + } + } + + // Format info for mask 0 and EC level L: 111011111000100 + var formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + + // Place format info + var formatPositions1 = [[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8]]; + var formatPositions2 = [[8, size - 1], [8, size - 2], [8, size - 3], [8, size - 4], [8, size - 5], [8, size - 6], [8, size - 7], [8, size - 8], [size - 7, 8], [size - 6, 8], [size - 5, 8], [size - 4, 8], [size - 3, 8], [size - 2, 8], [size - 1, 8]]; + + for (var i = 0; i < 15; i++) { + matrix[formatPositions1[i][0]][formatPositions1[i][1]] = formatBits[i]; + matrix[formatPositions2[i][0]][formatPositions2[i][1]] = formatBits[i]; + } + + // Version info (for version >= 7) + if (version >= 7) { + var versionInfo = getVersionInfo(version); + var vIdx = 0; + for (var i = 0; i < 6; i++) { + for (var j = 0; j < 3; j++) { + var bit = (versionInfo >> vIdx) & 1; + matrix[i][size - 11 + j] = bit; + matrix[size - 11 + j][i] = bit; + vIdx++; + } + } + } +} + +function getVersionInfo(version) { + var versionInfos = { + 7: 0x07C94, 8: 0x085BC, 9: 0x09A99, 10: 0x0A4D3, 11: 0x0BBF6, 12: 0x0C762, 13: 0x0D847, 14: 0x0E60D, + 15: 0x0F928, 16: 0x10B78, 17: 0x1145D, 18: 0x12A17, 19: 0x13532, 20: 0x149A6 + }; + return versionInfos[version] || 0; +} + +// Generate SVG +function generateSVG(text, displaySize) { + displaySize = displaySize || 250; + + try { + var result = createMatrix(text); + var matrix = result.matrix; + var size = result.size; + var quietZone = 4; + var totalSize = size + quietZone * 2; + var moduleSize = displaySize / totalSize; + + var svg = ''; + svg += ''; + + for (var row = 0; row < size; row++) { + for (var col = 0; col < size; col++) { + if (matrix[row][col] === 1) { + var x = (col + quietZone) * moduleSize; + var y = (row + quietZone) * moduleSize; + svg += ''; + } + } + } + + svg += ''; + return svg; + } catch (e) { + console.error('QR generation error:', e); + return null; + } +} + +return baseclass.extend({ + /** + * Generate QR code as SVG string + * @param {string} text - Text to encode (up to ~300 bytes) + * @param {number} size - Display size in pixels (default: 250) + * @returns {string} SVG markup or null on error + */ + generateSVG: function(text, size) { + return generateSVG(text, size); + }, + + /** + * Generate QR code as data URL + * @param {string} text - Text to encode + * @param {number} size - Display size in pixels + * @returns {string} Data URL for img src + */ + generateDataURL: function(text, size) { + var svg = generateSVG(text, size); + if (!svg) return null; + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + }, + + /** + * Render QR code to a DOM element + * @param {HTMLElement} container - Container element + * @param {string} text - Text to encode + * @param {number} size - Display size in pixels + */ + render: function(container, text, size) { + var svg = generateSVG(text, size); + if (svg) { + container.innerHTML = svg; + } else { + container.innerHTML = '
QR generation failed - text too long
'; + } + }, + + /** + * Get maximum capacity for current implementation + * @returns {number} Maximum bytes that can be encoded + */ + getMaxCapacity: function() { + return VERSION_CAPACITIES[20]; // 858 bytes + } +}); 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 new file mode 100644 index 00000000..d99518bf --- /dev/null +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/overview.js @@ -0,0 +1,630 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; +'require poll'; +'require metablogizer/qrcode as qrcode'; + +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 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; } \ +}'; + +return view.extend({ + load: function() { + return Promise.all([ + callStatus(), + callListSites() + ]); + }, + + render: function(data) { + var self = this; + var status = data[0] || {}; + var sites = (data[1] && data[1].sites) || []; + + // Inject styles + var styleEl = document.createElement('style'); + styleEl.textContent = styles; + document.head.appendChild(styleEl); + + var view = E('div', { 'class': 'mb-container' }, [ + // Header with status + 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('button', { + 'class': 'mb-btn-primary', + 'click': ui.createHandlerFn(this, 'showPublishModal') + }, _('+ New Site')) + ]), + + // Sites grid or empty state + 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('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')) + ]) + ]); + + 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('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-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')) + ]) + ]); + }, + + showPublishModal: function() { + var self = this; + + var modal = E('div', { 'class': 'mb-modal-overlay', 'id': 'mb-publish-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-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-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')) + ]) + ]) + ]); + + 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() { + 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 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'); + }); + }, + + showPublishedModal: 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' }, [ + 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-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-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('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')) + ]) + ]) + ]); + + 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'); + }); + }, + + 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')) + ]) + ]); + }, + + 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'); + }); + }, + + 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 new file mode 100644 index 00000000..0817dddd --- /dev/null +++ b/package/secubox/luci-app-metablogizer/htdocs/luci-static/resources/view/metablogizer/settings.js @@ -0,0 +1,60 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; + +return view.extend({ + load: function() { + return uci.load('metablogizer'); + }, + + render: function() { + var m, s, o; + + m = new form.Map('metablogizer', _('MetaBlogizer Settings'), + _('Configure the MetaBlogizer static site publisher settings.')); + + // Main settings + s = m.section(form.TypedSection, 'metablogizer', _('General Settings')); + s.anonymous = true; + s.addremove = false; + + o = s.option(form.Flag, 'enabled', _('Enabled'), + _('Enable the MetaBlogizer service')); + 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.Value, 'sites_root', _('Sites Root Path'), + _('Directory where site files are stored')); + o.placeholder = '/srv/metablogizer/sites'; + o.default = '/srv/metablogizer/sites'; + o.rmempty = false; + + // Info section + s = m.section(form.TypedSection, 'metablogizer', _('Information')); + s.anonymous = true; + + o = s.option(form.DummyValue, '_info', _('How it works')); + o.rawhtml = true; + o.cfgvalue = function() { + return '
' + + '

' + _('MetaBlogizer Flow:') + '

' + + '
    ' + + '
  1. ' + _('Create a site with name and domain') + '
  2. ' + + '
  3. ' + _('HAProxy vhost is auto-created with SSL/ACME') + '
  4. ' + + '
  5. ' + _('Nginx container serves static files') + '
  6. ' + + '
  7. ' + _('Optionally sync content from Gitea') + '
  8. ' + + '
  9. ' + _('Share via QR code or social networks') + '
  10. ' + + '
' + + '
'; + }; + + return m.render(); + } +}); 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 new file mode 100644 index 00000000..609cb341 --- /dev/null +++ b/package/secubox/luci-app-metablogizer/root/usr/libexec/rpcd/luci.metablogizer @@ -0,0 +1,605 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# RPCD backend for MetaBlogizer Static Site Publisher +# Copyright (C) 2025 CyberMind.fr + +. /lib/functions.sh +. /usr/share/libubox/jshn.sh + +UCI_CONFIG="metablogizer" +SITES_ROOT="/srv/metablogizer/sites" +NGINX_CONTAINER="nginx" + +# Helper: Get UCI value with default +get_uci() { + local section="$1" + local option="$2" + local default="$3" + local value + value=$(uci -q get "$UCI_CONFIG.$section.$option") + echo "${value:-$default}" +} + +# Status method - get overall status and list all sites +method_status() { + local enabled nginx_running site_count + + enabled=$(get_uci main enabled 0) + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER") + + # Check nginx container + if lxc-info -n "$NGINX_CONTAINER" -s 2>/dev/null | grep -q "RUNNING"; then + nginx_running="1" + else + nginx_running="0" + fi + + # Count sites + site_count=0 + config_load "$UCI_CONFIG" + config_foreach _count_site site + site_count="$_site_count" + + json_init + json_add_boolean "enabled" "$enabled" + json_add_boolean "nginx_running" "$nginx_running" + json_add_int "site_count" "$site_count" + json_add_string "sites_root" "$SITES_ROOT" + json_add_string "nginx_container" "$NGINX_CONTAINER" + json_dump +} + +_site_count=0 +_count_site() { + _site_count=$((_site_count + 1)) +} + +# List all sites with their status +method_list_sites() { + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + + json_init + json_add_array "sites" + + config_load "$UCI_CONFIG" + config_foreach _add_site site + + json_close_array + json_dump +} + +_add_site() { + local section="$1" + local name domain gitea_repo ssl enabled description + local has_content last_sync + + config_get name "$section" name "" + config_get domain "$section" domain "" + config_get gitea_repo "$section" gitea_repo "" + config_get ssl "$section" ssl "1" + config_get enabled "$section" enabled "1" + config_get description "$section" description "" + + # Check if site has content + has_content="0" + if [ -d "$SITES_ROOT/$name" ] && [ -f "$SITES_ROOT/$name/index.html" ]; then + has_content="1" + fi + + # Get last sync time + last_sync="" + if [ -d "$SITES_ROOT/$name/.git" ]; then + last_sync=$(cd "$SITES_ROOT/$name" && git log -1 --format="%ci" 2>/dev/null || echo "") + fi + + json_add_object + json_add_string "id" "$section" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_string "gitea_repo" "$gitea_repo" + json_add_string "description" "$description" + json_add_boolean "ssl" "$ssl" + json_add_boolean "enabled" "$enabled" + json_add_boolean "has_content" "$has_content" + json_add_string "last_sync" "$last_sync" + json_add_string "url" "https://$domain" + json_close_object +} + +# Create a new site with auto-vhost +method_create_site() { + local name domain gitea_repo ssl description + local section_id gitea_url + + read -r input + json_load "$input" + json_get_var name name + json_get_var domain domain + json_get_var gitea_repo gitea_repo + json_get_var ssl ssl "1" + json_get_var description description "" + + # Validate required fields + 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 for section ID + 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") + NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER") + + # 1. 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=$ssl" + uci set "$UCI_CONFIG.$section_id.enabled=1" + [ -n "$gitea_repo" ] && uci set "$UCI_CONFIG.$section_id.gitea_repo=$gitea_repo" + [ -n "$description" ] && uci set "$UCI_CONFIG.$section_id.description=$description" + + # 2. Create site directory + mkdir -p "$SITES_ROOT/$name" + + # 3. Clone from Gitea if repo specified + if [ -n "$gitea_repo" ]; then + gitea_url=$(uci -q get gitea.main.url || echo "http://192.168.255.1:3000") + git clone "$gitea_url/$gitea_repo.git" "$SITES_ROOT/$name" 2>/dev/null || true + fi + + # 4. Create default index.html with OG tags if no content + if [ ! -f "$SITES_ROOT/$name/index.html" ]; then + cat > "$SITES_ROOT/$name/index.html" < + + + + + $name + + + + + + + + + + +
+

$name

+

Site published with MetaBlogizer

+
+ + +EOF + fi + + # 5. 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 + + # 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" + + 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 + 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 commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$section_id" + json_add_string "url" "https://$domain" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_dump +} + +# Configure nginx location for a site +_configure_nginx() { + local site_name="$1" + local nginx_conf="/var/lib/lxc/$NGINX_CONTAINER/rootfs/etc/nginx/sites.d" + + # Create nginx config for site + mkdir -p "$nginx_conf" + + cat > "$nginx_conf/metablog-$site_name.conf" </dev/null || true +} + +# Delete a site and cleanup +method_delete_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 + + SITES_ROOT=$(get_uci main sites_root "$SITES_ROOT") + NGINX_CONTAINER=$(get_uci main nginx_container "$NGINX_CONTAINER") + + # 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 + + # 2. Delete HAProxy backend + 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 + + # 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 + + # 4. Remove site directory + rm -rf "$SITES_ROOT/$name" + + # 5. Delete UCI config + uci delete "$UCI_CONFIG.$id" + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Sync site from Gitea (git pull) +method_sync_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 gitea_repo + name=$(get_uci "$id" name "") + gitea_repo=$(get_uci "$id" gitea_repo "") + + 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" + + if [ -d "$site_path/.git" ]; then + # Pull latest changes + cd "$site_path" + local result + result=$(git pull 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "$result" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump + elif [ -n "$gitea_repo" ]; then + # Clone if not exists + local gitea_url + gitea_url=$(uci -q get gitea.main.url || echo "http://192.168.255.1:3000") + + rm -rf "$site_path" + local result + result=$(git clone "$gitea_url/$gitea_repo.git" "$site_path" 2>&1) + local rc=$? + + json_init + if [ $rc -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "message" "Cloned from $gitea_repo" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump + else + json_init + json_add_boolean "success" 0 + json_add_string "error" "No git repository configured for this site" + json_dump + fi +} + +# Get publish info for QR code generation +method_get_publish_info() { + 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 description ssl + name=$(get_uci "$id" name "") + domain=$(get_uci "$id" domain "") + description=$(get_uci "$id" description "") + ssl=$(get_uci "$id" ssl "1") + + if [ -z "$name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + local protocol="http" + [ "$ssl" = "1" ] && protocol="https" + local url="${protocol}://${domain}" + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$id" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_string "url" "$url" + json_add_string "description" "$description" + json_add_string "title" "$name - Published with SecuBox" + json_dump +} + +# Get site details +method_get_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 gitea_repo ssl enabled description + name=$(get_uci "$id" name "") + domain=$(get_uci "$id" domain "") + gitea_repo=$(get_uci "$id" gitea_repo "") + ssl=$(get_uci "$id" ssl "1") + enabled=$(get_uci "$id" enabled "1") + description=$(get_uci "$id" description "") + + 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 has_content last_sync + has_content="0" + if [ -d "$SITES_ROOT/$name" ] && [ -f "$SITES_ROOT/$name/index.html" ]; then + has_content="1" + fi + last_sync="" + if [ -d "$SITES_ROOT/$name/.git" ]; then + last_sync=$(cd "$SITES_ROOT/$name" && git log -1 --format="%ci" 2>/dev/null || echo "") + fi + + local protocol="http" + [ "$ssl" = "1" ] && protocol="https" + + json_init + json_add_boolean "success" 1 + json_add_string "id" "$id" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_string "gitea_repo" "$gitea_repo" + json_add_string "description" "$description" + json_add_boolean "ssl" "$ssl" + json_add_boolean "enabled" "$enabled" + json_add_boolean "has_content" "$has_content" + json_add_string "last_sync" "$last_sync" + json_add_string "url" "${protocol}://${domain}" + json_dump +} + +# Update site settings +method_update_site() { + local id name domain gitea_repo ssl enabled description + + read -r input + json_load "$input" + json_get_var id id + json_get_var name name + json_get_var domain domain + json_get_var gitea_repo gitea_repo + json_get_var ssl ssl + json_get_var enabled enabled + json_get_var description description + + if [ -z "$id" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Missing site id" + json_dump + return + fi + + # Check site exists + local current_name + current_name=$(get_uci "$id" name "") + if [ -z "$current_name" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Site not found" + json_dump + return + fi + + [ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name" + [ -n "$domain" ] && uci set "$UCI_CONFIG.$id.domain=$domain" + [ -n "$gitea_repo" ] && uci set "$UCI_CONFIG.$id.gitea_repo=$gitea_repo" + [ -n "$ssl" ] && uci set "$UCI_CONFIG.$id.ssl=$ssl" + [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" + [ -n "$description" ] && uci set "$UCI_CONFIG.$id.description=$description" + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Get global settings +method_get_settings() { + json_init + json_add_boolean "enabled" "$(get_uci main enabled 0)" + 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_dump +} + +# Save global settings +method_save_settings() { + local enabled nginx_container sites_root + + read -r input + json_load "$input" + json_get_var enabled enabled + json_get_var nginx_container nginx_container + json_get_var sites_root sites_root + + # 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 "$nginx_container" ] && uci set "$UCI_CONFIG.main.nginx_container=$nginx_container" + [ -n "$sites_root" ] && uci set "$UCI_CONFIG.main.sites_root=$sites_root" + uci commit "$UCI_CONFIG" + + json_init + json_add_boolean "success" 1 + json_dump +} + +# Main RPC interface +case "$1" in + list) + cat <<'EOF' +{ + "status": {}, + "list_sites": {}, + "get_site": { "id": "string" }, + "create_site": { "name": "string", "domain": "string", "gitea_repo": "string", "ssl": "boolean", "description": "string" }, + "update_site": { "id": "string", "name": "string", "domain": "string", "gitea_repo": "string", "ssl": "boolean", "enabled": "boolean", "description": "string" }, + "delete_site": { "id": "string" }, + "sync_site": { "id": "string" }, + "get_publish_info": { "id": "string" }, + "get_settings": {}, + "save_settings": { "enabled": "boolean", "nginx_container": "string", "sites_root": "string" } +} +EOF + ;; + call) + case "$2" in + status) method_status ;; + list_sites) method_list_sites ;; + get_site) method_get_site ;; + create_site) method_create_site ;; + update_site) method_update_site ;; + delete_site) method_delete_site ;; + sync_site) method_sync_site ;; + get_publish_info) method_get_publish_info ;; + get_settings) method_get_settings ;; + save_settings) method_save_settings ;; + *) echo '{"error": "unknown method"}' ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-metablogizer/root/usr/share/luci/menu.d/luci-app-metablogizer.json b/package/secubox/luci-app-metablogizer/root/usr/share/luci/menu.d/luci-app-metablogizer.json new file mode 100644 index 00000000..893c02c7 --- /dev/null +++ b/package/secubox/luci-app-metablogizer/root/usr/share/luci/menu.d/luci-app-metablogizer.json @@ -0,0 +1,30 @@ +{ + "admin/services/metablogizer": { + "title": "MetaBlogizer", + "action": { + "type": "view", + "path": "metablogizer/overview" + }, + "depends": { + "acl": ["luci-app-metablogizer"], + "uci": {"metablogizer": true} + }, + "order": 85 + }, + "admin/services/metablogizer/overview": { + "title": "Sites", + "action": { + "type": "view", + "path": "metablogizer/overview" + }, + "order": 10 + }, + "admin/services/metablogizer/settings": { + "title": "Settings", + "action": { + "type": "view", + "path": "metablogizer/settings" + }, + "order": 20 + } +} 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 new file mode 100644 index 00000000..9e17becb --- /dev/null +++ b/package/secubox/luci-app-metablogizer/root/usr/share/rpcd/acl.d/luci-app-metablogizer.json @@ -0,0 +1,36 @@ +{ + "luci-app-metablogizer": { + "description": "Grant access to MetaBlogizer Static Site Publisher", + "read": { + "ubus": { + "luci.metablogizer": [ + "status", + "list_sites", + "get_site", + "get_publish_info", + "get_settings" + ] + }, + "uci": ["metablogizer"] + }, + "write": { + "ubus": { + "luci.metablogizer": [ + "create_site", + "update_site", + "delete_site", + "sync_site", + "save_settings" + ], + "luci.haproxy": [ + "create_backend", + "create_server", + "create_vhost", + "delete_backend", + "delete_vhost" + ] + }, + "uci": ["metablogizer"] + } + } +}