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