fix(pluginHolder): reliable plugin upgrades, store UI dates, upgrades columns

- Harden meta.xml sync (cache-bust, no CDN downgrade); ZIP meta fallback; fail if version stuck
- Invalidate plugin store cache after successful upgrade
- Add modify_timestamp for browser-local DD.MM.yyyy / 24h display via toLocaleString
- Upgrades table: Your Version column before New Version; freshness uses timestamp when present
This commit is contained in:
master3395
2026-03-27 21:08:26 +01:00
parent e94293f1d3
commit ceee5297ee
2 changed files with 244 additions and 53 deletions

View File

@@ -1695,8 +1695,8 @@
<thead>
<tr>
<th>{% trans "Plugin Name" %}</th>
<th>{% trans "New Version" %}</th>
<th>{% trans "Your Version" %}</th>
<th>{% trans "New Version" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Status / Action" %}</th>
</tr>
@@ -1850,7 +1850,7 @@
</div>
<script>
// Cache-busting version: 2026-02-15-v1 - Grid/Table: collapsible Category Filter (like A-Å in store)
// Cache-busting version: 2026-03-27-v2 - Modify date: browser-local via modify_timestamp + nb-NO style
let storePlugins = [];
let currentFilter = 'all';
let currentCategory = 'all';
@@ -2013,11 +2013,11 @@ function displayUpgradesAvailable() {
const name = escapeHtml(plugin.name || plugin.plugin_dir || '');
const newVer = escapeHtml(plugin.version || '');
const yourVer = escapeHtml(plugin.installed_version || 'Unknown');
const date = escapeHtml(plugin.modify_date || '');
const date = formatPluginModifyDateDisplay(plugin);
const curVer = (plugin.installed_version || 'Unknown').replace(/'/g, '&#39;');
const nVer = (plugin.version || 'Unknown').replace(/'/g, '&#39;');
const actionHtml = '<button type="button" class="btn-action btn-upgrade" data-plugin-dir="' + dir + '" data-current-version="' + escapeHtml(curVer) + '" data-new-version="' + escapeHtml(nVer) + '" onclick="upgradePlugin(this.getAttribute(\'data-plugin-dir\'), this.getAttribute(\'data-current-version\'), this.getAttribute(\'data-new-version\'))"><i class="fas fa-arrow-up"></i> Upgrade</button>';
html += '<tr><td><strong>' + name + '</strong></td><td>' + newVer + '</td><td>' + yourVer + '</td><td>' + date + '</td><td>' + actionHtml + '</td></tr>';
html += '<tr><td><strong>' + name + '</strong></td><td>' + yourVer + '</td><td>' + newVer + '</td><td>' + date + '</td><td>' + actionHtml + '</td></tr>';
});
tbody.innerHTML = html;
}
@@ -2109,12 +2109,70 @@ function escapeHtml(text) {
return div.innerHTML;
}
function getFreshnessBadgeHtml(freshnessFromApi, modifyDate) {
/**
* Format meta.xml mtime for display: browser timezone + locale (nb-NO style DD.MM.YYYY, 24h).
* Uses modify_timestamp (Unix sec) from API when present; else legacy modify_date string.
*/
function formatPluginModifyDateDisplay(plugin) {
if (!plugin) return '';
const rawTs = plugin.modify_timestamp;
if (rawTs !== null && rawTs !== undefined && rawTs !== '') {
const sec = Number(rawTs);
if (!isNaN(sec)) {
const d = new Date(sec * 1000);
if (!isNaN(d.getTime())) {
const loc = (typeof navigator !== 'undefined' && navigator.language) ? navigator.language : 'nb-NO';
try {
const s = d.toLocaleString(loc, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
return escapeHtml(s);
} catch (e) {
try {
return escapeHtml(d.toLocaleString('nb-NO', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}));
} catch (e2) {
/* fall through */
}
}
}
}
}
return escapeHtml(plugin.modify_date || '');
}
function getFreshnessBadgeHtml(freshnessFromApi, modifyDate, modifyTimestamp) {
// Use API data if available
if (freshnessFromApi && freshnessFromApi.badge && freshnessFromApi.class) {
return `<br><span class="${escapeHtml(freshnessFromApi.class)}" title="${escapeHtml(freshnessFromApi.title || '')}">${escapeHtml(freshnessFromApi.badge)}</span>`;
}
// Compute from modify_date (for cached data without freshness_badge)
// Compute from instant when we have Unix timestamp (correct vs browser)
if (modifyTimestamp !== null && modifyTimestamp !== undefined && modifyTimestamp !== '') {
const sec = Number(modifyTimestamp);
if (!isNaN(sec)) {
const d = new Date(sec * 1000);
if (!isNaN(d.getTime())) {
const daysAgo = Math.floor((Date.now() - d.getTime()) / (24 * 60 * 60 * 1000));
if (daysAgo <= 90) {
return '<br><span class="freshness-badge-new" title="This plugin was released/updated within the last 3 months">NEW</span>';
} else if (daysAgo <= 365) {
return '<br><span class="freshness-badge-stable" title="This plugin was updated within the last year">Stable</span>';
} else if (daysAgo < 730) {
return '<br><span class="freshness-badge-unstable" title="This plugin has not been updated in over 1 year">Unstable</span>';
}
return '<br><span class="freshness-badge-stale" title="This plugin has not been updated in over 2 years">STALE</span>';
}
}
}
// Compute from modify_date (for cached data without freshness_badge or timestamp)
if (!modifyDate || modifyDate === 'N/A') return '';
try {
const m = modifyDate.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
@@ -2247,10 +2305,12 @@ function displayStorePlugins() {
</a>`;
// Modify Date column - show N/A for store plugins (they're from GitHub, not local)
const modifyDateHtml = plugin.modify_date ? `<small style="color: var(--text-secondary, #64748b);">${escapeHtml(plugin.modify_date)}</small>` : '<small style="color: var(--text-secondary, #64748b);">N/A</small>';
const modifyDateHtml = (plugin.modify_timestamp != null || (plugin.modify_date && plugin.modify_date !== 'N/A'))
? `<small style="color: var(--text-secondary, #64748b);">${formatPluginModifyDateDisplay(plugin)}</small>`
: '<small style="color: var(--text-secondary, #64748b);">N/A</small>';
// Freshness badge (NEW/Stable/STALE) - use API data or compute from modify_date
const freshnessBadgeHtml = getFreshnessBadgeHtml(plugin.freshness_badge || null, plugin.modify_date);
const freshnessBadgeHtml = getFreshnessBadgeHtml(plugin.freshness_badge || null, plugin.modify_date, plugin.modify_timestamp);
// Pricing badge - ALWAYS show a badge (default to Free if is_paid is missing/undefined/null)
// Version: 2026-01-25-v4 - Normalize is_paid to handle all possible values

View File

@@ -162,11 +162,10 @@ def _get_plugin_source_path(plugin_name):
return path
return None
def _get_local_plugin_meta_modify_date(plugin_name):
def _get_local_plugin_meta_modify_pair(plugin_name):
"""
Compute plugin modify date from local meta.xml file timestamps.
This avoids per-plugin GitHub commits API calls while still providing
a useful "Modify date" column in the plugin store UI.
Return (modify_date string server-local, unix seconds) from first found meta.xml.
Unix seconds represent the same instant everywhere; UI formats in browser timezone.
"""
candidate_paths = []
@@ -180,11 +179,39 @@ def _get_local_plugin_meta_modify_date(plugin_name):
try:
if os.path.exists(meta_path) and os.path.isfile(meta_path):
modify_time = os.path.getmtime(meta_path)
return datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
return (
datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S'),
int(modify_time),
)
except Exception:
continue
return 'N/A'
return ('N/A', None)
def _get_local_plugin_meta_modify_date(plugin_name):
"""
Compute plugin modify date from local meta.xml file timestamps.
This avoids per-plugin GitHub commits API calls while still providing
a useful "Modify date" column in the plugin store UI.
"""
return _get_local_plugin_meta_modify_pair(plugin_name)[0]
def _apply_modify_date_from_meta_path(data_dict, meta_xml_path):
"""Set modify_date, modify_timestamp, freshness_badge on plugin data dict."""
modify_date = 'N/A'
modify_timestamp = None
try:
if meta_xml_path and os.path.exists(meta_xml_path):
modify_time = os.path.getmtime(meta_xml_path)
modify_date = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
modify_timestamp = int(modify_time)
except Exception:
pass
data_dict['modify_date'] = modify_date
data_dict['modify_timestamp'] = modify_timestamp
data_dict['freshness_badge'] = _get_freshness_badge(modify_date)
def _ensure_plugin_meta_xml(plugin_name):
"""
@@ -402,16 +429,7 @@ def installed(request):
# Get modify date from local file (fast, no API calls)
# GitHub commit dates are fetched in the plugin store, not here to avoid timeouts
modify_date = 'N/A'
try:
if os.path.exists(metaXmlPath):
modify_time = os.path.getmtime(metaXmlPath)
modify_date = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
modify_date = 'N/A'
data['modify_date'] = modify_date
data['freshness_badge'] = _get_freshness_badge(modify_date)
_apply_modify_date_from_meta_path(data, metaXmlPath)
# Extract settings URL or main URL for "Manage" button
settings_url_elem = root.find('settings_url')
@@ -537,16 +555,7 @@ def installed(request):
data['patreon_url'] = None
# Get modify date from installed location
modify_date = 'N/A'
try:
if os.path.exists(metaXmlPath):
modify_time = os.path.getmtime(metaXmlPath)
modify_date = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
modify_date = 'N/A'
data['modify_date'] = modify_date
data['freshness_badge'] = _get_freshness_badge(modify_date)
_apply_modify_date_from_meta_path(data, metaXmlPath)
# Extract settings URL or main URL
settings_url_elem = root.find('settings_url')
@@ -639,12 +648,7 @@ def installed(request):
'manage_url': f'/plugins/{plugin_name}/',
'author': root.find('author').text if root.find('author') is not None and root.find('author').text else 'Unknown',
}
try:
modify_time = os.path.getmtime(meta_xml_path)
data['modify_date'] = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
data['modify_date'] = 'N/A'
data['freshness_badge'] = _get_freshness_badge(data['modify_date'])
_apply_modify_date_from_meta_path(data, meta_xml_path)
paid_elem = root.find('paid')
if paid_elem is not None and paid_elem.text and paid_elem.text.lower() == 'true':
data['is_paid'] = True
@@ -1186,32 +1190,127 @@ def _get_installed_version(plugin_dir, plugin_install_dir):
return None
def _parse_version_from_meta_xml_bytes(content):
"""Return <version> text from meta.xml bytes, or None."""
if not content:
return None
try:
if isinstance(content, bytes):
content = content.decode('utf-8', errors='replace')
root = ElementTree.fromstring(content)
ve = root.find('version')
if ve is not None and ve.text:
return ve.text.strip()
except Exception as e:
logging.writeToFile('Parse meta.xml version: %s' % str(e))
return None
def _read_version_from_plugin_zip(zip_path, plugin_name):
"""Read version from plugin_name/meta.xml inside the plugin ZIP (upgrade archive)."""
import zipfile
inner = '%s/meta.xml' % plugin_name
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
names = zf.namelist()
target = inner if inner in names else None
if target is None:
in_lower = inner.lower()
for n in names:
if n.lower() == in_lower:
target = n
break
if not target:
return None
return _parse_version_from_meta_xml_bytes(zf.read(target))
except Exception as e:
logging.writeToFile('read_version_from_plugin_zip: %s' % str(e))
return None
def _write_meta_xml_from_plugin_zip(zip_path, plugin_name, plugin_install_dir='/usr/local/CyberCP'):
"""Restore meta.xml on disk from the upgrade ZIP (fallback if sync/CDN overwrote with stale data)."""
import zipfile
inner = '%s/meta.xml' % plugin_name
try:
with zipfile.ZipFile(zip_path, 'r') as zf:
names = zf.namelist()
target = inner if inner in names else None
if target is None:
in_lower = inner.lower()
for n in names:
if n.lower() == in_lower:
target = n
break
if not target:
return False
data = zf.read(target)
meta_path = os.path.join(plugin_install_dir, plugin_name, 'meta.xml')
d = os.path.dirname(meta_path)
if d and not os.path.exists(d):
os.makedirs(d, mode=0o755, exist_ok=True)
with open(meta_path, 'wb') as f:
f.write(data)
f.flush()
if hasattr(os, 'fsync'):
try:
os.fsync(f.fileno())
except Exception:
pass
logging.writeToFile('Restored %s/meta.xml from upgrade ZIP' % plugin_name)
return True
except Exception as e:
logging.writeToFile('_write_meta_xml_from_plugin_zip: %s' % str(e))
return False
def _invalidate_plugin_store_cache():
"""Remove store cache so grid / upgrades-available refreshes installed vs store versions."""
try:
_ensure_cache_dir()
if os.path.isfile(PLUGIN_STORE_CACHE_FILE):
os.remove(PLUGIN_STORE_CACHE_FILE)
logging.writeToFile('Plugin store cache invalidated after upgrade')
except Exception as e:
logging.writeToFile('Could not invalidate plugin store cache: %s' % str(e))
def _sync_meta_xml_from_github(plugin_name, plugin_install_dir='/usr/local/CyberCP'):
"""
Fetch meta.xml from GitHub raw (main) and overwrite installed meta.xml.
Ensures installed version matches store even when archive ZIP is cached/stale.
Verifies write by re-reading version. Returns True if synced and version readable, False otherwise.
Never overwrites with an *older* <version> than already on disk (stale raw.githubusercontent CDN).
"""
meta_url = f'{GITHUB_RAW_BASE}/{plugin_name}/meta.xml'
meta_url = '%s/%s/meta.xml?t=%s' % (GITHUB_RAW_BASE, plugin_name, int(time.time()))
meta_path = os.path.join(plugin_install_dir, plugin_name, 'meta.xml')
for attempt in (1, 2):
try:
req = urllib.request.Request(meta_url, headers={'User-Agent': 'CyberPanel-Plugin-Store/1.0'})
req = urllib.request.Request(
meta_url,
headers={'User-Agent': 'CyberPanel-Plugin-Store/1.0', 'Cache-Control': 'no-cache'},
)
with urllib.request.urlopen(req, timeout=15) as resp:
content = resp.read()
if not content:
if attempt == 2:
logging.writeToFile(f"Sync meta.xml for {plugin_name}: empty response from GitHub")
continue
remote_ver = _parse_version_from_meta_xml_bytes(content)
current_ver = _get_installed_version(plugin_name, plugin_install_dir)
if current_ver and remote_ver and _compare_versions(remote_ver, current_ver) < 0:
logging.writeToFile(
"Skip meta.xml sync for %s: remote %s older than installed %s (CDN/stale raw)"
% (plugin_name, remote_ver, current_ver)
)
return False
with open(meta_path, 'wb') as f:
f.write(content)
f.flush()
if hasattr(os, 'fsync'):
try:
f.fsync()
os.fsync(f.fileno())
except Exception:
pass
# Verify we can read version back (ensures file is valid and readable)
ver = _get_installed_version(plugin_name, plugin_install_dir)
if ver:
logging.writeToFile(f"Synced meta.xml for {plugin_name} from GitHub raw (version {ver})")
@@ -1470,7 +1569,7 @@ def _fetch_plugins_from_github():
# Performance: avoid per-plugin GitHub commits API calls.
# Instead, compute modify_date from local meta.xml timestamps
# (installed meta.xml if present, otherwise plugin source meta.xml).
modify_date = _get_local_plugin_meta_modify_date(plugin_name)
modify_date, modify_timestamp = _get_local_plugin_meta_modify_pair(plugin_name)
freshness = _get_freshness_badge(modify_date)
# Extract paid plugin information
@@ -1509,6 +1608,7 @@ def _fetch_plugins_from_github():
'github_url': f'https://github.com/master3395/cyberpanel-plugins/tree/main/{plugin_name}',
'about_url': f'https://github.com/master3395/cyberpanel-plugins/tree/main/{plugin_name}',
'modify_date': modify_date,
'modify_timestamp': modify_timestamp,
'freshness_badge': freshness,
'is_paid': is_paid,
'patreon_tier': patreon_tier,
@@ -1747,6 +1847,12 @@ def upgrade_plugin(request, plugin_name):
zip_path_abs = os.path.abspath(zip_path)
if not os.path.exists(zip_path_abs):
raise Exception(f'Zip file not found: {zip_path_abs}')
expected_from_zip = _read_version_from_plugin_zip(zip_path_abs, plugin_name)
if expected_from_zip:
logging.writeToFile(
'Plugin %s: version in upgrade archive meta.xml: %s' % (plugin_name, expected_from_zip)
)
logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})")
@@ -1768,18 +1874,43 @@ def upgrade_plugin(request, plugin_name):
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade')
# Sync meta.xml from GitHub raw so version matches store (archive ZIP can be cached/stale)
# Sync meta.xml from GitHub raw (never downgrades vs disk — avoids stale CDN on raw.githubusercontent.com)
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
# If version unchanged, meta sync may have failed (e.g. network); retry once
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version unchanged after first meta sync, retrying sync")
_sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
if new_version == installed_version:
logging.writeToFile(f"Plugin {plugin_name}: version still {installed_version} after upgrade; meta.xml may not have been updated from GitHub")
logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}")
if (
new_version == installed_version
and expected_from_zip
and installed_version
and _compare_versions(expected_from_zip, installed_version) > 0
):
logging.writeToFile(
'Plugin %s: forcing meta.xml from upgrade ZIP (archive says %s, disk still %s)'
% (plugin_name, expected_from_zip, installed_version)
)
_write_meta_xml_from_plugin_zip(zip_path_abs, plugin_name, '/usr/local/CyberCP')
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
if (
new_version == installed_version
and expected_from_zip
and installed_version
and _compare_versions(expected_from_zip, installed_version) > 0
):
err = (
'Upgrade did not update version on disk (still %s; archive has %s). '
'Check ownership of /usr/local/CyberCP/%s and CyberPanel logs.'
% (installed_version, expected_from_zip, plugin_name)
)
logging.writeToFile('Plugin %s: %s' % (plugin_name, err))
return JsonResponse({'success': False, 'error': err}, status=500)
_invalidate_plugin_store_cache()
logging.writeToFile(
'Plugin %s upgraded successfully from %s to %s' % (plugin_name, installed_version, new_version)
)
backup_message = ''
if backup_path: