feat(metablogizer): Add KISS static site publisher with auto-vhost
New luci-app-metablogizer package replacing metabolizer with simplified static site publishing: - RPCD backend with create/delete/sync site methods - Auto HAProxy vhost creation with SSL/ACME - Nginx LXC container integration for serving static files - Git sync from Gitea repositories - QR code generation for published URLs - Social share buttons (Twitter, LinkedIn, Facebook, Telegram, WhatsApp, Email) - Drag-and-drop file upload UI - SecuBox light theme styling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4811613711
commit
51c2f9d1a1
38
package/secubox/luci-app-metablogizer/Makefile
Normal file
38
package/secubox/luci-app-metablogizer/Makefile
Normal file
@ -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 <contact@cybermind.fr>
|
||||
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)))
|
||||
@ -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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + displaySize + ' ' + displaySize + '" width="' + displaySize + '" height="' + displaySize + '">';
|
||||
svg += '<rect width="100%" height="100%" fill="white"/>';
|
||||
|
||||
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 += '<rect x="' + x.toFixed(2) + '" y="' + y.toFixed(2) + '" width="' + moduleSize.toFixed(2) + '" height="' + moduleSize.toFixed(2) + '" fill="black"/>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div style="color:red;padding:20px;">QR generation failed - text too long</div>';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get maximum capacity for current implementation
|
||||
* @returns {number} Maximum bytes that can be encoded
|
||||
*/
|
||||
getMaxCapacity: function() {
|
||||
return VERSION_CAPACITIES[20]; // 858 bytes
|
||||
}
|
||||
});
|
||||
@ -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 || '<p>QR unavailable</p>' })
|
||||
]),
|
||||
|
||||
// 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
|
||||
});
|
||||
@ -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 '<div style="padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #667eea;">' +
|
||||
'<p style="margin: 0 0 0.5rem 0;"><strong>' + _('MetaBlogizer Flow:') + '</strong></p>' +
|
||||
'<ol style="margin: 0; padding-left: 1.5rem;">' +
|
||||
'<li>' + _('Create a site with name and domain') + '</li>' +
|
||||
'<li>' + _('HAProxy vhost is auto-created with SSL/ACME') + '</li>' +
|
||||
'<li>' + _('Nginx container serves static files') + '</li>' +
|
||||
'<li>' + _('Optionally sync content from Gitea') + '</li>' +
|
||||
'<li>' + _('Share via QR code or social networks') + '</li>' +
|
||||
'</ol>' +
|
||||
'</div>';
|
||||
};
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
@ -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" <<EOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>$name</title>
|
||||
<meta property="og:title" content="$name">
|
||||
<meta property="og:url" content="https://$domain">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:description" content="$description">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="$name">
|
||||
<meta name="twitter:url" content="https://$domain">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #333; }
|
||||
p { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>$name</h1>
|
||||
<p>Site published with MetaBlogizer</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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" <<EOF
|
||||
location /$site_name/ {
|
||||
alias /srv/sites/$site_name/;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
EOF
|
||||
|
||||
# Reload nginx in container
|
||||
lxc-attach -n "$NGINX_CONTAINER" -- nginx -s reload 2>/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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user