Skip to content

Commit 97060a9

Browse files
authored
Merge pull request #125 from alleyinteractive/feature/twitter-oembeds
Improve support for X/Twitter oEmbeds
2 parents 16c1893 + 2c48d30 commit 97060a9

File tree

7 files changed

+286
-1
lines changed

7 files changed

+286
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ This library adheres to [Semantic Versioning](https://semver.org/) and [Keep a C
44

55
## Unreleased
66

7+
## 3.6.0
8+
9+
### Added
10+
11+
* Added a feature to improve Twitter/X oEmbed handling.
12+
713
## 3.5.2
814

915
* Optimize some unit tests that created a lot of posts.

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,15 @@ WordPress core ["doesn't consider usernames or user IDs to be private or secure
159159

160160
Our clients tend to not want information about the registered users on their sites to be discoverable; such lists can even disclose Alley's relationship with a client.
161161

162+
### `twitter_embeds`
163+
164+
This feature adds full support for `x.com` URLs for oEmbeds. Out of the box, only `twitter.com` URLs are fully supported in WordPress (the block editor, it should be noted, https://github.com/WordPress/gutenberg/blob/a2b6d39d01d023b6c7c48ad6df5002b78a06794d/packages/block-library/src/embed/edit.js#L161-L166).
165+
166+
This feature also adds fallback handling for Twitter's oEmbed API endpoint, which can unpredictably return 404 responses. There are two fallback options:
167+
168+
1. If the ENV variable `TWITTER_OEMBED_BACKSTOP_ENDPOINT` is set, this endpoint will be used in the event that a 404 is found. WPVIP offers a fallback proxy server which will reliably return a valid response.
169+
2. If that ENV variable is not set, the request is attempted again using fsockopen instead of curl. For some reason that smart people cannot explain, this is likely to produce a 200 when curl produces a 404.
170+
162171
## About
163172

164173
### License
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
/**
3+
* Class file for Twitter_Embeds
4+
*
5+
* (c) Alley <[email protected]>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*
10+
* @package wp-alleyvate
11+
*/
12+
13+
declare( strict_types=1 );
14+
15+
namespace Alley\WP\Alleyvate\Features;
16+
17+
use Alley\WP\Types\Feature;
18+
use WP_Error;
19+
use WpOrg\Requests\Transport\Fsockopen;
20+
21+
/**
22+
* Twitter_Embeds feature.
23+
*/
24+
final class Twitter_Embeds implements Feature {
25+
/**
26+
* Array of attempts to catch 404 responses from Twitter.
27+
*
28+
* @var int[] $attempts
29+
*/
30+
private array $attempts = [];
31+
32+
/**
33+
* Boot the feature.
34+
*/
35+
public function boot(): void {
36+
add_filter( 'oembed_providers', [ $this, 'add_twitter_oembed_provider' ] );
37+
add_filter( 'http_response', [ $this, 'filter_twitter_oembed_404s' ], 10, 3 );
38+
add_filter( 'alleyvate_twitter_embeds_404_backstop', [ $this, 'attempt_404_backstop' ], 10, 3 );
39+
}
40+
41+
/**
42+
* Add Twitter oEmbed provider.
43+
*
44+
* @param array{string, boolean}[] $providers Array of oEmbed providers.
45+
* @return array{string, boolean}[]
46+
*/
47+
public function add_twitter_oembed_provider( array $providers ): array {
48+
/* phpcs:disable WordPress.Arrays.MultipleStatementAlignment */
49+
return array_merge(
50+
$providers,
51+
[
52+
'#https?://(www\.)?x\.com/\w{1,15}/status(es)?/.*#i' => [ 'https://publish.twitter.com/oembed', true ],
53+
'#https?://(www\.)?x\.com/\w{1,15}$#i' => [ 'https://publish.twitter.com/oembed', true ],
54+
'#https?://(www\.)?x\.com/\w{1,15}/likes$#i' => [ 'https://publish.twitter.com/oembed', true ],
55+
'#https?://(www\.)?x\.com/\w{1,15}/lists/.*#i' => [ 'https://publish.twitter.com/oembed', true ],
56+
'#https?://(www\.)?x\.com/\w{1,15}/timelines/.*#i' => [ 'https://publish.twitter.com/oembed', true ],
57+
'#https?://(www\.)?x\.com/i/moments/.*#i' => [ 'https://publish.twitter.com/oembed', true ],
58+
],
59+
);
60+
/* phpcs:enable WordPress.Arrays.MultipleStatementAlignment.DoubleArrowNotAligned */
61+
}
62+
63+
/**
64+
* Attempt to catch 404 responses from Twitter.
65+
*
66+
* @param array<mixed>|WP_Error $response HTTP response.
67+
* @param array<mixed> $parsed_args HTTP request arguments.
68+
* @param string $url URL of the HTTP request.
69+
* @return array<mixed>|WP_Error
70+
*/
71+
public function filter_twitter_oembed_404s( array|WP_Error $response, array $parsed_args, string $url ): array|WP_Error {
72+
if (
73+
strpos( $url, 'publish.twitter.com' ) !== false
74+
&& ! is_wp_error( $response )
75+
&& 404 === $response['response']['code'] /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
76+
) {
77+
$this->attempts[ $url ] = ( $this->attempts[ $url ] ?? 0 ) + 1;
78+
79+
/**
80+
* Filter the response for a 404 from Twitter.
81+
*
82+
* @param array $response HTTP response.
83+
* @param string $url URL of the HTTP request.
84+
* @param int $attempts Number of times this filter has fired for this URL during this request.
85+
* @param array $parsed_args HTTP request arguments.
86+
*/
87+
return apply_filters(
88+
'alleyvate_twitter_embeds_404_backstop',
89+
$response,
90+
$url,
91+
$this->attempts[ $url ],
92+
$parsed_args
93+
);
94+
}
95+
96+
return $response;
97+
}
98+
99+
/**
100+
* Attempt to catch 404 responses from Twitter.
101+
*
102+
* @param array<mixed> $response HTTP response.
103+
* @param string $url URL of the HTTP request.
104+
* @param int $attempts Number of times this filter has fired for this URL during this request.
105+
* @return array<mixed>
106+
*/
107+
public function attempt_404_backstop( array $response, string $url, int $attempts ): array|WP_Error {
108+
if ( 1 === $attempts ) {
109+
$env_endpoint = \function_exists( 'vip_get_env_var' )
110+
? vip_get_env_var( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT' )
111+
: getenv( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT' );
112+
if ( $env_endpoint ) {
113+
// If there's a backstop endpoint defined, use it.
114+
$url = str_replace( 'https://publish.twitter.com/oembed', $env_endpoint, $url );
115+
$response = wp_safe_remote_get( $url );
116+
} else {
117+
// Attempt the request again using the fsockopen transport, which might get a different outcome.
118+
add_action( 'requests-requests.before_request', [ $this, 'attempt_fsockopen_for_twitter_oembeds' ], 10, 5 );
119+
$response = wp_safe_remote_get( $url );
120+
remove_action( 'requests-requests.before_request', [ $this, 'attempt_fsockopen_for_twitter_oembeds' ] );
121+
}
122+
}
123+
return $response;
124+
}
125+
126+
/**
127+
* Attempt to use fsockopen transport for Twitter oEmbeds.
128+
*
129+
* @param string $url URL of the HTTP request.
130+
* @param array<string> $headers HTTP request headers. Ignored.
131+
* @param array<mixed> $data HTTP request data. Ignored.
132+
* @param string $type HTTP request type. Ignored.
133+
* @param array<mixed> $options HTTP request options. Passed by reference.
134+
*/
135+
public function attempt_fsockopen_for_twitter_oembeds( &$url, &$headers, &$data, &$type, &$options ): void {
136+
if ( class_exists( Fsockopen::class ) && str_starts_with( $url, 'https://publish.twitter.com/oembed' ) ) {
137+
$options['transport'] = Fsockopen::class;
138+
}
139+
}
140+
}

src/alley/wp/alleyvate/load.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ function load(): void {
113113
'disable_block_editor_rest_api_preload_paths',
114114
new Features\Disable_Block_Editor_Rest_Api_Preload_Paths(),
115115
),
116+
new Feature(
117+
'twitter_embeds',
118+
new Features\Twitter_Embeds(),
119+
),
116120
);
117121

118122
$plugin->boot();
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
/**
3+
* Class file for Test_Twitter_Embeds
4+
*
5+
* (c) Alley <[email protected]>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*
10+
* @package wp-alleyvate
11+
*/
12+
13+
declare( strict_types=1 );
14+
15+
namespace Alley\WP\Alleyvate\Features;
16+
17+
use Mantle\Testing\Concerns\Refresh_Database;
18+
use Mantle\Testing\Mock_Http_Response;
19+
use Mantle\Testkit\Test_Case;
20+
21+
/**
22+
* Tests for Twitter_Embeds feature.
23+
*/
24+
final class TwitterEmbedsTest extends Test_Case {
25+
use Refresh_Database;
26+
27+
/**
28+
* Feature instance.
29+
*
30+
* @var Twitter_Embeds
31+
*/
32+
private Twitter_Embeds $feature;
33+
34+
/**
35+
* Set up.
36+
*
37+
* @throws \Exception If the TWITTER_OEMBED_BACKSTOP_ENDPOINT environment variable is set.
38+
*/
39+
protected function setUp(): void {
40+
parent::setUp();
41+
42+
$this->feature = new Twitter_Embeds();
43+
if ( getenv( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT' ) ) {
44+
throw new \Exception( 'Environment variable TWITTER_OEMBED_BACKSTOP_ENDPOINT is set and should not be.' );
45+
}
46+
}
47+
48+
/**
49+
* Tear down.
50+
*/
51+
protected function tearDown(): void {
52+
putenv( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
53+
54+
parent::tearDown();
55+
}
56+
57+
/**
58+
* Test that the default backstop executes when a 404 response is received from Twitter.
59+
*/
60+
public function test_default_backstop(): void {
61+
$this->feature->boot();
62+
$url = 'https://publish.twitter.com/oembed?format=json&url=https%3A%2F%2Ftwitter.com%2FWordPress%2Fstatus%2F1819377181035745510';
63+
64+
// Fire the filter with a 404 response and verify that the default backstop executes.
65+
$this->fake_request( $url );
66+
apply_filters(
67+
'http_response', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals
68+
Mock_Http_Response::create()->with_response_code( 404 )->to_array(),
69+
[],
70+
$url
71+
);
72+
$this->assertRequestSent( $url );
73+
}
74+
75+
/**
76+
* Test that the backstop endpoint can be set via an environment variable.
77+
*/
78+
public function test_backstop_through_env(): void {
79+
$this->feature->boot();
80+
putenv( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT=https://example.com' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
81+
82+
$url = 'https://example.com?format=json&url=https%3A%2F%2Ftwitter.com%2FWordPress%2Fstatus%2F1819377181035745510';
83+
$og_url = 'https://publish.twitter.com/oembed?format=json&url=https%3A%2F%2Ftwitter.com%2FWordPress%2Fstatus%2F1819377181035745510';
84+
85+
// Fire the filter with a 404 response and verify that the default backstop executes.
86+
$this->fake_request( [
87+
$url => new Mock_Http_Response(),
88+
$og_url => new Mock_Http_Response(),
89+
] );
90+
apply_filters(
91+
'http_response', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals
92+
Mock_Http_Response::create()->with_response_code( 404 )->to_array(),
93+
[],
94+
$og_url
95+
);
96+
putenv( 'TWITTER_OEMBED_BACKSTOP_ENDPOINT' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
97+
98+
$this->assertRequestSent( $url );
99+
$this->assertRequestNotSent( $og_url );
100+
}
101+
102+
/**
103+
* Test that x.com URLs are handled by the Twitter oEmbed provider.
104+
*/
105+
public function test_oembed_providers(): void {
106+
$body = '{"url":"https:\/\/twitter.com\/WordPress\/status\/1819377181035745510","author_name":"WordPress","author_url":"https:\/\/twitter.com\/WordPress","html":"\u003Cblockquote class=\"twitter-tweet\" data-width=\"550\" data-dnt=\"true\"\u003E\u003Cp lang=\"en\" dir=\"ltr\"\u003EMeet the brand-new, reimagined Learn WordPress experience and grow your WordPress skills at your own pace. Get more details: \u003Ca href=\"https:\/\/t.co\/6bj2bRr8BW\"\u003Ehttps:\/\/t.co\/6bj2bRr8BW\u003C\/a\u003E \u003Ca href=\"https:\/\/twitter.com\/hashtag\/WordPress?src=hash&amp;ref_src=twsrc%5Etfw\"\u003E#WordPress\u003C\/a\u003E \u003Ca href=\"https:\/\/t.co\/24TkZaB6pW\"\u003Epic.twitter.com\/24TkZaB6pW\u003C\/a\u003E\u003C\/p\u003E&mdash; WordPress (@WordPress) \u003Ca href=\"https:\/\/twitter.com\/WordPress\/status\/1819377181035745510?ref_src=twsrc%5Etfw\"\u003EAugust 2, 2024\u003C\/a\u003E\u003C\/blockquote\u003E\n\u003Cscript async src=\"https:\/\/platform.twitter.com\/widgets.js\" charset=\"utf-8\"\u003E\u003C\/script\u003E\n\n","width":550,"height":null,"type":"rich","cache_age":"3153600000","provider_name":"Twitter","provider_url":"https:\/\/twitter.com","version":"1.0"}';
107+
$this->fake_request( 'https://publish.twitter.com/oembed*' )
108+
->with_body( $body );
109+
$this->fake_request( 'https://x.com/WordPress/status/1868689630931059186' );
110+
111+
$this->assertFalse( wp_oembed_get( 'https://x.com/WordPress/status/1868689630931059186' ) );
112+
113+
$this->feature->boot();
114+
115+
// Kids, don't try this at home. Because _wp_oembed_get_object() stores a static reference to the WP_oEmbed
116+
// object, we rerun the constructor to ensure that the filtered oEmbed providers are loaded.
117+
$wp_oembed = _wp_oembed_get_object();
118+
$wp_oembed->__construct();
119+
120+
$response = wp_oembed_get( 'https://x.com/WordPress/status/1819377181035745510' );
121+
122+
$this->assertNotFalse( $response );
123+
$this->assertMatchesSnapshot( $response );
124+
}
125+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<blockquote class="twitter-tweet" data-width="550" data-dnt="true"><p lang="en" dir="ltr">Meet the brand-new, reimagined Learn WordPress experience and grow your WordPress skills at your own pace. Get more details: <a href="https://t.co/6bj2bRr8BW">https://t.co/6bj2bRr8BW</a> <a href="https://twitter.com/hashtag/WordPress?src=hash&amp;ref_src=twsrc%5Etfw">#WordPress</a> <a href="https://t.co/24TkZaB6pW">pic.twitter.com/24TkZaB6pW</a></p>&mdash; WordPress (@WordPress) <a href="https://twitter.com/WordPress/status/1819377181035745510?ref_src=twsrc%5Etfw">August 2, 2024</a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

wp-alleyvate.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* Plugin Name: Alleyvate
1515
* Plugin URI: https://github.com/alleyinteractive/wp-alleyvate
1616
* Description: Defaults for WordPress sites by Alley
17-
* Version: 3.5.2
17+
* Version: 3.6.0
1818
* Author: Alley
1919
* Author URI: https://www.alley.com
2020
* Requires at least: 6.2

0 commit comments

Comments
 (0)