diff --git a/composer.json b/composer.json index d667ae7..9d931bc 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.47", + "version": "1.6.48", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Http/Controllers/Internal/v1/LookupController.php b/src/Http/Controllers/Internal/v1/LookupController.php index bec8952..464231b 100644 --- a/src/Http/Controllers/Internal/v1/LookupController.php +++ b/src/Http/Controllers/Internal/v1/LookupController.php @@ -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; @@ -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 @@ -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 { @@ -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) { @@ -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). * @@ -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', diff --git a/src/Support/FleetbaseBlog.php b/src/Support/FleetbaseBlog.php new file mode 100644 index 0000000..73dbe32 --- /dev/null +++ b/src/Support/FleetbaseBlog.php @@ -0,0 +1,111 @@ +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} : ''; + } +} diff --git a/tests/Unit/FleetbaseBlogTest.php b/tests/Unit/FleetbaseBlogTest.php new file mode 100644 index 0000000..1ff3ba3 --- /dev/null +++ b/tests/Unit/FleetbaseBlogTest.php @@ -0,0 +1,67 @@ + + + + + <![CDATA[First Ghost Post]]> + First excerpt.

]]>
+ https://fleetbase.ghost.io/first-ghost-post/ + ghost-post-1 + + Wed, 06 May 2026 14:31:46 GMT + + +
+ + <![CDATA[Second Ghost Post]]> + Second excerpt.

]]>
+ https://fleetbase.ghost.io/second-ghost-post/ + ghost-post-2 + + Wed, 06 May 2026 14:30:46 GMT +
+
+
+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' => '

First excerpt.

', + '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('', 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'); +});