From 078e2dc01e6b1d4a36088435cba455b57e00de1f Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 14 Mar 2026 11:25:07 +0100 Subject: [PATCH] feat(droplet): Add one-drop content publisher Simple drag-and-drop publishing for HTML/ZIP files: - Auto-detects content type (static/streamlit/hexo) - Creates vhosts at gk2.secubox.in by default - Registers with metablogizer or streamlit accordingly - CLI: dropletctl publish/list/remove/rename - LuCI drag-drop interface at Services > Droplet Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-droplet/Makefile | 27 ++ .../resources/view/droplet/overview.js | 352 ++++++++++++++++++ .../root/usr/libexec/rpcd/luci.droplet | 138 +++++++ .../share/luci/menu.d/luci-app-droplet.json | 14 + .../share/rpcd/acl.d/luci-app-droplet.json | 16 + package/secubox/secubox-app-droplet/Makefile | 37 ++ .../files/etc/config/droplet | 3 + .../files/usr/sbin/dropletctl | 292 +++++++++++++++ 8 files changed, 879 insertions(+) create mode 100644 package/secubox/luci-app-droplet/Makefile create mode 100644 package/secubox/luci-app-droplet/htdocs/luci-static/resources/view/droplet/overview.js create mode 100644 package/secubox/luci-app-droplet/root/usr/libexec/rpcd/luci.droplet create mode 100644 package/secubox/luci-app-droplet/root/usr/share/luci/menu.d/luci-app-droplet.json create mode 100644 package/secubox/luci-app-droplet/root/usr/share/rpcd/acl.d/luci-app-droplet.json create mode 100644 package/secubox/secubox-app-droplet/Makefile create mode 100644 package/secubox/secubox-app-droplet/files/etc/config/droplet create mode 100644 package/secubox/secubox-app-droplet/files/usr/sbin/dropletctl diff --git a/package/secubox/luci-app-droplet/Makefile b/package/secubox/luci-app-droplet/Makefile new file mode 100644 index 00000000..1b58a65f --- /dev/null +++ b/package/secubox/luci-app-droplet/Makefile @@ -0,0 +1,27 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-droplet +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +LUCI_TITLE:=LuCI Droplet Publisher - One-Drop Content Publishing +LUCI_DEPENDS:=+secubox-app-droplet +luci-base +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-droplet/install + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-droplet.json $(1)/usr/share/luci/menu.d/ + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-droplet.json $(1)/usr/share/rpcd/acl.d/ + + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.droplet $(1)/usr/libexec/rpcd/ + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/droplet + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/droplet/*.js $(1)/www/luci-static/resources/view/droplet/ +endef + +$(eval $(call BuildPackage,luci-app-droplet)) diff --git a/package/secubox/luci-app-droplet/htdocs/luci-static/resources/view/droplet/overview.js b/package/secubox/luci-app-droplet/htdocs/luci-static/resources/view/droplet/overview.js new file mode 100644 index 00000000..e5ed1c37 --- /dev/null +++ b/package/secubox/luci-app-droplet/htdocs/luci-static/resources/view/droplet/overview.js @@ -0,0 +1,352 @@ +'use strict'; +'require view'; +'require rpc'; +'require ui'; +'require fs'; + +var callDropletStatus = rpc.declare({ + object: 'luci.droplet', + method: 'status', + expect: {} +}); + +var callDropletList = rpc.declare({ + object: 'luci.droplet', + method: 'list', + expect: { droplets: [] } +}); + +var callDropletUpload = rpc.declare({ + object: 'luci.droplet', + method: 'upload', + params: ['file', 'name', 'domain'], + expect: {} +}); + +var callDropletRemove = rpc.declare({ + object: 'luci.droplet', + method: 'remove', + params: ['name'], + expect: {} +}); + +return view.extend({ + load: function() { + return Promise.all([ + callDropletStatus(), + callDropletList() + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var droplets = data[1] || []; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('style', {}, ` + .droplet-container { max-width: 800px; margin: 0 auto; } + .drop-zone { + border: 3px dashed #00d4ff; + border-radius: 16px; + padding: 60px 40px; + text-align: center; + background: linear-gradient(135deg, rgba(0,212,255,0.05), rgba(124,58,237,0.05)); + transition: all 0.3s ease; + cursor: pointer; + margin-bottom: 20px; + } + .drop-zone:hover, .drop-zone.drag-over { + border-color: #7c3aed; + background: linear-gradient(135deg, rgba(0,212,255,0.1), rgba(124,58,237,0.1)); + transform: scale(1.02); + } + .drop-zone h2 { color: #00d4ff; margin: 0 0 10px; font-size: 1.5em; } + .drop-zone p { color: #888; margin: 0; } + .drop-zone input[type="file"] { display: none; } + .publish-form { + display: none; + background: #1a1a24; + border: 1px solid #2a2a3e; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + } + .publish-form.active { display: block; } + .publish-form .field { margin-bottom: 15px; } + .publish-form label { display: block; color: #888; margin-bottom: 5px; font-size: 0.9em; } + .publish-form input[type="text"] { + width: 100%; + padding: 10px 15px; + background: #12121a; + border: 1px solid #2a2a3e; + border-radius: 8px; + color: #e0e0e0; + font-size: 1em; + } + .publish-form input:focus { outline: none; border-color: #00d4ff; } + .publish-form .file-info { + background: #12121a; + padding: 10px 15px; + border-radius: 8px; + color: #00d4ff; + font-family: monospace; + margin-bottom: 15px; + } + .publish-form .buttons { display: flex; gap: 10px; } + .btn-publish { + flex: 1; + padding: 12px 24px; + background: linear-gradient(135deg, #00d4ff, #7c3aed); + border: none; + border-radius: 8px; + color: #fff; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s; + } + .btn-publish:hover { transform: translateY(-2px); } + .btn-cancel { + padding: 12px 24px; + background: #2a2a3e; + border: none; + border-radius: 8px; + color: #888; + cursor: pointer; + } + .droplet-list { margin-top: 30px; } + .droplet-list h3 { color: #e0e0e0; margin-bottom: 15px; } + .droplet-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + background: #1a1a24; + border: 1px solid #2a2a3e; + border-radius: 8px; + margin-bottom: 10px; + } + .droplet-item:hover { border-color: #00d4ff; } + .droplet-info { flex: 1; } + .droplet-name { font-weight: 600; color: #e0e0e0; } + .droplet-domain { font-size: 0.85em; color: #00d4ff; font-family: monospace; } + .droplet-type { + font-size: 0.75em; + padding: 2px 8px; + background: rgba(124,58,237,0.2); + color: #7c3aed; + border-radius: 4px; + margin-left: 10px; + } + .droplet-actions button { + padding: 6px 12px; + background: #2a2a3e; + border: none; + border-radius: 4px; + color: #888; + cursor: pointer; + margin-left: 5px; + } + .droplet-actions button:hover { background: #3a3a4e; color: #e0e0e0; } + .droplet-actions .btn-delete:hover { background: #ef4444; color: #fff; } + .result-message { + display: none; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + } + .result-message.success { display: block; background: rgba(16,185,129,0.1); border: 1px solid #10b981; color: #10b981; } + .result-message.error { display: block; background: rgba(239,68,68,0.1); border: 1px solid #ef4444; color: #ef4444; } + .result-message a { color: inherit; } + `), + + E('div', { 'class': 'droplet-container' }, [ + E('h2', { 'style': 'color: #e0e0e0; margin-bottom: 20px;' }, [ + E('span', { 'style': 'color: #00d4ff;' }, 'Droplet'), + ' Publisher' + ]), + + E('div', { 'class': 'result-message', 'id': 'result-msg' }), + + E('div', { 'class': 'drop-zone', 'id': 'drop-zone' }, [ + E('h2', {}, '📦 Drop to Publish'), + E('p', {}, 'Drop HTML file or ZIP archive here'), + E('p', { 'style': 'margin-top: 10px; font-size: 0.85em;' }, 'or click to browse'), + E('input', { 'type': 'file', 'id': 'file-input', 'accept': '.html,.htm,.zip' }) + ]), + + E('div', { 'class': 'publish-form', 'id': 'publish-form' }, [ + E('div', { 'class': 'file-info', 'id': 'file-info' }, ''), + E('div', { 'class': 'field' }, [ + E('label', {}, 'Site Name'), + E('input', { 'type': 'text', 'id': 'site-name', 'placeholder': 'mysite' }) + ]), + E('div', { 'class': 'field' }, [ + E('label', {}, 'Domain'), + E('input', { 'type': 'text', 'id': 'site-domain', 'value': status.default_domain || 'gk2.secubox.in', 'placeholder': 'gk2.secubox.in' }) + ]), + E('div', { 'class': 'buttons' }, [ + E('button', { 'class': 'btn-cancel', 'id': 'btn-cancel' }, 'Cancel'), + E('button', { 'class': 'btn-publish', 'id': 'btn-publish' }, '🚀 Publish') + ]) + ]), + + E('div', { 'class': 'droplet-list' }, [ + E('h3', {}, 'Published Droplets (' + droplets.length + ')'), + E('div', { 'id': 'droplet-items' }, + droplets.map(function(d) { + return E('div', { 'class': 'droplet-item', 'data-name': d.name }, [ + E('div', { 'class': 'droplet-info' }, [ + E('span', { 'class': 'droplet-name' }, d.name), + E('span', { 'class': 'droplet-type' }, d.type || 'static'), + E('div', { 'class': 'droplet-domain' }, [ + E('a', { 'href': 'https://' + d.domain + '/', 'target': '_blank' }, d.domain) + ]) + ]), + E('div', { 'class': 'droplet-actions' }, [ + E('button', { 'class': 'btn-open', 'data-url': 'https://' + d.domain + '/' }, '🔗'), + E('button', { 'class': 'btn-delete', 'data-name': d.name }, '🗑') + ]) + ]); + }) + ) + ]) + ]) + ]); + + // Event handlers + var dropZone = view.querySelector('#drop-zone'); + var fileInput = view.querySelector('#file-input'); + var publishForm = view.querySelector('#publish-form'); + var fileInfo = view.querySelector('#file-info'); + var siteName = view.querySelector('#site-name'); + var siteDomain = view.querySelector('#site-domain'); + var btnPublish = view.querySelector('#btn-publish'); + var btnCancel = view.querySelector('#btn-cancel'); + var resultMsg = view.querySelector('#result-msg'); + var selectedFile = null; + + // Drag & drop + dropZone.addEventListener('click', function() { fileInput.click(); }); + dropZone.addEventListener('dragover', function(e) { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', function() { + dropZone.classList.remove('drag-over'); + }); + dropZone.addEventListener('drop', function(e) { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + if (e.dataTransfer.files.length) { + handleFile(e.dataTransfer.files[0]); + } + }); + + fileInput.addEventListener('change', function() { + if (fileInput.files.length) { + handleFile(fileInput.files[0]); + } + }); + + function handleFile(file) { + selectedFile = file; + fileInfo.textContent = '📄 ' + file.name + ' (' + formatSize(file.size) + ')'; + + // Auto-generate name from filename + var name = file.name.replace(/\.(html?|zip)$/i, '').toLowerCase().replace(/[^a-z0-9_-]/g, '_'); + siteName.value = name; + + publishForm.classList.add('active'); + dropZone.style.display = 'none'; + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + btnCancel.addEventListener('click', function() { + publishForm.classList.remove('active'); + dropZone.style.display = 'block'; + selectedFile = null; + fileInput.value = ''; + }); + + btnPublish.addEventListener('click', function() { + if (!selectedFile || !siteName.value) { + showResult('error', 'Please select a file and enter a name'); + return; + } + + btnPublish.disabled = true; + btnPublish.textContent = '⏳ Publishing...'; + + // Upload file first + var formData = new FormData(); + formData.append('sessionid', rpc.getSessionID()); + formData.append('filename', '/tmp/droplet-upload/' + selectedFile.name); + formData.append('filedata', selectedFile); + + fetch('/cgi-bin/cgi-upload', { + method: 'POST', + body: formData + }) + .then(function(res) { return res.json(); }) + .then(function(uploadRes) { + if (uploadRes.size) { + // File uploaded, now publish + return callDropletUpload(selectedFile.name, siteName.value, siteDomain.value); + } else { + throw new Error('Upload failed'); + } + }) + .then(function(result) { + if (result.success) { + showResult('success', '✅ Published! ' + result.url + ''); + setTimeout(function() { location.reload(); }, 2000); + } else { + showResult('error', '❌ ' + (result.error || 'Failed to publish')); + } + }) + .catch(function(err) { + showResult('error', '❌ ' + err.message); + }) + .finally(function() { + btnPublish.disabled = false; + btnPublish.textContent = '🚀 Publish'; + }); + }); + + function showResult(type, msg) { + resultMsg.className = 'result-message ' + type; + resultMsg.innerHTML = msg; + } + + // Delete buttons + view.querySelectorAll('.btn-delete').forEach(function(btn) { + btn.addEventListener('click', function() { + var name = btn.dataset.name; + if (confirm('Delete "' + name + '"?')) { + callDropletRemove(name).then(function() { + btn.closest('.droplet-item').remove(); + showResult('success', 'Deleted: ' + name); + }); + } + }); + }); + + // Open buttons + view.querySelectorAll('.btn-open').forEach(function(btn) { + btn.addEventListener('click', function() { + window.open(btn.dataset.url, '_blank'); + }); + }); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-droplet/root/usr/libexec/rpcd/luci.droplet b/package/secubox/luci-app-droplet/root/usr/libexec/rpcd/luci.droplet new file mode 100644 index 00000000..a307b723 --- /dev/null +++ b/package/secubox/luci-app-droplet/root/usr/libexec/rpcd/luci.droplet @@ -0,0 +1,138 @@ +#!/bin/sh +# RPCD handler for Droplet Publisher + +. /usr/share/libubox/jshn.sh + +UPLOAD_DIR="/tmp/droplet-upload" +DEFAULT_DOMAIN="gk2.secubox.in" + +case "$1" in + list) + echo '{"publish":{},"upload":{"file":"string","name":"string","domain":"string"},"list":{},"remove":{"name":"string"},"rename":{"old":"string","new":"string"},"status":{}}' + ;; + call) + case "$2" in + status) + json_init + json_add_string "upload_dir" "$UPLOAD_DIR" + json_add_string "default_domain" "$DEFAULT_DOMAIN" + json_add_int "sites_count" "$(uci show metablogizer 2>/dev/null | grep -c '=site$')" + json_add_int "apps_count" "$(uci show streamlit 2>/dev/null | grep -c '=instance$')" + json_dump + ;; + + list) + json_init + json_add_array "droplets" + + # MetaBlog sites - use for loop to avoid subshell + for name in $(uci show metablogizer 2>/dev/null | grep "=site$" | sed "s/metablogizer\.\(.*\)=site/\1/"); do + domain=$(uci -q get "metablogizer.$name.domain") + enabled=$(uci -q get "metablogizer.$name.enabled") + [ -z "$enabled" ] && enabled="0" + json_add_object "" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_string "type" "static" + json_add_boolean "enabled" "$enabled" + json_close_object + done + + # Streamlit apps + for name in $(uci show streamlit 2>/dev/null | grep "=instance$" | sed "s/streamlit\.\(.*\)=instance/\1/"); do + domain=$(uci -q get "streamlit.$name.domain") + enabled=$(uci -q get "streamlit.$name.enabled") + [ -z "$enabled" ] && enabled="0" + json_add_object "" + json_add_string "name" "$name" + json_add_string "domain" "$domain" + json_add_string "type" "streamlit" + json_add_boolean "enabled" "$enabled" + json_close_object + done + + json_close_array + json_dump + ;; + + upload) + # Read params + read -r input + json_load "$input" + json_get_var file file + json_get_var name name + json_get_var domain domain + + [ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; } + [ -z "$file" ] && { echo '{"error":"File required"}'; exit 0; } + [ -z "$domain" ] && domain="$DEFAULT_DOMAIN" + + # File should be in upload dir (set by cgi-io) + local upload_file="$UPLOAD_DIR/$file" + if [ ! -f "$upload_file" ]; then + # Try direct path + upload_file="$file" + fi + + [ ! -f "$upload_file" ] && { echo '{"error":"File not found"}'; exit 0; } + + # Publish + result=$(dropletctl publish "$upload_file" "$name" "$domain" 2>&1) + exit_code=$? + + # Extract vhost from result + vhost=$(echo "$result" | grep -oE '[a-z0-9_-]+\.[a-z0-9.-]+' | tail -1) + + json_init + if [ $exit_code -eq 0 ]; then + json_add_boolean "success" 1 + json_add_string "vhost" "$vhost" + json_add_string "url" "https://$vhost/" + json_add_string "message" "Published successfully" + else + json_add_boolean "success" 0 + json_add_string "error" "$result" + fi + json_dump + + # Cleanup + rm -f "$upload_file" + ;; + + remove) + read -r input + json_load "$input" + json_get_var name name + + [ -z "$name" ] && { echo '{"error":"Name required"}'; exit 0; } + + result=$(dropletctl remove "$name" 2>&1) + + json_init + json_add_boolean "success" 1 + json_add_string "message" "Removed: $name" + json_dump + ;; + + rename) + read -r input + json_load "$input" + json_get_var old old + json_get_var new new + + [ -z "$old" ] || [ -z "$new" ] && { echo '{"error":"Old and new names required"}'; exit 0; } + + result=$(dropletctl rename "$old" "$new" 2>&1) + + json_init + json_add_boolean "success" 1 + json_add_string "message" "Renamed: $old -> $new" + json_dump + ;; + + *) + echo '{"error":"Unknown method"}' + ;; + esac + ;; +esac diff --git a/package/secubox/luci-app-droplet/root/usr/share/luci/menu.d/luci-app-droplet.json b/package/secubox/luci-app-droplet/root/usr/share/luci/menu.d/luci-app-droplet.json new file mode 100644 index 00000000..e2332ead --- /dev/null +++ b/package/secubox/luci-app-droplet/root/usr/share/luci/menu.d/luci-app-droplet.json @@ -0,0 +1,14 @@ +{ + "admin/services/droplet": { + "title": "Droplet", + "order": 45, + "action": { + "type": "view", + "path": "droplet/overview" + }, + "depends": { + "acl": ["luci-app-droplet"], + "uci": {} + } + } +} diff --git a/package/secubox/luci-app-droplet/root/usr/share/rpcd/acl.d/luci-app-droplet.json b/package/secubox/luci-app-droplet/root/usr/share/rpcd/acl.d/luci-app-droplet.json new file mode 100644 index 00000000..30a5d179 --- /dev/null +++ b/package/secubox/luci-app-droplet/root/usr/share/rpcd/acl.d/luci-app-droplet.json @@ -0,0 +1,16 @@ +{ + "luci-app-droplet": { + "description": "Droplet Publisher", + "read": { + "ubus": { + "luci.droplet": ["status", "list"] + } + }, + "write": { + "ubus": { + "luci.droplet": ["upload", "remove", "rename"] + }, + "cgi-io": ["upload"] + } + } +} diff --git a/package/secubox/secubox-app-droplet/Makefile b/package/secubox/secubox-app-droplet/Makefile new file mode 100644 index 00000000..c61c50d8 --- /dev/null +++ b/package/secubox/secubox-app-droplet/Makefile @@ -0,0 +1,37 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=secubox-app-droplet +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 + +PKG_MAINTAINER:=SecuBox + +include $(INCLUDE_DIR)/package.mk + +define Package/secubox-app-droplet + SECTION:=secubox + CATEGORY:=SecuBox + TITLE:=Droplet Publisher - One-Drop Content Publishing + DEPENDS:=+unzip + PKGARCH:=all +endef + +define Package/secubox-app-droplet/description + One-drop content publisher for SecuBox. + Drop HTML/ZIP files to instantly publish as sites with vhost configuration. +endef + +define Build/Compile +endef + +define Package/secubox-app-droplet/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/dropletctl $(1)/usr/sbin/ + + $(INSTALL_DIR) $(1)/etc/config + touch $(1)/etc/config/droplet + + $(INSTALL_DIR) $(1)/tmp/droplet-upload +endef + +$(eval $(call BuildPackage,secubox-app-droplet)) diff --git a/package/secubox/secubox-app-droplet/files/etc/config/droplet b/package/secubox/secubox-app-droplet/files/etc/config/droplet new file mode 100644 index 00000000..e530d37c --- /dev/null +++ b/package/secubox/secubox-app-droplet/files/etc/config/droplet @@ -0,0 +1,3 @@ +config droplet 'main' + option default_domain 'gk2.secubox.in' + option upload_dir '/tmp/droplet-upload' diff --git a/package/secubox/secubox-app-droplet/files/usr/sbin/dropletctl b/package/secubox/secubox-app-droplet/files/usr/sbin/dropletctl new file mode 100644 index 00000000..e39b6c65 --- /dev/null +++ b/package/secubox/secubox-app-droplet/files/usr/sbin/dropletctl @@ -0,0 +1,292 @@ +#!/bin/sh +# ═══════════════════════════════════════════════════════════════════════════════ +# Droplet Publisher - One-Drop Content Publishing +# Drop HTML/ZIP → Get published site with vhost + Gitea versioning +# ═══════════════════════════════════════════════════════════════════════════════ + +DROPLET_DIR="/srv/droplet" +SITES_DIR="/srv/metablogizer/sites" +APPS_DIR="/srv/streamlit/apps" +DEFAULT_DOMAIN="gk2.secubox.in" +GITEA_REPO="gandalf/droplet-sites" +GITEA_URL="https://git.gk2.secubox.in" + +# Logging +log_info() { logger -t droplet -p user.info "$*"; echo "[INFO] $*"; } +log_error() { logger -t droplet -p user.error "$*"; echo "[ERROR] $*" >&2; } +log_ok() { echo "[OK] $*"; } + +# ───────────────────────────────────────────────────────────────────────────────── +# Detect content type from file/directory +# Returns: static|streamlit|hexo|unknown +# ───────────────────────────────────────────────────────────────────────────────── +detect_type() { + local path="$1" + + # Check for Streamlit app + if [ -f "$path/app.py" ] || [ -f "$path/main.py" ]; then + grep -qE "import streamlit|from streamlit" "$path"/*.py 2>/dev/null && { + echo "streamlit" + return + } + fi + + # Check for Hexo + if [ -f "$path/_config.yml" ] && [ -d "$path/source" ]; then + echo "hexo" + return + fi + + # Check for static HTML + if [ -f "$path/index.html" ] || [ -f "$path/index.htm" ]; then + echo "static" + return + fi + + # Single HTML file + if [ -f "$path" ] && echo "$path" | grep -qiE '\.html?$'; then + echo "static" + return + fi + + echo "unknown" +} + +# ───────────────────────────────────────────────────────────────────────────────── +# Publish content +# Usage: dropletctl publish [domain] +# ───────────────────────────────────────────────────────────────────────────────── +cmd_publish() { + local file="$1" + local name="$2" + local domain="${3:-$DEFAULT_DOMAIN}" + + [ -z "$file" ] && { log_error "Usage: dropletctl publish [domain]"; return 1; } + [ -z "$name" ] && { log_error "Name required"; return 1; } + [ ! -f "$file" ] && { log_error "File not found: $file"; return 1; } + + # Sanitize name + name=$(echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g') + local vhost="${name}.${domain}" + local tmp_dir="/tmp/droplet_$$" + + mkdir -p "$tmp_dir" + + log_info "Publishing: $file as $vhost" + + # Extract if ZIP + local content_type=$(file -b --mime-type "$file") + if echo "$content_type" | grep -q "zip"; then + log_info "Extracting ZIP..." + unzip -q "$file" -d "$tmp_dir" || { log_error "Failed to extract ZIP"; rm -rf "$tmp_dir"; return 1; } + + # Handle nested directory + local nested=$(find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d | head -1) + if [ -n "$nested" ] && [ $(find "$tmp_dir" -mindepth 1 -maxdepth 1 | wc -l) -eq 1 ]; then + mv "$nested"/* "$tmp_dir/" 2>/dev/null + rmdir "$nested" 2>/dev/null + fi + elif echo "$content_type" | grep -qE "html|text"; then + # Single HTML file + cp "$file" "$tmp_dir/index.html" + else + log_error "Unsupported file type: $content_type" + rm -rf "$tmp_dir" + return 1 + fi + + # Detect content type + local app_type=$(detect_type "$tmp_dir") + log_info "Detected type: $app_type" + + local target_dir="" + local publish_method="" + + case "$app_type" in + streamlit) + target_dir="$APPS_DIR/$name" + publish_method="streamlit" + ;; + static|hexo) + target_dir="$SITES_DIR/$name" + publish_method="metablog" + ;; + *) + # Default to static site + target_dir="$SITES_DIR/$name" + publish_method="metablog" + ;; + esac + + # Deploy content + log_info "Deploying to $target_dir..." + mkdir -p "$target_dir" + cp -r "$tmp_dir"/* "$target_dir/" + + # Create vhost via haproxyctl + log_info "Creating vhost: $vhost" + if command -v haproxyctl >/dev/null 2>&1; then + haproxyctl vhost add "$vhost" 2>/dev/null || true + fi + + # Register with appropriate system + if [ "$publish_method" = "streamlit" ]; then + # Add to streamlit config + local port=$(uci show streamlit 2>/dev/null | grep -oE "port='[0-9]+'" | grep -oE "[0-9]+" | sort -n | tail -1) + port=$((${port:-8500} + 1)) + + uci set "streamlit.${name}=instance" + uci set "streamlit.${name}.name=$name" + uci set "streamlit.${name}.domain=$vhost" + uci set "streamlit.${name}.port=$port" + uci set "streamlit.${name}.enabled=1" + uci commit streamlit + + log_info "Registered Streamlit app on port $port" + else + # Add to metablogizer config + uci set "metablogizer.${name}=site" + uci set "metablogizer.${name}.name=$name" + uci set "metablogizer.${name}.domain=$vhost" + uci set "metablogizer.${name}.enabled=1" + uci commit metablogizer + + log_info "Registered MetaBlog site" + fi + + # Git commit if available + if [ -d "$target_dir/.git" ] || command -v git >/dev/null 2>&1; then + cd "$target_dir" + if [ ! -d ".git" ]; then + git init -q + git remote add origin "${GITEA_URL}/${GITEA_REPO}/${name}.git" 2>/dev/null || true + fi + git add -A + git commit -q -m "Droplet publish: $name" 2>/dev/null || true + log_info "Committed to git" + fi + + # Reload HAProxy + /etc/init.d/haproxy reload 2>/dev/null || true + + # Cleanup + rm -rf "$tmp_dir" + + log_ok "Published: https://$vhost/" + echo "$vhost" +} + +# ───────────────────────────────────────────────────────────────────────────────── +# List published droplets +# ───────────────────────────────────────────────────────────────────────────────── +cmd_list() { + echo "=== Published Droplets ===" + + # MetaBlog sites + uci show metablogizer 2>/dev/null | grep "=site$" | sed "s/metablogizer\.\(.*\)=site/\1/" | while read name; do + domain=$(uci -q get "metablogizer.$name.domain") + enabled=$(uci -q get "metablogizer.$name.enabled") + [ "$enabled" = "1" ] && status="[ON]" || status="[OFF]" + printf "%-30s %s %s\n" "$name" "$status" "https://$domain/" + done + + # Streamlit apps + uci show streamlit 2>/dev/null | grep "=instance$" | sed "s/streamlit\.\(.*\)=instance/\1/" | while read name; do + domain=$(uci -q get "streamlit.$name.domain") + enabled=$(uci -q get "streamlit.$name.enabled") + [ "$enabled" = "1" ] && status="[ON]" || status="[OFF]" + printf "%-30s %s %s (streamlit)\n" "$name" "$status" "https://$domain/" + done +} + +# ───────────────────────────────────────────────────────────────────────────────── +# Remove a droplet +# ───────────────────────────────────────────────────────────────────────────────── +cmd_remove() { + local name="$1" + [ -z "$name" ] && { log_error "Usage: dropletctl remove "; return 1; } + + # Check metablogizer + if uci -q get "metablogizer.$name" >/dev/null 2>&1; then + uci delete "metablogizer.$name" + uci commit metablogizer + rm -rf "$SITES_DIR/$name" + log_ok "Removed MetaBlog: $name" + fi + + # Check streamlit + if uci -q get "streamlit.$name" >/dev/null 2>&1; then + uci delete "streamlit.$name" + uci commit streamlit + rm -rf "$APPS_DIR/$name" + log_ok "Removed Streamlit: $name" + fi + + # Remove vhost + if command -v haproxyctl >/dev/null 2>&1; then + haproxyctl vhost remove "${name}.${DEFAULT_DOMAIN}" 2>/dev/null || true + fi + + /etc/init.d/haproxy reload 2>/dev/null || true +} + +# ───────────────────────────────────────────────────────────────────────────────── +# Rename a droplet +# ───────────────────────────────────────────────────────────────────────────────── +cmd_rename() { + local old="$1" + local new="$2" + [ -z "$old" ] || [ -z "$new" ] && { log_error "Usage: dropletctl rename "; return 1; } + + new=$(echo "$new" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g') + + # Check metablogizer + if uci -q get "metablogizer.$old" >/dev/null 2>&1; then + local domain="${new}.${DEFAULT_DOMAIN}" + mv "$SITES_DIR/$old" "$SITES_DIR/$new" 2>/dev/null + uci rename "metablogizer.$old=$new" + uci set "metablogizer.$new.name=$new" + uci set "metablogizer.$new.domain=$domain" + uci commit metablogizer + log_ok "Renamed MetaBlog: $old -> $new" + fi + + # Check streamlit + if uci -q get "streamlit.$old" >/dev/null 2>&1; then + local domain="${new}.${DEFAULT_DOMAIN}" + mv "$APPS_DIR/$old" "$APPS_DIR/$new" 2>/dev/null + uci rename "streamlit.$old=$new" + uci set "streamlit.$new.name=$new" + uci set "streamlit.$new.domain=$domain" + uci commit streamlit + log_ok "Renamed Streamlit: $old -> $new" + fi + + /etc/init.d/haproxy reload 2>/dev/null || true +} + +# ───────────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────────── +case "$1" in + publish) shift; cmd_publish "$@" ;; + list) cmd_list ;; + remove) shift; cmd_remove "$@" ;; + rename) shift; cmd_rename "$@" ;; + *) + echo "Droplet Publisher - One-Drop Content Publishing" + echo "" + echo "Usage: dropletctl [args]" + echo "" + echo "Commands:" + echo " publish [domain] Publish HTML/ZIP as site" + echo " list List published droplets" + echo " remove Remove a droplet" + echo " rename Rename a droplet" + echo "" + echo "Examples:" + echo " dropletctl publish mysite.zip mysite" + echo " dropletctl publish index.html landing" + echo " dropletctl rename landing homepage" + ;; +esac