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
57 changes: 57 additions & 0 deletions framework/core/js/src/admin/components/BasicsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -112,6 +114,32 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> 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
Expand Down Expand Up @@ -203,5 +231,34 @@ export default class BasicsPage<CustomAttrs extends IPageAttrs = IPageAttrs> ext
});
}
});

app.registry.registerSetting(
function (this: AdminPage) {
return (
<div className="Form-group">
<label>{app.translator.trans('core.admin.basics.abandoned_extensions_heading')}</label>
<div className="helpText">
{app.translator.trans('core.admin.basics.abandoned_extensions_text', {
a: <Link href="https://github.com/flarum/abandoned-extensions" target="_blank" external={true} />,
})}
</div>
<div className="Form-row">
<Button className="Button" onclick={syncAbandoned} loading={abandonedSyncing} disabled={abandonedSyncing}>
{app.translator.trans('core.admin.basics.abandoned_extensions_sync_button')}
</Button>
{abandonedSyncMessage && <span className="helpText">{abandonedSyncMessage}</span>}
</div>
<br />
{this.buildSettingComponent({
type: 'switch',
setting: 'flarum-core.notify_admins_on_abandoned',
label: app.translator.trans('core.admin.basics.abandoned_extensions_notify_admins_label'),
})}
</div>
);
},
-10,
'abandoned-extensions'
);
}
}
14 changes: 14 additions & 0 deletions framework/core/locale/core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>community list of abandoned extensions</a>. 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:
Expand Down Expand Up @@ -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!
##
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Api\Controller;

use Flarum\Extension\AbandonedExtensionsFetcher;
use Flarum\Http\RequestUtil;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class SyncAbandonedExtensionsController implements RequestHandlerInterface
{
public function __construct(
protected AbandonedExtensionsFetcher $fetcher,
) {
}

public function handle(ServerRequestInterface $request): ResponseInterface
{
RequestUtil::getActor($request)->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']]);
}
}
7 changes: 7 additions & 0 deletions framework/core/src/Api/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
170 changes: 170 additions & 0 deletions framework/core/src/Extension/AbandonedExtensionsFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Extension;

use Flarum\Foundation\Application;
use Flarum\Group\Group;
use Flarum\Mail\Job\SendAbandonedExtensionsEmailJob;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\User;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Contracts\Queue\Queue;
use RuntimeException;
use Symfony\Contracts\Translation\TranslatorInterface;

class AbandonedExtensionsFetcher
{
public const SETTINGS_KEY = 'flarum-core.abandoned_extensions_map';
public const NOTIFY_ADMINS_SETTING = 'flarum-core.notify_admins_on_abandoned';

protected const SOURCE_URL = 'https://raw.githubusercontent.com/flarum/abandoned-extensions/main/abandoned.json';

public function __construct(
protected ExtensionManager $extensions,
protected SettingsRepositoryInterface $settings,
protected Client $client,
protected Queue $queue,
protected TranslatorInterface $translator,
) {
}

/**
* Fetch the upstream abandoned extensions list, filter to installed packages,
* persist the result to settings, and optionally notify admins.
*
* When $notify is true and the notify-admins setting is enabled:
* - On a scheduled (automatic) run: only notifies about packages newly flagged
* since the last sync, to avoid repeating the same email every week.
* - On a manual run ($manual = true): notifies about all currently installed
* abandoned extensions, since the admin explicitly requested the check.
*
* @throws RuntimeException
* @return array{count: int, new: string[]}
*/
public function sync(bool $notify = false, bool $manual = false): array
{
$map = $this->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<string, array{replacement?: string}>
*/
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 : [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Extension\Console;

use Flarum\Extension\AbandonedExtensionsFetcher;
use Illuminate\Console\Command;

class SyncAbandonedExtensionsCommand extends Command
{
protected $signature = 'extensions:sync-abandoned {--notify : Email admins if newly abandoned extensions are found}';
protected $description = 'Sync the list of abandoned extensions from flarum/abandoned-extensions.';

public function handle(AbandonedExtensionsFetcher $fetcher): int
{
$this->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;
}
}
20 changes: 20 additions & 0 deletions framework/core/src/Extension/Console/WeeklySchedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Extension\Console;

use Illuminate\Console\Scheduling\Event;

class WeeklySchedule
{
public function __invoke(Event $event): void
{
$event->weekly()->withoutOverlapping();
}
}
Loading
Loading