From 8d3e2cd51abc208c3440f9a993fccf62cb6c5999 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 22:45:54 +0100 Subject: [PATCH] pluginHolder: fix plugin store stale-cache refresh + hourly scheduler Remove stuck plugin-store refresh locks, show correct cache status in UI, and add a management command for hourly refresh. --- pluginHolder/management/__init__.py | 1 + pluginHolder/management/commands/__init__.py | 1 + .../commands/refresh_plugin_store_cache.py | 102 ++++++++++++++++++ .../templates/pluginHolder/plugins.html | 4 +- pluginHolder/views.py | 17 +++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 pluginHolder/management/__init__.py create mode 100644 pluginHolder/management/commands/__init__.py create mode 100644 pluginHolder/management/commands/refresh_plugin_store_cache.py diff --git a/pluginHolder/management/__init__.py b/pluginHolder/management/__init__.py new file mode 100644 index 000000000..792d60054 --- /dev/null +++ b/pluginHolder/management/__init__.py @@ -0,0 +1 @@ +# diff --git a/pluginHolder/management/commands/__init__.py b/pluginHolder/management/commands/__init__.py new file mode 100644 index 000000000..792d60054 --- /dev/null +++ b/pluginHolder/management/commands/__init__.py @@ -0,0 +1 @@ +# diff --git a/pluginHolder/management/commands/refresh_plugin_store_cache.py b/pluginHolder/management/commands/refresh_plugin_store_cache.py new file mode 100644 index 000000000..e2a0fa2bd --- /dev/null +++ b/pluginHolder/management/commands/refresh_plugin_store_cache.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +import os +import time + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Refresh CyberPanel plugin store cache (hourly scheduler)." + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Refresh even if cache is not expired.", + ) + parser.add_argument( + "--stale-lock-seconds", + type=int, + default=900, + help="Remove the cache-refresh lock if it is older than this many seconds.", + ) + + def handle(self, *args, **options): + force = bool(options.get("force", False)) + stale_lock_seconds = int(options.get("stale_lock_seconds", 900)) + + try: + from pluginHolder import views as plugin_views + except Exception as e: + # Avoid printing secrets; just show a minimal message. + self.stderr.write("Failed to import pluginHolder views for cache refresh.") + return 1 + + # Only refresh when needed (unless --force is used). + try: + cache_expiry_timestamp, _ = plugin_views._get_cache_expiry_time() + cache_expired = plugin_views._is_cache_expired(cache_expiry_timestamp) + except Exception: + cache_expired = True + + if not force and cache_expiry_timestamp and not cache_expired: + self.stdout.write("Plugin store cache is still fresh; no refresh needed.") + return 0 + + lock_path = plugin_views.PLUGIN_STORE_REFRESH_LOCK_FILE + try: + plugin_views._ensure_cache_dir() + except Exception: + pass + + # Remove stale lock left behind by a crashed/aborted refresh. + if os.path.exists(lock_path): + try: + age_s = time.time() - os.path.getmtime(lock_path) + if age_s > stale_lock_seconds: + os.remove(lock_path) + try: + plugin_views.logging.writeToFile( + f"Management refresh: removed stale plugin store refresh lock (age: {age_s:.0f}s)" + ) + except Exception: + pass + except Exception: + pass + + # Acquire lock to avoid stampedes when multiple instances refresh. + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + with os.fdopen(fd, "w") as f: + f.write(str(os.getpid())) + except FileExistsError: + self.stdout.write("Plugin store refresh skipped: lock already exists.") + return 0 + except Exception: + self.stderr.write("Plugin store refresh failed: could not acquire refresh lock.") + return 1 + + try: + plugins = plugin_views._fetch_plugins_from_github() + if not plugins: + self.stdout.write("Plugin store refresh fetched 0 plugins; cache not updated.") + return 0 + + plugin_views._save_plugins_cache(plugins) + self.stdout.write(f"Plugin store cache refreshed successfully. plugins={len(plugins)}") + return 0 + except Exception as e: + # Log error summary server-side; don't leak internal exception details to stdout. + try: + plugin_views.logging.writeToFile(f"Plugin store cache refresh failed: {str(e)}") + except Exception: + pass + self.stderr.write("Plugin store cache refresh failed. Check error logs.") + return 1 + finally: + try: + if os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c82e92e7a..450348aae 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -3278,8 +3278,8 @@ function updateCacheExpiryTime() { if (expired) { expiryElement.textContent = refreshStarted - ? ('Utlopt (' + formatted + ') - oppdaterer i bakgrunnen') - : ('Utlopt (' + formatted + ')'); + ? ('Expired (' + formatted + ') - updating in background') + : ('Expired (' + formatted + ')'); } else { expiryElement.textContent = formatted; } diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 8939e430f..ee790b486 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -33,6 +33,7 @@ PLUGIN_STORE_CACHE_FILE = os.path.join(PLUGIN_STORE_CACHE_DIR, 'plugins_cache.js PLUGIN_STORE_CACHE_DURATION = 3600 # Base cache duration: 1 hour (3600 seconds) PLUGIN_STORE_CACHE_RANDOM_OFFSET = 600 # Random offset: ±10 minutes (600 seconds) to prevent simultaneous requests PLUGIN_STORE_REFRESH_LOCK_FILE = os.path.join(PLUGIN_STORE_CACHE_DIR, 'plugins_cache_refresh.lock') +PLUGIN_STORE_REFRESH_LOCK_STALE_SECONDS = 900 # 15 minutes; remove leftover lock if stuck GITHUB_REPO_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/contents' GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/master3395/cyberpanel-plugins/main' GITHUB_COMMITS_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/commits' @@ -1046,6 +1047,22 @@ def _try_start_plugin_store_refresh_background(): try: _ensure_cache_dir() + # If a previous refresh crashed and left the lock behind, remove it + # so background refresh can resume. This is critical for hourly updates. + try: + if os.path.exists(lock_path): + age_s = time.time() - os.path.getmtime(lock_path) + if age_s > PLUGIN_STORE_REFRESH_LOCK_STALE_SECONDS: + try: + os.remove(lock_path) + logging.writeToFile( + f"Removed stale plugin store refresh lock (age: {age_s:.0f}s)" + ) + except Exception: + pass + except Exception: + pass + # Try to acquire a file lock so multiple workers don't stampede GitHub. try: fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)