From f9b73ea62cdad377aa584743e92889d83ef54e31 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 24 Jan 2026 12:51:34 +0100 Subject: [PATCH] feat(hexojs): Add Gitea integration for content sync - Add gitea config section to /etc/config/hexojs - Add hexoctl gitea {setup|clone|sync|status} commands - Token-based authentication for content repo cloning - Auto-sync from Gitea to Hexo source directory - Add comprehensive README documentation Also: - Create luci-app-metabolizer package with dashboard - Update CMS pages with emoji names for Streamlit sidebar - Bump hexojs to r2, metabolizer to r3 Co-Authored-By: Claude Opus 4.5 --- package/secubox/luci-app-metabolizer/Makefile | 22 ++ .../resources/view/metabolizer/overview.js | 165 +++++++++++ .../resources/view/metabolizer/settings.js | 90 ++++++ .../root/usr/libexec/rpcd/luci.metabolizer | 156 +++++++++++ .../luci/menu.d/luci-app-metabolizer.json | 30 ++ .../rpcd/acl.d/luci-app-metabolizer.json | 17 ++ package/secubox/secubox-app-hexojs/Makefile | 2 +- package/secubox/secubox-app-hexojs/README.md | 262 ++++++++++++++++++ .../files/etc/config/hexojs | 9 + .../secubox-app-hexojs/files/usr/sbin/hexoctl | 214 ++++++++++++++ .../secubox/secubox-app-metabolizer/Makefile | 2 +- .../files/usr/share/metabolizer/cms/app.py | 180 ++++++------ .../share/metabolizer/cms/pages/1_editor.py | 192 ------------- .../share/metabolizer/cms/pages/1_✏️_Editor.py | 130 +++++++++ .../share/metabolizer/cms/pages/2_posts.py | 173 ------------ .../share/metabolizer/cms/pages/2_📚_Posts.py | 100 +++++++ .../share/metabolizer/cms/pages/3_media.py | 119 -------- .../share/metabolizer/cms/pages/3_🖼️_Media.py | 75 +++++ .../share/metabolizer/cms/pages/4_settings.py | 137 --------- .../metabolizer/cms/pages/4_⚙️_Settings.py | 141 ++++++++++ 20 files changed, 1499 insertions(+), 717 deletions(-) create mode 100644 package/secubox/luci-app-metabolizer/Makefile create mode 100644 package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/overview.js create mode 100644 package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/settings.js create mode 100644 package/secubox/luci-app-metabolizer/root/usr/libexec/rpcd/luci.metabolizer create mode 100644 package/secubox/luci-app-metabolizer/root/usr/share/luci/menu.d/luci-app-metabolizer.json create mode 100644 package/secubox/luci-app-metabolizer/root/usr/share/rpcd/acl.d/luci-app-metabolizer.json create mode 100644 package/secubox/secubox-app-hexojs/README.md delete mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_editor.py create mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_✏️_Editor.py delete mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_posts.py create mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_📚_Posts.py delete mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_media.py create mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_🖼️_Media.py delete mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_settings.py create mode 100644 package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_⚙️_Settings.py diff --git a/package/secubox/luci-app-metabolizer/Makefile b/package/secubox/luci-app-metabolizer/Makefile new file mode 100644 index 00000000..a4bd0638 --- /dev/null +++ b/package/secubox/luci-app-metabolizer/Makefile @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +# Copyright (C) 2025 CyberMind.fr +# +# LuCI App for Metabolizer CMS + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for Metabolizer CMS +LUCI_DEPENDS:=+luci-base +secubox-app-metabolizer +LUCI_PKGARCH:=all + +PKG_NAME:=luci-app-metabolizer +PKG_VERSION:=1.0.0 +PKG_RELEASE:=1 +PKG_MAINTAINER:=CyberMind +PKG_LICENSE:=GPL-2.0 + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildance, see include/package.mk +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/overview.js b/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/overview.js new file mode 100644 index 00000000..c8093df6 --- /dev/null +++ b/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/overview.js @@ -0,0 +1,165 @@ +'use strict'; +'require view'; +'require ui'; +'require rpc'; +'require poll'; + +var callStatus = rpc.declare({ + object: 'luci.metabolizer', + method: 'status', + expect: {} +}); + +var callListPosts = rpc.declare({ + object: 'luci.metabolizer', + method: 'list_posts', + expect: { '': [] } +}); + +var callSync = rpc.declare({ + object: 'luci.metabolizer', + method: 'sync', + expect: {} +}); + +var callBuild = rpc.declare({ + object: 'luci.metabolizer', + method: 'build', + expect: {} +}); + +var callGiteaSync = rpc.declare({ + object: 'luci.metabolizer', + method: 'gitea_sync', + expect: {} +}); + +return view.extend({ + load: function() { + return Promise.all([ + callStatus(), + callListPosts() + ]); + }, + + render: function(data) { + var status = data[0] || {}; + var posts = data[1] || []; + + var view = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, _('Metabolizer CMS')), + + // Status cards + E('div', { 'class': 'cbi-section', 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [ + this.renderStatusCard('CMS', status.cms_running ? 'Running' : 'Stopped', + status.cms_running ? 'green' : 'red'), + this.renderStatusCard('Hexo', status.hexo_running ? 'Running' : 'Stopped', + status.hexo_running ? 'green' : 'red'), + this.renderStatusCard('Gitea', status.gitea_connected ? 'Connected' : 'Offline', + status.gitea_connected ? 'green' : 'red'), + this.renderStatusCard('Posts', status.post_count || 0, 'blue'), + this.renderStatusCard('Drafts', status.draft_count || 0, 'orange') + ]), + + // Quick links + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Quick Access')), + E('div', { 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [ + E('a', { + 'class': 'btn cbi-button cbi-button-action', + 'href': status.cms_url || '#', + 'target': '_blank' + }, _('Open CMS Editor')), + E('a', { + 'class': 'btn cbi-button', + 'href': status.blog_url || '/blog/', + 'target': '_blank' + }, _('View Blog')) + ]) + ]), + + // Actions + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Actions')), + E('div', { 'style': 'display: flex; gap: 1rem; flex-wrap: wrap;' }, [ + E('button', { + 'class': 'btn cbi-button', + 'click': ui.createHandlerFn(this, 'handleSync') + }, _('Sync Content')), + E('button', { + 'class': 'btn cbi-button', + 'click': ui.createHandlerFn(this, 'handleBuild') + }, _('Build Site')), + E('button', { + 'class': 'btn cbi-button', + 'click': ui.createHandlerFn(this, 'handleGiteaSync') + }, _('Pull from Gitea')) + ]) + ]), + + // Recent posts + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Recent Posts')), + E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th' }, _('Title')), + E('th', { 'class': 'th' }, _('Date')), + E('th', { 'class': 'th' }, _('Slug')) + ]), + E('tbody', {}, posts.slice(0, 10).map(function(post) { + return E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td' }, post.title || '(untitled)'), + E('td', { 'class': 'td' }, post.date || '-'), + E('td', { 'class': 'td' }, post.slug || '-') + ]); + })) + ]) + ]) + ]); + + return view; + }, + + renderStatusCard: function(label, value, color) { + var colors = { + 'green': '#22c55e', + 'red': '#ef4444', + 'blue': '#3b82f6', + 'orange': '#f97316' + }; + return E('div', { + 'style': 'background: var(--cbi-section-background); padding: 1rem; border-radius: 8px; min-width: 120px; text-align: center; border-left: 4px solid ' + (colors[color] || '#666') + ';' + }, [ + E('div', { 'style': 'font-size: 0.9em; color: #666;' }, label), + E('div', { 'style': 'font-size: 1.5em; font-weight: bold;' }, String(value)) + ]); + }, + + handleSync: function(ev) { + return callSync().then(function() { + ui.addNotification(null, E('p', _('Content synced successfully'))); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Sync failed: ') + e.message), 'error'); + }); + }, + + handleBuild: function(ev) { + return callBuild().then(function() { + ui.addNotification(null, E('p', _('Site built successfully'))); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Build failed: ') + e.message), 'error'); + }); + }, + + handleGiteaSync: function(ev) { + return callGiteaSync().then(function() { + ui.addNotification(null, E('p', _('Pulled from Gitea successfully'))); + }).catch(function(e) { + ui.addNotification(null, E('p', _('Gitea sync failed: ') + e.message), 'error'); + }); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/settings.js b/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/settings.js new file mode 100644 index 00000000..d134b097 --- /dev/null +++ b/package/secubox/luci-app-metabolizer/htdocs/luci-static/resources/view/metabolizer/settings.js @@ -0,0 +1,90 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; + +return view.extend({ + load: function() { + return uci.load('metabolizer'); + }, + + render: function() { + var m, s, o; + + m = new form.Map('metabolizer', _('Metabolizer Settings'), + _('Configure the Metabolizer CMS pipeline settings.')); + + // Main settings + s = m.section(form.TypedSection, 'metabolizer', _('General')); + s.anonymous = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.rmempty = false; + + o = s.option(form.Value, 'gitea_url', _('Gitea URL')); + o.placeholder = 'http://192.168.255.1:3000'; + + o = s.option(form.Value, 'gitea_user', _('Gitea Username')); + o.placeholder = 'admin'; + + o = s.option(form.Value, 'webhook_port', _('Webhook Port')); + o.placeholder = '8088'; + o.datatype = 'port'; + + // Content repository + s = m.section(form.TypedSection, 'content', _('Content Repository')); + s.anonymous = true; + + o = s.option(form.Value, 'repo_name', _('Repository Name')); + o.placeholder = 'blog-content'; + + o = s.option(form.Value, 'repo_path', _('Local Path')); + o.placeholder = '/srv/metabolizer/content'; + + o = s.option(form.Value, 'github_mirror', _('GitHub Mirror URL'), + _('Optional GitHub URL to mirror')); + o.optional = true; + + // CMS settings + s = m.section(form.TypedSection, 'cms', _('Streamlit CMS')); + s.anonymous = true; + + o = s.option(form.Value, 'repo_name', _('CMS Repository')); + o.placeholder = 'metabolizer-cms'; + + o = s.option(form.Value, 'streamlit_app', _('Streamlit App Name')); + o.placeholder = 'metabolizer'; + + // Hexo integration + s = m.section(form.TypedSection, 'hexo', _('Hexo Integration')); + s.anonymous = true; + + o = s.option(form.Value, 'source_path', _('Hexo Source Path')); + o.placeholder = '/srv/hexojs/site/source/_posts'; + + o = s.option(form.Value, 'public_path', _('Hexo Public Path')); + o.placeholder = '/srv/hexojs/site/public'; + + o = s.option(form.Value, 'portal_path', _('Portal Path')); + o.placeholder = '/www/blog'; + + o = s.option(form.Flag, 'auto_publish', _('Auto Publish'), + _('Automatically publish to portal after build')); + o.rmempty = false; + + // Portal settings + s = m.section(form.TypedSection, 'portal', _('Portal')); + s.anonymous = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.rmempty = false; + + o = s.option(form.Value, 'url_path', _('URL Path')); + o.placeholder = '/blog'; + + o = s.option(form.Value, 'title', _('Portal Title')); + o.placeholder = 'SecuBox Blog'; + + return m.render(); + } +}); diff --git a/package/secubox/luci-app-metabolizer/root/usr/libexec/rpcd/luci.metabolizer b/package/secubox/luci-app-metabolizer/root/usr/libexec/rpcd/luci.metabolizer new file mode 100644 index 00000000..0529dc84 --- /dev/null +++ b/package/secubox/luci-app-metabolizer/root/usr/libexec/rpcd/luci.metabolizer @@ -0,0 +1,156 @@ +#!/bin/sh +# RPCD backend for Metabolizer CMS LuCI app + +. /lib/functions.sh + +# Helpers +json_output() { + echo "$1" +} + +get_status() { + local enabled running cms_running post_count draft_count + local gitea_connected hexo_running + + # Check metabolizer enabled + enabled=$(uci -q get metabolizer.main.enabled || echo "0") + + # Check Streamlit CMS app + if pgrep -f "streamlit.*metabolizer" >/dev/null 2>&1; then + cms_running="true" + else + cms_running="false" + fi + + # Check Hexo + if lxc-info -n hexojs -s 2>/dev/null | grep -q "RUNNING"; then + hexo_running="true" + else + hexo_running="false" + fi + + # Check Gitea + if lxc-info -n gitea -s 2>/dev/null | grep -q "RUNNING"; then + gitea_connected="true" + else + gitea_connected="false" + fi + + # Count posts/drafts + local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content") + post_count=0 + draft_count=0 + if [ -d "$content_path/_posts" ]; then + post_count=$(ls -1 "$content_path/_posts/"*.md 2>/dev/null | wc -l) + fi + if [ -d "$content_path/_drafts" ]; then + draft_count=$(ls -1 "$content_path/_drafts/"*.md 2>/dev/null | wc -l) + fi + + cat </dev/null | sed 's/^title:[[:space:]]*//' | tr -d '"' | tr -d "'") + local date=$(grep -m1 "^date:" "$f" 2>/dev/null | sed 's/^date:[[:space:]]*//') + + [ "$first" = "1" ] || echo "," + first=0 + echo " {\"slug\": \"$slug\", \"title\": \"$title\", \"date\": \"$date\"}" + done + fi + echo "]" +} + +gitea_status() { + local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content") + + local has_repo="false" + local last_sync="" + local branch="" + + if [ -d "$content_path/.git" ]; then + has_repo="true" + cd "$content_path" + last_sync=$(git log -1 --format="%ci" 2>/dev/null || echo "never") + branch=$(git branch --show-current 2>/dev/null || echo "unknown") + fi + + cat <&1 + echo '{"status": "ok"}' +} + +do_build() { + metabolizerctl build 2>&1 + echo '{"status": "ok"}' +} + +do_publish() { + metabolizerctl publish 2>&1 + echo '{"status": "ok"}' +} + +do_gitea_sync() { + hexoctl gitea sync 2>&1 + echo '{"status": "ok"}' +} + +# RPCD interface +case "$1" in + list) + cat < diff --git a/package/secubox/secubox-app-hexojs/README.md b/package/secubox/secubox-app-hexojs/README.md new file mode 100644 index 00000000..006d5665 --- /dev/null +++ b/package/secubox/secubox-app-hexojs/README.md @@ -0,0 +1,262 @@ +# SecuBox HexoJS + +Self-hosted static blog generator for OpenWrt with Gitea integration. + +## Features + +- Hexo 8.x static site generator with Node.js 22 LTS +- CyberMind theme with dark mode and modern design +- Gitea integration for content management +- Post and page management with Markdown +- Media library for images and files +- GitHub Pages deployment support +- Preview server for local testing + +Runs in LXC container with Alpine Linux. + +## Installation + +```bash +# Install the package +opkg install secubox-app-hexojs + +# Setup container and create site +hexoctl install +hexoctl site create default + +# Enable and start service +uci set hexojs.main.enabled=1 +uci commit hexojs +/etc/init.d/hexojs enable +/etc/init.d/hexojs start +``` + +Preview at `http://:4000` + +## Commands + +### Container Management + +```bash +hexoctl install # Download and setup LXC container +hexoctl uninstall # Remove container (keeps data) +hexoctl update # Update Hexo and dependencies +hexoctl status # Show service status +hexoctl shell # Open shell in container +hexoctl logs # View container logs +hexoctl exec # Execute command in container +``` + +### Site Management + +```bash +hexoctl site create # Create new Hexo site +hexoctl site list # List all sites +hexoctl site delete # Delete a site +``` + +### Content Commands + +```bash +hexoctl new post "Title" # Create new blog post +hexoctl new page "Title" # Create new page +hexoctl new draft "Title" # Create new draft +hexoctl publish # Publish a draft +hexoctl list posts # List all posts (JSON) +hexoctl list drafts # List all drafts (JSON) +``` + +### Build Commands + +```bash +hexoctl serve # Start preview server (port 4000) +hexoctl build # Generate static files +hexoctl clean # Clean generated files +hexoctl deploy # Deploy to configured target +``` + +## Gitea Integration + +Sync blog content from a Gitea repository. + +### Setup + +```bash +# Enable Gitea integration +uci set hexojs.gitea.enabled=1 +uci set hexojs.gitea.url='http://192.168.255.1:3000' +uci set hexojs.gitea.user='admin' +uci set hexojs.gitea.token='your-gitea-access-token' +uci set hexojs.gitea.content_repo='blog-content' +uci set hexojs.gitea.content_branch='main' +uci commit hexojs +``` + +### Commands + +```bash +hexoctl gitea setup # Configure git credentials in container +hexoctl gitea clone # Clone content repo from Gitea +hexoctl gitea sync # Pull latest content and sync to Hexo +hexoctl gitea status # Show Gitea sync status (JSON) +``` + +### Workflow + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Gitea │───►│ HexoJS │───►│ Portal │ +│ Content │ │ Build │ │ Static │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ + blog-content/ hexo generate /www/blog/ + _posts/*.md public/ index.html +``` + +1. Create/edit posts in Gitea repository +2. Run `hexoctl gitea sync` to pull changes +3. Run `hexoctl build` to generate static site +4. Static files available in `/srv/hexojs/site/public/` + +### Content Repository Structure + +``` +blog-content/ +├── _posts/ # Published posts +│ └── 2025-01-24-hello-world.md +├── _drafts/ # Draft posts +├── images/ # Media files +├── about/ # About page +├── portfolio/ # Portfolio page +└── services/ # Services page +``` + +## Configuration + +Edit `/etc/config/hexojs`: + +``` +config hexojs 'main' + option enabled '1' + option http_port '4000' + option data_path '/srv/hexojs' + option active_site 'default' + option memory_limit '512M' + +config site 'default' + option title 'My Blog' + option subtitle 'Self-hosted on OpenWrt' + option author 'Admin' + option language 'en' + option theme 'cybermind' + option url 'http://localhost:4000' + option root '/' + option per_page '10' + +config deploy 'deploy' + option type 'git' + option repo '' + option branch 'gh-pages' + +config gitea 'gitea' + option enabled '0' + option url 'http://192.168.255.1:3000' + option user 'admin' + option token '' + option content_repo 'blog-content' + option content_branch 'main' + option auto_sync '0' + +config theme_config 'theme' + option default_mode 'dark' + option allow_toggle '1' + option accent_color '#f97316' +``` + +## Directory Structure + +``` +/srv/hexojs/ +├── site/ # Hexo site +│ ├── source/ +│ │ ├── _posts/ # Blog posts +│ │ ├── _drafts/ # Drafts +│ │ └── images/ # Media +│ ├── themes/ +│ │ └── cybermind/ # CyberMind theme +│ ├── public/ # Generated static files +│ └── _config.yml # Hexo config +├── content/ # Cloned Gitea content repo +├── themes/ # Shared themes +└── media/ # Shared media +``` + +## CyberMind Theme + +Included dark theme with: + +- Responsive design +- Dark/light mode toggle +- Orange accent color (#f97316) +- Terminal-style logo +- Categories and tags support +- Apps portfolio section + +## Troubleshooting + +### Container not starting + +```bash +# Check container status +lxc-info -n hexojs + +# View logs +hexoctl logs + +# Reinstall container +hexoctl uninstall +hexoctl install +``` + +### Gitea clone fails + +```bash +# Verify credentials +hexoctl gitea status + +# Re-setup git credentials +hexoctl gitea setup + +# Check token has repo access +curl -H "Authorization: token YOUR_TOKEN" \ + http://192.168.255.1:3000/api/v1/user/repos +``` + +### Build errors + +```bash +# Clean and rebuild +hexoctl clean +hexoctl build + +# Check inside container +hexoctl shell +cd /opt/hexojs/site +npm install +hexo generate --debug +``` + +## Integration with Metabolizer + +HexoJS works with the Metabolizer CMS pipeline: + +``` +Streamlit CMS → Gitea → HexoJS → Portal + (edit) (store) (build) (serve) +``` + +See `secubox-app-metabolizer` for the full CMS experience. + +## License + +MIT License - CyberMind Studio 2025 diff --git a/package/secubox/secubox-app-hexojs/files/etc/config/hexojs b/package/secubox/secubox-app-hexojs/files/etc/config/hexojs index 319e6def..ba0fe3ef 100644 --- a/package/secubox/secubox-app-hexojs/files/etc/config/hexojs +++ b/package/secubox/secubox-app-hexojs/files/etc/config/hexojs @@ -23,6 +23,15 @@ config deploy 'deploy' option branch 'gh-pages' option message 'Site updated: {{ now("YYYY-MM-DD HH:mm:ss") }}' +config gitea 'gitea' + option enabled '0' + option url 'http://192.168.255.1:3000' + option user 'admin' + option token '' + option content_repo 'blog-content' + option content_branch 'main' + option auto_sync '0' + config theme_config 'theme' option default_mode 'dark' option allow_toggle '1' diff --git a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl index c26f03f3..d3efd960 100644 --- a/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl +++ b/package/secubox/secubox-app-hexojs/files/usr/sbin/hexoctl @@ -56,6 +56,15 @@ load_config() { deploy_repo="$(uci_get deploy.repo)" || deploy_repo="" deploy_branch="$(uci_get deploy.branch)" || deploy_branch="gh-pages" + # Gitea config + gitea_enabled="$(uci_get gitea.enabled)" || gitea_enabled="0" + gitea_url="$(uci_get gitea.url)" || gitea_url="http://192.168.255.1:3000" + gitea_user="$(uci_get gitea.user)" || gitea_user="admin" + gitea_token="$(uci_get gitea.token)" || gitea_token="" + gitea_content_repo="$(uci_get gitea.content_repo)" || gitea_content_repo="blog-content" + gitea_content_branch="$(uci_get gitea.content_branch)" || gitea_content_branch="main" + gitea_auto_sync="$(uci_get gitea.auto_sync)" || gitea_auto_sync="0" + ensure_dir "$data_path" ensure_dir "$data_path/sites" ensure_dir "$data_path/media" @@ -98,6 +107,12 @@ Service Commands: service-run Run in foreground (for init) service-stop Stop service +Gitea Integration: + gitea setup Configure git credentials in container + gitea clone Clone content repo from Gitea + gitea sync Pull latest content from Gitea + gitea status Show Gitea sync status + Utility: shell Open shell in container logs View container logs @@ -837,6 +852,194 @@ cmd_service_stop() { lxc_stop } +# Gitea integration commands +cmd_gitea_setup() { + require_root + load_config + + if [ -z "$gitea_token" ]; then + log_error "Gitea token not configured" + log_info "Set with: uci set hexojs.gitea.token='your-token' && uci commit hexojs" + return 1 + fi + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + log_info "Configuring git credentials for Gitea..." + + # Extract host from URL + local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') + + # Configure git credential helper in container + lxc_exec sh -c " + git config --global user.name '$gitea_user' + git config --global user.email '${gitea_user}@localhost' + git config --global credential.helper store + + # Store credentials + mkdir -p ~/.git-credentials + cat > ~/.git-credentials << CRED +https://${gitea_user}:${gitea_token}@${gitea_host} +http://${gitea_user}:${gitea_token}@${gitea_host} +CRED + chmod 600 ~/.git-credentials + git config --global credential.helper 'store --file ~/.git-credentials' + " + + log_info "Git credentials configured" +} + +cmd_gitea_clone() { + require_root + load_config + + if [ "$gitea_enabled" != "1" ]; then + log_error "Gitea integration not enabled" + log_info "Enable with: uci set hexojs.gitea.enabled=1 && uci commit hexojs" + return 1 + fi + + if [ -z "$gitea_token" ]; then + log_error "Gitea token not configured" + return 1 + fi + + if ! lxc_running; then + log_error "Container not running" + return 1 + fi + + local content_path="$data_path/content" + local site_source="$data_path/site/source" + + # Clone content repo + if [ -d "$content_path/.git" ]; then + log_info "Content repo already cloned, pulling latest..." + cd "$content_path" && git pull + else + log_info "Cloning content repo from Gitea..." + + # Build clone URL with token + local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||') + local clone_url="http://${gitea_user}:${gitea_token}@${gitea_host}/${gitea_user}/${gitea_content_repo}.git" + + ensure_dir "$(dirname "$content_path")" + rm -rf "$content_path" + + git clone -b "$gitea_content_branch" "$clone_url" "$content_path" || { + log_error "Failed to clone content repo" + return 1 + } + fi + + # Sync to hexo source + cmd_gitea_sync_files + + log_info "Content cloned successfully" +} + +cmd_gitea_sync() { + require_root + load_config + + local content_path="$data_path/content" + + if [ ! -d "$content_path/.git" ]; then + log_error "Content repo not cloned. Run: hexoctl gitea clone" + return 1 + fi + + log_info "Pulling latest content from Gitea..." + + cd "$content_path" && git pull || { + log_error "Git pull failed" + return 1 + } + + cmd_gitea_sync_files + + log_info "Content synced" +} + +cmd_gitea_sync_files() { + load_config + + local content_path="$data_path/content" + local site_source="$data_path/site/source" + + if [ ! -d "$site_source" ]; then + log_error "Hexo site not created. Run: hexoctl site create default" + return 1 + fi + + log_info "Syncing content files to Hexo source..." + + # Sync _posts + if [ -d "$content_path/_posts" ]; then + ensure_dir "$site_source/_posts" + cp -r "$content_path/_posts/"* "$site_source/_posts/" 2>/dev/null || true + log_info "Synced _posts" + fi + + # Sync _drafts + if [ -d "$content_path/_drafts" ]; then + ensure_dir "$site_source/_drafts" + cp -r "$content_path/_drafts/"* "$site_source/_drafts/" 2>/dev/null || true + log_info "Synced _drafts" + fi + + # Sync images + if [ -d "$content_path/images" ]; then + ensure_dir "$site_source/images" + cp -r "$content_path/images/"* "$site_source/images/" 2>/dev/null || true + log_info "Synced images" + fi + + # Sync pages (about, etc) + for page in about portfolio services; do + if [ -d "$content_path/$page" ]; then + ensure_dir "$site_source/$page" + cp -r "$content_path/$page/"* "$site_source/$page/" 2>/dev/null || true + fi + done +} + +cmd_gitea_status() { + load_config + + local content_path="$data_path/content" + local has_repo="false" + local last_commit="" + local remote_url="" + local branch="" + + if [ -d "$content_path/.git" ]; then + has_repo="true" + cd "$content_path" + last_commit=$(git log -1 --format="%h %s" 2>/dev/null || echo "unknown") + remote_url=$(git remote get-url origin 2>/dev/null | sed "s|${gitea_token}|***|g" || echo "none") + branch=$(git branch --show-current 2>/dev/null || echo "unknown") + fi + + cat << EOF +{ + "gitea_enabled": $([ "$gitea_enabled" = "1" ] && echo "true" || echo "false"), + "gitea_url": "$gitea_url", + "gitea_user": "$gitea_user", + "content_repo": "$gitea_content_repo", + "content_branch": "$gitea_content_branch", + "has_local_repo": $has_repo, + "local_branch": "$branch", + "last_commit": "$last_commit", + "remote_url": "$remote_url", + "auto_sync": $([ "$gitea_auto_sync" = "1" ] && echo "true" || echo "false") +} +EOF +} + # Main case "${1:-}" in install) shift; cmd_install "$@" ;; @@ -894,5 +1097,16 @@ case "${1:-}" in service-run) shift; cmd_service_run "$@" ;; service-stop) shift; cmd_service_stop "$@" ;; + gitea) + shift + case "${1:-}" in + setup) shift; cmd_gitea_setup "$@" ;; + clone) shift; cmd_gitea_clone "$@" ;; + sync) shift; cmd_gitea_sync "$@" ;; + status) shift; cmd_gitea_status "$@" ;; + *) echo "Usage: hexoctl gitea {setup|clone|sync|status}" ;; + esac + ;; + *) usage ;; esac diff --git a/package/secubox/secubox-app-metabolizer/Makefile b/package/secubox/secubox-app-metabolizer/Makefile index ced17822..e8388eb1 100644 --- a/package/secubox/secubox-app-metabolizer/Makefile +++ b/package/secubox/secubox-app-metabolizer/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=secubox-app-metabolizer PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_ARCH:=all PKG_MAINTAINER:=CyberMind Studio diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/app.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/app.py index 1cf0146e..0043439e 100644 --- a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/app.py +++ b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/app.py @@ -1,8 +1,13 @@ """ Metabolizer CMS - SecuBox Blog Management -Main entry point with navigation sidebar +Multi-page Streamlit app for blog content management """ import streamlit as st +import os +from pathlib import Path + +# Configuration +CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) st.set_page_config( page_title="Metabolizer CMS", @@ -11,117 +16,104 @@ st.set_page_config( initial_sidebar_state="expanded" ) -# Cyberpunk styling +# Custom CSS st.markdown(""" """, unsafe_allow_html=True) -# Header -st.title("📝 METABOLIZER CMS") -st.markdown("### Neural Blog Matrix for SecuBox") +# Sidebar +with st.sidebar: + st.image("https://img.icons8.com/fluency/96/blog.png", width=64) + st.title("Metabolizer") + st.caption("SecuBox Blog CMS") + st.divider() -# Quick stats in columns + # Content path status + if CONTENT_PATH.exists(): + post_count = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0 + draft_count = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0 + st.success(f"📁 Content: {post_count} posts, {draft_count} drafts") + else: + st.warning("📁 Content path not found") + + st.divider() + st.caption("v1.0.0 | CyberMind Studio") + +# Main content +st.title("📝 Metabolizer CMS") +st.markdown("### Blog Content Management System") + +# Status cards col1, col2, col3, col4 = st.columns(4) with col1: st.metric("Status", "ONLINE", delta="Active") + with col2: - st.metric("Posts", "0", delta=None) + posts = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0 + st.metric("Published", posts) + with col3: - st.metric("Drafts", "0", delta=None) + drafts = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0 + st.metric("Drafts", drafts) + with col4: - st.metric("Pipeline", "Ready") + git_ok = (CONTENT_PATH / ".git").exists() + st.metric("Git", "Connected" if git_ok else "Not Init") st.divider() -# Navigation info -st.info(""" -**Navigation:** Use the sidebar to access different sections: -- **Editor** - Create and edit blog posts with live preview -- **Posts** - Manage published posts -- **Media** - Upload and manage images -- **Settings** - Configure Git and Hexo integration -""") +# Quick start guide +st.subheader("🚀 Quick Start") -# Quick actions - simplified without switch_page -st.subheader("Quick Actions") +col1, col2 = st.columns(2) -st.markdown(""" -Use the **sidebar** on the left to navigate to: -- 📝 **1_editor** - Write new posts -- 📚 **2_posts** - Manage posts -- 🖼️ **3_media** - Media library -- ⚙️ **4_settings** - Settings -""") +with col1: + st.markdown(""" + **Navigation** (use sidebar pages): + - **✏️ Editor** - Create new posts with live preview + - **📚 Posts** - Manage published posts and drafts + - **🖼️ Media** - Upload and manage images + - **⚙️ Settings** - Git sync and configuration + """) + +with col2: + st.markdown(""" + **Workflow**: + 1. Write posts in the **Editor** + 2. Save as draft or publish directly + 3. Posts sync to **Gitea** repository + 4. **HexoJS** generates static site + 5. View blog at `/blog/` + """) + +st.divider() + +# Recent activity +st.subheader("📋 Recent Posts") + +posts_path = CONTENT_PATH / "_posts" +if posts_path.exists(): + posts = sorted(posts_path.glob("*.md"), reverse=True)[:5] + if posts: + for post in posts: + with st.container(): + cols = st.columns([4, 1]) + with cols[0]: + st.markdown(f"📄 **{post.stem}**") + with cols[1]: + st.caption(post.stat().st_mtime) + else: + st.info("No posts yet. Create your first post in the Editor!") +else: + st.warning("Content directory not initialized. Go to Settings to set up.") # Footer st.divider() -st.caption("Metabolizer CMS v1.0 | SecuBox Blog Pipeline") +st.caption("Metabolizer CMS | Powered by Streamlit + Gitea + HexoJS") diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_editor.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_editor.py deleted file mode 100644 index c152c695..00000000 --- a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_editor.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Metabolizer CMS - Markdown Editor with Live Preview -""" -import streamlit as st -from datetime import datetime -from pathlib import Path -import subprocess -import os - -st.set_page_config(page_title="Editor - Metabolizer", page_icon="✏️", layout="wide") - -# Paths -CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) -POSTS_PATH = CONTENT_PATH / "_posts" -DRAFTS_PATH = CONTENT_PATH / "_drafts" - -# Ensure directories exist -POSTS_PATH.mkdir(parents=True, exist_ok=True) -DRAFTS_PATH.mkdir(parents=True, exist_ok=True) - -st.title("✏️ Post Editor") - -# Initialize session state -if 'post_content' not in st.session_state: - st.session_state.post_content = "" -if 'post_title' not in st.session_state: - st.session_state.post_title = "" - -# Two-column layout: Editor | Preview -col_edit, col_preview = st.columns(2) - -with col_edit: - st.subheader("Editor") - - # Front Matter Section - with st.expander("📋 Front Matter", expanded=True): - title = st.text_input("Title", value=st.session_state.post_title, key="title_input") - - col1, col2 = st.columns(2) - with col1: - date = st.date_input("Date", value=datetime.now()) - with col2: - time = st.time_input("Time", value=datetime.now().time()) - - categories = st.multiselect( - "Categories", - ["Security", "Tutorial", "News", "Tech", "Review", "Guide"], - default=[] - ) - - tags = st.text_input("Tags (comma-separated)", placeholder="linux, security, howto") - - excerpt = st.text_area("Excerpt", height=60, placeholder="Brief summary of the post...") - - # Markdown Content - st.markdown("**Content (Markdown)**") - content = st.text_area( - "content", - value=st.session_state.post_content, - height=400, - placeholder="""Write your post in Markdown... - -## Heading - -Regular paragraph with **bold** and *italic* text. - -```python -# Code block -print("Hello, World!") -``` - -- List item 1 -- List item 2 - -> Blockquote - -[Link text](https://example.com) - -![Image alt](/images/example.jpg) -""", - label_visibility="collapsed" - ) - - # Update session state - st.session_state.post_content = content - st.session_state.post_title = title - -with col_preview: - st.subheader("Preview") - - if title: - st.markdown(f"# {title}") - st.caption(f"📅 {date} | 🏷️ {', '.join(categories) if categories else 'Uncategorized'}") - if excerpt: - st.info(excerpt) - st.divider() - - if content: - st.markdown(content) - else: - st.markdown("*Start typing to see preview...*") - -# Actions -st.divider() -col1, col2, col3, col4 = st.columns(4) - -def generate_filename(title, date): - """Generate Hexo-compatible filename""" - slug = title.lower().replace(" ", "-").replace("'", "") - slug = "".join(c for c in slug if c.isalnum() or c == "-") - return f"{date}-{slug}.md" - -def generate_frontmatter(title, date, time, categories, tags, excerpt): - """Generate YAML front matter without yaml module""" - lines = ["---"] - lines.append(f"title: {title}") - lines.append(f"date: {date} {time.strftime('%H:%M:%S')}") - - if categories: - lines.append(f"categories: [{', '.join(categories)}]") - else: - lines.append("categories: []") - - if tags: - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - lines.append(f"tags: [{', '.join(tag_list)}]") - else: - lines.append("tags: []") - - if excerpt: - lines.append(f"excerpt: \"{excerpt}\"") - - lines.append("---") - lines.append("") - return "\n".join(lines) - -def save_post(path, title, date, time, categories, tags, excerpt, content): - """Save post to file""" - filename = generate_filename(title, date) - filepath = path / filename - - frontmatter = generate_frontmatter(title, date, time, categories, tags, excerpt) - full_content = frontmatter + content - - filepath.write_text(full_content) - return filepath - -def git_commit_push(message): - """Commit and push to Gitea""" - try: - subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True) - subprocess.run(['git', 'commit', '-m', message], cwd=CONTENT_PATH, capture_output=True) - subprocess.run(['git', 'push', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True) - except: - pass - -with col1: - if st.button("💾 Save Draft", use_container_width=True): - if title and content: - filepath = save_post(DRAFTS_PATH, title, date, time, categories, tags, excerpt, content) - st.success(f"Draft saved: {filepath.name}") - else: - st.error("Title and content required") - -with col2: - if st.button("📤 Publish", use_container_width=True, type="primary"): - if title and content: - filepath = save_post(POSTS_PATH, title, date, time, categories, tags, excerpt, content) - - # Commit and push - with st.spinner("Publishing..."): - git_commit_push(f"Add post: {title}") - - st.success(f"Published: {filepath.name}") - st.info("Post saved to repository") - else: - st.error("Title and content required") - -with col3: - if st.button("🔄 Sync", use_container_width=True): - with st.spinner("Syncing..."): - try: - subprocess.run(['git', 'pull', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True) - st.success("Synced!") - except: - st.error("Sync failed") - -with col4: - if st.button("🗑️ Clear", use_container_width=True): - st.session_state.post_content = "" - st.session_state.post_title = "" - st.rerun() diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_✏️_Editor.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_✏️_Editor.py new file mode 100644 index 00000000..16e87f5d --- /dev/null +++ b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/1_✏️_Editor.py @@ -0,0 +1,130 @@ +""" +Metabolizer CMS - Markdown Editor +""" +import streamlit as st +from datetime import datetime +from pathlib import Path +import subprocess +import os + +CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) +POSTS_PATH = CONTENT_PATH / "_posts" +DRAFTS_PATH = CONTENT_PATH / "_drafts" + +st.set_page_config(page_title="Editor", page_icon="✏️", layout="wide") + +# Ensure directories exist +POSTS_PATH.mkdir(parents=True, exist_ok=True) +DRAFTS_PATH.mkdir(parents=True, exist_ok=True) + +st.title("✏️ Post Editor") + +# Session state +if 'content' not in st.session_state: + st.session_state.content = "" +if 'title' not in st.session_state: + st.session_state.title = "" + +# Layout +col_edit, col_preview = st.columns(2) + +with col_edit: + st.subheader("Write") + + # Metadata + title = st.text_input("Title", value=st.session_state.title) + + col1, col2 = st.columns(2) + with col1: + date = st.date_input("Date", value=datetime.now()) + with col2: + categories = st.multiselect("Categories", + ["Tech", "Security", "Tutorial", "News", "Review"], default=[]) + + tags = st.text_input("Tags", placeholder="tag1, tag2, tag3") + excerpt = st.text_area("Excerpt", height=60, placeholder="Brief description...") + + # Content + content = st.text_area("Content (Markdown)", value=st.session_state.content, + height=350, placeholder="Write your post here...") + + st.session_state.content = content + st.session_state.title = title + +with col_preview: + st.subheader("Preview") + + if title: + st.markdown(f"# {title}") + st.caption(f"📅 {date} | 🏷️ {', '.join(categories) if categories else 'Uncategorized'}") + if excerpt: + st.info(excerpt) + st.divider() + + if content: + st.markdown(content) + else: + st.markdown("*Start typing to see preview...*") + +# Actions +st.divider() + +def make_slug(text): + return "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-") + +def make_frontmatter(): + lines = ["---", f"title: \"{title}\"", f"date: {date}"] + if categories: + lines.append(f"categories: [{', '.join(categories)}]") + if tags: + lines.append(f"tags: [{', '.join(t.strip() for t in tags.split(','))}]") + if excerpt: + lines.append(f"excerpt: \"{excerpt}\"") + lines.extend(["---", ""]) + return "\n".join(lines) + +def save_file(path): + filename = f"{date}-{make_slug(title)}.md" + filepath = path / filename + filepath.write_text(make_frontmatter() + content) + return filepath + +def git_push(msg): + try: + subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True) + return True + except: + return False + +col1, col2, col3, col4 = st.columns(4) + +with col1: + if st.button("💾 Save Draft", use_container_width=True): + if title and content: + fp = save_file(DRAFTS_PATH) + st.success(f"Saved: {fp.name}") + else: + st.error("Title and content required") + +with col2: + if st.button("📤 Publish", use_container_width=True, type="primary"): + if title and content: + fp = save_file(POSTS_PATH) + with st.spinner("Publishing..."): + git_push(f"Add: {title}") + st.success(f"Published: {fp.name}") + else: + st.error("Title and content required") + +with col3: + if st.button("🔄 Sync", use_container_width=True): + subprocess.run(['git', 'pull'], cwd=CONTENT_PATH, capture_output=True) + st.success("Synced!") + +with col4: + if st.button("🗑️ Clear", use_container_width=True): + st.session_state.content = "" + st.session_state.title = "" + st.rerun() diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_posts.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_posts.py deleted file mode 100644 index f0e58817..00000000 --- a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_posts.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Metabolizer CMS - Post Management -""" -import streamlit as st -from pathlib import Path -from datetime import datetime -import subprocess -import os -import re - -st.set_page_config(page_title="Posts - Metabolizer", page_icon="📚", layout="wide") - -# Paths -CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) -POSTS_PATH = CONTENT_PATH / "_posts" -DRAFTS_PATH = CONTENT_PATH / "_drafts" - -st.title("📚 Post Management") - -def parse_frontmatter(filepath): - """Parse YAML front matter from markdown file (without yaml module)""" - try: - content = filepath.read_text() - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - fm_text = parts[1].strip() - body = parts[2].strip() - - # Simple parsing - fm = {} - for line in fm_text.split('\n'): - if ':' in line: - key, value = line.split(':', 1) - key = key.strip() - value = value.strip() - # Handle arrays - if value.startswith('[') and value.endswith(']'): - value = [v.strip().strip('"\'') for v in value[1:-1].split(',') if v.strip()] - elif value.startswith('"') and value.endswith('"'): - value = value[1:-1] - fm[key] = value - return fm, body - except Exception as e: - pass - return {}, "" - -def get_posts(path): - """Get all posts from a directory""" - posts = [] - if path.exists(): - for f in sorted(path.glob("*.md"), reverse=True): - fm, body = parse_frontmatter(f) - posts.append({ - 'filename': f.name, - 'path': f, - 'title': fm.get('title', f.stem), - 'date': fm.get('date', ''), - 'categories': fm.get('categories', []), - 'tags': fm.get('tags', []), - 'excerpt': fm.get('excerpt', body[:150] + '...' if len(body) > 150 else body), - 'body': body - }) - return posts - -def git_commit_push(message): - """Commit and push to Gitea""" - try: - subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True) - subprocess.run(['git', 'commit', '-m', message], cwd=CONTENT_PATH, capture_output=True) - subprocess.run(['git', 'push', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True) - except: - pass - -# Tabs for Published and Drafts -tab1, tab2 = st.tabs(["📰 Published", "📝 Drafts"]) - -with tab1: - posts = get_posts(POSTS_PATH) - - if not posts: - st.info("No published posts yet. Create your first post in the Editor!") - else: - st.write(f"**{len(posts)} published posts**") - - for post in posts: - with st.expander(f"📄 {post['title']}", expanded=False): - col1, col2 = st.columns([3, 1]) - - with col1: - st.caption(f"📅 {post['date']}") - if post['categories']: - cats = post['categories'] if isinstance(post['categories'], list) else [post['categories']] - st.caption(f"📁 {', '.join(cats)}") - if post['tags']: - tags = post['tags'] if isinstance(post['tags'], list) else [post['tags']] - st.caption(f"🏷️ {', '.join(tags)}") - st.markdown(post['excerpt']) - - with col2: - if st.button("✏️ Edit", key=f"edit_{post['filename']}"): - st.session_state.post_title = post['title'] - st.session_state.post_content = post['body'] - st.switch_page("pages/1_editor.py") - - if st.button("🗑️ Delete", key=f"del_{post['filename']}"): - post['path'].unlink() - git_commit_push(f"Delete post: {post['title']}") - st.success(f"Deleted: {post['filename']}") - st.rerun() - - if st.button("📥 Unpublish", key=f"unpub_{post['filename']}"): - DRAFTS_PATH.mkdir(parents=True, exist_ok=True) - new_path = DRAFTS_PATH / post['filename'] - post['path'].rename(new_path) - git_commit_push(f"Unpublish: {post['title']}") - st.success(f"Moved to drafts") - st.rerun() - -with tab2: - drafts = get_posts(DRAFTS_PATH) - - if not drafts: - st.info("No drafts. Save a draft from the Editor!") - else: - st.write(f"**{len(drafts)} drafts**") - - for draft in drafts: - with st.expander(f"📝 {draft['title']}", expanded=False): - col1, col2 = st.columns([3, 1]) - - with col1: - st.caption(f"📅 {draft['date']}") - st.markdown(draft['excerpt']) - - with col2: - if st.button("✏️ Edit", key=f"edit_draft_{draft['filename']}"): - st.session_state.post_title = draft['title'] - st.session_state.post_content = draft['body'] - st.switch_page("pages/1_editor.py") - - if st.button("📤 Publish", key=f"pub_{draft['filename']}"): - POSTS_PATH.mkdir(parents=True, exist_ok=True) - new_path = POSTS_PATH / draft['filename'] - draft['path'].rename(new_path) - git_commit_push(f"Publish: {draft['title']}") - st.success(f"Published!") - st.rerun() - - if st.button("🗑️ Delete", key=f"del_draft_{draft['filename']}"): - draft['path'].unlink() - st.success(f"Deleted") - st.rerun() - -# Sync action -st.divider() -col1, col2 = st.columns(2) - -with col1: - if st.button("🔄 Sync from Git", use_container_width=True): - with st.spinner("Syncing..."): - try: - subprocess.run(['git', 'pull', 'origin', 'master'], cwd=CONTENT_PATH, capture_output=True) - st.success("Synced!") - st.rerun() - except: - st.error("Sync failed") - -with col2: - if st.button("📤 Push to Git", use_container_width=True): - with st.spinner("Pushing..."): - git_commit_push("Update posts") - st.success("Pushed!") diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_📚_Posts.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_📚_Posts.py new file mode 100644 index 00000000..148f126d --- /dev/null +++ b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/2_📚_Posts.py @@ -0,0 +1,100 @@ +""" +Metabolizer CMS - Post Management +""" +import streamlit as st +from pathlib import Path +import subprocess +import os + +CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) +POSTS_PATH = CONTENT_PATH / "_posts" +DRAFTS_PATH = CONTENT_PATH / "_drafts" + +st.set_page_config(page_title="Posts", page_icon="📚", layout="wide") +st.title("📚 Post Management") + +def parse_post(filepath): + """Parse markdown file with frontmatter""" + try: + text = filepath.read_text() + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + meta = {} + for line in parts[1].strip().split('\n'): + if ':' in line: + k, v = line.split(':', 1) + meta[k.strip()] = v.strip().strip('"') + return meta, parts[2].strip() + except: + pass + return {'title': filepath.stem}, "" + +def git_commit(msg): + subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True) + +# Tabs +tab1, tab2 = st.tabs(["📰 Published", "📝 Drafts"]) + +with tab1: + if POSTS_PATH.exists(): + posts = sorted(POSTS_PATH.glob("*.md"), reverse=True) + st.caption(f"{len(posts)} published posts") + + for post in posts: + meta, body = parse_post(post) + with st.expander(f"📄 {meta.get('title', post.stem)}"): + col1, col2 = st.columns([3, 1]) + with col1: + st.caption(f"📅 {meta.get('date', 'Unknown')}") + st.markdown(body[:200] + "..." if len(body) > 200 else body) + with col2: + if st.button("🗑️ Delete", key=f"del_{post.name}"): + post.unlink() + git_commit(f"Delete: {post.name}") + st.rerun() + if st.button("📥 Unpublish", key=f"unpub_{post.name}"): + DRAFTS_PATH.mkdir(exist_ok=True) + post.rename(DRAFTS_PATH / post.name) + git_commit(f"Unpublish: {post.name}") + st.rerun() + else: + st.info("No published posts") + +with tab2: + if DRAFTS_PATH.exists(): + drafts = sorted(DRAFTS_PATH.glob("*.md"), reverse=True) + st.caption(f"{len(drafts)} drafts") + + for draft in drafts: + meta, body = parse_post(draft) + with st.expander(f"📝 {meta.get('title', draft.stem)}"): + col1, col2 = st.columns([3, 1]) + with col1: + st.markdown(body[:200] + "..." if len(body) > 200 else body) + with col2: + if st.button("📤 Publish", key=f"pub_{draft.name}"): + POSTS_PATH.mkdir(exist_ok=True) + draft.rename(POSTS_PATH / draft.name) + git_commit(f"Publish: {draft.name}") + st.rerun() + if st.button("🗑️ Delete", key=f"deld_{draft.name}"): + draft.unlink() + st.rerun() + else: + st.info("No drafts") + +# Sync +st.divider() +col1, col2 = st.columns(2) +with col1: + if st.button("🔄 Pull from Git", use_container_width=True): + subprocess.run(['git', 'pull'], cwd=CONTENT_PATH, capture_output=True) + st.success("Synced!") + st.rerun() +with col2: + if st.button("📤 Push to Git", use_container_width=True): + git_commit("Update posts") + st.success("Pushed!") diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_media.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_media.py deleted file mode 100644 index 633920d9..00000000 --- a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_media.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Metabolizer CMS - Media Library -""" -import streamlit as st -from pathlib import Path -import subprocess -import os -import shutil - -st.set_page_config(page_title="Media - Metabolizer", page_icon="🖼️", layout="wide") - -# Paths -CONTENT_PATH = Path("/srv/metabolizer/content") -MEDIA_PATH = CONTENT_PATH / "images" - -# Ensure directory exists -MEDIA_PATH.mkdir(parents=True, exist_ok=True) - -st.title("🖼️ Media Library") - -def git_commit_push(message): - """Commit and push to Gitea""" - os.chdir(CONTENT_PATH) - subprocess.run(['git', 'add', '-A'], capture_output=True) - subprocess.run(['git', 'commit', '-m', message], capture_output=True) - subprocess.run(['git', 'push', 'origin', 'main'], capture_output=True) - -def get_media_files(): - """Get all media files""" - files = [] - if MEDIA_PATH.exists(): - for ext in ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg']: - files.extend(MEDIA_PATH.glob(ext)) - files.extend(MEDIA_PATH.glob(ext.upper())) - return sorted(files, key=lambda x: x.stat().st_mtime, reverse=True) - -# Upload Section -st.subheader("📤 Upload") - -uploaded_files = st.file_uploader( - "Choose images", - type=['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'], - accept_multiple_files=True -) - -if uploaded_files: - for uploaded_file in uploaded_files: - save_path = MEDIA_PATH / uploaded_file.name - - # Save file - with open(save_path, 'wb') as f: - f.write(uploaded_file.getbuffer()) - - st.success(f"Uploaded: {uploaded_file.name}") - - # Show markdown code - st.code(f"![{uploaded_file.name}](/images/{uploaded_file.name})") - - # Commit to git - git_commit_push(f"Add {len(uploaded_files)} media files") - st.rerun() - -st.divider() - -# Media Gallery -st.subheader("📁 Library") - -media_files = get_media_files() - -if not media_files: - st.info("No media files yet. Upload some images above!") -else: - st.write(f"**{len(media_files)} files**") - - # Display in grid - cols = st.columns(4) - - for idx, media_file in enumerate(media_files): - col = cols[idx % 4] - - with col: - # Display image - try: - st.image(str(media_file), use_container_width=True) - except: - st.write(f"📄 {media_file.name}") - - # File info - size = media_file.stat().st_size - size_str = f"{size / 1024:.1f} KB" if size < 1024 * 1024 else f"{size / 1024 / 1024:.1f} MB" - st.caption(f"{media_file.name} ({size_str})") - - # Copy markdown button - markdown_code = f"![{media_file.name}](/images/{media_file.name})" - - col1, col2 = st.columns(2) - with col1: - if st.button("📋 Copy", key=f"copy_{media_file.name}"): - st.code(markdown_code) - st.info("Copy the code above") - - with col2: - if st.button("🗑️", key=f"del_{media_file.name}"): - media_file.unlink() - git_commit_push(f"Delete: {media_file.name}") - st.rerun() - -# Sync action -st.divider() -if st.button("🔄 Sync Media to Blog", use_container_width=True): - with st.spinner("Syncing..."): - result = subprocess.run( - ['/usr/sbin/metabolizerctl', 'build'], - capture_output=True, text=True - ) - if result.returncode == 0: - st.success("Media synced to blog!") - else: - st.error("Sync failed") diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_🖼️_Media.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_🖼️_Media.py new file mode 100644 index 00000000..c8a530ff --- /dev/null +++ b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/3_🖼️_Media.py @@ -0,0 +1,75 @@ +""" +Metabolizer CMS - Media Library +""" +import streamlit as st +from pathlib import Path +import subprocess +import os + +CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) +MEDIA_PATH = CONTENT_PATH / "images" + +st.set_page_config(page_title="Media", page_icon="🖼️", layout="wide") +st.title("🖼️ Media Library") + +MEDIA_PATH.mkdir(parents=True, exist_ok=True) + +def git_commit(msg): + subprocess.run(['git', 'add', '-A'], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'commit', '-m', msg], cwd=CONTENT_PATH, capture_output=True) + subprocess.run(['git', 'push'], cwd=CONTENT_PATH, capture_output=True) + +# Upload +st.subheader("📤 Upload") +uploaded = st.file_uploader("Choose images", type=['png', 'jpg', 'jpeg', 'gif', 'webp'], accept_multiple_files=True) + +if uploaded: + for f in uploaded: + save_path = MEDIA_PATH / f.name + save_path.write_bytes(f.getbuffer()) + st.success(f"Uploaded: {f.name}") + st.code(f"![{f.name}](/images/{f.name})") + git_commit(f"Add {len(uploaded)} images") + st.rerun() + +st.divider() + +# Gallery +st.subheader("📁 Library") + +images = list(MEDIA_PATH.glob("*")) +images = [i for i in images if i.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']] + +if images: + st.caption(f"{len(images)} files") + cols = st.columns(4) + + for idx, img in enumerate(sorted(images, key=lambda x: x.stat().st_mtime, reverse=True)): + col = cols[idx % 4] + with col: + try: + st.image(str(img), use_container_width=True) + except: + st.markdown(f"📄 {img.name}") + + size = img.stat().st_size + size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB" + st.caption(f"{img.name} ({size_str})") + + col1, col2 = st.columns(2) + with col1: + if st.button("📋", key=f"copy_{img.name}", help="Copy markdown"): + st.code(f"![{img.name}](/images/{img.name})") + with col2: + if st.button("🗑️", key=f"del_{img.name}", help="Delete"): + img.unlink() + git_commit(f"Delete: {img.name}") + st.rerun() +else: + st.info("No images yet. Upload some above!") + +# Sync +st.divider() +if st.button("🔄 Sync Media", use_container_width=True): + git_commit("Sync media") + st.success("Media synced!") diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_settings.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_settings.py deleted file mode 100644 index 291c1b6c..00000000 --- a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_settings.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Metabolizer CMS - Settings -""" -import streamlit as st -import subprocess -import json -import os -from pathlib import Path - -st.set_page_config(page_title="Settings - Metabolizer", page_icon="⚙️", layout="wide") - -st.title("⚙️ Settings") - -# Paths -CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) -GITEA_URL = os.environ.get('GITEA_URL', 'http://host.containers.internal:3000') - -def git_command(args, cwd=None): - """Run git command""" - try: - result = subprocess.run( - ['git'] + args, - cwd=cwd or CONTENT_PATH, - capture_output=True, text=True - ) - return result.returncode == 0, result.stdout, result.stderr - except Exception as e: - return False, "", str(e) - -# Pipeline Status -st.subheader("📊 Pipeline Status") - -col1, col2, col3 = st.columns(3) - -with col1: - st.metric("Gitea", "EXTERNAL", delta="Host") - -with col2: - st.metric("Streamlit", "RUNNING", delta="OK") - -with col3: - st.metric("HexoJS", "EXTERNAL", delta="Host") - -st.divider() - -# Content Repository -st.subheader("📁 Content Repository") - -col1, col2 = st.columns(2) - -with col1: - st.text_input("Content Path", value=str(CONTENT_PATH), disabled=True) - - # Check if git repo exists - if (CONTENT_PATH / '.git').exists(): - success, stdout, _ = git_command(['remote', '-v']) - if success and stdout: - remote = stdout.split('\n')[0] if stdout else "No remote" - st.text_input("Remote", value=remote.split()[1] if '\t' in remote or ' ' in remote else remote, disabled=True) - - success, stdout, _ = git_command(['rev-parse', '--abbrev-ref', 'HEAD']) - st.text_input("Branch", value=stdout.strip() if success else "unknown", disabled=True) - else: - st.warning("Content directory is not a git repository") - -with col2: - # Count posts - posts_path = CONTENT_PATH / '_posts' - post_count = len(list(posts_path.glob('*.md'))) if posts_path.exists() else 0 - st.metric("Posts", post_count) - - drafts_path = CONTENT_PATH / '_drafts' - draft_count = len(list(drafts_path.glob('*.md'))) if drafts_path.exists() else 0 - st.metric("Drafts", draft_count) - -# Git Operations -st.subheader("🔗 Git Operations") - -col1, col2, col3 = st.columns(3) - -with col1: - if st.button("🔄 Pull Latest", use_container_width=True): - with st.spinner("Pulling..."): - success, stdout, stderr = git_command(['pull', 'origin', 'master']) - if success: - st.success("Pulled latest changes") - else: - st.error(f"Pull failed: {stderr}") - -with col2: - if st.button("📊 Git Status", use_container_width=True): - success, stdout, stderr = git_command(['status', '--short']) - if stdout: - st.code(stdout) - else: - st.info("Working tree clean") - -with col3: - if st.button("📤 Push Changes", use_container_width=True): - with st.spinner("Pushing..."): - success, stdout, stderr = git_command(['push', 'origin', 'master']) - if success: - st.success("Pushed changes") - else: - st.error(f"Push failed: {stderr}") - -st.divider() - -# Initialize Repository -st.subheader("🆕 Initialize Content Repository") - -with st.expander("Setup New Repository"): - repo_url = st.text_input("Gitea Repository URL", placeholder="http://host:3000/user/blog-content.git") - - if st.button("Clone Repository", use_container_width=True): - if repo_url: - with st.spinner("Cloning..."): - CONTENT_PATH.mkdir(parents=True, exist_ok=True) - success, stdout, stderr = git_command(['clone', repo_url, str(CONTENT_PATH)], cwd='/srv') - if success: - st.success("Repository cloned!") - st.rerun() - else: - st.error(f"Clone failed: {stderr}") - else: - st.warning("Enter a repository URL") - -st.divider() - -# Environment Info -with st.expander("🔧 Debug: Environment"): - st.json({ - "CONTENT_PATH": str(CONTENT_PATH), - "GITEA_URL": GITEA_URL, - "CWD": os.getcwd(), - "PATH": os.environ.get('PATH', ''), - }) diff --git a/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_⚙️_Settings.py b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_⚙️_Settings.py new file mode 100644 index 00000000..bf50eb94 --- /dev/null +++ b/package/secubox/secubox-app-metabolizer/files/usr/share/metabolizer/cms/pages/4_⚙️_Settings.py @@ -0,0 +1,141 @@ +""" +Metabolizer CMS - Settings +""" +import streamlit as st +import subprocess +import os +from pathlib import Path + +CONTENT_PATH = Path(os.environ.get('METABOLIZER_CONTENT', '/srv/content')) +GITEA_URL = os.environ.get('GITEA_URL', 'http://192.168.255.1:3000') + +st.set_page_config(page_title="Settings", page_icon="⚙️", layout="wide") +st.title("⚙️ Settings") + +def run_git(args): + try: + r = subprocess.run(['git'] + args, cwd=CONTENT_PATH, capture_output=True, text=True) + return r.returncode == 0, r.stdout, r.stderr + except Exception as e: + return False, "", str(e) + +# Status +st.subheader("📊 Status") +col1, col2, col3 = st.columns(3) + +with col1: + st.metric("Gitea", "External", delta="Host :3000") +with col2: + st.metric("Streamlit", "Running", delta="OK") +with col3: + st.metric("Content", "Local", delta=str(CONTENT_PATH)) + +st.divider() + +# Repository +st.subheader("📁 Content Repository") + +col1, col2 = st.columns(2) + +with col1: + st.text_input("Content Path", value=str(CONTENT_PATH), disabled=True) + + if (CONTENT_PATH / ".git").exists(): + ok, out, _ = run_git(['remote', '-v']) + if ok and out: + remote = out.split('\n')[0].split()[1] if out else "None" + st.text_input("Remote", value=remote, disabled=True) + + ok, out, _ = run_git(['branch', '--show-current']) + st.text_input("Branch", value=out.strip() if ok else "unknown", disabled=True) + + st.success("✅ Git repository initialized") + else: + st.warning("⚠️ Not a git repository") + +with col2: + posts = len(list((CONTENT_PATH / "_posts").glob("*.md"))) if (CONTENT_PATH / "_posts").exists() else 0 + drafts = len(list((CONTENT_PATH / "_drafts").glob("*.md"))) if (CONTENT_PATH / "_drafts").exists() else 0 + images = len(list((CONTENT_PATH / "images").glob("*"))) if (CONTENT_PATH / "images").exists() else 0 + + st.metric("Posts", posts) + st.metric("Drafts", drafts) + st.metric("Images", images) + +st.divider() + +# Git Operations +st.subheader("🔗 Git Operations") + +col1, col2, col3 = st.columns(3) + +with col1: + if st.button("🔄 Pull", use_container_width=True): + ok, out, err = run_git(['pull']) + if ok: + st.success("Pulled!") + else: + st.error(err or "Pull failed") + +with col2: + if st.button("📊 Status", use_container_width=True): + ok, out, _ = run_git(['status', '--short']) + if out: + st.code(out) + else: + st.info("Working tree clean") + +with col3: + if st.button("📤 Push", use_container_width=True): + run_git(['add', '-A']) + run_git(['commit', '-m', 'Update from CMS']) + ok, _, err = run_git(['push']) + if ok: + st.success("Pushed!") + else: + st.error(err or "Push failed") + +st.divider() + +# Initialize Repository +st.subheader("🆕 Initialize Repository") + +with st.expander("Clone from Gitea"): + repo_url = st.text_input("Repository URL", placeholder=f"{GITEA_URL}/user/blog-content.git") + + if st.button("Clone", use_container_width=True): + if repo_url: + with st.spinner("Cloning..."): + if CONTENT_PATH.exists(): + import shutil + shutil.rmtree(CONTENT_PATH, ignore_errors=True) + CONTENT_PATH.parent.mkdir(parents=True, exist_ok=True) + r = subprocess.run(['git', 'clone', repo_url, str(CONTENT_PATH)], capture_output=True, text=True) + if r.returncode == 0: + st.success("Cloned!") + st.rerun() + else: + st.error(r.stderr) + else: + st.warning("Enter URL") + +with st.expander("Initialize New"): + if st.button("Initialize Empty Repo", use_container_width=True): + CONTENT_PATH.mkdir(parents=True, exist_ok=True) + (CONTENT_PATH / "_posts").mkdir(exist_ok=True) + (CONTENT_PATH / "_drafts").mkdir(exist_ok=True) + (CONTENT_PATH / "images").mkdir(exist_ok=True) + run_git(['init']) + st.success("Initialized!") + st.rerun() + +st.divider() + +# Debug +with st.expander("🔧 Debug"): + st.json({ + "CONTENT_PATH": str(CONTENT_PATH), + "GITEA_URL": GITEA_URL, + "PATH": os.environ.get('PATH', ''), + "git_exists": (CONTENT_PATH / ".git").exists(), + })