Skip to content
Draft
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
84 changes: 52 additions & 32 deletions src/Http/Controllers/Internal/v1/LookupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Http\Controllers\Internal\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Support\FleetbaseBlog;
use Fleetbase\Support\Http;
use Fleetbase\Types\Country;
use Fleetbase\Types\Currency;
Expand Down Expand Up @@ -186,8 +187,8 @@ public function country($code, Request $request)
*/
public function fleetbaseBlog(Request $request)
{
$limit = $request->integer('limit', 6);
$cacheKey = "fleetbase_blog_posts_{$limit}";
$limit = max(1, min($request->integer('limit', 6), 20));
$cacheKey = $this->getFleetbaseBlogCacheKey($limit);
$cacheTTL = now()->addDays(4); // 4 days as requested

// Try to get from cache
Expand All @@ -212,7 +213,8 @@ public function fleetbaseBlog(Request $request)
*/
protected function fetchBlogPosts(int $limit): array
{
$rssUrl = 'https://www.fleetbase.io/post/rss.xml';
$limit = max(1, min($limit, 20));
$rssUrl = $this->getFleetbaseBlogFeedUrl();
$posts = [];

try {
Expand All @@ -230,31 +232,7 @@ protected function fetchBlogPosts(int $limit): array
return [];
}

// Parse XML
$rss = simplexml_load_string($response->body());

if (!$rss || !isset($rss->channel->item)) {
Log::error('[Blog] Invalid RSS feed structure');

return [];
}

foreach ($rss->channel->item as $item) {
$posts[] = [
'title' => (string) $item->title,
'link' => (string) $item->link,
'guid' => (string) $item->guid,
'description' => (string) $item->description,
'pubDate' => (string) $item->pubDate,
'media_content' => (string) data_get($item, 'media:content.url'),
'media_thumbnail' => (string) data_get($item, 'media:thumbnail.url'),
];

// Early exit if we have enough
if (count($posts) >= $limit) {
break;
}
}
$posts = $this->parseBlogPostsFromRss($response->body(), $limit);

Log::info('[Blog] Successfully fetched blog posts', ['count' => count($posts)]);
} catch (\Exception $e) {
Expand All @@ -267,6 +245,48 @@ protected function fetchBlogPosts(int $limit): array
return array_slice($posts, 0, $limit);
}

/**
* Parse blog posts from an RSS payload.
*/
protected function parseBlogPostsFromRss(string $rssXml, int $limit): array
{
return FleetbaseBlog::parseRss($rssXml, $limit, $this->getFleetbaseBlogUrl());
}

/**
* Get the cache key for the Fleetbase blog feed.
*/
protected function getFleetbaseBlogCacheKey(int $limit): string
{
$sourceHash = md5($this->getFleetbaseBlogFeedUrl() . '|' . $this->getFleetbaseBlogUrl());

return "fleetbase_blog_posts_{$limit}_{$sourceHash}";
}

/**
* Get the public Fleetbase blog RSS feed URL.
*/
protected function getFleetbaseBlogFeedUrl(): string
{
return FleetbaseBlog::getFeedUrl();
}

/**
* Get the canonical Fleetbase blog URL.
*/
protected function getFleetbaseBlogUrl(): string
{
return FleetbaseBlog::getBlogUrl();
}

/**
* Rewrite Ghost publication links to the canonical Fleetbase.io blog URL.
*/
protected function normalizeFleetbaseBlogLink(?string $link): string
{
return FleetbaseBlog::normalizeLink($link, $this->getFleetbaseBlogUrl());
}

/**
* Manually refresh blog cache (can be called via webhook or admin panel).
*
Expand All @@ -275,13 +295,13 @@ protected function fetchBlogPosts(int $limit): array
public function refreshBlogCache()
{
// Clear all blog caches
Cache::forget('fleetbase_blog_posts_6');
Cache::forget('fleetbase_blog_posts_10');
Cache::forget('fleetbase_blog_posts_20');
Cache::forget($this->getFleetbaseBlogCacheKey(6));
Cache::forget($this->getFleetbaseBlogCacheKey(10));
Cache::forget($this->getFleetbaseBlogCacheKey(20));

// Warm up cache with default limit
$posts = $this->fetchBlogPosts(6);
Cache::put('fleetbase_blog_posts_6', $posts, now()->addDays(4));
Cache::put($this->getFleetbaseBlogCacheKey(6), $posts, now()->addDays(4));

return response()->json([
'status' => 'success',
Expand Down
111 changes: 111 additions & 0 deletions src/Support/FleetbaseBlog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

namespace Fleetbase\Support;

use Illuminate\Support\Str;

class FleetbaseBlog
{
/**
* Parse blog posts from an RSS payload.
*/
public static function parseRss(string $rssXml, int $limit, ?string $blogUrl = null): array
{
$limit = max(1, min($limit, 20));
$posts = [];

$previousLibxmlState = libxml_use_internal_errors(true);
$rss = simplexml_load_string($rssXml);
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlState);

if (!$rss || !isset($rss->channel->item)) {
return [];
}

foreach ($rss->channel->item as $item) {
$publishedAt = self::getSimpleXmlText($item->pubDate);
$timestamp = $publishedAt ? strtotime($publishedAt) : false;

$posts[] = [
'title' => self::getSimpleXmlText($item->title),
'link' => self::normalizeLink(self::getSimpleXmlText($item->link), $blogUrl),
'guid' => self::getSimpleXmlText($item->guid),
'description' => self::getSimpleXmlText($item->description),
'pubDate' => $publishedAt,
'published_at' => $timestamp ? gmdate('c', $timestamp) : null,
'author' => self::getSimpleXmlText($item->children('http://purl.org/dc/elements/1.1/')->creator),
'media_content' => self::getSimpleXmlAttribute($item, 'http://search.yahoo.com/mrss/', 'content', 'url'),
'media_thumbnail' => self::getSimpleXmlAttribute($item, 'http://search.yahoo.com/mrss/', 'thumbnail', 'url'),
];

if (count($posts) >= $limit) {
break;
}
}

return $posts;
}

/**
* Rewrite Ghost publication links to the canonical Fleetbase.io blog URL.
*/
public static function normalizeLink(?string $link, ?string $blogUrl = null): string
{
$link = trim((string) $link);
$blogUrl = self::getBlogUrl($blogUrl);

if (!$link) {
return $blogUrl;
}

$host = parse_url($link, PHP_URL_HOST);
$path = trim((string) parse_url($link, PHP_URL_PATH), '/');

if ($host && Str::contains($host, 'ghost.io') && $path) {
return $blogUrl . '/' . $path;
}

return $link;
}

/**
* Get the public Fleetbase blog RSS feed URL.
*/
public static function getFeedUrl(?string $feedUrl = null): string
{
return rtrim($feedUrl ?: getenv('FLEETBASE_BLOG_FEED_URL') ?: 'https://fleetbase.ghost.io/rss/', '/') . '/';
}

/**
* Get the canonical Fleetbase blog URL.
*/
public static function getBlogUrl(?string $blogUrl = null): string
{
return rtrim($blogUrl ?: getenv('FLEETBASE_BLOG_URL') ?: 'https://www.fleetbase.io/blog', '/');
}

/**
* Get trimmed text from a SimpleXML element.
*/
protected static function getSimpleXmlText($node): string
{
return trim((string) $node);
}

/**
* Get an attribute from a namespaced SimpleXML child.
*/
protected static function getSimpleXmlAttribute($item, string $namespace, string $childName, string $attributeName): string
{
$children = $item->children($namespace);

if (!isset($children->{$childName})) {
return '';
}

$attributes = $children->{$childName}->attributes();

return isset($attributes->{$attributeName}) ? (string) $attributes->{$attributeName} : '';
}
}
67 changes: 67 additions & 0 deletions tests/Unit/FleetbaseBlogTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

use Fleetbase\Support\FleetbaseBlog;

function fleetbaseBlogRssFixture(): string
{
return <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
<channel>
<item>
<title><![CDATA[First Ghost Post]]></title>
<description><![CDATA[<p>First excerpt.</p>]]></description>
<link>https://fleetbase.ghost.io/first-ghost-post/</link>
<guid isPermaLink="false">ghost-post-1</guid>
<dc:creator><![CDATA[Fleetbase Team]]></dc:creator>
<pubDate>Wed, 06 May 2026 14:31:46 GMT</pubDate>
<media:content url="https://static.ghost.org/first.jpg" />
<media:thumbnail url="https://static.ghost.org/first-thumb.jpg" />
</item>
<item>
<title><![CDATA[Second Ghost Post]]></title>
<description><![CDATA[<p>Second excerpt.</p>]]></description>
<link>https://fleetbase.ghost.io/second-ghost-post/</link>
<guid isPermaLink="false">ghost-post-2</guid>
<dc:creator><![CDATA[Fleetbase Team]]></dc:creator>
<pubDate>Wed, 06 May 2026 14:30:46 GMT</pubDate>
</item>
</channel>
</rss>
XML;
}

test('fleetbase blog parser maps ghost rss posts to the widget response shape', function () {
$posts = FleetbaseBlog::parseRss(fleetbaseBlogRssFixture(), 6);

expect($posts)->toHaveCount(2)
->and($posts[0])->toMatchArray([
'title' => 'First Ghost Post',
'link' => 'https://www.fleetbase.io/blog/first-ghost-post',
'guid' => 'ghost-post-1',
'description' => '<p>First excerpt.</p>',
'pubDate' => 'Wed, 06 May 2026 14:31:46 GMT',
'published_at' => '2026-05-06T14:31:46+00:00',
'author' => 'Fleetbase Team',
'media_content' => 'https://static.ghost.org/first.jpg',
'media_thumbnail' => 'https://static.ghost.org/first-thumb.jpg',
]);
});

test('fleetbase blog parser clamps limit to a small safe range', function () {
$posts = FleetbaseBlog::parseRss(fleetbaseBlogRssFixture(), 1);

expect($posts)->toHaveCount(1)
->and($posts[0]['title'])->toBe('First Ghost Post');
});

test('fleetbase blog parser returns an empty array for malformed rss', function () {
$posts = FleetbaseBlog::parseRss('<rss><channel><item>', 6);

expect($posts)->toBe([]);
});

test('fleetbase blog link normalization keeps non ghost links unchanged', function () {
expect(FleetbaseBlog::normalizeLink('https://www.fleetbase.io/blog/already-canonical'))->toBe('https://www.fleetbase.io/blog/already-canonical')
->and(FleetbaseBlog::normalizeLink('https://fleetbase.ghost.io/ghost-post/'))->toBe('https://www.fleetbase.io/blog/ghost-post');
});
Loading