Skip to content

Commit 609b756

Browse files
imorlandStyleCIBot
andauthored
[1.x] feat: sync abandoned extensions list from flarum/abandoned-extensions (#4559)
* 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 * Apply fixes from StyleCI * fix: use PHP 7.3 compatible syntax in AbandonedExtensionsFetcher and controller * fix: update test stub signature to match sync(notify, manual) --------- Co-authored-by: StyleCI Bot <bot@styleci.io>
1 parent a6de568 commit 609b756

11 files changed

Lines changed: 603 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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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\JsonResponse;
15+
use Psr\Http\Message\ResponseInterface;
16+
use Psr\Http\Message\ServerRequestInterface;
17+
use Psr\Http\Server\RequestHandlerInterface;
18+
19+
class SyncAbandonedExtensionsController implements RequestHandlerInterface
20+
{
21+
/**
22+
* @var AbandonedExtensionsFetcher
23+
*/
24+
protected $fetcher;
25+
26+
public function __construct(AbandonedExtensionsFetcher $fetcher)
27+
{
28+
$this->fetcher = $fetcher;
29+
}
30+
31+
public function handle(ServerRequestInterface $request): ResponseInterface
32+
{
33+
RequestUtil::getActor($request)->assertAdmin();
34+
35+
try {
36+
$result = $this->fetcher->sync(true, true);
37+
} catch (\RuntimeException $e) {
38+
return new JsonResponse(['error' => $e->getMessage()], 500);
39+
}
40+
41+
return new JsonResponse(['count' => $result['count'], 'new' => $result['new']]);
42+
}
43+
}

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: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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+
/**
31+
* @var ExtensionManager
32+
*/
33+
protected $extensions;
34+
35+
/**
36+
* @var SettingsRepositoryInterface
37+
*/
38+
protected $settings;
39+
40+
/**
41+
* @var Client
42+
*/
43+
protected $client;
44+
45+
/**
46+
* @var Queue
47+
*/
48+
protected $queue;
49+
50+
/**
51+
* @var TranslatorInterface
52+
*/
53+
protected $translator;
54+
55+
public function __construct(
56+
ExtensionManager $extensions,
57+
SettingsRepositoryInterface $settings,
58+
Client $client,
59+
Queue $queue,
60+
TranslatorInterface $translator
61+
) {
62+
$this->extensions = $extensions;
63+
$this->settings = $settings;
64+
$this->client = $client;
65+
$this->queue = $queue;
66+
$this->translator = $translator;
67+
}
68+
69+
/**
70+
* Fetch the upstream abandoned extensions list, filter to installed packages,
71+
* persist the result to settings, and optionally notify admins.
72+
*
73+
* When $notify is true and the notify-admins setting is enabled:
74+
* - On a scheduled (automatic) run: only notifies about packages newly flagged
75+
* since the last sync, to avoid repeating the same email every week.
76+
* - On a manual run ($manual = true): notifies about all currently installed
77+
* abandoned extensions, since the admin explicitly requested the check.
78+
*
79+
* @throws RuntimeException
80+
* @return array{count: int, new: string[]}
81+
*/
82+
public function sync(bool $notify = false, bool $manual = false): array
83+
{
84+
$map = $this->fetch();
85+
$installed = $this->installedPackageNames();
86+
87+
$filtered = array_filter(
88+
$map,
89+
function (string $name) use ($installed) {
90+
return isset($installed[$name]);
91+
},
92+
ARRAY_FILTER_USE_KEY
93+
);
94+
95+
$previous = static::getCachedMap($this->settings);
96+
$new = array_keys(array_diff_key($filtered, $previous));
97+
98+
$this->settings->set(self::SETTINGS_KEY, json_encode($filtered));
99+
100+
if ($notify && $this->settings->get(self::NOTIFY_ADMINS_SETTING)) {
101+
// Manual trigger: notify about all installed abandoned extensions.
102+
// Scheduled trigger: only notify about newly detected ones.
103+
$toNotify = $manual ? array_keys($filtered) : $new;
104+
105+
if ($toNotify) {
106+
$this->notifyAdmins($toNotify, $filtered);
107+
}
108+
}
109+
110+
return ['count' => count($filtered), 'new' => $new];
111+
}
112+
113+
/**
114+
* @throws RuntimeException
115+
*/
116+
protected function fetch(): array
117+
{
118+
try {
119+
$response = $this->client->get(self::SOURCE_URL, [
120+
'allow_redirects' => false,
121+
'timeout' => 10,
122+
'headers' => [
123+
'Accept' => 'application/json',
124+
'User-Agent' => 'Flarum/'.Application::VERSION,
125+
],
126+
]);
127+
} catch (GuzzleException $e) {
128+
throw new RuntimeException('Could not fetch abandoned extensions list: '.$e->getMessage(), 0, $e);
129+
}
130+
131+
$data = json_decode((string) $response->getBody(), true);
132+
133+
if (! is_array($data)) {
134+
throw new RuntimeException('Abandoned extensions list returned invalid JSON.');
135+
}
136+
137+
return $data;
138+
}
139+
140+
protected function notifyAdmins(array $newPackages, array $map): void
141+
{
142+
$admins = User::whereHas('groups', function ($q) {
143+
$q->where('id', Group::ADMINISTRATOR_ID);
144+
})->get();
145+
146+
$lines = array_map(function (string $package) use ($map) {
147+
$replacement = $map[$package]['replacement'] ?? null;
148+
149+
return $replacement
150+
? $this->translator->trans('core.email.abandoned_extensions.line_with_replacement', compact('package', 'replacement'))
151+
: $this->translator->trans('core.email.abandoned_extensions.line_no_replacement', compact('package'));
152+
}, $newPackages);
153+
154+
$subject = $this->translator->trans('core.email.abandoned_extensions.subject');
155+
156+
foreach ($admins as $admin) {
157+
$body = $this->translator->trans('core.email.abandoned_extensions.body', [
158+
'username' => $admin->display_name,
159+
'extensions' => implode("\n", $lines),
160+
]);
161+
162+
$this->queue->push(new SendRawEmailJob($admin->email, $subject, $body));
163+
}
164+
}
165+
166+
/**
167+
* Returns an associative array of composer package name => true for all
168+
* installed Flarum extensions.
169+
*/
170+
protected function installedPackageNames(): array
171+
{
172+
$names = [];
173+
174+
foreach ($this->extensions->getExtensions() as $extension) {
175+
$names[$extension->name] = true;
176+
}
177+
178+
return $names;
179+
}
180+
181+
/**
182+
* Return the cached map from settings, or an empty array if not yet fetched.
183+
*
184+
* @return array<string, array{replacement?: string}>
185+
*/
186+
public static function getCachedMap(SettingsRepositoryInterface $settings): array
187+
{
188+
$raw = $settings->get(self::SETTINGS_KEY);
189+
190+
if (! $raw) {
191+
return [];
192+
}
193+
194+
$data = json_decode($raw, true);
195+
196+
return is_array($data) ? $data : [];
197+
}
198+
}

0 commit comments

Comments
 (0)