diff --git a/framework/core/js/src/admin/components/BasicsPage.tsx b/framework/core/js/src/admin/components/BasicsPage.tsx index 9496d7fe89..30a20666b3 100644 --- a/framework/core/js/src/admin/components/BasicsPage.tsx +++ b/framework/core/js/src/admin/components/BasicsPage.tsx @@ -6,6 +6,8 @@ import type { IPageAttrs } from '../../common/components/Page'; import type Mithril from 'mithril'; import Form from '../../common/components/Form'; import extractText from '../../common/utils/extractText'; +import Button from '../../common/components/Button'; +import Link from '../../common/components/Link'; export type HomePageItem = { path: string; label: Mithril.Children }; export type DriverLocale = { @@ -112,6 +114,32 @@ export default class BasicsPage ext }); }); + let abandonedSyncing = false; + let abandonedSyncMessage: Mithril.Children = null; + + function syncAbandoned() { + if (abandonedSyncing) return; + + abandonedSyncing = true; + abandonedSyncMessage = null; + + app + .request<{ count: number }>({ + method: 'POST', + url: app.forum.attribute('apiUrl') + '/extensions/abandoned/sync', + }) + .then((response) => { + abandonedSyncing = false; + abandonedSyncMessage = app.translator.trans('core.admin.basics.abandoned_extensions_sync_success', { count: response.count }); + m.redraw(); + }) + .catch(() => { + abandonedSyncing = false; + abandonedSyncMessage = app.translator.trans('core.admin.basics.abandoned_extensions_sync_error'); + m.redraw(); + }); + } + app.registry.for('core-basics'); app.registry @@ -203,5 +231,34 @@ export default class BasicsPage ext }); } }); + + app.registry.registerSetting( + function (this: AdminPage) { + return ( +
+ +
+ {app.translator.trans('core.admin.basics.abandoned_extensions_text', { + a: , + })} +
+
+ + {abandonedSyncMessage && {abandonedSyncMessage}} +
+
+ {this.buildSettingComponent({ + type: 'switch', + setting: 'flarum-core.notify_admins_on_abandoned', + label: app.translator.trans('core.admin.basics.abandoned_extensions_notify_admins_label'), + })} +
+ ); + }, + -10, + 'abandoned-extensions' + ); } } diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index be266eacab..1b97d1edca 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -159,6 +159,12 @@ core: title: Basics welcome_banner_heading: Welcome Banner welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum. + abandoned_extensions_heading: Abandoned Extensions + abandoned_extensions_text: "Flarum maintains a community list of abandoned extensions. When an installed extension appears on the list, it will be flagged in the admin panel." + abandoned_extensions_sync_button: Check Now + abandoned_extensions_sync_success: "Abandoned extensions list updated. {count} matching installed extension(s) found." + abandoned_extensions_sync_error: Failed to fetch the abandoned extensions list. Please try again later. + abandoned_extensions_notify_admins_label: Email admins when a newly abandoned extension is detected during the weekly check # These translations are used in the Create User modal. create_user: @@ -1132,6 +1138,14 @@ core: If this was not you, please ignore this email. + # These translations are used in emails sent to admins when abandoned extensions are detected + abandoned_extensions: + subject: "Action required: abandoned extension(s) detected" + body_intro: "The following installed extension(s) have been flagged as abandoned:" + body_outro: "Please review these extensions and consider migrating to alternatives where available." + line_with_replacement: "{package} (suggested replacement: {replacement})" + line_no_replacement: "{package} (no replacement available)" + ## # REUSED TRANSLATIONS - These keys should not be used directly in code! ## diff --git a/framework/core/src/Api/Controller/SyncAbandonedExtensionsController.php b/framework/core/src/Api/Controller/SyncAbandonedExtensionsController.php new file mode 100644 index 0000000000..1a8849992d --- /dev/null +++ b/framework/core/src/Api/Controller/SyncAbandonedExtensionsController.php @@ -0,0 +1,38 @@ +assertAdmin(); + + try { + $result = $this->fetcher->sync(true, true); + } catch (\RuntimeException $e) { + return new JsonResponse(['error' => $e->getMessage()], 500); + } + + return new JsonResponse(['count' => $result['count'], 'new' => $result['new']]); + } +} diff --git a/framework/core/src/Api/routes.php b/framework/core/src/Api/routes.php index c5d7b7067a..8b6406763b 100644 --- a/framework/core/src/Api/routes.php +++ b/framework/core/src/Api/routes.php @@ -204,6 +204,13 @@ $route->toController(Controller\SendTestMailController::class) ); + // Trigger a sync of the abandoned extensions list + $map->post( + '/extensions/abandoned/sync', + 'extensions.abandoned.sync', + $route->toController(Controller\SyncAbandonedExtensionsController::class) + ); + // List Flarum community announcements from discuss.flarum.org $map->get( '/flarum/announcements', diff --git a/framework/core/src/Extension/AbandonedExtensionsFetcher.php b/framework/core/src/Extension/AbandonedExtensionsFetcher.php new file mode 100644 index 0000000000..cdd6b87ea1 --- /dev/null +++ b/framework/core/src/Extension/AbandonedExtensionsFetcher.php @@ -0,0 +1,170 @@ +fetch(); + $installed = $this->installedPackageNames(); + + $filtered = array_filter( + $map, + function (string $name) use ($installed) { + return isset($installed[$name]); + }, + ARRAY_FILTER_USE_KEY + ); + + $previous = static::getCachedMap($this->settings); + $new = array_keys(array_diff_key($filtered, $previous)); + + $this->settings->set(self::SETTINGS_KEY, json_encode($filtered)); + + if ($notify && $this->settings->get(self::NOTIFY_ADMINS_SETTING)) { + // Manual trigger: notify about all installed abandoned extensions. + // Scheduled trigger: only notify about newly detected ones. + $toNotify = $manual ? array_keys($filtered) : $new; + + if ($toNotify) { + $this->notifyAdmins($toNotify, $filtered); + } + } + + return ['count' => count($filtered), 'new' => $new]; + } + + /** + * @throws RuntimeException + */ + protected function fetch(): array + { + try { + $response = $this->client->get(self::SOURCE_URL, [ + 'allow_redirects' => false, + 'timeout' => 10, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Flarum/'.Application::VERSION, + ], + ]); + } catch (GuzzleException $e) { + throw new RuntimeException('Could not fetch abandoned extensions list: '.$e->getMessage(), 0, $e); + } + + $data = json_decode((string) $response->getBody(), true); + + if (! is_array($data)) { + throw new RuntimeException('Abandoned extensions list returned invalid JSON.'); + } + + return $data; + } + + protected function notifyAdmins(array $newPackages, array $map): void + { + $admins = User::whereHas('groups', function ($q) { + $q->where('id', Group::ADMINISTRATOR_ID); + })->get(); + + $lines = array_map(function (string $package) use ($map) { + $replacement = $map[$package]['replacement'] ?? null; + + return $replacement + ? $this->translator->trans('core.email.abandoned_extensions.line_with_replacement', compact('package', 'replacement')) + : $this->translator->trans('core.email.abandoned_extensions.line_no_replacement', compact('package')); + }, $newPackages); + + $subject = $this->translator->trans('core.email.abandoned_extensions.subject'); + $forumTitle = $this->settings->get('forum_title', ''); + + foreach ($admins as $admin) { + $this->queue->push(new SendAbandonedExtensionsEmailJob( + email: $admin->email, + username: $admin->display_name, + subject: $subject, + extensionLines: $lines, + forumTitle: $forumTitle, + )); + } + } + + /** + * Returns an associative array of composer package name => true for all + * installed Flarum extensions. + */ + protected function installedPackageNames(): array + { + $names = []; + + foreach ($this->extensions->getExtensions() as $extension) { + $names[$extension->name] = true; + } + + return $names; + } + + /** + * Return the cached map from settings, or an empty array if not yet fetched. + * + * @return array + */ + public static function getCachedMap(SettingsRepositoryInterface $settings): array + { + $raw = $settings->get(self::SETTINGS_KEY); + + if (! $raw) { + return []; + } + + $data = json_decode($raw, true); + + return is_array($data) ? $data : []; + } +} diff --git a/framework/core/src/Extension/Console/SyncAbandonedExtensionsCommand.php b/framework/core/src/Extension/Console/SyncAbandonedExtensionsCommand.php new file mode 100644 index 0000000000..6074fd824c --- /dev/null +++ b/framework/core/src/Extension/Console/SyncAbandonedExtensionsCommand.php @@ -0,0 +1,40 @@ +info('Fetching abandoned extensions list...'); + + try { + $result = $fetcher->sync($this->option('notify')); + } catch (\RuntimeException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $this->info("Stored {$result['count']} abandoned extension(s) matching installed packages."); + + if ($result['new']) { + $this->info('Newly flagged: '.implode(', ', $result['new'])); + } + + return self::SUCCESS; + } +} diff --git a/framework/core/src/Extension/Console/WeeklySchedule.php b/framework/core/src/Extension/Console/WeeklySchedule.php new file mode 100644 index 0000000000..b841d53c71 --- /dev/null +++ b/framework/core/src/Extension/Console/WeeklySchedule.php @@ -0,0 +1,20 @@ +weekly()->withoutOverlapping(); + } +} diff --git a/framework/core/src/Extension/ExtensionManager.php b/framework/core/src/Extension/ExtensionManager.php index ac7e4ddd1f..24b1c0c9c3 100644 --- a/framework/core/src/Extension/ExtensionManager.php +++ b/framework/core/src/Extension/ExtensionManager.php @@ -520,21 +520,31 @@ protected function extensionFromJson(array $package, string $path): Extension $extension->setInstalled(true); $extension->setVersion(Arr::get($package, 'version', '0.0')); - // Set abandoned status if the package is marked as abandoned in composer - // The abandoned field can be either true (no replacement) or a string (replacement package name) - $abandoned = Arr::get($package, 'abandoned'); - if (is_string($abandoned) && ! empty($abandoned)) { - // Always set abandoned status if a replacement package is specified - $extension->setAbandoned($abandoned); - } elseif ($abandoned === true) { - // If abandoned is just true (no replacement), check the package source - // Packages from flarum.org/composer may have unreliable abandoned flags - $distUrl = Arr::get($package, 'dist.url', ''); - $isFromFlarumComposer = str_contains($distUrl, 'flarum.org/composer'); - - // Only set abandoned if NOT from flarum.org/composer - if (! $isFromFlarumComposer) { + $packageName = Arr::get($package, 'name'); + + // The flarum/abandoned-extensions list takes precedence over composer's abandoned field, + // allowing us to flag extensions that haven't been marked on Packagist. + $abandonedMap = AbandonedExtensionsFetcher::getCachedMap($this->config); + if (isset($abandonedMap[$packageName])) { + $replacement = Arr::get($abandonedMap[$packageName], 'replacement', true); + $extension->setAbandoned($replacement); + } else { + // Set abandoned status if the package is marked as abandoned in composer. + // The abandoned field can be either true (no replacement) or a string (replacement package name). + $abandoned = Arr::get($package, 'abandoned'); + if (is_string($abandoned) && ! empty($abandoned)) { + // Always set abandoned status if a replacement package is specified. $extension->setAbandoned($abandoned); + } elseif ($abandoned === true) { + // If abandoned is just true (no replacement), check the package source. + // Packages from flarum.org/composer may have unreliable abandoned flags. + $distUrl = Arr::get($package, 'dist.url', ''); + $isFromFlarumComposer = str_contains($distUrl, 'flarum.org/composer'); + + // Only set abandoned if NOT from flarum.org/composer. + if (! $isFromFlarumComposer) { + $extension->setAbandoned($abandoned); + } } } diff --git a/framework/core/src/Extension/ExtensionServiceProvider.php b/framework/core/src/Extension/ExtensionServiceProvider.php index 7a6eab251a..20f62495e5 100644 --- a/framework/core/src/Extension/ExtensionServiceProvider.php +++ b/framework/core/src/Extension/ExtensionServiceProvider.php @@ -9,11 +9,16 @@ namespace Flarum\Extension; +use Flarum\Extension\Console\SyncAbandonedExtensionsCommand; +use Flarum\Extension\Console\WeeklySchedule; use Flarum\Extension\Event\Disabling; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Settings\SettingsRepositoryInterface; +use GuzzleHttp\Client; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Contracts\Queue\Queue; +use Symfony\Contracts\Translation\TranslatorInterface; class ExtensionServiceProvider extends AbstractServiceProvider { @@ -22,6 +27,16 @@ public function register(): void $this->container->singleton(ExtensionManager::class); $this->container->alias(ExtensionManager::class, 'flarum.extensions'); + $this->container->singleton(AbandonedExtensionsFetcher::class, function ($container) { + return new AbandonedExtensionsFetcher( + $container->make(ExtensionManager::class), + $container->make('flarum.settings'), + new Client(), + $container->make(Queue::class), + $container->make(TranslatorInterface::class) + ); + }); + // Boot extensions when the app is booting. This must be done as a boot // listener on the app rather than in the service provider's boot method // below, so that extensions have a chance to register things on the @@ -42,5 +57,21 @@ public function boot(Dispatcher $events, SettingsRepositoryInterface $settings): Disabling::class, DefaultLanguagePackGuard::class ); + + $this->container->extend('flarum.console.commands', function (array $commands) { + $commands[] = SyncAbandonedExtensionsCommand::class; + + return $commands; + }); + + $this->container->extend('flarum.console.scheduled', function (array $scheduled) { + $scheduled[] = [ + 'command' => SyncAbandonedExtensionsCommand::class, + 'args' => ['--notify'], + 'callback' => new WeeklySchedule(), + ]; + + return $scheduled; + }); } } diff --git a/framework/core/src/Mail/Job/SendAbandonedExtensionsEmailJob.php b/framework/core/src/Mail/Job/SendAbandonedExtensionsEmailJob.php new file mode 100644 index 0000000000..690cfaeb86 --- /dev/null +++ b/framework/core/src/Mail/Job/SendAbandonedExtensionsEmailJob.php @@ -0,0 +1,49 @@ +username; + $forumTitle = $this->forumTitle; + $userEmail = $this->email; + $extensionLines = $this->extensionLines; + + $view->share(compact('forumTitle', 'userEmail', 'username')); + + $mailer->send( + [ + 'html' => 'mail::html.abandoned_extensions.notify', + 'text' => 'mail::plain.abandoned_extensions.notify', + ], + compact('extensionLines'), + function (Message $message) { + $message->to($this->email); + $message->subject($this->subject); + } + ); + } +} diff --git a/framework/core/tests/integration/api/extensions/SyncAbandonedExtensionsTest.php b/framework/core/tests/integration/api/extensions/SyncAbandonedExtensionsTest.php new file mode 100644 index 0000000000..44b5351804 --- /dev/null +++ b/framework/core/tests/integration/api/extensions/SyncAbandonedExtensionsTest.php @@ -0,0 +1,96 @@ +extend( + (new Extend\ServiceProvider())->register(StubAbandonedExtensionsProvider::class) + ); + + $this->prepareDatabase([ + User::class => [$this->normalUser()], + ]); + } + + #[Test] + public function guest_cannot_trigger_sync(): void + { + $response = $this->send( + $this->request('POST', '/api/extensions/abandoned/sync') + ->withAttribute('bypassCsrfToken', true) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + #[Test] + public function normal_user_cannot_trigger_sync(): void + { + $response = $this->send( + $this->request('POST', '/api/extensions/abandoned/sync', [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + #[Test] + public function admin_can_trigger_sync(): void + { + $response = $this->send( + $this->request('POST', '/api/extensions/abandoned/sync', [ + 'authenticatedAs' => 1, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('count', $body); + $this->assertIsInt($body['count']); + } +} + +class StubAbandonedExtensionsFetcher extends AbandonedExtensionsFetcher +{ + public function __construct() + { + } + + public function sync(bool $notify = false, bool $manual = false): array + { + return ['count' => 2, 'new' => ['vendor/pkg-a']]; + } +} + +class StubAbandonedExtensionsProvider extends AbstractServiceProvider +{ + public function register(): void + { + $this->container->instance(AbandonedExtensionsFetcher::class, new StubAbandonedExtensionsFetcher()); + } +} diff --git a/framework/core/tests/unit/Extension/AbandonedExtensionsFetcherTest.php b/framework/core/tests/unit/Extension/AbandonedExtensionsFetcherTest.php new file mode 100644 index 0000000000..4063030af3 --- /dev/null +++ b/framework/core/tests/unit/Extension/AbandonedExtensionsFetcherTest.php @@ -0,0 +1,78 @@ +createMock(SettingsRepositoryInterface::class); + $settings->method('get') + ->with(AbandonedExtensionsFetcher::SETTINGS_KEY) + ->willReturn($json); + + return $settings; + } + + #[Test] + public function returns_empty_array_when_no_cached_map(): void + { + $map = AbandonedExtensionsFetcher::getCachedMap($this->settingsWithMap(null)); + + $this->assertSame([], $map); + } + + #[Test] + public function returns_empty_array_when_cached_value_is_invalid_json(): void + { + $map = AbandonedExtensionsFetcher::getCachedMap($this->settingsWithMap('not-valid-json')); + + $this->assertSame([], $map); + } + + #[Test] + public function returns_abandoned_entry_without_replacement(): void + { + $map = AbandonedExtensionsFetcher::getCachedMap($this->settingsWithMap(json_encode([ + 'vendor/old-package' => [], + ]))); + + $this->assertArrayHasKey('vendor/old-package', $map); + $this->assertSame([], $map['vendor/old-package']); + } + + #[Test] + public function returns_abandoned_entry_with_replacement(): void + { + $map = AbandonedExtensionsFetcher::getCachedMap($this->settingsWithMap(json_encode([ + 'vendor/old-package' => ['replacement' => 'vendor/new-package'], + ]))); + + $this->assertSame('vendor/new-package', $map['vendor/old-package']['replacement']); + } + + #[Test] + public function returns_multiple_entries(): void + { + $map = AbandonedExtensionsFetcher::getCachedMap($this->settingsWithMap(json_encode([ + 'vendor/pkg-a' => ['replacement' => 'vendor/pkg-b'], + 'vendor/pkg-c' => [], + ]))); + + $this->assertCount(2, $map); + $this->assertArrayHasKey('vendor/pkg-a', $map); + $this->assertArrayHasKey('vendor/pkg-c', $map); + } +} diff --git a/framework/core/views/email/html/abandoned_extensions/notify.blade.php b/framework/core/views/email/html/abandoned_extensions/notify.blade.php new file mode 100644 index 0000000000..35f54161cd --- /dev/null +++ b/framework/core/views/email/html/abandoned_extensions/notify.blade.php @@ -0,0 +1,11 @@ + + +

{{ $translator->trans('core.email.abandoned_extensions.body_intro') }}

+
    + @foreach ($extensionLines as $line) +
  • {{ $line }}
  • + @endforeach +
+

{{ $translator->trans('core.email.abandoned_extensions.body_outro') }}

+
+
diff --git a/framework/core/views/email/plain/abandoned_extensions/notify.blade.php b/framework/core/views/email/plain/abandoned_extensions/notify.blade.php new file mode 100644 index 0000000000..309c059a15 --- /dev/null +++ b/framework/core/views/email/plain/abandoned_extensions/notify.blade.php @@ -0,0 +1,11 @@ + + +{{ $translator->trans('core.email.abandoned_extensions.body_intro') }} + +@foreach ($extensionLines as $line) +{{ $line }} +@endforeach + +{{ $translator->trans('core.email.abandoned_extensions.body_outro') }} + +