mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
7c16733c16
I shipped both daily calls as the same kind of thing — an operator's preference — and only one of them is. That was wrong in the direction that matters, because it handed a decision over rather than keeping it. An update notice on a hosted tenant is useless: they cannot act on it, the image is ours, and the screen that would show it is closed by capability. So that check does not run there at all, which is right and unchanged. News is the reverse. Announcements about the product are exactly what a hosted customer should be told, and a Cloud client with view_news sees that card today. One administrator switching it off for everybody on that instance is not a decision the platform meant to hand over — so on a managed instance the news now runs whatever any setting says, including a row left behind by an instance that used to be self-hosted. Capability::NewsConfigure, Community-only, and the thing it gates is the *choice* rather than the news. A self-hosted operator keeps the switch, because there nobody else decides what their installation reaches out for. An edition difference through the capability registry rather than an edition check, as everything here is. Gated in all three places rather than only the screen: the command ignores the setting without the capability, the controller neither sends nor reads the field, and the checkbox is absent. There is a test that a hand-crafted PATCH cannot do what the missing checkbox could not, and the guard is proved load-bearing — remove it and the managed-instance test goes red. The changelog and product highlights said "two switches" and now say what is actually true, including that neither appears on Cloud and why they are absent for opposite reasons.
157 lines
5.7 KiB
PHP
157 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\News\Console;
|
|
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Stevebauman\Purify\Facades\Purify;
|
|
|
|
/**
|
|
* Both editions, and on a managed instance not switchable off — unlike
|
|
* CheckForUpdatesCommand, which does not run there at all. Dashboard news
|
|
* is informational content rather than an update action, so hosted
|
|
* customers see it too, and see it whether their administrator would have
|
|
* chosen to or not. Capability::NewsConfigure is what a self-hosted
|
|
* installation holds and a managed one does not: the choice is the
|
|
* edition difference, not the news.
|
|
*
|
|
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
|
|
* here, once, before it's ever cached or sent to the frontend, so the
|
|
* dashboard can render it directly without its own sanitization step.
|
|
*/
|
|
class FetchNewsCommand extends Command
|
|
{
|
|
protected $signature = 'projectsend:fetch-news';
|
|
|
|
protected $description = 'Fetch the ProjectSend news feed for the dashboard (both editions, runs daily)';
|
|
|
|
private const FEED_URL = 'https://projectsend.org/serve/news';
|
|
|
|
private const MAX_ITEMS = 5;
|
|
|
|
private const ALLOWED_HTML = 'a[href],p,br,strong,em,ul,ol,li';
|
|
|
|
public function __construct(
|
|
private readonly Settings $settings,
|
|
private readonly CapabilityRegistry $capabilities,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
// The setting only decides where the installation is allowed to
|
|
// make that choice. On a managed instance it is not: announcements
|
|
// about the product are what a hosted customer should be told, and
|
|
// one administrator switching them off for everybody on that
|
|
// instance is not a decision the platform hands over. So the news
|
|
// runs there regardless of what any row says — including a row
|
|
// left behind by an instance that used to be self-hosted.
|
|
//
|
|
// The opposite of the update check, which does not run on a
|
|
// managed instance at all because nobody there could act on it.
|
|
// The two look alike and point in different directions.
|
|
//
|
|
// Returns success rather than failure: a scheduled task that was
|
|
// asked not to run has not failed, and reporting it as a failure
|
|
// would put a red line in the scheduler history every night for
|
|
// an installation that is behaving exactly as configured.
|
|
if ($this->capabilities->has(Capability::NewsConfigure)
|
|
&& $this->settings->get(Setting::FetchNews) !== true) {
|
|
$this->info('The news feed is switched off for this installation.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
|
|
->timeout(10)
|
|
->get(self::FEED_URL);
|
|
|
|
if (! $response->successful()) {
|
|
$this->warn('Could not reach the news feed.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$raw = $response->json();
|
|
|
|
if (! is_array($raw)) {
|
|
$this->warn('News feed response was not a JSON array — skipping.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$items = collect($raw)
|
|
->map(fn (mixed $entry): ?array => $this->normalize($entry))
|
|
->filter()
|
|
->sortByDesc('date')
|
|
->take(self::MAX_ITEMS)
|
|
->values()
|
|
->all();
|
|
|
|
$this->settings->set(Setting::NewsItems, $items);
|
|
$this->settings->set(Setting::NewsLastFetchedAt, now()->toIso8601String());
|
|
|
|
$this->info('Fetched '.count($items).' news item(s).');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array{title: string, date: string, content: string, link: string}|null
|
|
*/
|
|
private function normalize(mixed $entry): ?array
|
|
{
|
|
if (! is_array($entry)) {
|
|
return null;
|
|
}
|
|
|
|
$title = $entry['title'] ?? null;
|
|
$date = $entry['date'] ?? null;
|
|
$content = $entry['content'] ?? null;
|
|
$link = $entry['link'] ?? null;
|
|
|
|
if (! is_string($title) || ! is_string($date) || ! is_string($content) || ! is_string($link)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$parsed = Carbon::createFromFormat('d-m-Y', $date);
|
|
} catch (\Throwable) {
|
|
$parsed = false;
|
|
}
|
|
|
|
if (! $parsed instanceof Carbon) {
|
|
return null;
|
|
}
|
|
|
|
// Plain Y-m-d, not a full timestamp — matches the dashboard's
|
|
// existing shortDate() helper, which appends its own T00:00:00
|
|
// (same convention as the Transfers chart's date points).
|
|
$parsedDate = $parsed->toDateString();
|
|
|
|
// The feed separates paragraphs with raw \r\n, not <p> tags —
|
|
// convert to <br> (already allowlisted) before purifying, or
|
|
// they'd collapse into one run-on blob once rendered as HTML.
|
|
$cleaned = Purify::config(['HTML.Allowed' => self::ALLOWED_HTML])->clean(nl2br($content));
|
|
|
|
return [
|
|
// The feed HTML-encodes title (e.g. "’") even though
|
|
// it's rendered as plain JSX text on the dashboard, not HTML —
|
|
// decode here so it displays as a real apostrophe instead of
|
|
// the literal entity string.
|
|
'title' => html_entity_decode($title, ENT_QUOTES | ENT_HTML5),
|
|
'date' => $parsedDate,
|
|
'content' => is_string($cleaned) ? $cleaned : '',
|
|
'link' => $link,
|
|
];
|
|
}
|
|
}
|