Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please

<!-- COUNT-STATS:START -->

_Last updated: 2026-05-14T19:20:29.097Z_
_Last updated: 2026-05-14T21:32:57.470Z_

| Extension | Files | Lines |
| --- | ---: | ---: |
| `.php` | 504 | 126,600 |
| `.tsx` | 355 | 111,800 |
| `.php` | 504 | 126,711 |
| `.tsx` | 355 | 112,571 |
| `.ts` | 70 | 7,661 |
| `.yaml` | 3 | 5,940 |
| `.rs` | 16 | 3,395 |
| `.sql` | 130 | 2,034 |
| `.yml` | 18 | 1,877 |
| `.css` | 7 | 445 |
| **Total** | 1,103 | 259,752 |
| **Total** | 1,103 | 260,634 |

<!-- COUNT-STATS:END -->

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.3.7.2 STABLE

### Fixed

- Enhanced the plugin loader strategy to resolve minor issues introduced in the previous update. by @nayskutzu

## v1.3.7.1 STABLE

### Improved
Expand Down
2 changes: 1 addition & 1 deletion app
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ define('APP_ADDONS_DIR', APP_STORAGE_DIR . 'addons');
define('APP_SOURCECODE_DIR', APP_DIR . 'app');
define('APP_ROUTES_DIR', APP_SOURCECODE_DIR . '/Api');
define('SYSTEM_KERNEL_NAME', php_uname('s'));
define('APP_VERSION', 'v1.3.7.1');
define('APP_VERSION', 'v1.3.7.2');
define('APP_UPSTREAM', 'stable');
define('TELEMETRY', true);
define('IS_CLI', true);
Expand Down
22 changes: 21 additions & 1 deletion backend/app/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,20 @@ public function registerApiRoutes(RouteCollection $routes): void
}
}

// Load plugin routes from backend/storage/addons/*/routes/
// Load plugin routes from backend/storage/addons/*/Routes/
$addonsDir = __DIR__ . '/../storage/addons';
if (is_dir($addonsDir)) {
$addonIdentifiers = [];
foreach (new \DirectoryIterator($addonsDir) as $entry) {
if ($entry->isDir() && !$entry->isDot()) {
$addonIdentifiers[$entry->getBasename()] = true;
}
}

$pluginDirs = new \DirectoryIterator($addonsDir);
foreach ($pluginDirs as $pluginDir) {
if ($pluginDir->isDir() && !$pluginDir->isDot()) {
$currentAddonId = $pluginDir->getBasename();
$pluginRoutesDir = $pluginDir->getPathname() . '/Routes';
if (is_dir($pluginRoutesDir)) {
$pluginIterator = new \RecursiveIteratorIterator(
Expand All @@ -216,6 +224,18 @@ public function registerApiRoutes(RouteCollection $routes): void

foreach ($pluginIterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$routeBasename = $file->getBasename('.php');
if (
isset($addonIdentifiers[$routeBasename])
&& $routeBasename !== $currentAddonId
) {
self::getLogger()->warning(
'Skipping addon route file whose name matches another addon identifier '
. "(likely a mistaken copy): {$file->getPathname()}"
);

continue;
}
try {
$register = require $file->getPathname();
if (is_callable($register)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,14 @@ public function getOAuth2Link(Request $request): Response

// Get panel information
$panelName = $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel');
$panelUrl = $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems');
// app_url may exist in DB as an empty string, which bypasses the default argument to getSetting()
$panelUrl = trim((string) ($config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems') ?? ''));
if ($panelUrl === '') {
$panelUrl = rtrim($request->getSchemeAndHttpHost() . $request->getBasePath(), '/');
}
if ($panelUrl === '') {
$panelUrl = 'https://featherpanel.mythical.systems';
}
$logoUrl = $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png');

// Get or generate panel credentials
Expand Down
394 changes: 247 additions & 147 deletions backend/app/Controllers/Admin/CloudPluginsController.php

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions backend/app/Plugins/PluginProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public static function getEventProcessor(string $identifier): ?AppPlugin
$entryClass = $config['plugin']['name'];

$eventClass = "App\\Addons\\{$identifier}\\{$entryClass}";
if (!class_exists($eventClass)) {
$discovered = self::discoverAppPluginClassInAddonRoot($identifier);
if ($discovered !== null) {
$eventClass = $discovered;
}
}
if (!class_exists($eventClass)) {
$logger->warning("Event class not found: {$eventClass}");

Expand Down Expand Up @@ -197,4 +203,39 @@ public static function hasMixin(string $identifier, string $mixinId): bool
return false;
}
}

/**
* When plugin.name in conf.yml does not match the AppPlugin entry class (for example
* after a copy-paste between similar addons), locate the single AppPlugin implementation
* shipped at the root of the addon directory.
*/
private static function discoverAppPluginClassInAddonRoot(string $identifier): ?string
{
$dir = PluginHelper::getPluginsDir() . '/' . $identifier;
if (!is_dir($dir)) {
return null;
}

$candidates = [];
foreach (glob($dir . '/*.php') ?: [] as $file) {
$basename = basename($file, '.php');
$class = "App\\Addons\\{$identifier}\\{$basename}";
if (class_exists($class) && is_subclass_of($class, AppPlugin::class)) {
$candidates[] = $class;
}
}

if (count($candidates) === 1) {
return $candidates[0];
}

if (count($candidates) > 1) {
App::getInstance(true)->getLogger()->warning(
'Multiple AppPlugin classes in addon root for ' . $identifier
. '; set plugin.name in conf.yml to the correct entry class name.'
);
}

return null;
}
}
2 changes: 1 addition & 1 deletion backend/cli
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ define('APP_START', microtime(true));
define('APP_DIR', APP_PUBLIC . '/');
define('APP_CRON_DIR', APP_PUBLIC . '/storage/cron/');
define('SYSTEM_KERNEL_NAME', php_uname('s'));
define('APP_VERSION', 'v1.3.7.1');
define('APP_VERSION', 'v1.3.7.2');
define('APP_UPSTREAM', 'stable');
define('REQUEST_ID', uniqid());
define('TELEMETRY', true);
Expand Down
2 changes: 1 addition & 1 deletion backend/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
define('SYSTEM_OS_NAME', gethostname() . '/' . PHP_OS_FAMILY);
define('SYSTEM_KERNEL_NAME', php_uname('s'));
define('TELEMETRY', true);
define('APP_VERSION', 'v1.3.7.1');
define('APP_VERSION', 'v1.3.7.2');
define('APP_UPSTREAM', 'stable');
define('REQUEST_ID', uniqid());

Expand Down
2 changes: 1 addition & 1 deletion frontendv2/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "frontendv2",
"version": "1.3.7+1",
"version": "1.3.7+2",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
Expand Down
36 changes: 35 additions & 1 deletion frontendv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5983,6 +5983,10 @@
"action": "Cloud management"
},
"popular": "Popular Plugins",
"search_section_title": "Search plugins",
"search_helper": "Search by addon name, author, or keyword from the description. Press Enter or use the Search button to load results.",
"search_button": "Search",
"sort_label": "Sort plugin list",
"search_placeholder": "Search online addons...",
"verified_only": "Verified Only",
"loading": "Loading online addons...",
Expand All @@ -5997,8 +6001,10 @@
"install": "Install",
"installing": "Installing...",
"requires_cloud": "Requires Cloud Account",
"premium_not_licensed": "Not licensed on this FeatherCloud team",
"verified": "Verified",
"premium": "Premium",
"purchase_at_store": "Buy on store",
"website": "Website",
"downloads": "downloads",
"tag_label": "Tag:",
Expand All @@ -6010,6 +6016,14 @@
"available": "Available Plugins",
"clear_search": "Clear Search",
"no_results": "No results found",
"online_list_heading": "Online addons",
"online_list_count": "Showing {shown} of {total} addons",
"purchase_official_store": "Official store",
"results_title": "Search results",
"results_showing": "Showing {shown} of {total} addons",
"load_more": "Load more",
"load_more_hint": "Loaded page {page} of {pages}. Tap to fetch the next batch.",
"results_count": "{count} addons",
"featured": "Featured",
"has_dependencies": "Has Dependencies",
"install_success": "Successfully installed {identifier}",
Expand Down Expand Up @@ -6041,7 +6055,20 @@
"install_success_multiple": "Installed {count} plugins successfully",
"install_failed": "Failed to install selected plugins",
"install_failed_single": "Failed to install {identifier}",
"multiple_requirements_issues": "{count} plugins have unmet requirements and cannot be installed"
"multiple_requirements_issues": "{count} plugins have unmet requirements and cannot be installed",
"premium_not_owned": "This premium addon is not in your FeatherCloud purchases. Only addons your team already owns can go in the download list.",
"premium_skipped_not_owned": "Some premium plugins were skipped because your FeatherCloud team does not own them."
},
"ui_preview": {
"banner_title": "UI preview mode",
"banner_body": "You are viewing mock marketplace cards only. Installs are simulated (no server changes). Type a preview keyword in the search field and press Enter again to exit.",
"preview_on": "Plugin UI preview turned on",
"preview_off": "Plugin UI preview turned off",
"install_disabled": "Preview mode: install actions are disabled",
"install_simulated": "Simulated install: {identifier} (preview only)",
"bulk_simulated": "Simulated batch install for {count} plugin(s) (preview only)",
"open_sample_dialog": "Open sample missing-requirements dialog",
"secret_hint": "Tip: type testpluginuinow or testingpluginui in the search field and press Enter to toggle UI preview (mock list, simulated installs)."
},
"repo": {
"title": "Plugin Repositories",
Expand Down Expand Up @@ -6073,6 +6100,13 @@
"learn_more": "Learn more"
},
"search_placeholder": "Search online spells...",
"search_section_title": "Search spells",
"search_helper": "Search by spell name, category, or keyword from the description. Press Enter or Search to load results.",
"search_button": "Search",
"online_list_heading": "Online spells",
"online_list_count": "Showing {shown} of {total} spells",
"load_more": "Load more",
"load_more_hint": "Loaded page {page} of {pages}. Tap to fetch the next batch.",
"loading": "Loading online spells...",
"grid": {
"verified": "Verified",
Expand Down
65 changes: 34 additions & 31 deletions frontendv2/src/app/(app)/admin/analytics/system/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,38 +208,41 @@ export default function SystemAnalyticsPage() {
<CardContent>
{stats.recent_queued.length > 0 ? (
<div className='space-y-6'>
{stats.recent_queued.map((item) => (
<div
key={item.id}
className='flex items-center justify-between border-b pb-4 last:border-0 last:pb-0'
>
<div className='space-y-1'>
<p className='text-sm font-medium'>{item.subject}</p>
<p className='text-muted-foreground text-xs'>
{item.recipient}
</p>
{stats.recent_queued.map((item) => {
const statusKey = item.status === 'pending' ? 'queued' : item.status;
return (
<div
key={item.id}
className='flex items-center justify-between border-b pb-4 last:border-0 last:pb-0'
>
<div className='space-y-1'>
<p className='text-sm font-medium'>{item.subject}</p>
<p className='text-muted-foreground text-xs'>
{item.recipient}
</p>
</div>
<div className='text-right'>
<span
className={`rounded-full px-2 py-1 text-xs ${
item.status === 'sent'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: item.status === 'failed'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}
>
{t(`admin.analytics.system.status.${statusKey}`) ||
item.status}
</span>
<p className='text-muted-foreground mt-1 text-xs'>
{formatDistanceToNow(new Date(item.created_at), {
addSuffix: true,
})}
</p>
</div>
</div>
<div className='text-right'>
<span
className={`rounded-full px-2 py-1 text-xs ${
item.status === 'sent'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: item.status === 'failed'
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}
>
{t(`admin.analytics.system.status.${item.status}`) ||
item.status}
</span>
<p className='text-muted-foreground mt-1 text-xs'>
{formatDistanceToNow(new Date(item.created_at), {
addSuffix: true,
})}
</p>
</div>
</div>
))}
);
})}
</div>
) : (
<div className='text-muted-foreground flex justify-center py-8'>
Expand Down
Loading
Loading