Skip to content
Open
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
154 changes: 27 additions & 127 deletions app/Services/EmbedProcessorService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class EmbedProcessorService
{
/**
* Process HTML content and convert embed blocks to renderable format
* Process HTML content and convert embed blocks to renderable format.
*
* Every provider is handled with markup only. The provider scripts that
* PostContentText.vue loads (platform.twitter.com/widgets.js,
* instagram.com/embed.js, embed.reddit.com/widgets.js) turn the markup
* below into the real embed on the client — the same path the previous
* oEmbed fallback already relied on whenever a request failed.
*/
public static function processContent(string $content): string
{
Expand All @@ -36,52 +38,17 @@ private static function processTwitterEmbeds(string $content): string
if (preg_match('/status\/(\d+)/', $url, $idMatch)) {
$tweetId = $idMatch[1];

// Try to get oEmbed data from Twitter API (cached)
$embedHtml = self::getTwitterOEmbed($url);

if ($embedHtml) {
return '<div class="embed-container embed-twitter" data-tweet-id="' . $tweetId . '">' . $embedHtml . '</div>';
}

// Fallback: create a basic embed structure that Twitter widget.js can enhance
return '<div class="embed-container embed-twitter" data-tweet-id="' . $tweetId . '">' .
'<blockquote class="twitter-tweet" data-dnt="true">' .
'<a href="' . htmlspecialchars($url) . '">Ver tweet</a>' .
'</blockquote>' .
return '<div class="embed-container embed-twitter" data-tweet-id="'.$tweetId.'">'.
'<blockquote class="twitter-tweet" data-dnt="true">'.
'<a href="'.htmlspecialchars($url).'">Ver tweet</a>'.
'</blockquote>'.
'</div>';
}

return $matches[0];
}, $content) ?? $content;
}

/**
* Get Twitter oEmbed HTML
*/
private static function getTwitterOEmbed(string $url): ?string
{
$cacheKey = 'twitter_oembed_' . md5($url);

return Cache::remember($cacheKey, 86400, function () use ($url) {
try {
$response = Http::timeout(5)->get('https://publish.twitter.com/oembed', [
'url' => $url,
'omit_script' => 'true',
'dnt' => 'true',
]);

if ($response->successful()) {
$data = $response->json();
return $data['html'] ?? null;
}
} catch (\Exception $e) {
Log::warning('Failed to fetch Twitter oEmbed: ' . $e->getMessage());
}

return null;
});
}

/**
* Process YouTube embed blocks
*/
Expand All @@ -93,14 +60,14 @@ private static function processYouTubeEmbeds(string $content): string
return preg_replace_callback($pattern, function ($matches) {
$videoId = $matches[2];

return '<div class="embed-container embed-youtube">' .
'<iframe ' .
'src="https://www.youtube-nocookie.com/embed/' . htmlspecialchars($videoId) . '" ' .
'frameborder="0" ' .
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" ' .
'allowfullscreen ' .
'loading="lazy">' .
'</iframe>' .
return '<div class="embed-container embed-youtube">'.
'<iframe '.
'src="https://www.youtube-nocookie.com/embed/'.htmlspecialchars($videoId).'" '.
'frameborder="0" '.
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" '.
'allowfullscreen '.
'loading="lazy">'.
'</iframe>'.
'</div>';
}, $content) ?? $content;
}
Expand All @@ -117,48 +84,14 @@ private static function processInstagramEmbeds(string $content): string
$url = html_entity_decode(trim($matches[1]));
$postId = $matches[2];

// Try to get oEmbed data from Instagram API (cached)
$embedHtml = self::getInstagramOEmbed($url);

if ($embedHtml) {
return '<div class="embed-container embed-instagram" data-instagram-id="' . $postId . '">' . $embedHtml . '</div>';
}

// Fallback
return '<div class="embed-container embed-instagram" data-instagram-id="' . $postId . '">' .
'<blockquote class="instagram-media" data-instgrm-permalink="' . htmlspecialchars($url) . '">' .
'<a href="' . htmlspecialchars($url) . '">Ver no Instagram</a>' .
'</blockquote>' .
return '<div class="embed-container embed-instagram" data-instagram-id="'.$postId.'">'.
'<blockquote class="instagram-media" data-instgrm-permalink="'.htmlspecialchars($url).'">'.
'<a href="'.htmlspecialchars($url).'">Ver no Instagram</a>'.
'</blockquote>'.
'</div>';
}, $content) ?? $content;
}

/**
* Get Instagram oEmbed HTML
*/
private static function getInstagramOEmbed(string $url): ?string
{
$cacheKey = 'instagram_oembed_' . md5($url);

return Cache::remember($cacheKey, 86400, function () use ($url) {
try {
$response = Http::timeout(5)->get('https://api.instagram.com/oembed', [
'url' => $url,
'omitscript' => 'true',
]);

if ($response->successful()) {
$data = $response->json();
return $data['html'] ?? null;
}
} catch (\Exception $e) {
Log::warning('Failed to fetch Instagram oEmbed: ' . $e->getMessage());
}

return null;
});
}

/**
* Process Reddit embed blocks
*/
Expand All @@ -170,44 +103,11 @@ private static function processRedditEmbeds(string $content): string
return preg_replace_callback($pattern, function ($matches) {
$url = html_entity_decode(trim($matches[1]));

// Try to get oEmbed data from Reddit API (cached)
$embedHtml = self::getRedditOEmbed($url);

if ($embedHtml) {
return '<div class="embed-container embed-reddit">' . $embedHtml . '</div>';
}

// Fallback: link to the Reddit post
return '<div class="embed-container embed-reddit">' .
'<blockquote class="reddit-embed-bq">' .
'<a href="' . htmlspecialchars($url) . '">Ver no Reddit</a>' .
'</blockquote>' .
return '<div class="embed-container embed-reddit">'.
'<blockquote class="reddit-embed-bq">'.
'<a href="'.htmlspecialchars($url).'">Ver no Reddit</a>'.
'</blockquote>'.
'</div>';
}, $content) ?? $content;
}

/**
* Get Reddit oEmbed HTML
*/
private static function getRedditOEmbed(string $url): ?string
{
$cacheKey = 'reddit_oembed_' . md5($url);

return Cache::remember($cacheKey, 86400, function () use ($url) {
try {
$response = Http::timeout(5)->get('https://www.reddit.com/oembed', [
'url' => $url,
]);

if ($response->successful()) {
$data = $response->json();
return $data['html'] ?? null;
}
} catch (\Exception $e) {
Log::warning('Failed to fetch Reddit oEmbed: ' . $e->getMessage());
}

return null;
});
}
}
3 changes: 2 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
Expand Down
103 changes: 103 additions & 0 deletions tests/Feature/EmbedProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

use App\Services\EmbedProcessorService;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
Http::fake();
});

afterEach(function () {
// The assertion that matters. Http::fake() records requests instead of
// throwing, so this still catches a call that the caller wraps in a
// try/catch — which is exactly what the previous oEmbed code did.
Http::assertNothingSent();
});

function wpEmbedBlock(string $provider, string $url): string
{
return '<figure class="wp-block-embed is-type-rich is-provider-'.$provider.'">'
.'<div class="wp-block-embed__wrapper">'.$url.'</div>'
.'</figure>';
}

it('renders a twitter embed without calling the network', function () {
$html = EmbedProcessorService::processContent(
wpEmbedBlock('twitter', 'https://twitter.com/anthropicai/status/1234567890')
);

expect($html)
->toContain('embed-twitter')
->toContain('data-tweet-id="1234567890"')
// the class the twitter widget script looks for
->toContain('class="twitter-tweet"');
});

it('renders an instagram embed without calling the network', function () {
$html = EmbedProcessorService::processContent(
wpEmbedBlock('instagram', 'https://www.instagram.com/p/AbCdEf123/')
);

expect($html)
->toContain('embed-instagram')
->toContain('data-instagram-id="AbCdEf123"')
// the class instagram's embed.js looks for
->toContain('class="instagram-media"')
->toContain('data-instgrm-permalink');
});

it('renders a reddit embed without calling the network', function () {
$html = EmbedProcessorService::processContent(
wpEmbedBlock('reddit', 'https://www.reddit.com/r/php/comments/abc/titulo/')
);

expect($html)
->toContain('embed-reddit')
->toContain('class="reddit-embed-bq"');
});

it('renders a youtube embed as a privacy friendly iframe', function () {
$html = EmbedProcessorService::processContent(
wpEmbedBlock('youtube', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
);

expect($html)
->toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
->toContain('loading="lazy"');
});

it('processes several embeds in one document without any request', function () {
$content = wpEmbedBlock('twitter', 'https://x.com/a/status/1')
.'<p>Texto entre os embeds.</p>'
.wpEmbedBlock('youtube', 'https://youtu.be/abc123')
.wpEmbedBlock('instagram', 'https://www.instagram.com/reel/XyZ/')
.wpEmbedBlock('reddit', 'https://www.reddit.com/r/a/comments/b/c/');

$html = EmbedProcessorService::processContent($content);

expect($html)
->toContain('embed-twitter')
->toContain('embed-youtube')
->toContain('embed-instagram')
->toContain('embed-reddit')
->toContain('Texto entre os embeds.');
});

it('escapes the url it interpolates into attributes', function () {
// No "<" here on purpose: the pattern stops at it, so this is the shape
// that actually reaches htmlspecialchars().
$html = EmbedProcessorService::processContent(
wpEmbedBlock('reddit', 'https://www.reddit.com/r/a"onmouseover="alert(1)')
);

expect($html)
->toContain('embed-reddit')
->toContain('&quot;onmouseover=&quot;')
->not->toContain('"onmouseover="alert(1)');
});

it('leaves content without embeds untouched', function () {
$content = '<p>Um post comum.</p><h2>Com titulo</h2>';

expect(EmbedProcessorService::processContent($content))->toBe($content);
});
7 changes: 0 additions & 7 deletions tests/Feature/ExampleTest.php

This file was deleted.

Loading