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 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-24 12:51:34 +01:00
parent 35957e34ab
commit f9b73ea62c
20 changed files with 1499 additions and 717 deletions

View File

@ -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 <contact@cybermind.fr>
PKG_LICENSE:=GPL-2.0
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildance, see include/package.mk
$(eval $(call BuildPackage,$(PKG_NAME)))

View File

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

View File

@ -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();
}
});

View File

@ -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 <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"cms_running": $cms_running,
"hexo_running": $hexo_running,
"gitea_connected": $gitea_connected,
"post_count": $post_count,
"draft_count": $draft_count,
"cms_url": "http://$(uci -q get network.lan.ipaddr || echo "192.168.1.1"):8501",
"blog_url": "/blog/"
}
EOF
}
list_posts() {
local content_path=$(uci -q get metabolizer.content.repo_path || echo "/srv/metabolizer/content")
local posts_dir="$content_path/_posts"
echo "["
local first=1
if [ -d "$posts_dir" ]; then
for f in "$posts_dir"/*.md; do
[ -f "$f" ] || continue
local filename=$(basename "$f")
local slug="${filename%.md}"
local title=$(grep -m1 "^title:" "$f" 2>/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 <<EOF
{
"has_repo": $has_repo,
"last_sync": "$last_sync",
"branch": "$branch"
}
EOF
}
do_sync() {
metabolizerctl sync 2>&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 <<EOF
{
"status": {},
"list_posts": {},
"gitea_status": {},
"sync": {},
"build": {},
"publish": {},
"gitea_sync": {}
}
EOF
;;
call)
case "$2" in
status) get_status ;;
list_posts) list_posts ;;
gitea_status) gitea_status ;;
sync) do_sync ;;
build) do_build ;;
publish) do_publish ;;
gitea_sync) do_gitea_sync ;;
*) echo '{"error": "unknown method"}' ;;
esac
;;
esac

View File

@ -0,0 +1,30 @@
{
"admin/services/metabolizer": {
"title": "Metabolizer CMS",
"action": {
"type": "view",
"path": "metabolizer/overview"
},
"depends": {
"acl": ["luci-app-metabolizer"],
"uci": {"metabolizer": true}
},
"order": 85
},
"admin/services/metabolizer/overview": {
"title": "Overview",
"action": {
"type": "view",
"path": "metabolizer/overview"
},
"order": 10
},
"admin/services/metabolizer/settings": {
"title": "Settings",
"action": {
"type": "view",
"path": "metabolizer/settings"
},
"order": 20
}
}

View File

@ -0,0 +1,17 @@
{
"luci-app-metabolizer": {
"description": "Grant access to Metabolizer CMS",
"read": {
"ubus": {
"luci.metabolizer": ["status", "list_posts", "gitea_status"]
},
"uci": ["metabolizer"]
},
"write": {
"ubus": {
"luci.metabolizer": ["sync", "build", "publish", "gitea_sync"]
},
"uci": ["metabolizer"]
}
}
}

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-hexojs
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>

View File

@ -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://<router-ip>: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 <cmd> # Execute command in container
```
### Site Management
```bash
hexoctl site create <name> # Create new Hexo site
hexoctl site list # List all sites
hexoctl site delete <name> # 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 <slug> # 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

View File

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

View File

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

View File

@ -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 <contact@cybermind.fr>

View File

@ -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("""
<style>
/* CRT Monitor Effect */
@keyframes scanline {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
.stApp {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%);
}
/* Neon glow effect */
h1, h2, h3 {
color: #ff5f1f !important;
text-shadow: 0 0 10px rgba(255, 95, 31, 0.5);
}
/* Sidebar styling */
.css-1d391kg {
background: rgba(10, 10, 20, 0.95);
border-right: 1px solid #ff5f1f;
}
/* Button styling */
.stButton > button {
background: transparent;
border: 1px solid #ff5f1f;
color: #ff5f1f;
transition: all 0.3s ease;
}
.stButton > button:hover {
background: rgba(255, 95, 31, 0.2);
box-shadow: 0 0 15px rgba(255, 95, 31, 0.3);
}
/* Text area styling */
.stTextArea textarea {
background: #0a0a0a;
border: 1px solid #333;
color: #00ff88;
font-family: 'Courier New', monospace;
}
/* Success/Error messages */
.stSuccess {
background: rgba(0, 255, 136, 0.1);
border: 1px solid #00ff88;
}
.stError {
background: rgba(255, 68, 68, 0.1);
border: 1px solid #ff4444;
}
/* Metric styling */
.css-1xarl3l {
background: rgba(20, 20, 30, 0.8);
border: 1px solid #333;
border-radius: 8px;
padding: 10px;
}
/* Code blocks */
code {
color: #0ff !important;
background: rgba(0, 255, 255, 0.1) !important;
}
.stApp { background: linear-gradient(135deg, #0d1117 0%, #161b22 100%); }
h1, h2, h3 { color: #58a6ff !important; }
.stMetric { background: rgba(22, 27, 34, 0.8); border-radius: 8px; padding: 10px; }
.stButton>button { border: 1px solid #30363d; background: #21262d; color: #c9d1d9; }
.stButton>button:hover { background: #30363d; border-color: #58a6ff; }
</style>
""", 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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', ''),
})

View File

@ -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(),
})