mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-02-05 14:19:09 +01:00
feat: no plugins required by default; Plugin Store category updates
- Remove emailMarketing from default INSTALLED_APPS - Comment out emailMarketing URL (plugin installer adds when installed) - Bump emailMarketing, examplePlugin meta.xml to 1.0.1 - Plugin Holder: remove Plugin category, enforce Utility/Security/Backup/Performance - Add to-do/PLUGIN-DEFAULT-REMOVAL-2026-02-01.md
This commit is contained in:
@@ -65,7 +65,8 @@ INSTALLED_APPS = [
|
||||
|
||||
# Apps with multiple or complex dependencies
|
||||
'emailPremium',
|
||||
'emailMarketing', # Depends on websiteFunctions and loginSystem
|
||||
# Optional plugins (e.g. emailMarketing, discordWebhooks) - install via Plugin Store
|
||||
# from https://github.com/master3395/cyberpanel-plugins - plugin installer adds them
|
||||
'cloudAPI', # Depends on websiteFunctions
|
||||
'containerization', # Depends on websiteFunctions
|
||||
'IncBackups', # Depends on websiteFunctions and loginSystem
|
||||
|
||||
@@ -51,7 +51,8 @@ urlpatterns = [
|
||||
path('CloudLinux/', include('CLManager.urls')),
|
||||
path('IncrementalBackups/', include('IncBackups.urls')),
|
||||
path('aiscanner/', include('aiScanner.urls')),
|
||||
path('emailMarketing/', include('emailMarketing.urls')),
|
||||
# Optional plugin routes - added by plugin installer when plugins are installed from Plugin Store
|
||||
# path('emailMarketing/', include('emailMarketing.urls')),
|
||||
# path('Terminal/', include('WebTerminal.urls')),
|
||||
path('', include('loginSystem.urls')),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<cyberpanelPluginConfig>
|
||||
<name>Email Marketing</name>
|
||||
<type>plugin</type>
|
||||
<type>Utility</type>
|
||||
<description>Email Marketing plugin for CyberPanel.</description>
|
||||
<version>1.0.0</version>
|
||||
<version>1.0.1</version>
|
||||
</cyberpanelPluginConfig>
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<cyberpanelPluginConfig>
|
||||
<name>examplePlugin</name>
|
||||
<type>plugin</type>
|
||||
<type>Utility</type>
|
||||
<description>This is an example plugin</description>
|
||||
<version>1.0.0</version>
|
||||
<version>1.0.1</version>
|
||||
<author>usmannasir</author>
|
||||
</cyberpanelPluginConfig>
|
||||
@@ -401,6 +401,10 @@ mkdir -p migrations</code></pre>
|
||||
<settings_url>/plugins/myFirstPlugin/settings/</settings_url>
|
||||
</cyberpanelPluginConfig></code></pre>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>{% trans "Required: Category (type)" %}:</strong> {% trans "The <type> field is required. Valid categories: Utility, Security, Backup, Performance. Plugins without a valid category will not appear in the Plugin Store." %}
|
||||
</div>
|
||||
|
||||
<h3>{% trans "Step 3: Create urls.py" %}</h3>
|
||||
<pre><code>from django.urls import path
|
||||
from . import views
|
||||
|
||||
@@ -592,11 +592,150 @@
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
padding: 15px;
|
||||
background: var(--bg-secondary, #f8f9ff);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-primary, #e8e9ff);
|
||||
background: var(--bg-primary, white);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary, #64748b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
background: var(--bg-hover, #f0f1ff);
|
||||
border-color: #5856d6;
|
||||
color: #5856d6;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: #5856d6;
|
||||
color: white;
|
||||
border-color: #5856d6;
|
||||
}
|
||||
|
||||
.category-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.store-search-bar {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.store-search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-search-input {
|
||||
width: 100%;
|
||||
padding-left: 46px;
|
||||
padding-right: 40px;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.store-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #5856d6;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 86, 214, 0.15);
|
||||
}
|
||||
|
||||
.store-search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.store-search-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.store-search-clear:hover {
|
||||
color: #5856d6;
|
||||
background: var(--bg-secondary, #f8f9ff);
|
||||
}
|
||||
|
||||
.alphabet-filter-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alphabet-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-primary, #e8e9ff);
|
||||
background: var(--bg-primary, white);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.alphabet-toggle-btn:hover {
|
||||
background: var(--bg-hover, #f8f9ff);
|
||||
border-color: #5856d6;
|
||||
color: #5856d6;
|
||||
}
|
||||
|
||||
.alphabet-toggle-btn[aria-expanded="true"] .alphabet-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.alphabet-chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.alphabet-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 12px;
|
||||
padding: 15px;
|
||||
background: var(--bg-secondary, #f8f9ff);
|
||||
border-radius: 8px;
|
||||
@@ -971,15 +1110,15 @@
|
||||
{% if plugins %}
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" onclick="toggleView('grid')">
|
||||
<button class="view-btn active" onclick="toggleView('grid', true)">
|
||||
<i class="fas fa-th-large"></i>
|
||||
{% trans "Grid View" %}
|
||||
</button>
|
||||
<button class="view-btn" onclick="toggleView('table')">
|
||||
<button class="view-btn" onclick="toggleView('table', true)">
|
||||
<i class="fas fa-list"></i>
|
||||
{% trans "Table View" %}
|
||||
</button>
|
||||
<button class="view-btn" onclick="toggleView('store')">
|
||||
<button class="view-btn" onclick="toggleView('store', true)">
|
||||
<i class="fas fa-store"></i>
|
||||
{% trans "CyberPanel Plugin Store" %}
|
||||
</button>
|
||||
@@ -995,13 +1134,13 @@
|
||||
<div class="plugin-card">
|
||||
<div class="plugin-header">
|
||||
<div class="plugin-icon">
|
||||
{% if plugin.type == "Security" %}
|
||||
{% if plugin.type|lower == "security" %}
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
{% elif plugin.type == "Performance" %}
|
||||
{% elif plugin.type|lower == "performance" %}
|
||||
<i class="fas fa-rocket"></i>
|
||||
{% elif plugin.type == "Utility" %}
|
||||
{% elif plugin.type|lower == "utility" %}
|
||||
<i class="fas fa-tools"></i>
|
||||
{% elif plugin.type == "Backup" %}
|
||||
{% elif plugin.type|lower == "backup" %}
|
||||
<i class="fas fa-save"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-puzzle-piece"></i>
|
||||
@@ -1216,15 +1355,15 @@
|
||||
|
||||
<!-- View Toggle (only shown when no plugins installed) -->
|
||||
<div class="view-toggle" style="margin-top: 25px;">
|
||||
<button class="view-btn" onclick="toggleView('grid')">
|
||||
<button class="view-btn" onclick="toggleView('grid', true)">
|
||||
<i class="fas fa-th-large"></i>
|
||||
{% trans "Grid View" %}
|
||||
</button>
|
||||
<button class="view-btn" onclick="toggleView('table')">
|
||||
<button class="view-btn" onclick="toggleView('table', true)">
|
||||
<i class="fas fa-list"></i>
|
||||
{% trans "Table View" %}
|
||||
</button>
|
||||
<button class="view-btn active" onclick="toggleView('store')">
|
||||
<button class="view-btn active" onclick="toggleView('store', true)">
|
||||
<i class="fas fa-store"></i>
|
||||
{% trans "CyberPanel Plugin Store" %}
|
||||
</button>
|
||||
@@ -1274,35 +1413,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Loading Indicator -->
|
||||
<div id="storeLoading" class="store-loading" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{% trans "Loading plugins from store..." %}</p>
|
||||
<!-- Search Bar (always visible in store view) -->
|
||||
<div class="store-search-bar">
|
||||
<i class="fas fa-search store-search-icon"></i>
|
||||
<input type="text" id="pluginSearchInput" class="store-search-input" placeholder="{% trans 'Search plugins by name or description...' %}" aria-label="{% trans 'Search plugins' %}">
|
||||
<button type="button" class="store-search-clear" id="pluginSearchClear" onclick="clearPluginSearch()" style="display: none;" aria-label="{% trans 'Clear search' %}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Store Error -->
|
||||
<div id="storeError" class="alert alert-danger" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle alert-icon"></i>
|
||||
<span id="storeErrorText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Store Content -->
|
||||
<div id="storeContent" style="display: none;">
|
||||
<!-- Alphabetical Filter -->
|
||||
<div class="alphabet-filter">
|
||||
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
|
||||
<button class="alpha-btn" onclick="filterByLetter('{{ letter }}')">{{ letter }}</button>
|
||||
{% endfor %}
|
||||
<button class="alpha-btn active" onclick="filterByLetter('all')">{% trans "All" %}</button>
|
||||
<!-- Category Filter (always visible in store view) - v2026-02-01 no Plugin category -->
|
||||
<div class="category-filter">
|
||||
<button class="category-btn active" onclick="filterByCategory('all', event)" data-category="all">
|
||||
<i class="fas fa-th"></i>
|
||||
{% trans "All Categories" %}
|
||||
</button>
|
||||
<button class="category-btn" onclick="filterByCategory('Utility', event)" data-category="Utility">
|
||||
<i class="fas fa-wrench"></i>
|
||||
{% trans "Utility" %}
|
||||
</button>
|
||||
<button class="category-btn" onclick="filterByCategory('Security', event)" data-category="Security">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
{% trans "Security" %}
|
||||
</button>
|
||||
<button class="category-btn" onclick="filterByCategory('Backup', event)" data-category="Backup">
|
||||
<i class="fas fa-archive"></i>
|
||||
{% trans "Backup" %}
|
||||
</button>
|
||||
<button class="category-btn" onclick="filterByCategory('Performance', event)" data-category="Performance">
|
||||
<i class="fas fa-rocket"></i>
|
||||
{% trans "Performance" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Store Table -->
|
||||
|
||||
<!-- Alphabetical Filter (collapsible, hidden by default) -->
|
||||
<div class="alphabet-filter-wrapper">
|
||||
<button type="button" class="alphabet-toggle-btn" id="alphabetToggleBtn" onclick="toggleAlphabetFilter()" aria-expanded="false">
|
||||
<i class="fas fa-sort-alpha-down alphabet-toggle-icon"></i>
|
||||
<span>{% trans "A-Å Filter" %}</span>
|
||||
<i class="fas fa-chevron-down alphabet-chevron"></i>
|
||||
</button>
|
||||
<div class="alphabet-filter" id="alphabetFilter" aria-hidden="true" style="display: none;">
|
||||
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
|
||||
<button class="alpha-btn" onclick="filterByLetter('{{ letter }}', event)">{{ letter }}</button>
|
||||
{% endfor %}
|
||||
<button class="alpha-btn active" onclick="filterByLetter('all', event)">{% trans "All" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Content (table area) -->
|
||||
<div id="storeContent" style="display: block;">
|
||||
<div class="store-table-wrapper">
|
||||
<table class="store-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Icon" %}</th>
|
||||
<th>{% trans "Plugin Name" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th>{% trans "Version" %}</th>
|
||||
<th>{% trans "Pricing" %}</th>
|
||||
<th>{% trans "Modify Date" %}</th>
|
||||
@@ -1319,19 +1486,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-controller="listWebsites" id="listFail" class="alert alert-danger" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle alert-icon"></i>
|
||||
<span>{% trans "Cannot list plugins. Error message:" %} {$ errorMessage $}</span>
|
||||
</div>
|
||||
<!-- Plugin store errors shown in storeError div; listWebsites controller removed - not applicable to plugins page -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Cache-busting version: 2026-01-25-v4 - Fixed is_paid normalization and ensured consistent rendering
|
||||
// Force browser to reload this script by changing version number
|
||||
// Cache-busting version: 2026-02-01-v1 - Fixed category filter, added search bar, collapsible A-Å
|
||||
let storePlugins = [];
|
||||
let currentFilter = 'all';
|
||||
let currentCategory = 'all';
|
||||
let currentSearchQuery = '';
|
||||
let isSettingHash = false; // Flag to prevent infinite loops
|
||||
|
||||
// Get CSRF cookie helper function
|
||||
function getCookie(name) {
|
||||
@@ -1349,7 +1515,7 @@ function getCookie(name) {
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
function toggleView(view) {
|
||||
function toggleView(view, updateHash = true) {
|
||||
const gridView = document.getElementById('gridView');
|
||||
const tableView = document.getElementById('tableView');
|
||||
const storeView = document.getElementById('storeView');
|
||||
@@ -1357,6 +1523,23 @@ function toggleView(view) {
|
||||
|
||||
viewBtns.forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Update URL hash only if explicitly requested (user clicked a button, not initial load)
|
||||
if (updateHash) {
|
||||
isSettingHash = true;
|
||||
const hash = '#' + view;
|
||||
// Use replaceState to update URL - this updates the hash without triggering hashchange event
|
||||
if (window.history && window.history.replaceState) {
|
||||
// Get current pathname and preserve it, just update the hash
|
||||
const newUrl = window.location.pathname + window.location.search + hash;
|
||||
window.history.replaceState(null, null, newUrl);
|
||||
} else {
|
||||
// Fallback for older browsers - this will trigger hashchange but we have the flag
|
||||
window.location.hash = hash;
|
||||
}
|
||||
// Reset flag after a short delay
|
||||
setTimeout(() => { isSettingHash = false; }, 100);
|
||||
}
|
||||
|
||||
if (view === 'grid') {
|
||||
gridView.style.display = 'grid';
|
||||
tableView.style.display = 'none';
|
||||
@@ -1387,18 +1570,34 @@ function loadPluginStore() {
|
||||
const storeError = document.getElementById('storeError');
|
||||
const storeErrorText = document.getElementById('storeErrorText');
|
||||
const storeContent = document.getElementById('storeContent');
|
||||
const storeTableBody = document.getElementById('storeTableBody');
|
||||
if (!storeLoading || !storeError || !storeErrorText || !storeContent) return;
|
||||
|
||||
storeLoading.style.display = 'block';
|
||||
storeError.style.display = 'none';
|
||||
storeContent.style.display = 'none';
|
||||
storeContent.style.display = 'block';
|
||||
|
||||
fetch('/plugins/api/store/plugins/', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
'X-CSRFToken': getCookie('csrftoken'),
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Session expired or access denied. Please refresh the page and log in again.');
|
||||
}
|
||||
if (response.redirected || response.status >= 400) {
|
||||
throw new Error('Could not load plugin store. Your session may have expired. Please refresh the page and log in again.');
|
||||
}
|
||||
throw new Error('Server returned invalid response. Please refresh the page and try again.');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
storeLoading.style.display = 'none';
|
||||
|
||||
@@ -1413,8 +1612,12 @@ function loadPluginStore() {
|
||||
})
|
||||
.catch(error => {
|
||||
storeLoading.style.display = 'none';
|
||||
storeErrorText.textContent = 'Error loading plugin store: ' + error.message;
|
||||
storeErrorText.textContent = error.message || 'Error loading plugin store. Please refresh the page and try again.';
|
||||
storeError.style.display = 'block';
|
||||
storeContent.style.display = 'block';
|
||||
if (storeTableBody && storeTableBody.innerHTML === '') {
|
||||
storeTableBody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: var(--text-secondary, #64748b);">Unable to load plugins. Please check your connection and try again.</td></tr>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1426,8 +1629,8 @@ function escapeHtml(text) {
|
||||
}
|
||||
|
||||
function displayStorePlugins() {
|
||||
// Version: 2026-01-25-v4 - Store view: Removed Status column, always show Free/Paid badges
|
||||
// CRITICAL: This function MUST create exactly 7 columns (no Status, no Deactivate/Uninstall)
|
||||
// Version: 2026-01-27-v1 - Added category filtering support
|
||||
// CRITICAL: This function MUST create exactly 8 columns (Icon, Plugin Name, Version, Pricing, Modify Date, Action, Help, About)
|
||||
const tbody = document.getElementById('storeTableBody');
|
||||
if (!tbody) {
|
||||
console.error('storeTableBody not found!');
|
||||
@@ -1436,23 +1639,50 @@ function displayStorePlugins() {
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!storePlugins || storePlugins.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 20px; color: var(--text-secondary, #64748b);">No plugins available in store</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: var(--text-secondary, #64748b);">No plugins available in store</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let filteredPlugins = storePlugins;
|
||||
|
||||
// Apply category filter (case-insensitive); plugins without category are excluded
|
||||
if (currentCategory !== 'all') {
|
||||
const categoryLower = currentCategory.toLowerCase();
|
||||
filteredPlugins = filteredPlugins.filter(plugin => {
|
||||
const pluginType = ((plugin.type || '') + '').trim().toLowerCase();
|
||||
return pluginType && pluginType === categoryLower;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter (name + description)
|
||||
if ((currentSearchQuery || '').trim()) {
|
||||
const searchLower = (currentSearchQuery || '').trim().toLowerCase();
|
||||
const searchTerms = searchLower.split(/\s+/).filter(t => t.length > 0);
|
||||
filteredPlugins = filteredPlugins.filter(plugin => {
|
||||
const name = (plugin.name || '').toLowerCase();
|
||||
const desc = (plugin.description || '').toLowerCase();
|
||||
const combined = name + ' ' + desc + ' ' + (plugin.plugin_dir || '');
|
||||
return searchTerms.every(term => combined.includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
// Apply alphabetical filter
|
||||
if (currentFilter !== 'all') {
|
||||
filteredPlugins = storePlugins.filter(plugin =>
|
||||
plugin.name.charAt(0).toUpperCase() === currentFilter
|
||||
filteredPlugins = filteredPlugins.filter(plugin =>
|
||||
(plugin.name || '').charAt(0).toUpperCase() === currentFilter
|
||||
);
|
||||
}
|
||||
|
||||
filteredPlugins.forEach(plugin => {
|
||||
if (filteredPlugins.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: var(--text-secondary, #64748b);">No plugins match your filters. Try adjusting the category, search, or letter filter.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredPlugins.forEach(plugin => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Plugin icon - based on plugin type (same logic as Grid/Table views)
|
||||
const pluginType = (plugin.type || 'Plugin').toLowerCase();
|
||||
const pluginType = ((plugin.type || '') + '').trim().toLowerCase() || 'utility';
|
||||
let iconClass = 'fas fa-puzzle-piece'; // Default icon
|
||||
if (pluginType.includes('security')) {
|
||||
iconClass = 'fas fa-shield-alt';
|
||||
@@ -1516,12 +1746,16 @@ function displayStorePlugins() {
|
||||
? '<span class="plugin-pricing-badge paid">Paid</span>'
|
||||
: '<span class="plugin-pricing-badge free">Free</span>';
|
||||
|
||||
// Version: 2026-01-25-v5 - Added plugin icons to Store view (8 columns: Icon, Plugin Name, Version, Pricing, Modify Date, Action, Help, About)
|
||||
// Version: 2026-01-27-v1 - Added Category column; category is required, no default
|
||||
const pluginCategory = ((plugin.type || '') + '').trim() || '—';
|
||||
const categoryBadge = `<span class="plugin-type">${escapeHtml(pluginCategory.toUpperCase())}</span>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="text-align: center;">${iconHtml}</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(plugin.name)}</strong>
|
||||
</td>
|
||||
<td>${categoryBadge}</td>
|
||||
<td><span class="plugin-version-number">${escapeHtml(plugin.version)}</span></td>
|
||||
<td>${pricingBadge}</td>
|
||||
<td>${modifyDateHtml}</td>
|
||||
@@ -1534,16 +1768,16 @@ function displayStorePlugins() {
|
||||
});
|
||||
}
|
||||
|
||||
function filterByLetter(letter) {
|
||||
currentFilter = letter;
|
||||
const alphaBtns = document.querySelectorAll('.alpha-btn');
|
||||
alphaBtns.forEach(btn => btn.classList.remove('active'));
|
||||
if (event && event.target) {
|
||||
event.target.classList.add('active');
|
||||
function filterByCategory(category, evt) {
|
||||
currentCategory = category;
|
||||
const categoryBtns = document.querySelectorAll('.category-btn');
|
||||
categoryBtns.forEach(btn => btn.classList.remove('active'));
|
||||
const clickedBtn = evt && (evt.currentTarget || (evt.target && evt.target.closest('.category-btn')));
|
||||
if (clickedBtn) {
|
||||
clickedBtn.classList.add('active');
|
||||
} else {
|
||||
// Find and activate the clicked button
|
||||
alphaBtns.forEach(btn => {
|
||||
if (btn.textContent.trim() === letter || (letter === 'all' && btn.textContent.trim() === 'All')) {
|
||||
categoryBtns.forEach(btn => {
|
||||
if (btn.getAttribute('data-category') === category) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
@@ -1551,6 +1785,46 @@ function filterByLetter(letter) {
|
||||
displayStorePlugins();
|
||||
}
|
||||
|
||||
function filterByLetter(letter, evt) {
|
||||
currentFilter = letter;
|
||||
const alphaBtns = document.querySelectorAll('.alpha-btn');
|
||||
alphaBtns.forEach(btn => btn.classList.remove('active'));
|
||||
const clickedBtn = evt && (evt.currentTarget || (evt.target && evt.target.closest('.alpha-btn')));
|
||||
if (clickedBtn) {
|
||||
clickedBtn.classList.add('active');
|
||||
} else {
|
||||
const label = letter === 'all' ? 'All' : letter;
|
||||
alphaBtns.forEach(btn => {
|
||||
if (btn.textContent.trim() === label) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
displayStorePlugins();
|
||||
}
|
||||
|
||||
function clearPluginSearch() {
|
||||
const input = document.getElementById('pluginSearchInput');
|
||||
const clearBtn = document.getElementById('pluginSearchClear');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
currentSearchQuery = '';
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
displayStorePlugins();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAlphabetFilter() {
|
||||
const filter = document.getElementById('alphabetFilter');
|
||||
const toggleBtn = document.getElementById('alphabetToggleBtn');
|
||||
if (!filter || !toggleBtn) return;
|
||||
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
filter.style.display = isExpanded ? 'none' : 'flex';
|
||||
filter.setAttribute('aria-hidden', isExpanded ? 'true' : 'false');
|
||||
toggleBtn.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
|
||||
}
|
||||
|
||||
function upgradePlugin(pluginName, currentVersion, newVersion) {
|
||||
// Show confirmation dialog with backup warning
|
||||
const message = `⚠️ WARNING: Plugin Upgrade\n\n` +
|
||||
@@ -2206,16 +2480,46 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Update cache expiry time to local timezone
|
||||
updateCacheExpiryTime();
|
||||
|
||||
// Default to grid view if plugins exist, otherwise show store
|
||||
const gridView = document.getElementById('gridView');
|
||||
const storeView = document.getElementById('storeView');
|
||||
if (gridView && gridView.children.length > 0) {
|
||||
toggleView('grid');
|
||||
} else if (storeView) {
|
||||
// No plugins installed, show store by default
|
||||
toggleView('store');
|
||||
// Search input listener (debounced)
|
||||
const searchInput = document.getElementById('pluginSearchInput');
|
||||
const searchClearBtn = document.getElementById('pluginSearchClear');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function() {
|
||||
currentSearchQuery = this.value;
|
||||
if (searchClearBtn) {
|
||||
searchClearBtn.style.display = currentSearchQuery.trim() ? 'block' : 'none';
|
||||
}
|
||||
displayStorePlugins();
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
clearPluginSearch();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check URL hash for view preference
|
||||
const hash = window.location.hash.substring(1); // Remove #
|
||||
const validViews = ['grid', 'table', 'store'];
|
||||
|
||||
let initialView = 'grid'; // Default
|
||||
if (validViews.includes(hash)) {
|
||||
initialView = hash;
|
||||
} else {
|
||||
// Default to grid view if plugins exist, otherwise show store
|
||||
const gridView = document.getElementById('gridView');
|
||||
if (gridView && gridView.children.length > 0) {
|
||||
initialView = 'grid';
|
||||
} else {
|
||||
initialView = 'store';
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial view without updating hash (only update hash if there was already one)
|
||||
const hadHash = hash.length > 0;
|
||||
toggleView(initialView, hadHash);
|
||||
|
||||
// Load store plugins if store view is visible (either from toggleView or already displayed)
|
||||
setTimeout(function() {
|
||||
const storeViewCheck = document.getElementById('storeView');
|
||||
@@ -2224,5 +2528,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
});
|
||||
|
||||
// Handle hash changes (back/forward browser buttons)
|
||||
window.addEventListener('hashchange', function() {
|
||||
// Prevent infinite loops when we programmatically set the hash
|
||||
if (isSettingHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
const validViews = ['grid', 'table', 'store'];
|
||||
|
||||
if (validViews.includes(hash)) {
|
||||
// Don't update hash again since it's already set (user navigated via browser)
|
||||
toggleView(hash, false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -115,20 +115,27 @@ def installed(request):
|
||||
desc_elem = root.find('description')
|
||||
version_elem = root.find('version')
|
||||
|
||||
# Type field is optional (testPlugin doesn't have it)
|
||||
if name_elem is None or desc_elem is None or version_elem is None:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Missing required metadata fields (name, description, or version)'})
|
||||
# All fields required including type (category) - no default
|
||||
if name_elem is None or type_elem is None or desc_elem is None or version_elem is None:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Missing required metadata fields (name, type/category, description, or version)'})
|
||||
logging.writeToFile(f"Plugin {plugin}: Missing required metadata fields in meta.xml")
|
||||
continue
|
||||
|
||||
# Check if text is None (empty elements)
|
||||
if name_elem.text is None or desc_elem.text is None or version_elem.text is None:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Empty metadata fields'})
|
||||
# Check if text is None or empty (all required)
|
||||
type_text = type_elem.text.strip() if type_elem.text else ''
|
||||
if name_elem.text is None or desc_elem.text is None or version_elem.text is None or not type_text:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Empty metadata fields (name, type/category, description, or version required)'})
|
||||
logging.writeToFile(f"Plugin {plugin}: Empty metadata fields in meta.xml")
|
||||
continue
|
||||
|
||||
# Valid categories only: Utility, Security, Backup, Performance (Plugin category removed)
|
||||
if type_text.lower() not in ('utility', 'security', 'backup', 'performance'):
|
||||
errorPlugins.append({'name': plugin, 'error': f'Invalid category "{type_text}". Use: Utility, Security, Backup, or Performance.'})
|
||||
logging.writeToFile(f"Plugin {plugin}: Invalid category '{type_text}'")
|
||||
continue
|
||||
|
||||
data['name'] = name_elem.text
|
||||
data['type'] = type_elem.text if type_elem is not None and type_elem.text is not None else 'Plugin'
|
||||
data['type'] = type_text
|
||||
data['desc'] = desc_elem.text
|
||||
data['version'] = version_elem.text
|
||||
data['plugin_dir'] = plugin # Plugin directory name
|
||||
@@ -252,20 +259,28 @@ def installed(request):
|
||||
pluginMetaData = ElementTree.parse(metaXmlPath)
|
||||
root = pluginMetaData.getroot()
|
||||
|
||||
# Validate required fields
|
||||
# Validate required fields (including type/category - no default)
|
||||
name_elem = root.find('name')
|
||||
type_elem = root.find('type')
|
||||
desc_elem = root.find('description')
|
||||
version_elem = root.find('version')
|
||||
|
||||
if name_elem is None or desc_elem is None or version_elem is None:
|
||||
if name_elem is None or type_elem is None or desc_elem is None or version_elem is None:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Missing required metadata (name, type/category, description, or version)'})
|
||||
continue
|
||||
|
||||
if name_elem.text is None or desc_elem.text is None or version_elem.text is None:
|
||||
type_text = type_elem.text.strip() if type_elem.text else ''
|
||||
if name_elem.text is None or desc_elem.text is None or version_elem.text is None or not type_text:
|
||||
errorPlugins.append({'name': plugin, 'error': 'Empty metadata (type/category required)'})
|
||||
continue
|
||||
|
||||
# Valid categories only: Utility, Security, Backup, Performance (Plugin category removed)
|
||||
if type_text.lower() not in ('utility', 'security', 'backup', 'performance'):
|
||||
errorPlugins.append({'name': plugin, 'error': f'Invalid category "{type_text}". Use: Utility, Security, Backup, or Performance.'})
|
||||
continue
|
||||
|
||||
data['name'] = name_elem.text
|
||||
data['type'] = type_elem.text if type_elem is not None and type_elem.text is not None else 'Plugin'
|
||||
data['type'] = type_text
|
||||
data['desc'] = desc_elem.text
|
||||
data['version'] = version_elem.text
|
||||
data['plugin_dir'] = plugin
|
||||
@@ -943,6 +958,7 @@ def _enrich_store_plugins(plugins):
|
||||
elif 'is_paid' not in plugin or plugin.get('is_paid') is None:
|
||||
# Try to check from local meta.xml if available
|
||||
meta_path = None
|
||||
source_path = os.path.join(plugin_source_dir, plugin_dir)
|
||||
if os.path.exists(installed_path):
|
||||
meta_path = os.path.join(installed_path, 'meta.xml')
|
||||
elif os.path.exists(source_path):
|
||||
@@ -1050,10 +1066,20 @@ def _fetch_plugins_from_github():
|
||||
patreon_url_elem = root.find('patreon_url')
|
||||
patreon_url = patreon_url_elem.text if patreon_url_elem is not None else 'https://www.patreon.com/c/newstargeted/membership'
|
||||
|
||||
# Category (type) is required - valid: Utility, Security, Backup, Performance (Plugin removed)
|
||||
type_elem = root.find('type')
|
||||
if type_elem is None or not type_elem.text or not type_elem.text.strip():
|
||||
logging.writeToFile(f"Plugin {plugin_name}: Missing required type/category in meta.xml, skipping")
|
||||
continue
|
||||
type_text = type_elem.text.strip().lower()
|
||||
if type_text not in ('utility', 'security', 'backup', 'performance'):
|
||||
logging.writeToFile(f"Plugin {plugin_name}: Invalid category '{type_elem.text}', skipping (use Utility, Security, Backup, or Performance)")
|
||||
continue
|
||||
|
||||
plugin_data = {
|
||||
'plugin_dir': plugin_name,
|
||||
'name': root.find('name').text if root.find('name') is not None else plugin_name,
|
||||
'type': root.find('type').text if root.find('type') is not None else 'Plugin',
|
||||
'type': type_elem.text.strip(),
|
||||
'description': root.find('description').text if root.find('description') is not None else '',
|
||||
'version': root.find('version').text if root.find('version') is not None else '1.0.0',
|
||||
'url': root.find('url').text if root.find('url') is not None else f'/plugins/{plugin_name}/',
|
||||
@@ -1110,21 +1136,29 @@ def _fetch_plugins_from_github():
|
||||
@require_http_methods(["GET"])
|
||||
def fetch_plugin_store(request):
|
||||
"""Fetch plugins from the plugin store with caching"""
|
||||
mailUtilities.checkHome()
|
||||
|
||||
# Try to get from cache first
|
||||
cached_plugins = _get_cached_plugins()
|
||||
if cached_plugins is not None:
|
||||
# Enrich cached plugins with installed/enabled status
|
||||
enriched_plugins = _enrich_store_plugins(cached_plugins)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'plugins': enriched_plugins,
|
||||
'cached': True
|
||||
})
|
||||
|
||||
# Cache miss or expired - fetch from GitHub
|
||||
try:
|
||||
mailUtilities.checkHome()
|
||||
except Exception as e:
|
||||
logging.writeToFile(f"fetch_plugin_store: checkHome failed: {str(e)}")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Authentication required. Please log in again.',
|
||||
'plugins': []
|
||||
}, status=401)
|
||||
|
||||
try:
|
||||
# Try to get from cache first
|
||||
cached_plugins = _get_cached_plugins()
|
||||
if cached_plugins is not None:
|
||||
# Enrich cached plugins with installed/enabled status
|
||||
enriched_plugins = _enrich_store_plugins(cached_plugins)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'plugins': enriched_plugins,
|
||||
'cached': True
|
||||
})
|
||||
|
||||
# Cache miss or expired - fetch from GitHub
|
||||
plugins = _fetch_plugins_from_github()
|
||||
|
||||
# Enrich plugins with installed/enabled status
|
||||
@@ -1139,7 +1173,7 @@ def fetch_plugin_store(request):
|
||||
'plugins': enriched_plugins,
|
||||
'cached': False
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
|
||||
|
||||
13
to-do/PLUGIN-DEFAULT-REMOVAL-2026-02-01.md
Normal file
13
to-do/PLUGIN-DEFAULT-REMOVAL-2026-02-01.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Plugin Default Removal - 2026-02-01
|
||||
|
||||
## Summary
|
||||
CyberPanel repository no longer requires any plugins by default. Plugins are installed by users from the [Plugin Store](https://github.com/master3395/cyberpanel-plugins) via the CyberPanel Plugin Manager.
|
||||
|
||||
## Changes
|
||||
- **settings.py**: Removed `emailMarketing` from `INSTALLED_APPS`
|
||||
- **urls.py**: Commented out `emailMarketing` route (plugin installer adds it when plugin is installed)
|
||||
|
||||
## Plugin Installation
|
||||
Users install plugins from: https://github.com/master3395/cyberpanel-plugins
|
||||
|
||||
The plugin installer adds apps to `INSTALLED_APPS` and URL routes when plugins are installed via the Plugin Store UI.
|
||||
Reference in New Issue
Block a user