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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [4.6.1] - 2026-10-30
### Fixed
- X/Twitter status URLs get their oEmbed from `publish.twitter.com` with the status URL that was requested, even when logged-out visitors are redirected to `x.com/home`, the `x.com/x/migrate` form or a login wall. `mobile.twitter.com` and `/statuses/` URLs are accepted, query strings like `?s=20` are dropped, and the title is no longer "x.com" [#520] [#536]
- A `<` that cannot open a tag (like in `Brytere =<250A`) no longer makes the rest of a title or description disappear [#372]
- Image and other URLs with inner spaces (like `1024-576 logo.jpg`) keep them, encoded as `%20`, instead of being collapsed [#485]
- Images and other media detected by content type use the final URL after redirects as their `src` [#524]
- On PHP 8, curl handles (and their file descriptors) are released as soon as a request finishes, instead of waiting for the garbage collector, so long running workers no longer hit "Too many open files" [#283]

## [4.6.0] - 2026-10-30
### Added
- `mastodon:iframe` setting (off by default). Mastodon oEmbed html (`blockquote.mastodon-embed` with `data-embed-url`) becomes a sandboxed `<iframe>` to the post's `/embed` page, so no `embed.js` is needed. Only same-host `https` embed URLs are used
Expand Down Expand Up @@ -356,9 +364,16 @@ Full library refactoring.
[#584]: https://github.com/php-embed/Embed/issues/584
[#243]: https://github.com/php-embed/Embed/issues/243
[#575]: https://github.com/php-embed/Embed/issues/575
[#283]: https://github.com/php-embed/Embed/issues/283
[#372]: https://github.com/php-embed/Embed/issues/372
[#485]: https://github.com/php-embed/Embed/issues/485
[#520]: https://github.com/php-embed/Embed/issues/520
[#524]: https://github.com/php-embed/Embed/issues/524
[#536]: https://github.com/php-embed/Embed/issues/536
[#574]: https://github.com/oscarotero/Embed/issues/574
[#577]: https://github.com/oscarotero/Embed/issues/577

[4.6.1]: https://github.com/php-embed/Embed/compare/v4.6.0...v4.6.1
[4.6.0]: https://github.com/php-embed/Embed/compare/v4.5.4...v4.6.0
[4.5.4]: https://github.com/php-embed/Embed/compare/v4.5.3...v4.5.4
[4.5.3]: https://github.com/php-embed/Embed/compare/v4.5.2...v4.5.3
Expand Down
11 changes: 11 additions & 0 deletions src/Adapters/Twitter/Detectors/Title.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ public function detect(): ?string
return "Tweet by $name";
}

// After a redirect to a login or migration page the HTML title is
// not about the tweet, so use the oEmbed author instead.
$status = $this->extractor->getStatusUri();
$final = $this->extractor->getUri();
if ($status !== null && strpos($final->getPath(), $status->getPath()) !== 0) {
$author = $this->extractor->getOEmbed()->str('author_name');
if ($author !== null) {
return "Tweet by $author";
}
}

return parent::detect();
}
}
20 changes: 20 additions & 0 deletions src/Adapters/Twitter/Extractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,26 @@ public function __construct(
) {
parent::__construct($uri, $request, $response, $crawler);
$this->api = new Api($this);
$this->oembed = new OEmbed($this);
}

/**
* The status URL of the tweet, taken from the requested URL first because
* the final URL can be a login or migration page after the redirects.
*/
public function getStatusUri(): ?UriInterface
{
foreach ([$this->getRequest()->getUri(), $this->getUri()] as $uri) {
$host = strtolower($uri->getHost());
if (preg_match('/^(?:(?:www|mobile)\\.)?(twitter|x)\\.com$/', $host, $site) !== 1) {
continue;
}
if (preg_match('#^/([^/]+)/status(?:es)?/(\\d+)#', $uri->getPath(), $match) === 1) {
return $this->getCrawler()->createUri("https://{$site[1]}.com/{$match[1]}/status/{$match[2]}");
}
}

return null;
}

public function getApi(): Api
Expand Down
48 changes: 48 additions & 0 deletions src/Adapters/Twitter/OEmbed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Twitter;

use Embed\OEmbed as Base;
use Psr\Http\Message\UriInterface;

/**
* Twitter and X redirect logged-out visitors to /home, a login wall or the
* x.com/x/migrate form, so the oEmbed lookup always uses the status URL
* that was requested instead of the page where the redirects ended.
*/
class OEmbed extends Base
{
const ENDPOINT = 'https://publish.twitter.com/oembed';

private ?string $statusUrl = null;

/**
* @return array<string, mixed>
*/
protected function fetchData(): array
{
$data = parent::fetchData();

if ($data !== [] && $this->statusUrl !== null) {
$data += ['url' => $this->statusUrl];
}

return $data;
}

protected function detectEndpoint(): ?UriInterface
{
$status = $this->extractor instanceof Extractor ? $this->extractor->getStatusUri() : null;

if ($status === null) {
return parent::detectEndpoint();
}

$this->statusUrl = (string) $status;

return $this->extractor->getCrawler()
->createUri(self::ENDPOINT)
->withQuery(http_build_query($this->getOembedQueryParameters((string) $status)));
}
}
24 changes: 23 additions & 1 deletion src/ApiTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public function int(string ...$keys): ?int

public function url(string ...$keys): ?UriInterface
{
$url = $this->str(...$keys);
$url = $this->rawUrl(...$keys);

try {
return $url !== null ? $this->extractor->resolveUri($url) : null;
Expand All @@ -113,6 +113,28 @@ public function url(string ...$keys): ?UriInterface
}
}

/**
* Inner spaces are part of the URL, so they are kept (and later encoded)
* instead of being collapsed like in str(). Tabs and line breaks are
* removed, as browsers do.
*/
private function rawUrl(string ...$keys): ?string
{
$value = $this->get(...$keys);

if (is_array($value)) {
$value = array_shift($value);
}

if (!is_string($value)) {
return null;
}

$value = trim(str_replace(["\t", "\r", "\n"], '', $value));

return $value === '' ? null : $value;
}

public function time(string ...$keys): ?DateTime
{
$time = $this->str(...$keys);
Expand Down
3 changes: 2 additions & 1 deletion src/Detectors/Code.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ private function detectFromContentType(): ?EmbedCode
return null;
}

$url = $this->extractor->getRequest()->getUri();
// The final URL, so a file behind a redirect is embedded from where it is.
$url = $this->extractor->getUri();

if (strpos($contentType, 'video/') === 0 || $contentType === 'application/mp4') {
$code = html('video', [
Expand Down
16 changes: 15 additions & 1 deletion src/Http/CurlDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -704,8 +704,22 @@ private function error(string $message, int $code): void

private function closeCurl(): void
{
if ($this->closed || PHP_VERSION_ID >= 80000) {
if ($this->closed) {
return;
}

if (PHP_VERSION_ID >= 80000) {
// The header and body callbacks point back to $this, so the handle
// and this object keep each other alive until the garbage
// collector runs, and every request leaves its sockets open.
// Replacing them with static callbacks breaks that cycle, so the
// handle is freed as soon as the response is built.
$this->closed = true;
$this->setopt(CURLOPT_HEADERFUNCTION, static fn ($curl, string $data): int => strlen($data));
$this->setopt(CURLOPT_WRITEFUNCTION, static fn ($curl, string $data): int => strlen($data));
if (PHP_VERSION_ID >= 80400 && defined('CURLOPT_PREREQFUNCTION')) {
$this->setopt(CURLOPT_PREREQFUNCTION, static fn (): int => CURL_PREREQFUNC_OK);
}

return;
}
Expand Down
4 changes: 4 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ function clean(string $value, bool $allowHTML = false): ?string

if (!$allowHTML) {
$value = html_entity_decode($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401);
// A "<" before a digit, space or "=" (like in "=<250A") is plain text, but
// strip_tags() would drop everything after it.
$value = preg_replace('/<(?=[\d\s=]|$)/', "\u{E000}", $value) ?? $value;
$value = strip_tags($value);
$value = str_replace("\u{E000}", '<', $value);
}

$replaced = preg_replace('/\s+/u', ' ', $value);
Expand Down
25 changes: 25 additions & 0 deletions tests/CurlClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,31 @@ public function testDnsRebindingDoesNotMoveTheConnection(): void
$this->assertSame(['/landed'], self::$server->paths());
}

/**
* @see https://github.com/php-embed/Embed/issues/283
*/
public function testRequestsDoNotLeaveFileDescriptorsOpen(): void
{
if (!is_dir('/dev/fd')) {
$this->markTestSkipped('/dev/fd is not available');
}

$client = $this->client();
$factory = new Psr17Factory();
$client->sendRequest($this->request($this->url('/landed')));
$before = count((array) glob('/dev/fd/*'));

for ($i = 0; $i < 10; ++$i) {
$client->sendRequest($this->request($this->url('/chain?i=0&n=1')));
$client->sendRequests(
$factory->createRequest('GET', $this->url('/landed')),
$factory->createRequest('GET', $this->url('/landed'))
);
}

$this->assertLessThanOrEqual($before + 2, count((array) glob('/dev/fd/*')));
}

public function testParallelRequestsFollowRedirects(): void
{
$factory = new Psr17Factory();
Expand Down
12 changes: 12 additions & 0 deletions tests/FunctionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Embed\Tests;

use function Embed\clean;
use function Embed\isHttp;
use function Embed\resolveUri;
use Nyholm\Psr7\Factory\Psr17Factory;
Expand Down Expand Up @@ -52,4 +53,15 @@ public function testResolveUriKeepsTheBasePort(): void
$other = resolveUri($base, $factory->createUri('https://other.example:9/z'));
$this->assertSame('https://other.example:9/z', (string) $other);
}

/**
* @see https://github.com/php-embed/Embed/issues/372
*/
public function testCleanKeepsALessThanSignThatIsNotATag(): void
{
$this->assertSame('Brytere =<250A | Shop', clean('Brytere =&lt;250A | Shop'));
$this->assertSame('1 < 2 and 3 <= 4', clean('1 < 2 and 3 &lt;= 4'));
$this->assertSame('Bold text', clean('<b>Bold</b> <img src="x"/>text<!-- c -->'));
$this->assertSame('alert(1)Hi', clean('&lt;script&gt;alert(1)&lt;/script&gt;Hi'));
}
}
Loading
Loading