Skip to content

Commit 308604d

Browse files
committed
feat: sync abandoned extensions list from flarum/abandoned-extensions
Adds a weekly scheduled task that fetches a community-maintained list of abandoned Flarum extensions from GitHub, filters to installed packages, and stores the result in settings. Admins are flagged in the extension list and can optionally be notified by email when newly abandoned extensions are detected. - AbandonedExtensionsFetcher: fetches, filters, stores, and diffs the list - SyncAbandonedExtensionsCommand: CLI with --notify flag, scheduled weekly - SyncAbandonedExtensionsController: POST /api/extensions/abandoned/sync - BasicsPage: "Check Now" button and notify-admins toggle - ExtensionManager: applies abandoned status from the stored map
1 parent a6de568 commit 308604d

11 files changed

Lines changed: 563 additions & 14 deletions

File tree

framework/core/js/src/admin/components/BasicsPage.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import app from '../../admin/app';
22
import FieldSet from '../../common/components/FieldSet';
3+
import Button from '../../common/components/Button';
34
import ItemList from '../../common/utils/ItemList';
45
import AdminPage from './AdminPage';
56
import type { IPageAttrs } from '../../common/components/Page';
67
import Mithril from 'mithril';
8+
import Link from '../../common/components/Link';
79

810
export type HomePageItem = { path: string; label: Mithril.Children };
911

1012
export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
1113
localeOptions: Record<string, string> = {};
1214
displayNameOptions: Record<string, string> = {};
1315
slugDriverOptions: Record<string, Record<string, string>> = {};
16+
abandonedSyncing = false;
17+
abandonedSyncMessage: Mithril.Children = null;
1418

1519
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
1620
super.oninit(vnode);
@@ -65,6 +69,29 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
6569
return items;
6670
}
6771

72+
syncAbandoned() {
73+
if (this.abandonedSyncing) return;
74+
75+
this.abandonedSyncing = true;
76+
this.abandonedSyncMessage = null;
77+
78+
app
79+
.request<{ count: number }>({
80+
method: 'POST',
81+
url: app.forum.attribute('apiUrl') + '/extensions/abandoned/sync',
82+
})
83+
.then((response) => {
84+
this.abandonedSyncing = false;
85+
this.abandonedSyncMessage = app.translator.trans('core.admin.basics.abandoned_extensions_sync_success', { count: response.count });
86+
m.redraw();
87+
})
88+
.catch(() => {
89+
this.abandonedSyncing = false;
90+
this.abandonedSyncMessage = app.translator.trans('core.admin.basics.abandoned_extensions_sync_error');
91+
m.redraw();
92+
});
93+
}
94+
6895
contentItems(): ItemList<Mithril.Children> {
6996
const items = new ItemList<Mithril.Children>();
7097

@@ -169,6 +196,31 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
169196
40
170197
);
171198

199+
items.add(
200+
'abandoned-extensions',
201+
<div className="Form-group">
202+
<label>{app.translator.trans('core.admin.basics.abandoned_extensions_heading')}</label>
203+
<div className="helpText">
204+
{app.translator.trans('core.admin.basics.abandoned_extensions_text', {
205+
a: <Link href="https://github.com/flarum/abandoned-extensions" target="_blank" external={true} />,
206+
})}
207+
</div>
208+
<div className="Form-row">
209+
<Button className="Button" onclick={this.syncAbandoned.bind(this)} loading={this.abandonedSyncing} disabled={this.abandonedSyncing}>
210+
{app.translator.trans('core.admin.basics.abandoned_extensions_sync_button')}
211+
</Button>
212+
{this.abandonedSyncMessage && <span className="helpText">{this.abandonedSyncMessage}</span>}
213+
</div>
214+
<br />
215+
{this.buildSettingComponent({
216+
type: 'switch',
217+
setting: 'flarum-core.notify_admins_on_abandoned',
218+
label: app.translator.trans('core.admin.basics.abandoned_extensions_notify_admins_label'),
219+
})}
220+
</div>,
221+
30
222+
);
223+
172224
return items;
173225
}
174226
}

framework/core/locale/core.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ core:
5151
title: Basics
5252
welcome_banner_heading: Welcome Banner
5353
welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
54+
abandoned_extensions_heading: Abandoned Extensions
55+
abandoned_extensions_text: "Flarum maintains a <a>community list of abandoned extensions</a>. When an installed extension appears on the list, it will be flagged in the admin panel."
56+
abandoned_extensions_sync_button: Check Now
57+
abandoned_extensions_sync_success: "Abandoned extensions list updated. {count} matching installed extension(s) found."
58+
abandoned_extensions_sync_error: Failed to fetch the abandoned extensions list. Please try again later.
59+
abandoned_extensions_notify_admins_label: Email admins when a newly abandoned extension is detected during the weekly check
5460

5561
# These translations are used in the Create User modal.
5662
create_user:
@@ -825,6 +831,20 @@ core:
825831
826832
If this was not you, please ignore this email.
827833
834+
# These translations are used in emails sent to admins when abandoned extensions are detected
835+
abandoned_extensions:
836+
subject: "Action required: abandoned extension(s) detected"
837+
body: |
838+
Hi {username},
839+
840+
The following installed extension(s) have been flagged as abandoned:
841+
842+
{extensions}
843+
844+
Please review these extensions and consider migrating to alternatives where available.
845+
line_with_replacement: "- {package} (suggested replacement: {replacement})"
846+
line_no_replacement: "- {package} (no replacement available)"
847+
828848
##
829849
# REUSED TRANSLATIONS - These keys should not be used directly in code!
830850
##
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Api\Controller;
11+
12+
use Flarum\Extension\AbandonedExtensionsFetcher;
13+
use Flarum\Http\RequestUtil;
14+
use Laminas\Diactoros\Response\EmptyResponse;
15+
use Laminas\Diactoros\Response\JsonResponse;
16+
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\ServerRequestInterface;
18+
use Psr\Http\Server\RequestHandlerInterface;
19+
20+
class SyncAbandonedExtensionsController implements RequestHandlerInterface
21+
{
22+
public function __construct(
23+
protected AbandonedExtensionsFetcher $fetcher
24+
) {
25+
}
26+
27+
public function handle(ServerRequestInterface $request): ResponseInterface
28+
{
29+
RequestUtil::getActor($request)->assertAdmin();
30+
31+
try {
32+
$result = $this->fetcher->sync(notify: true, manual: true);
33+
} catch (\RuntimeException $e) {
34+
return new JsonResponse(['error' => $e->getMessage()], 500);
35+
}
36+
37+
return new JsonResponse(['count' => $result['count'], 'new' => $result['new']]);
38+
}
39+
}

framework/core/src/Api/routes.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,13 @@
377377
$route->toController(Controller\SendTestMailController::class)
378378
);
379379

380+
// Trigger a sync of the abandoned extensions list
381+
$map->post(
382+
'/extensions/abandoned/sync',
383+
'extensions.abandoned.sync',
384+
$route->toController(Controller\SyncAbandonedExtensionsController::class)
385+
);
386+
380387
// List Flarum community announcements from discuss.flarum.org
381388
$map->get(
382389
'/flarum/announcements',
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Extension;
11+
12+
use Flarum\Foundation\Application;
13+
use Flarum\Group\Group;
14+
use Flarum\Mail\Job\SendRawEmailJob;
15+
use Flarum\Settings\SettingsRepositoryInterface;
16+
use Flarum\User\User;
17+
use GuzzleHttp\Client;
18+
use GuzzleHttp\Exception\GuzzleException;
19+
use Illuminate\Contracts\Queue\Queue;
20+
use RuntimeException;
21+
use Symfony\Contracts\Translation\TranslatorInterface;
22+
23+
class AbandonedExtensionsFetcher
24+
{
25+
public const SETTINGS_KEY = 'flarum-core.abandoned_extensions_map';
26+
public const NOTIFY_ADMINS_SETTING = 'flarum-core.notify_admins_on_abandoned';
27+
28+
protected const SOURCE_URL = 'https://raw.githubusercontent.com/flarum/abandoned-extensions/main/abandoned.json';
29+
30+
public function __construct(
31+
protected ExtensionManager $extensions,
32+
protected SettingsRepositoryInterface $settings,
33+
protected Client $client,
34+
protected Queue $queue,
35+
protected TranslatorInterface $translator
36+
) {
37+
}
38+
39+
/**
40+
* Fetch the upstream abandoned extensions list, filter to installed packages,
41+
* persist the result to settings, and optionally notify admins.
42+
*
43+
* When $notify is true and the notify-admins setting is enabled:
44+
* - On a scheduled (automatic) run: only notifies about packages newly flagged
45+
* since the last sync, to avoid repeating the same email every week.
46+
* - On a manual run ($manual = true): notifies about all currently installed
47+
* abandoned extensions, since the admin explicitly requested the check.
48+
*
49+
* @throws RuntimeException
50+
* @return array{count: int, new: string[]}
51+
*/
52+
public function sync(bool $notify = false, bool $manual = false): array
53+
{
54+
$map = $this->fetch();
55+
$installed = $this->installedPackageNames();
56+
57+
$filtered = array_filter(
58+
$map,
59+
fn (string $name) => isset($installed[$name]),
60+
ARRAY_FILTER_USE_KEY
61+
);
62+
63+
$previous = static::getCachedMap($this->settings);
64+
$new = array_keys(array_diff_key($filtered, $previous));
65+
66+
$this->settings->set(self::SETTINGS_KEY, json_encode($filtered));
67+
68+
if ($notify && $this->settings->get(self::NOTIFY_ADMINS_SETTING)) {
69+
// Manual trigger: notify about all installed abandoned extensions.
70+
// Scheduled trigger: only notify about newly detected ones.
71+
$toNotify = $manual ? array_keys($filtered) : $new;
72+
73+
if ($toNotify) {
74+
$this->notifyAdmins($toNotify, $filtered);
75+
}
76+
}
77+
78+
return ['count' => count($filtered), 'new' => $new];
79+
}
80+
81+
/**
82+
* @throws RuntimeException
83+
*/
84+
protected function fetch(): array
85+
{
86+
try {
87+
$response = $this->client->get(self::SOURCE_URL, [
88+
'allow_redirects' => false,
89+
'timeout' => 10,
90+
'headers' => [
91+
'Accept' => 'application/json',
92+
'User-Agent' => 'Flarum/'.Application::VERSION,
93+
],
94+
]);
95+
} catch (GuzzleException $e) {
96+
throw new RuntimeException('Could not fetch abandoned extensions list: '.$e->getMessage(), 0, $e);
97+
}
98+
99+
$data = json_decode((string) $response->getBody(), true);
100+
101+
if (! is_array($data)) {
102+
throw new RuntimeException('Abandoned extensions list returned invalid JSON.');
103+
}
104+
105+
return $data;
106+
}
107+
108+
protected function notifyAdmins(array $newPackages, array $map): void
109+
{
110+
$admins = User::whereHas('groups', fn ($q) => $q->where('id', Group::ADMINISTRATOR_ID))->get();
111+
112+
$lines = array_map(function (string $package) use ($map) {
113+
$replacement = $map[$package]['replacement'] ?? null;
114+
115+
return $replacement
116+
? $this->translator->trans('core.email.abandoned_extensions.line_with_replacement', compact('package', 'replacement'))
117+
: $this->translator->trans('core.email.abandoned_extensions.line_no_replacement', compact('package'));
118+
}, $newPackages);
119+
120+
$subject = $this->translator->trans('core.email.abandoned_extensions.subject');
121+
122+
foreach ($admins as $admin) {
123+
$body = $this->translator->trans('core.email.abandoned_extensions.body', [
124+
'username' => $admin->display_name,
125+
'extensions' => implode("\n", $lines),
126+
]);
127+
128+
$this->queue->push(new SendRawEmailJob($admin->email, $subject, $body));
129+
}
130+
}
131+
132+
/**
133+
* Returns an associative array of composer package name => true for all
134+
* installed Flarum extensions.
135+
*/
136+
protected function installedPackageNames(): array
137+
{
138+
$names = [];
139+
140+
foreach ($this->extensions->getExtensions() as $extension) {
141+
$names[$extension->name] = true;
142+
}
143+
144+
return $names;
145+
}
146+
147+
/**
148+
* Return the cached map from settings, or an empty array if not yet fetched.
149+
*
150+
* @return array<string, array{replacement?: string}>
151+
*/
152+
public static function getCachedMap(SettingsRepositoryInterface $settings): array
153+
{
154+
$raw = $settings->get(self::SETTINGS_KEY);
155+
156+
if (! $raw) {
157+
return [];
158+
}
159+
160+
$data = json_decode($raw, true);
161+
162+
return is_array($data) ? $data : [];
163+
}
164+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Extension\Console;
11+
12+
use Flarum\Extension\AbandonedExtensionsFetcher;
13+
use Illuminate\Console\Command;
14+
15+
class SyncAbandonedExtensionsCommand extends Command
16+
{
17+
protected $signature = 'extensions:sync-abandoned {--notify : Email admins if newly abandoned extensions are found}';
18+
protected $description = 'Sync the list of abandoned extensions from flarum/abandoned-extensions.';
19+
20+
public function handle(AbandonedExtensionsFetcher $fetcher): int
21+
{
22+
$this->info('Fetching abandoned extensions list...');
23+
24+
try {
25+
$result = $fetcher->sync($this->option('notify'));
26+
} catch (\RuntimeException $e) {
27+
$this->error($e->getMessage());
28+
29+
return self::FAILURE;
30+
}
31+
32+
$this->info("Stored {$result['count']} abandoned extension(s) matching installed packages.");
33+
34+
if ($result['new']) {
35+
$this->info('Newly flagged: '.implode(', ', $result['new']));
36+
}
37+
38+
return self::SUCCESS;
39+
}
40+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Extension\Console;
11+
12+
use Illuminate\Console\Scheduling\Event;
13+
14+
class WeeklySchedule
15+
{
16+
public function __invoke(Event $event): void
17+
{
18+
$event->weekly()->withoutOverlapping();
19+
}
20+
}

0 commit comments

Comments
 (0)