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

tags — // convert to
(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, ]; } }