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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-14 11:25:07 +01:00
parent 296eac8562
commit 078e2dc01e
8 changed files with 879 additions and 0 deletions

View File

@ -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))

View File

@ -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! <a href="' + result.url + '" target="_blank">' + result.url + '</a>');
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
});

View File

@ -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

View File

@ -0,0 +1,14 @@
{
"admin/services/droplet": {
"title": "Droplet",
"order": 45,
"action": {
"type": "view",
"path": "droplet/overview"
},
"depends": {
"acl": ["luci-app-droplet"],
"uci": {}
}
}
}

View File

@ -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"]
}
}
}

View File

@ -0,0 +1,37 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-droplet
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_MAINTAINER:=SecuBox <secubox@cybermind.fr>
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))

View File

@ -0,0 +1,3 @@
config droplet 'main'
option default_domain 'gk2.secubox.in'
option upload_dir '/tmp/droplet-upload'

View File

@ -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 <file> <name> [domain]
# ─────────────────────────────────────────────────────────────────────────────────
cmd_publish() {
local file="$1"
local name="$2"
local domain="${3:-$DEFAULT_DOMAIN}"
[ -z "$file" ] && { log_error "Usage: dropletctl publish <file> <name> [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 <name>"; 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 <old> <new>"; 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 <command> [args]"
echo ""
echo "Commands:"
echo " publish <file> <name> [domain] Publish HTML/ZIP as site"
echo " list List published droplets"
echo " remove <name> Remove a droplet"
echo " rename <old> <new> Rename a droplet"
echo ""
echo "Examples:"
echo " dropletctl publish mysite.zip mysite"
echo " dropletctl publish index.html landing"
echo " dropletctl rename landing homepage"
;;
esac