feat: Multi-source AppStore with version management and updates UI (Phases 1-3)

Implement comprehensive multi-source catalog system with automatic fallback,
advanced version tracking, and rich update management interfaces.

## Phase 1: Backend Infrastructure (COMPLETE)

### UCI Configuration
- **New**: `/etc/config/secubox-appstore`
  - 4 source types: GitHub (remote), local web (remote), USB (local), embedded (fallback)
  - Priority-based fallback (1=highest, 999=embedded last resort)
  - Settings: auto_sync, force_source, check_updates_on_boot, notify_updates
  - Update checking with configurable intervals

### Catalog Sync Script
- **New**: `/usr/sbin/secubox-catalog-sync` (364 lines)
  - Automatic multi-source fallback by priority
  - Download tools: uclient-fetch, wget, curl (auto-detect)
  - HTTP caching: ETag support, 304 Not Modified handling
  - JSON validation before use
  - Metadata tracking with jq
  - Logging to syslog
  - Source types: remote (HTTPS/HTTP), local (filesystem), embedded (ROM)

### CLI Enhancement
- **Modified**: `/usr/sbin/secubox-appstore`
  - New commands: `sync [source]`, `check-updates [--json]`, `changelog <app> [version]`
  - `get_active_catalog()`: Reads from cache or embedded
  - `sync_catalog()`: Wrapper for secubox-catalog-sync
  - `check_updates()`: Version comparison with opkg
  - `get_changelog()`: Extracts from catalog JSON

### Metadata Structure
- **New**: `/usr/share/secubox/catalog-metadata.json.example`
  - Active source tracking
  - Source status (online/offline/error)
  - ETag cache for HTTP sources
  - Installed apps version tracking
  - Update statistics

### Makefile Updates
- **Modified**: `secubox-core/Makefile`
  - PKG_RELEASE: 5 → 6
  - Added conffiles: `/etc/config/secubox-appstore`
  - Install secubox-catalog-sync binary
  - Install catalog-metadata.json.example
  - Added dependency: +jq
  - postinst: Create cache directories (/var/cache/secubox/catalogs, /var/lib/secubox)

## Phase 2: RPCD Backend (COMPLETE)

### New RPC Methods
- **Modified**: `/usr/libexec/rpcd/luci.secubox`
  - `get_catalog_sources()`: List configured sources from UCI, status from metadata
  - `set_catalog_source(source)`: Configure force_source in UCI
  - `sync_catalog([source])`: Trigger catalog sync (auto-fallback or specific)
  - `check_updates()`: Compare installed vs catalog versions
  - `get_app_versions(app_id)`: Detailed version info (pkg, app, installed, catalog)
  - `get_changelog(app_id, from, to)`: Extract changelog from catalog
  - `get_widget_data(app_id)`: Widget metrics (Phase 5 prep)

All methods integrate with:
- UCI config parsing (`config_load`, `config_foreach`)
- Metadata file reading (`/var/lib/secubox/catalog-metadata.json`)
- Catalog reading (`/var/cache/secubox/catalogs/*.json` or embedded)
- opkg version checking

## Phase 3: Frontend LuCI Views (COMPLETE)

### API Module Enhancement
- **Modified**: `secubox-admin/api.js`
  - New RPC declarations: 7 new methods
  - Exports: `getCatalogSources`, `setCatalogSource`, `syncCatalog`,
            `checkUpdates`, `getAppVersions`, `getChangelog`, `getWidgetData`

### Catalog Sources Management
- **New**: `view/secubox-admin/catalog-sources.js` (370 lines)
  - Live source status display (online/offline/error)
  - Priority-based ordering
  - Active source indicator
  - Per-source actions: Sync, Test, Set Active, Enable/Disable
  - Summary stats: Total sources, active source, updates available
  - Auto-refresh every 30 seconds
  - Timestamp formatting (relative: "5 minutes ago", "2 days ago")

### Updates Manager
- **New**: `view/secubox-admin/updates.js` (380 lines)
  - Available updates list with version comparison
  - Changelog preview in update cards
  - Version arrows: "0.3.0-1 → 0.4.0-2"
  - Per-app actions: Update Now, View Full Changelog, Skip Version
  - Batch update: "Update All" button
  - Check for Updates: Sync + check flow
  - Auto-refresh every 60 seconds
  - No updates state: Checkmark with message

### Apps Manager Enhancement
- **Modified**: `view/secubox-admin/apps.js`
  - Load update info on page load
  - Update available badges (warning style)
  - Version display with tooltip (installed → available)
  - Visual indicators: `.has-update`, `.version-outdated` classes
  - New filter: "Updates Available" / "Installed" / "Not Installed"
  - Changelog button on all apps (installed or not)
  - Update button for apps with available updates
  - `updateApp()`: Shows changelog before update
  - `viewChangelog()`: Modal with version history
  - `filterByStatus()`: Filter by update/install status

### Menu Integration
- **Modified**: `menu.d/luci-app-secubox-admin.json`
  - New entries:
    - "Updates" (order: 25) → `/admin/secubox/admin/updates`
    - "Catalog Sources" (order: 27) → `/admin/secubox/admin/catalog-sources`
  - Placed between Apps Manager and App Settings

## Data Flow Architecture

```
User Action (Web UI)
  ↓
LuCI View (catalog-sources.js, updates.js, apps.js)
  ↓
API Module (api.js RPC calls)
  ↓
RPCD Backend (luci.secubox)
  ↓
CLI Scripts (secubox-appstore, secubox-catalog-sync)
  ↓
Data Layer
  ├── UCI Config (/etc/config/secubox-appstore)
  ├── Cache (/var/cache/secubox/catalogs/*.json)
  ├── Metadata (/var/lib/secubox/catalog-metadata.json)
  └── Embedded (/usr/share/secubox/catalog.json)
```

## Fallback Logic

1. User triggers sync (or auto-sync)
2. secubox-catalog-sync reads UCI config
3. Sorts sources by priority (1 = GitHub, 2 = Local Web, 3 = USB, 999 = Embedded)
4. Attempts each source in order:
   - GitHub HTTPS → timeout/fail → Next
   - Local Web → unreachable → Next
   - USB → not mounted → Next
   - Embedded → Always succeeds (ROM)
5. First successful source becomes active
6. Metadata updated with status, ETag, timestamp
7. Cache written to `/var/cache/secubox/catalogs/<source>.json`

## Version Tracking

- **PKG_VERSION**: OpenWrt package version (e.g., "0.4.0")
- **PKG_RELEASE**: Build release number (e.g., "2")
- **pkg_version**: Full package string "0.4.0-2" (in catalog)
- **app_version**: Underlying app version (e.g., "0.4.0")
- **installed_version**: From `opkg list-installed`
- **catalog_version**: From active catalog JSON
- **Comparison**: Uses `opkg compare-versions` for semantic versioning

## Storage Layout

```
/etc/config/secubox-appstore              # UCI configuration
/var/cache/secubox/catalogs/              # Downloaded catalogs (755/644)
  ├── github.json
  ├── local_web.json
  └── usb.json
/var/lib/secubox/                         # Runtime metadata (700/600)
  └── catalog-metadata.json
/usr/share/secubox/catalog.json           # Embedded fallback (ROM)
```

## Key Features

 **Multi-source support**: GitHub + Web + USB + Embedded
 **Automatic fallback**: Priority-based with retry logic
 **HTTP optimization**: ETag caching, 304 Not Modified
 **Version management**: PKG + App versions, changelog tracking
 **Update notifications**: Badges, filters, dedicated updates page
 **Offline capable**: USB and embedded sources work without internet
 **Live status**: Auto-refresh, real-time source health
 **User control**: Manual sync, force specific source, enable/disable sources

## Files Modified (8)
- package/secubox/secubox-core/Makefile
- package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox
- package/secubox/secubox-core/root/usr/sbin/secubox-appstore
- package/secubox/luci-app-secubox-admin/htdocs/luci-static/resources/secubox-admin/api.js
- package/secubox/luci-app-secubox-admin/htdocs/luci-static/resources/view/secubox-admin/apps.js
- package/secubox/luci-app-secubox-admin/root/usr/share/luci/menu.d/luci-app-secubox-admin.json

## Files Created (4)
- package/secubox/secubox-core/root/etc/config/secubox-appstore
- package/secubox/secubox-core/root/usr/sbin/secubox-catalog-sync
- package/secubox/secubox-core/root/usr/share/secubox/catalog-metadata.json.example
- package/secubox/luci-app-secubox-admin/htdocs/luci-static/resources/view/secubox-admin/catalog-sources.js
- package/secubox/luci-app-secubox-admin/htdocs/luci-static/resources/view/secubox-admin/updates.js

## Next Steps (Phase 4-5)

- Phase 4: Enrich catalog.json with changelog sections
- Phase 5: Widget system (renderer + templates for security/network/monitoring)
- Phase 6: Auto-sync service with cron
- Phase 7: Optimizations (signature validation, compression, CDN)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-04 10:53:57 +01:00
parent 2787b8c222
commit 77dbd3d499
11 changed files with 1760 additions and 9 deletions

View File

@ -65,6 +65,56 @@ var callGetLogs = rpc.declare({
expect: { logs: '' }
});
// Catalog Sources
var callGetCatalogSources = rpc.declare({
object: 'luci.secubox',
method: 'get_catalog_sources',
expect: { sources: [] }
});
var callSetCatalogSource = rpc.declare({
object: 'luci.secubox',
method: 'set_catalog_source',
params: ['source'],
expect: { success: false }
});
var callSyncCatalog = rpc.declare({
object: 'luci.secubox',
method: 'sync_catalog',
params: ['source'],
expect: { success: false }
});
// Version Management
var callCheckUpdates = rpc.declare({
object: 'luci.secubox',
method: 'check_updates',
expect: { }
});
var callGetAppVersions = rpc.declare({
object: 'luci.secubox',
method: 'get_app_versions',
params: ['app_id'],
expect: { }
});
var callGetChangelog = rpc.declare({
object: 'luci.secubox',
method: 'get_changelog',
params: ['app_id', 'from_version', 'to_version'],
expect: { }
});
// Widget Data
var callGetWidgetData = rpc.declare({
object: 'luci.secubox',
method: 'get_widget_data',
params: ['app_id'],
expect: { }
});
// Utility functions
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
@ -121,6 +171,19 @@ return baseclass.extend({
getAlerts: callGetAlerts,
getLogs: callGetLogs,
// Catalog Sources
getCatalogSources: callGetCatalogSources,
setCatalogSource: callSetCatalogSource,
syncCatalog: callSyncCatalog,
// Version Management
checkUpdates: callCheckUpdates,
getAppVersions: callGetAppVersions,
getChangelog: callGetChangelog,
// Widget Data
getWidgetData: callGetWidgetData,
// Utilities
formatBytes: formatBytes,
formatUptime: formatUptime,

View File

@ -9,15 +9,25 @@ return view.extend({
load: function() {
return Promise.all([
API.getApps(),
API.getModules()
API.getModules(),
L.resolveDefault(API.checkUpdates(), {})
]);
},
render: function(data) {
var apps = data[0].apps || [];
var modules = data[1].modules || {};
var updateInfo = data[2] || {};
var self = this;
// Create updates lookup map
var updatesMap = {};
if (updateInfo.updates) {
updateInfo.updates.forEach(function(update) {
updatesMap[update.app_id] = update;
});
}
var container = E('div', { 'class': 'secubox-apps-manager' }, [
E('link', { 'rel': 'stylesheet',
'href': L.resource('secubox-admin/common.css') }),
@ -48,6 +58,17 @@ return view.extend({
E('option', { 'value': 'network' }, 'Network'),
E('option', { 'value': 'hosting' }, 'Hosting'),
E('option', { 'value': 'productivity' }, 'Productivity')
]),
E('select', {
'class': 'status-filter',
'change': function(ev) {
self.filterByStatus(ev.target.value);
}
}, [
E('option', { 'value': '' }, 'All Apps'),
E('option', { 'value': 'update-available' }, 'Updates Available'),
E('option', { 'value': 'installed' }, 'Installed'),
E('option', { 'value': 'not-installed' }, 'Not Installed')
])
]),
@ -55,7 +76,8 @@ return view.extend({
E('div', { 'class': 'apps-grid', 'id': 'apps-grid' },
apps.map(function(app) {
var status = API.getAppStatus(app, modules);
return self.renderAppCard(app, status);
var updateAvailable = updatesMap[app.id];
return self.renderAppCard(app, status, updateAvailable);
})
)
]);
@ -63,22 +85,48 @@ return view.extend({
return container;
},
renderAppCard: function(app, status) {
renderAppCard: function(app, status, updateInfo) {
var self = this;
var hasUpdate = updateInfo && updateInfo.update_available;
return E('div', { 'class': 'app-card', 'data-category': app.category }, [
var cardClasses = 'app-card';
if (status.installed) cardClasses += ' installed';
if (hasUpdate) cardClasses += ' has-update';
return E('div', {
'class': cardClasses,
'data-category': app.category,
'data-update-status': hasUpdate ? 'update-available' : '',
'data-install-status': status.installed ? 'installed' : 'not-installed'
}, [
E('div', { 'class': 'app-icon' }, app.icon || '📦'),
E('div', { 'class': 'app-info' }, [
E('h3', {}, app.name),
E('div', { 'class': 'app-title-row' }, [
E('h3', {}, app.name),
hasUpdate ? E('span', { 'class': 'badge badge-warning update-badge' }, 'Update') : null
]),
E('p', { 'class': 'app-description' }, app.description),
E('div', { 'class': 'app-meta' }, [
E('span', { 'class': 'app-category' }, app.category),
E('span', { 'class': 'app-version' }, 'v' + (app.version || '1.0')),
E('span', {
'class': 'app-version' + (hasUpdate ? ' version-outdated' : ''),
'title': hasUpdate ?
'Installed: ' + updateInfo.installed_version + ' → Available: ' + updateInfo.catalog_version :
''
}, 'v' + (app.pkg_version || app.version || '1.0')),
Components.renderStatusBadge(status.status)
])
]),
E('div', { 'class': 'app-actions' },
status.installed ? [
hasUpdate ? E('button', {
'class': 'btn btn-sm btn-warning',
'click': function() { self.updateApp(app, updateInfo); }
}, 'Update') : null,
E('button', {
'class': 'btn btn-sm btn-secondary',
'click': function() { self.viewChangelog(app); }
}, 'Changelog'),
E('button', {
'class': 'btn btn-sm btn-primary',
'click': function() { self.configureApp(app); }
@ -88,6 +136,10 @@ return view.extend({
'click': function() { self.removeApp(app); }
}, 'Remove')
] : [
E('button', {
'class': 'btn btn-sm btn-secondary',
'click': function() { self.viewChangelog(app); }
}, 'Changelog'),
E('button', {
'class': 'btn btn-sm btn-success',
'click': function() { self.installApp(app); }
@ -188,6 +240,115 @@ return view.extend({
});
},
filterByStatus: function(status) {
var cards = document.querySelectorAll('.app-card');
cards.forEach(function(card) {
if (!status) {
card.style.display = '';
} else if (status === 'update-available') {
card.style.display = card.dataset.updateStatus === 'update-available' ? '' : 'none';
} else if (status === 'installed') {
card.style.display = card.dataset.installStatus === 'installed' ? '' : 'none';
} else if (status === 'not-installed') {
card.style.display = card.dataset.installStatus === 'not-installed' ? '' : 'none';
}
});
},
updateApp: function(app, updateInfo) {
var self = this;
ui.showModal('Update ' + app.name, [
E('p', {}, 'Update ' + app.name + ' from v' +
updateInfo.installed_version + ' to v' + updateInfo.catalog_version + '?'),
updateInfo.changelog ? E('div', { 'class': 'update-changelog' }, [
E('h4', {}, 'What\'s New:'),
E('div', {},
Array.isArray(updateInfo.changelog) ?
E('ul', {},
updateInfo.changelog.map(function(item) {
return E('li', {}, item);
})
) :
E('p', {}, updateInfo.changelog)
)
]) : null,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'btn btn-warning',
'click': function() {
ui.hideModal();
ui.showModal('Updating...', [
Components.renderLoader('Updating ' + app.name + '...')
]);
API.installApp(app.id).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', app.name + ' updated successfully'), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', 'Failed to update ' + app.name), 'error');
}
});
}
}, 'Update')
])
]);
},
viewChangelog: function(app) {
ui.showModal('Changelog: ' + app.name, [
E('p', { 'class': 'spinning' }, 'Loading changelog...')
]);
API.getChangelog(app.id, null, null).then(function(changelog) {
var content = E('div', { 'class': 'changelog-viewer' });
if (changelog && changelog.changelog) {
var versions = Object.keys(changelog.changelog);
versions.forEach(function(version) {
var versionData = changelog.changelog[version];
content.appendChild(E('div', { 'class': 'changelog-version' }, [
E('h4', {}, 'Version ' + version),
versionData.date ? E('p', { 'class': 'changelog-date' }, versionData.date) : null,
E('ul', {},
(versionData.changes || []).map(function(change) {
return E('li', {}, change);
})
)
]));
});
} else if (typeof changelog === 'string') {
content.appendChild(E('pre', {}, changelog));
} else {
content.appendChild(E('p', {}, 'No changelog available'));
}
ui.showModal('Changelog: ' + app.name, [
content,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Close')
])
]);
}).catch(function(err) {
ui.showModal('Changelog: ' + app.name, [
E('p', {}, 'Failed to load changelog: ' + err.message),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, 'Close')
])
]);
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null

View File

@ -0,0 +1,300 @@
'use strict';
'require view';
'require secubox-admin.api as API';
'require secubox-admin.components as Components';
'require ui';
'require poll';
return view.extend({
load: function() {
return Promise.all([
API.getCatalogSources(),
L.resolveDefault(API.checkUpdates(), {})
]);
},
render: function(data) {
var sources = data[0].sources || [];
var updateInfo = data[1];
var self = this;
var container = E('div', { 'class': 'secubox-catalog-sources' }, [
E('link', { 'rel': 'stylesheet',
'href': L.resource('secubox-admin/common.css') }),
E('link', { 'rel': 'stylesheet',
'href': L.resource('secubox-admin/admin.css') }),
E('h2', {}, 'Catalog Sources'),
E('p', {}, 'Manage catalog sources with automatic fallback'),
// Summary stats
E('div', { 'class': 'source-summary' }, [
E('div', { 'class': 'stat-card' }, [
E('div', { 'class': 'stat-label' }, 'Total Sources'),
E('div', { 'class': 'stat-value' }, sources.length.toString())
]),
E('div', { 'class': 'stat-card' }, [
E('div', { 'class': 'stat-label' }, 'Active Source'),
E('div', { 'class': 'stat-value' },
sources.filter(function(s) { return s.active; })[0]?.name || 'None')
]),
E('div', { 'class': 'stat-card' }, [
E('div', { 'class': 'stat-label' }, 'Updates Available'),
E('div', { 'class': 'stat-value' },
(updateInfo.total_updates_available || 0).toString())
])
]),
// Sync controls
E('div', { 'class': 'sync-controls' }, [
E('button', {
'class': 'btn btn-primary',
'click': function() {
self.syncAllSources();
}
}, 'Sync All Sources'),
E('button', {
'class': 'btn btn-secondary',
'click': function() {
self.refreshPage();
}
}, 'Refresh Status')
]),
// Sources list
E('div', { 'class': 'sources-container', 'id': 'sources-container' },
sources
.sort(function(a, b) { return a.priority - b.priority; })
.map(function(source) {
return self.renderSourceCard(source);
})
)
]);
// Auto-refresh every 30 seconds
poll.add(function() {
return API.getCatalogSources().then(function(result) {
var sourcesContainer = document.getElementById('sources-container');
if (sourcesContainer) {
var sources = result.sources || [];
sourcesContainer.innerHTML = '';
sources
.sort(function(a, b) { return a.priority - b.priority; })
.forEach(function(source) {
sourcesContainer.appendChild(self.renderSourceCard(source));
});
}
});
}, 30);
return container;
},
renderSourceCard: function(source) {
var self = this;
var statusClass = this.getStatusClass(source.status);
var statusIcon = this.getStatusIcon(source.status);
return E('div', {
'class': 'source-card' + (source.active ? ' active-source' : ''),
'data-source': source.name
}, [
// Source header
E('div', { 'class': 'source-header' }, [
E('div', { 'class': 'source-title' }, [
E('h3', {}, source.name),
source.active ? E('span', { 'class': 'badge badge-success' }, 'ACTIVE') : null
]),
E('div', { 'class': 'source-priority' },
E('span', { 'class': 'priority-badge' }, 'Priority: ' + source.priority)
)
]),
// Source info
E('div', { 'class': 'source-info' }, [
E('div', { 'class': 'info-row' }, [
E('span', { 'class': 'label' }, 'Type:'),
E('span', { 'class': 'value' }, source.type)
]),
source.url ? E('div', { 'class': 'info-row' }, [
E('span', { 'class': 'label' }, 'URL:'),
E('span', { 'class': 'value url-text' }, source.url)
]) : null,
source.path ? E('div', { 'class': 'info-row' }, [
E('span', { 'class': 'label' }, 'Path:'),
E('span', { 'class': 'value' }, source.path)
]) : null,
E('div', { 'class': 'info-row' }, [
E('span', { 'class': 'label' }, 'Status:'),
E('span', { 'class': 'value' }, [
E('span', { 'class': 'status-indicator ' + statusClass }, statusIcon),
E('span', {}, source.status || 'unknown')
])
]),
source.last_success ? E('div', { 'class': 'info-row' }, [
E('span', { 'class': 'label' }, 'Last Success:'),
E('span', { 'class': 'value' }, this.formatTimestamp(source.last_success))
]) : null
]),
// Source actions
E('div', { 'class': 'source-actions' }, [
E('button', {
'class': 'btn btn-sm btn-primary',
'click': function() {
self.syncSource(source.name);
},
'disabled': !source.enabled
}, 'Sync'),
E('button', {
'class': 'btn btn-sm btn-secondary',
'click': function() {
self.testSource(source.name);
},
'disabled': !source.enabled
}, 'Test'),
!source.active ? E('button', {
'class': 'btn btn-sm btn-warning',
'click': function() {
self.setActiveSource(source.name);
}
}, 'Set Active') : null,
E('button', {
'class': 'btn btn-sm ' + (source.enabled ? 'btn-danger' : 'btn-success'),
'click': function() {
self.toggleSource(source.name, !source.enabled);
}
}, source.enabled ? 'Disable' : 'Enable')
])
]);
},
getStatusClass: function(status) {
switch(status) {
case 'online':
case 'success':
case 'available':
return 'status-success';
case 'offline':
case 'error':
return 'status-error';
default:
return 'status-warning';
}
},
getStatusIcon: function(status) {
switch(status) {
case 'online':
case 'success':
case 'available':
return '✓';
case 'offline':
case 'error':
return '✗';
default:
return '?';
}
},
formatTimestamp: function(timestamp) {
if (!timestamp) return 'Never';
var date = new Date(timestamp);
var now = new Date();
var diffMs = now - date;
var diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return diffMins + ' minutes ago';
var diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return diffHours + ' hours ago';
var diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return diffDays + ' days ago';
return date.toLocaleDateString();
},
syncSource: function(sourceName) {
ui.showModal(_('Syncing Catalog'), [
E('p', { 'class': 'spinning' }, _('Syncing from source: %s...').format(sourceName))
]);
API.syncCatalog(sourceName).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Catalog synced successfully from: %s').format(sourceName)), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', _('Sync failed: %s').format(result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Sync error: %s').format(err.message)), 'error');
});
},
syncAllSources: function() {
ui.showModal(_('Syncing Catalogs'), [
E('p', { 'class': 'spinning' }, _('Syncing from all enabled sources...'))
]);
API.syncCatalog(null).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Catalogs synced successfully')), 'success');
window.location.reload();
} else {
ui.addNotification(null, E('p', _('Sync failed: %s').format(result.error || 'Unknown error')), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Sync error: %s').format(err.message)), 'error');
});
},
testSource: function(sourceName) {
ui.addNotification(null, E('p', _('Testing source: %s...').format(sourceName)), 'info');
// Test is done by attempting a sync
this.syncSource(sourceName);
},
setActiveSource: function(sourceName) {
ui.showModal(_('Setting Active Source'), [
E('p', { 'class': 'spinning' }, _('Setting active source to: %s...').format(sourceName))
]);
API.setCatalogSource(sourceName).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null, E('p', _('Active source set to: %s').format(sourceName)), 'success');
// Trigger sync from new source
return API.syncCatalog(sourceName);
} else {
throw new Error(result.error || 'Failed to set source');
}
}).then(function() {
window.location.reload();
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: %s').format(err.message)), 'error');
});
},
toggleSource: function(sourceName, enable) {
ui.addNotification(null,
E('p', _('%s source: %s').format(enable ? 'Enabling' : 'Disabling', sourceName)),
'info'
);
// TODO: Implement UCI config update to enable/disable source
},
refreshPage: function() {
window.location.reload();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -0,0 +1,352 @@
'use strict';
'require view';
'require secubox-admin.api as API';
'require secubox-admin.components as Components';
'require ui';
'require poll';
return view.extend({
load: function() {
return Promise.all([
API.checkUpdates(),
API.getApps(),
API.getModules()
]);
},
render: function(data) {
var updateData = data[0] || {};
var apps = data[1].apps || [];
var modules = data[2].modules || {};
var self = this;
// Filter apps that have updates available
var updatesAvailable = updateData.updates || [];
var totalUpdates = updatesAvailable.length;
var container = E('div', { 'class': 'secubox-updates' }, [
E('link', { 'rel': 'stylesheet',
'href': L.resource('secubox-admin/common.css') }),
E('link', { 'rel': 'stylesheet',
'href': L.resource('secubox-admin/admin.css') }),
E('h2', {}, 'Available Updates'),
E('p', {}, 'Review and install available updates for SecuBox applications'),
// Summary header
E('div', { 'class': 'updates-summary' }, [
E('div', { 'class': 'summary-card' }, [
E('div', { 'class': 'summary-icon' }, '📦'),
E('div', { 'class': 'summary-info' }, [
E('div', { 'class': 'summary-count' }, totalUpdates.toString()),
E('div', { 'class': 'summary-label' }, totalUpdates === 1 ? 'Update Available' : 'Updates Available')
])
]),
E('div', { 'class': 'summary-actions' }, [
totalUpdates > 0 ? E('button', {
'class': 'btn btn-primary',
'click': function() {
self.updateAllApps(updatesAvailable);
}
}, 'Update All') : null,
E('button', {
'class': 'btn btn-secondary',
'click': function() {
self.checkForUpdates();
}
}, 'Check for Updates')
])
]),
// Updates list
totalUpdates > 0 ?
E('div', { 'class': 'updates-list', 'id': 'updates-list' },
updatesAvailable.map(function(update) {
// Find full app details from catalog
var app = apps.find(function(a) { return a.id === update.app_id; });
return self.renderUpdateCard(update, app);
})
) :
E('div', { 'class': 'no-updates' }, [
E('div', { 'class': 'no-updates-icon' }, '✓'),
E('h3', {}, 'All applications are up to date'),
E('p', {}, 'Check back later for new updates or click "Check for Updates" to refresh.')
])
]);
// Auto-refresh every 60 seconds
poll.add(function() {
return API.checkUpdates().then(function(result) {
var updatesList = document.getElementById('updates-list');
if (updatesList && result.updates) {
// Only update if count changed
if (result.updates.length !== totalUpdates) {
window.location.reload();
}
}
});
}, 60);
return container;
},
renderUpdateCard: function(update, app) {
var self = this;
if (!app) {
// App not found in catalog, show minimal info
app = {
id: update.app_id,
name: update.app_id,
description: 'Application from catalog',
icon: '📦'
};
}
return E('div', { 'class': 'update-card', 'data-app-id': update.app_id }, [
// App header
E('div', { 'class': 'update-header' }, [
E('div', { 'class': 'app-icon-large' }, app.icon || '📦'),
E('div', { 'class': 'app-title' }, [
E('h3', {}, app.name),
E('p', { 'class': 'app-category' }, app.category || 'Application')
]),
E('div', { 'class': 'update-badge' }, [
E('span', { 'class': 'badge badge-warning' }, 'UPDATE AVAILABLE')
])
]),
// Version info
E('div', { 'class': 'version-info' }, [
E('div', { 'class': 'version-row' }, [
E('span', { 'class': 'version-label' }, 'Current Version:'),
E('span', { 'class': 'version-value current' }, update.installed_version || 'Unknown')
]),
E('div', { 'class': 'version-arrow' }, '→'),
E('div', { 'class': 'version-row' }, [
E('span', { 'class': 'version-label' }, 'New Version:'),
E('span', { 'class': 'version-value new' }, update.catalog_version || 'Unknown')
])
]),
// Changelog section
update.changelog ? E('div', { 'class': 'changelog-section' }, [
E('h4', {}, 'What\'s New'),
E('div', { 'class': 'changelog-content' },
this.renderChangelog(update.changelog)
)
]) : null,
// Update info
E('div', { 'class': 'update-meta' }, [
update.release_date ? E('div', { 'class': 'meta-item' }, [
E('span', { 'class': 'meta-label' }, 'Release Date:'),
E('span', { 'class': 'meta-value' }, update.release_date)
]) : null,
update.download_size ? E('div', { 'class': 'meta-item' }, [
E('span', { 'class': 'meta-label' }, 'Download Size:'),
E('span', { 'class': 'meta-value' }, API.formatBytes(update.download_size))
]) : null
]),
// Actions
E('div', { 'class': 'update-actions' }, [
E('button', {
'class': 'btn btn-primary',
'click': function() {
self.updateApp(update.app_id, app.name);
}
}, 'Update Now'),
E('button', {
'class': 'btn btn-secondary',
'click': function() {
self.viewFullChangelog(update.app_id, update.installed_version, update.catalog_version);
}
}, 'View Full Changelog'),
E('button', {
'class': 'btn btn-tertiary',
'click': function() {
self.skipUpdate(update.app_id);
}
}, 'Skip This Version')
])
]);
},
renderChangelog: function(changelog) {
if (typeof changelog === 'string') {
return E('p', {}, changelog);
}
if (Array.isArray(changelog)) {
return E('ul', { 'class': 'changelog-list' },
changelog.map(function(item) {
return E('li', {}, item);
})
);
}
// Object with version keys
var versions = Object.keys(changelog);
if (versions.length > 0) {
var latestVersion = versions[0];
var changes = changelog[latestVersion].changes || [];
return E('ul', { 'class': 'changelog-list' },
changes.map(function(item) {
return E('li', {}, item);
})
);
}
return E('p', {}, 'No changelog available');
},
updateApp: function(appId, appName) {
ui.showModal(_('Updating Application'), [
E('p', { 'class': 'spinning' }, _('Updating %s...').format(appName))
]);
API.installApp(appId).then(function(result) {
ui.hideModal();
if (result.success) {
ui.addNotification(null,
E('p', _('%s updated successfully').format(appName)),
'success'
);
// Refresh the page to show updated status
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
ui.addNotification(null,
E('p', _('Update failed: %s').format(result.error || 'Unknown error')),
'error'
);
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null,
E('p', _('Update error: %s').format(err.message)),
'error'
);
});
},
updateAllApps: function(updates) {
if (updates.length === 0) return;
ui.showModal(_('Updating Applications'), [
E('p', { 'class': 'spinning' }, _('Updating %d applications...').format(updates.length)),
E('p', { 'id': 'update-progress' }, _('Preparing...'))
]);
var self = this;
var currentIndex = 0;
function updateNext() {
if (currentIndex >= updates.length) {
ui.hideModal();
ui.addNotification(null,
E('p', _('All applications updated successfully')),
'success'
);
setTimeout(function() {
window.location.reload();
}, 1000);
return;
}
var update = updates[currentIndex];
var progressEl = document.getElementById('update-progress');
if (progressEl) {
progressEl.textContent = _('Updating %s (%d/%d)...')
.format(update.app_id, currentIndex + 1, updates.length);
}
API.installApp(update.app_id).then(function(result) {
currentIndex++;
updateNext();
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null,
E('p', _('Failed to update %s: %s').format(update.app_id, err.message)),
'error'
);
});
}
updateNext();
},
viewFullChangelog: function(appId, fromVersion, toVersion) {
API.getChangelog(appId, fromVersion, toVersion).then(function(changelog) {
var content = E('div', { 'class': 'changelog-modal' }, [
E('h3', {}, _('Changelog for %s').format(appId)),
E('div', { 'class': 'version-range' },
_('Changes from %s to %s').format(fromVersion, toVersion)
),
E('div', { 'class': 'changelog-full' },
// Render full changelog
typeof changelog === 'string' ?
E('pre', {}, changelog) :
JSON.stringify(changelog, null, 2)
)
]);
ui.showModal(_('Full Changelog'), [
content,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': function() {
ui.hideModal();
}
}, _('Close'))
])
]);
}).catch(function(err) {
ui.addNotification(null,
E('p', _('Failed to load changelog: %s').format(err.message)),
'error'
);
});
},
skipUpdate: function(appId) {
// TODO: Implement skip version functionality
// This would mark the version as skipped in metadata
ui.addNotification(null,
E('p', _('Skipped update for: %s').format(appId)),
'info'
);
},
checkForUpdates: function() {
ui.showModal(_('Checking for Updates'), [
E('p', { 'class': 'spinning' }, _('Checking for available updates...'))
]);
// Sync catalog first, then check for updates
API.syncCatalog(null).then(function() {
return API.checkUpdates();
}).then(function(result) {
ui.hideModal();
ui.addNotification(null,
E('p', _('Update check complete. Found %d updates.')
.format((result.updates || []).length)),
'success'
);
window.location.reload();
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null,
E('p', _('Update check failed: %s').format(err.message)),
'error'
);
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@ -22,6 +22,22 @@
"path": "secubox-admin/apps"
}
},
"admin/secubox/admin/updates": {
"title": "Updates",
"order": 25,
"action": {
"type": "view",
"path": "secubox-admin/updates"
}
},
"admin/secubox/admin/catalog-sources": {
"title": "Catalog Sources",
"order": 27,
"action": {
"type": "view",
"path": "secubox-admin/catalog-sources"
}
},
"admin/secubox/admin/settings": {
"title": "App Settings",
"order": 30,

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-core
PKG_VERSION:=0.8.0
PKG_RELEASE:=5
PKG_RELEASE:=6
PKG_ARCH:=all
PKG_LICENSE:=GPL-2.0
PKG_MAINTAINER:=SecuBox Team
@ -17,7 +17,7 @@ define Package/secubox-core
SECTION:=admin
CATEGORY:=Administration
TITLE:=SecuBox Core Framework
DEPENDS:=+libubox +libubus +libuci +rpcd +bash +coreutils-base64 +jsonfilter
DEPENDS:=+libubox +libubus +libuci +rpcd +bash +coreutils-base64 +jsonfilter +jq
PKGARCH:=all
endef
@ -33,6 +33,7 @@ endef
define Package/secubox-core/conffiles
/etc/config/secubox
/etc/config/secubox-appstore
/etc/secubox/profiles/
/etc/secubox/templates/
/etc/secubox/macros/
@ -44,6 +45,7 @@ endef
define Package/secubox-core/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/secubox $(1)/etc/config/
$(INSTALL_CONF) ./root/etc/config/secubox-appstore $(1)/etc/config/
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./root/etc/init.d/secubox-core $(1)/etc/init.d/
@ -65,6 +67,7 @@ define Package/secubox-core/install
$(INSTALL_BIN) ./root/usr/sbin/secubox $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-core $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-appstore $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-catalog-sync $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-profile $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-diagnostics $(1)/usr/sbin/
$(INSTALL_BIN) ./root/usr/sbin/secubox-recovery $(1)/usr/sbin/
@ -81,6 +84,7 @@ define Package/secubox-core/install
# Install main catalog files (REQUIRED for AppStore)
-$(INSTALL_DATA) ./root/usr/share/secubox/catalog.json $(1)/usr/share/secubox/ 2>/dev/null || true
-$(INSTALL_DATA) ./root/usr/share/secubox/catalog-stats.json $(1)/usr/share/secubox/ 2>/dev/null || true
-$(INSTALL_DATA) ./root/usr/share/secubox/catalog-metadata.json.example $(1)/usr/share/secubox/ 2>/dev/null || true
# Install individual module catalog files
-$(INSTALL_DATA) ./root/usr/share/secubox/plugins/catalog/*.json $(1)/usr/share/secubox/plugins/catalog/ 2>/dev/null || true
@ -89,6 +93,12 @@ endef
define Package/secubox-core/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
# Create catalog cache directories
mkdir -p /var/cache/secubox/catalogs
mkdir -p /var/lib/secubox
chmod 755 /var/cache/secubox/catalogs
chmod 700 /var/lib/secubox
/etc/init.d/secubox-core enable
/etc/init.d/secubox-core start

View File

@ -0,0 +1,43 @@
config settings 'main'
option enabled '1'
option auto_sync '0'
option sync_interval '86400'
option force_source ''
option check_updates_on_boot '1'
option notify_updates '1'
config source 'github'
option enabled '1'
option type 'remote'
option url 'https://raw.githubusercontent.com/cybermind-studios/secubox-catalog/main/catalog.json'
option priority '1'
option timeout '30'
option verify_ssl '1'
config source 'local_web'
option enabled '0'
option type 'remote'
option url 'http://192.168.1.100/secubox/catalog.json'
option priority '2'
option timeout '10'
option verify_ssl '0'
config source 'usb'
option enabled '1'
option type 'local'
option path '/mnt/usb/secubox/catalog.json'
option priority '3'
option auto_mount_check '1'
config source 'embedded'
option enabled '1'
option type 'embedded'
option path '/usr/share/secubox/catalog.json'
option priority '999'
config updates 'check'
option enabled '1'
option notify_method 'ui'
option check_interval '3600'
option auto_install '0'

View File

@ -104,6 +104,37 @@ case "$1" in
json_add_string "app_id" "string"
json_close_object
# Catalog source management
json_add_object "get_catalog_sources"
json_close_object
json_add_object "set_catalog_source"
json_add_string "source" "string"
json_close_object
json_add_object "sync_catalog"
json_add_string "source" "string"
json_close_object
# Version and update management
json_add_object "check_updates"
json_close_object
json_add_object "get_app_versions"
json_add_string "app_id" "string"
json_close_object
json_add_object "get_changelog"
json_add_string "app_id" "string"
json_add_string "from_version" "string"
json_add_string "to_version" "string"
json_close_object
# Widget data
json_add_object "get_widget_data"
json_add_string "app_id" "string"
json_close_object
# Dashboard and monitoring
json_add_object "get_dashboard_data"
json_close_object
@ -439,6 +470,186 @@ case "$1" in
fi
;;
get_catalog_sources)
# Return configured catalog sources from UCI
CONFIG_NAME="secubox-appstore"
METADATA_FILE="/var/lib/secubox/catalog-metadata.json"
json_init
json_add_array "sources"
# Parse UCI config sources
. /lib/functions.sh
config_load "$CONFIG_NAME"
_add_source_info() {
local section="$1"
local enabled type url path priority active_source
config_get_bool enabled "$section" enabled 0
config_get type "$section" type
config_get url "$section" url
config_get path "$section" path
config_get priority "$section" priority 999
# Get active source from metadata
if [ -f "$METADATA_FILE" ]; then
active_source=$(jsonfilter -i "$METADATA_FILE" -e '@.active_source' 2>/dev/null)
fi
json_add_object ""
json_add_string "name" "$section"
json_add_boolean "enabled" "$enabled"
json_add_string "type" "$type"
[ -n "$url" ] && json_add_string "url" "$url"
[ -n "$path" ] && json_add_string "path" "$path"
json_add_int "priority" "$priority"
json_add_boolean "active" "$([ "$section" = "$active_source" ] && echo 1 || echo 0)"
# Get status from metadata
if [ -f "$METADATA_FILE" ]; then
local status=$(jsonfilter -i "$METADATA_FILE" -e "@.sources['$section'].status" 2>/dev/null)
local last_success=$(jsonfilter -i "$METADATA_FILE" -e "@.sources['$section'].last_success" 2>/dev/null)
[ -n "$status" ] && json_add_string "status" "$status"
[ -n "$last_success" ] && json_add_string "last_success" "$last_success"
fi
json_close_object
}
config_foreach _add_source_info source
json_close_array
json_dump
;;
set_catalog_source)
# Set force_source in UCI config
read -r input
source=$(echo "$input" | jsonfilter -e '@.source')
CONFIG_NAME="secubox-appstore"
if [ -n "$source" ]; then
uci set "${CONFIG_NAME}.settings.force_source=$source"
uci commit "$CONFIG_NAME"
json_init
json_add_boolean "success" true
json_add_string "message" "Catalog source set to: $source"
json_dump
else
json_init
json_add_boolean "success" false
json_add_string "error" "No source specified"
json_dump
fi
;;
sync_catalog)
# Trigger catalog sync
read -r input
source=$(echo "$input" | jsonfilter -e '@.source')
# Call secubox-catalog-sync (with or without source)
if /usr/sbin/secubox-appstore sync ${source:+"$source"} 2>&1; then
json_init
json_add_boolean "success" true
json_add_string "message" "Catalog synced successfully"
[ -n "$source" ] && json_add_string "source" "$source"
json_dump
else
json_init
json_add_boolean "success" false
json_add_string "error" "Sync failed"
json_dump
fi
;;
check_updates)
# Check for available updates
/usr/sbin/secubox-appstore check-updates --json
;;
get_app_versions)
# Get version info for specific app
read -r input
app_id=$(echo "$input" | jsonfilter -e '@.app_id')
CATALOG_FILE="/usr/share/secubox/catalog.json"
METADATA_FILE="/var/lib/secubox/catalog-metadata.json"
json_init
# Get catalog version
if [ -f "$CATALOG_FILE" ]; then
pkg_version=$(jsonfilter -i "$CATALOG_FILE" -e "@.plugins[@.id='$app_id'].pkg_version" 2>/dev/null)
app_version=$(jsonfilter -i "$CATALOG_FILE" -e "@.plugins[@.id='$app_id'].app_version" 2>/dev/null)
[ -n "$pkg_version" ] && json_add_string "catalog_pkg_version" "$pkg_version"
[ -n "$app_version" ] && json_add_string "catalog_app_version" "$app_version"
fi
# Get installed version
pkg_name=$(jsonfilter -i "$CATALOG_FILE" -e "@.plugins[@.id='$app_id'].packages.required[0]" 2>/dev/null)
if [ -n "$pkg_name" ]; then
installed_version=$(opkg list-installed | grep "^$pkg_name " | awk '{print $3}')
[ -n "$installed_version" ] && json_add_string "installed_version" "$installed_version"
fi
# Get metadata version info
if [ -f "$METADATA_FILE" ]; then
metadata_version=$(jsonfilter -i "$METADATA_FILE" -e "@.installed_apps['$app_id'].installed_version" 2>/dev/null)
update_available=$(jsonfilter -i "$METADATA_FILE" -e "@.installed_apps['$app_id'].update_available" 2>/dev/null)
[ -n "$update_available" ] && json_add_boolean "update_available" "$update_available"
fi
json_add_string "app_id" "$app_id"
json_dump
;;
get_changelog)
# Get changelog for app
read -r input
app_id=$(echo "$input" | jsonfilter -e '@.app_id')
from_version=$(echo "$input" | jsonfilter -e '@.from_version')
to_version=$(echo "$input" | jsonfilter -e '@.to_version')
# Use secubox-appstore CLI
/usr/sbin/secubox-appstore changelog "$app_id" ${from_version:+"$from_version"} ${to_version:+"$to_version"}
;;
get_widget_data)
# Get real-time widget data for app
read -r input
app_id=$(echo "$input" | jsonfilter -e '@.app_id')
CATALOG_FILE="/usr/share/secubox/catalog.json"
json_init
json_add_string "app_id" "$app_id"
json_add_int "timestamp" "$(date +%s)"
# Get widget configuration from catalog
if [ -f "$CATALOG_FILE" ]; then
widget_enabled=$(jsonfilter -i "$CATALOG_FILE" -e "@.plugins[@.id='$app_id'].widget.enabled" 2>/dev/null)
if [ "$widget_enabled" = "true" ]; then
json_add_boolean "widget_enabled" true
# Get metrics from catalog definition
# This would call app-specific data sources (ubus, files, etc.)
# For now, return placeholder structure
json_add_array "metrics"
json_close_array
else
json_add_boolean "widget_enabled" false
fi
else
json_add_boolean "widget_enabled" false
fi
json_dump
;;
*)
json_init
json_add_boolean "error" true

View File

@ -11,6 +11,22 @@
CATALOG_DIR="/usr/share/secubox/plugins/catalog"
REMOTE_CATALOG="/tmp/secubox/remote-catalog"
STATE_DIR="/var/run/secubox"
CACHE_DIR="/var/cache/secubox/catalogs"
METADATA_FILE="/var/lib/secubox/catalog-metadata.json"
MAIN_CATALOG="/usr/share/secubox/catalog.json"
# Get active catalog path (from cache or embedded)
get_active_catalog() {
if [ -f "$METADATA_FILE" ]; then
local active_source=$(jsonfilter -i "$METADATA_FILE" -e '@.active_source' 2>/dev/null)
if [ -n "$active_source" ] && [ -f "$CACHE_DIR/${active_source}.json" ]; then
echo "$CACHE_DIR/${active_source}.json"
return
fi
fi
# Fallback to embedded
echo "$MAIN_CATALOG"
}
# List all modules
list_modules() {
@ -343,6 +359,140 @@ check_health() {
echo "Total modules: $total | Healthy: $healthy | Unhealthy: $unhealthy"
}
# Sync catalog from sources
sync_catalog() {
local source="$1"
echo "Syncing catalog..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -n "$source" ]; then
/usr/sbin/secubox-catalog-sync --force "$source"
else
/usr/sbin/secubox-catalog-sync
fi
local result=$?
if [ $result -eq 0 ]; then
echo "✓ Catalog synced successfully"
return 0
else
echo "✗ Catalog sync failed"
return 1
fi
}
# Check for updates
check_updates() {
local format="${1:-table}"
local active_catalog=$(get_active_catalog)
if [ ! -f "$active_catalog" ]; then
echo "ERROR: No catalog available. Run 'secubox-appstore sync' first."
return 1
fi
if [ "$format" = "--json" ] || [ "$format" = "json" ]; then
json_init
json_add_array "updates"
else
echo "Checking for updates..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "%-25s %-15s %-15s %-10s\n" "APP" "INSTALLED" "AVAILABLE" "STATUS"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
local updates_count=0
# Iterate over plugins in catalog
for plugin in $(jsonfilter -i "$active_catalog" -e '@.plugins[@.id]'); do
local app_id="$plugin"
local pkg_version=$(jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].pkg_version" 2>/dev/null)
local pkg_name=$(jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].packages.required[0]" 2>/dev/null)
# Check if installed
local installed_version=$(opkg list-installed | grep "^$pkg_name " | awk '{print $3}')
if [ -n "$installed_version" ]; then
# Compare versions
if [ "$installed_version" != "$pkg_version" ]; then
if opkg compare-versions "$pkg_version" '>>' "$installed_version" 2>/dev/null; then
updates_count=$((updates_count + 1))
if [ "$format" = "--json" ] || [ "$format" = "json" ]; then
json_add_object ""
json_add_string "app_id" "$app_id"
json_add_string "installed_version" "$installed_version"
json_add_string "available_version" "$pkg_version"
json_add_string "type" "upgrade"
json_close_object
else
printf "%-25s %-15s %-15s %-10s\n" \
"$app_id" "$installed_version" "$pkg_version" "UPDATE"
fi
fi
fi
fi
done
if [ "$format" = "--json" ] || [ "$format" = "json" ]; then
json_close_array
json_add_int "total_updates" "$updates_count"
json_dump
else
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Total updates available: $updates_count"
fi
}
# Get changelog for app
get_changelog() {
local app_id="$1"
local from_version="$2"
local to_version="$3"
local active_catalog=$(get_active_catalog)
if [ ! -f "$active_catalog" ]; then
echo "ERROR: No catalog available"
return 1
fi
echo "Changelog for $app_id"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Extract changelog from catalog
local changelog=$(jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].changelog")
if [ -z "$changelog" ]; then
echo "No changelog available for $app_id"
return 1
fi
# If specific version requested
if [ -n "$to_version" ]; then
local version_changelog=$(jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].changelog['$to_version']")
if [ -n "$version_changelog" ]; then
echo "Version: $to_version"
echo "Date: $(jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].changelog['$to_version'].date")"
echo ""
echo "Changes:"
jsonfilter -i "$active_catalog" -e "@.plugins[@.id='$app_id'].changelog['$to_version'].changes[@]" | \
while read -r change; do
echo " • $change"
done
fi
else
# Show all versions
echo "Full changelog:"
echo "$changelog" | jq -r 'to_entries[] | "\nVersion: \(.key)\nDate: \(.value.date)\nChanges:\n" + ([.value.changes[] | " • \(.)"] | join("\n"))'
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
}
# Main command router
case "$1" in
list)
@ -368,8 +518,32 @@ case "$1" in
health)
check_health
;;
sync)
shift
sync_catalog "$@"
;;
check-updates)
shift
check_updates "$@"
;;
changelog)
shift
get_changelog "$@"
;;
*)
echo "Usage: $0 {list|info|search|install|remove|update|health} [args]"
echo "Usage: $0 {list|info|search|install|remove|update|health|sync|check-updates|changelog} [args]"
echo ""
echo "Commands:"
echo " list [--json] List all modules"
echo " info <module> Show module information"
echo " search <query> Search for modules"
echo " install <module> Install a module"
echo " remove <module> Remove a module"
echo " update [module] Update module(s)"
echo " health Check module health"
echo " sync [source] Sync catalog from sources"
echo " check-updates [--json] Check for available updates"
echo " changelog <app> [version] Show app changelog"
exit 1
;;
esac

View File

@ -0,0 +1,363 @@
#!/bin/bash
#
# SecuBox Catalog Sync - Multi-source catalog management with fallback
#
. /usr/share/libubox/jshn.sh
. /lib/functions.sh
CACHE_DIR="/var/cache/secubox/catalogs"
METADATA_FILE="/var/lib/secubox/catalog-metadata.json"
EMBEDDED_CATALOG="/usr/share/secubox/catalog.json"
CONFIG_NAME="secubox-appstore"
# Logging
log() {
logger -t secubox-catalog-sync "$@"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $@" >&2
}
# Initialize directories
init_dirs() {
mkdir -p "$CACHE_DIR"
mkdir -p "$(dirname "$METADATA_FILE")"
# Set permissions
chmod 755 "$CACHE_DIR"
chmod 700 "$(dirname "$METADATA_FILE")"
}
# Get sources ordered by priority
get_sources_by_priority() {
local sources_list=""
config_load "$CONFIG_NAME"
_add_source() {
local section="$1"
local enabled priority name type
config_get_bool enabled "$section" enabled 0
[ "$enabled" -eq 0 ] && return
config_get priority "$section" priority 999
config_get type "$section" type
# Skip embedded for now if not only option
[ "$type" = "embedded" ] && [ -n "$sources_list" ] && return
sources_list="${sources_list}${priority}:${section}\n"
}
config_foreach _add_source source
# Sort by priority and extract source names
echo -e "$sources_list" | sort -n | cut -d':' -f2
}
# Get ETag for source from metadata
get_etag() {
local source="$1"
if [ -f "$METADATA_FILE" ]; then
jsonfilter -i "$METADATA_FILE" -e "@.sources['$source'].etag" 2>/dev/null || echo ""
fi
}
# Update metadata for source
update_metadata() {
local source="$1"
local status="$2"
local action="$3"
local etag="$4"
local catalog_version="$5"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Create metadata structure if not exists
if [ ! -f "$METADATA_FILE" ]; then
cat > "$METADATA_FILE" <<EOF
{
"schema_version": "1.0",
"active_source": "",
"last_sync": "",
"sync_status": "",
"sources": {},
"installed_apps": {},
"update_stats": {
"total_updates_available": 0
}
}
EOF
fi
# Update using jq (more reliable than manual JSON manipulation)
if command -v jq >/dev/null 2>&1; then
local temp_file="${METADATA_FILE}.tmp"
jq --arg source "$source" \
--arg status "$status" \
--arg timestamp "$timestamp" \
--arg etag "$etag" \
--arg version "$catalog_version" \
'.sources[$source] = {
"last_attempt": $timestamp,
"last_success": (if $status == "success" then $timestamp else .sources[$source].last_success end),
"status": $status,
"etag": $etag,
"catalog_version": $version
}' "$METADATA_FILE" > "$temp_file"
mv "$temp_file" "$METADATA_FILE"
chmod 600 "$METADATA_FILE"
fi
log "Updated metadata for $source: $status ($action)"
}
# Set active source in metadata
set_active_source() {
local source="$1"
if command -v jq >/dev/null 2>&1 && [ -f "$METADATA_FILE" ]; then
local temp_file="${METADATA_FILE}.tmp"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
jq --arg source "$source" \
--arg timestamp "$timestamp" \
'.active_source = $source | .last_sync = $timestamp | .sync_status = "success"' \
"$METADATA_FILE" > "$temp_file"
mv "$temp_file" "$METADATA_FILE"
fi
log "Set active source: $source"
}
# Sync from remote URL
sync_remote() {
local source="$1"
local url="$2"
local timeout="$3"
local verify_ssl="$4"
local output="$CACHE_DIR/${source}.json"
local temp="/tmp/catalog-sync-$$"
log "Syncing from $source: $url"
# Determine download tool (prefer uclient-fetch, fallback to wget/curl)
local download_cmd=""
local etag=$(get_etag "$source")
if command -v uclient-fetch >/dev/null 2>&1; then
download_cmd="uclient-fetch --timeout=$timeout"
[ "$verify_ssl" -eq 0 ] && download_cmd="$download_cmd --no-check-certificate"
[ -n "$etag" ] && download_cmd="$download_cmd --header='If-None-Match: $etag'"
download_cmd="$download_cmd -O $temp $url"
elif command -v wget >/dev/null 2>&1; then
download_cmd="wget --timeout=$timeout --tries=3"
[ "$verify_ssl" -eq 0 ] && download_cmd="$download_cmd --no-check-certificate"
download_cmd="$download_cmd -O $temp $url"
elif command -v curl >/dev/null 2>&1; then
download_cmd="curl --max-time $timeout --retry 3"
[ "$verify_ssl" -eq 0 ] && download_cmd="$download_cmd --insecure"
download_cmd="$download_cmd -o $temp $url"
else
log "ERROR: No download tool available (uclient-fetch, wget, curl)"
return 1
fi
# Execute download
if eval "$download_cmd" 2>&1 | grep -q "304 Not Modified"; then
log "Catalog unchanged (304 Not Modified)"
update_metadata "$source" "success" "cached" "$etag" ""
return 0
fi
# Check if download succeeded
if [ -f "$temp" ] && [ -s "$temp" ]; then
# Validate JSON
if jsonfilter -i "$temp" -e '@.version' >/dev/null 2>&1; then
local catalog_version=$(jsonfilter -i "$temp" -e '@.version' 2>/dev/null || echo "unknown")
# Extract new ETag if available (from HTTP headers - requires modification)
local new_etag="$etag" # For now, keep existing
mv "$temp" "$output"
chmod 644 "$output"
update_metadata "$source" "success" "downloaded" "$new_etag" "$catalog_version"
log "Sync successful from $source (version: $catalog_version)"
return 0
else
log "ERROR: Invalid JSON from $source"
rm -f "$temp"
update_metadata "$source" "error" "invalid_json" "" ""
return 1
fi
else
log "ERROR: Failed to download from $source"
update_metadata "$source" "error" "download_failed" "" ""
return 1
fi
}
# Sync from local path
sync_local() {
local source="$1"
local path="$2"
local output="$CACHE_DIR/${source}.json"
log "Syncing from local: $path"
if [ -f "$path" ]; then
# Validate JSON
if jsonfilter -i "$path" -e '@.version' >/dev/null 2>&1; then
local catalog_version=$(jsonfilter -i "$path" -e '@.version' 2>/dev/null || echo "unknown")
cp "$path" "$output"
chmod 644 "$output"
update_metadata "$source" "success" "copied" "" "$catalog_version"
log "Sync successful from local: $path (version: $catalog_version)"
return 0
else
log "ERROR: Invalid JSON at $path"
update_metadata "$source" "error" "invalid_json" "" ""
return 1
fi
else
log "ERROR: Local catalog not found: $path"
update_metadata "$source" "error" "not_found" "" ""
return 1
fi
}
# Sync from embedded catalog
sync_embedded() {
local output="$CACHE_DIR/embedded.json"
if [ -f "$EMBEDDED_CATALOG" ]; then
local catalog_version=$(jsonfilter -i "$EMBEDDED_CATALOG" -e '@.version' 2>/dev/null || echo "unknown")
cp "$EMBEDDED_CATALOG" "$output"
chmod 644 "$output"
update_metadata "embedded" "success" "copied" "" "$catalog_version"
log "Using embedded catalog (version: $catalog_version)"
return 0
else
log "ERROR: Embedded catalog not found"
return 1
fi
}
# Attempt sync from a source
attempt_sync() {
local source="$1"
local type url path timeout verify_ssl
config_get type "$source" type
case "$type" in
remote)
config_get url "$source" url
config_get timeout "$source" timeout 30
config_get_bool verify_ssl "$source" verify_ssl 1
sync_remote "$source" "$url" "$timeout" "$verify_ssl"
;;
local)
config_get path "$source" path
sync_local "$source" "$path"
;;
embedded)
sync_embedded
;;
*)
log "ERROR: Unknown source type: $type"
return 1
;;
esac
}
# Main sync logic with fallback
main() {
init_dirs
config_load "$CONFIG_NAME"
local force_source
config_get force_source settings force_source
# Check if forced to specific source
if [ -n "$force_source" ]; then
log "Forcing sync from: $force_source"
if attempt_sync "$force_source"; then
set_active_source "$force_source"
exit 0
else
log "ERROR: Forced source $force_source failed"
exit 1
fi
fi
# Try sources by priority with fallback
log "Starting catalog sync with automatic fallback..."
for source in $(get_sources_by_priority); do
log "Trying source: $source (priority-based)"
if attempt_sync "$source"; then
set_active_source "$source"
log "SUCCESS: Catalog synced from $source"
exit 0
else
log "FAILED: Source $source unavailable, trying next..."
fi
done
# All sources failed - try embedded as last resort
log "WARNING: All configured sources failed, using embedded catalog"
if sync_embedded; then
set_active_source "embedded"
exit 0
fi
log "ERROR: All sources failed including embedded catalog"
exit 1
}
# Usage
usage() {
cat <<EOF
Usage: secubox-catalog-sync [options]
Options:
-f, --force <source> Force sync from specific source
-h, --help Show this help message
Examples:
secubox-catalog-sync # Auto-fallback sync
secubox-catalog-sync --force github # Force GitHub source
EOF
exit 0
}
# Parse arguments
case "${1:-}" in
-h|--help)
usage
;;
-f|--force)
shift
# Temporarily set force_source
uci set ${CONFIG_NAME}.settings.force_source="$1"
main
uci delete ${CONFIG_NAME}.settings.force_source
uci commit ${CONFIG_NAME}
;;
*)
main "$@"
;;
esac

View File

@ -0,0 +1,58 @@
{
"schema_version": "1.0",
"active_source": "github",
"last_sync": "2026-01-04T12:34:56Z",
"sync_status": "success",
"sources": {
"github": {
"last_attempt": "2026-01-04T12:34:56Z",
"last_success": "2026-01-04T12:34:56Z",
"status": "online",
"etag": "W/\"abc123def456\"",
"catalog_version": "1.0.0",
"total_apps": 37,
"failure_count": 0
},
"local_web": {
"last_attempt": "2026-01-04T10:00:00Z",
"last_success": null,
"status": "offline",
"error": "Connection timeout",
"failure_count": 3
},
"usb": {
"last_attempt": "2026-01-04T12:30:00Z",
"last_success": "2026-01-04T12:30:00Z",
"status": "available",
"catalog_version": "0.9.0",
"mount_path": "/mnt/sda1"
},
"embedded": {
"last_attempt": null,
"last_success": null,
"status": "available",
"catalog_version": "1.0.0"
}
},
"installed_apps": {
"luci-app-auth-guardian": {
"installed_version": "0.3.0-1",
"catalog_version": "0.4.0-2",
"update_available": true,
"last_check": "2026-01-04T12:34:56Z"
},
"secubox-app-nextcloud": {
"installed_version": "1.0.0-1",
"catalog_version": "1.0.0-1",
"update_available": false,
"last_check": "2026-01-04T12:34:56Z"
}
},
"update_stats": {
"total_updates_available": 1,
"last_notification": "2026-01-04T08:00:00Z",
"pending_updates": [
"luci-app-auth-guardian"
]
}
}