PluginProbe
Embed Privacy / 1.11.0
Embed Privacy v1.11.0
1.14.0 1.13.0 trunk 0.1 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.1.3 1.10.0 1.10.1 1.10.10 1.10.2 1.10.3 1.10.4 1.10.5 1.10.6 1.10.7 1.10.8 1.10.9 1.11.0 1.11.1 1.11.2 All 68 releases
embed-privacy / inc / embed / class-replacement.php

class-replacement.php in Embed Privacy 1.11.0, at inc/embed/class-replacement.php

550 lines 16.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace epiphyt\Embed_Privacy\embed;
3
4 use DOMDocument;
5 use DOMElement;
6 use DOMNode;
7 use epiphyt\Embed_Privacy\data\Providers;
8 use epiphyt\Embed_Privacy\data\Replacer;
9 use epiphyt\Embed_Privacy\Embed_Privacy;
10
11 /**
12 * Embed replacement representation.
13 *
14 * @author Epiphyt
15 * @license GPL2
16 * @package epiphyt\Embed_Privacy
17 * @since 1.10.0
18 */
19 final class Replacement {
20 /**
21 * @var string Original content
22 */
23 private $content = '';
24
25 /**
26 * @var \epiphyt\Embed_privacy\embed\Provider|null Current processed provider for this replacement
27 */
28 private $provider;
29
30 /**
31 * @var \epiphyt\Embed_privacy\embed\Provider[] List of matching providers for this replacement
32 */
33 private $providers = [];
34
35 /**
36 * @var array List of replacements
37 */
38 private $replacements = [];
39
40 /**
41 * Replacement constructor
42 *
43 * @param string $content Original embedded content
44 * @param string $url Embedded content URL
45 */
46 public function __construct( $content, $url = '' ) {
47 $this->content = $content;
48 $this->set_provider( $content, $url );
49 }
50
51 /**
52 * Get the content with an overlay.
53 *
54 * @param array $attributes Embed attributes
55 * @param \epiphyt\Embed_Privacy\embed\Provider|null $provider Embed provider
56 * @return string Content with embeds replaced by an overlay
57 */
58 public function get( array $attributes = [], $provider = null ) {
59 /**
60 * Filter the content after it has been replaced with an overlay.
61 *
62 * @since 1.10.0
63 *
64 * @param string $content Replaced content
65 */
66 $content = (string) \apply_filters( 'embed_privacy_overlay_replaced_content', $this->content );
67
68 /**
69 * If set to true, unknown providers are not handled via Embed Privacy.
70 *
71 * @since 1.5.0
72 *
73 * @param bool $ignore_unknown Whether unknown providers should be ignored
74 * @param string $content The original content
75 */
76 $ignore_unknown_providers = (bool) \apply_filters( 'embed_privacy_ignore_unknown_providers', false, $content );
77
78 // get default external content
79 // special case for youtube-nocookie.com as it is part of YouTube provider
80 if ( ! $ignore_unknown_providers || \str_contains( $content, 'youtube-nocookie.com' ) ) {
81 if ( $provider instanceof Provider ) {
82 $this->provider = $provider;
83 $content = $this->replace( $content, $attributes );
84 }
85 else {
86 foreach ( $this->get_providers() as $provider ) {
87 $this->provider = $provider;
88 $content = $this->replace( $content, $attributes );
89 }
90 }
91
92 $this->provider = null;
93 }
94
95 return $content;
96 }
97
98 /**
99 * Get a list of characters to replace to prevent problems with DOMDocument.
100 *
101 * @return array List of character replacements
102 */
103 private static function get_character_replacements() {
104 $replacements = [
105 '%' => '@@epi_percentage',
106 ' ' => ' data-epi-spacing ',
107 '[' => '@@epi_square_bracket_start',
108 ']' => '@@epi_square_bracket_end',
109 '{' => '@@epi_curly_bracket_start',
110 '}' => '@@epi_curly_bracket_end',
111 ];
112
113 /**
114 * Filter character replacements.
115 *
116 * @since 1.10.0
117 *
118 * @param array $replacements Current replacements
119 */
120 $replacements = (array) \apply_filters( 'embed_privacy_overlay_character_replacements', $replacements );
121
122 return $replacements;
123 }
124
125 /**
126 * Get the current provider.
127 *
128 * @deprecated 1.10.4
129 *
130 * @return \epiphyt\Embed_privacy\embed\Provider|null Provider object
131 */
132 public function get_provider() {
133 \_doing_it_wrong(
134 __METHOD__,
135 \esc_html__( 'This method is outdated and will be removed in the future.', 'embed-privacy' ),
136 '1.10.4'
137 );
138
139 return $this->provider;
140 }
141
142 /**
143 * Get all providers to replace an embed of.
144 *
145 * @return \epiphyt\Embed_privacy\embed\Provider[] Provider object
146 */
147 public function get_providers() {
148 return $this->providers;
149 }
150
151 /**
152 * Replace content with an overlay and print assets.
153 *
154 * @param string $content Content to replace embeds in
155 * @param array $attributes Additional attributes
156 * @return string Replaced content
157 */
158 private function replace( $content, array $attributes ) {
159 $new_content = $this->replace_content( $content, $attributes );
160
161 if ( $new_content !== $content ) {
162 Embed_Privacy::get_instance()->has_embed = true;
163 Embed_Privacy::get_instance()->frontend->print_assets();
164 $content = $new_content;
165 }
166
167 return $content;
168 }
169
170 /**
171 * Replace embedded content with an overlay.
172 *
173 * @param string $content Content to replace embeds in
174 * @param array $attributes Additional attributes
175 * @return string Updated content
176 */
177 private function replace_content( $content, array $attributes ) {
178 if ( empty( $content ) ) {
179 return $content;
180 }
181
182 if ( ! $this->provider instanceof Provider ) {
183 return $content;
184 }
185
186 /**
187 * Filter whether to ignore this embed.
188 *
189 * @since 1.9.0
190 *
191 * @param bool $ignore_embed Whether to ignore this embed
192 * @param string $content The original content
193 * @param string $provider_title Embed provider title
194 * @param string $provider_name Embed provider name
195 * @param array $attributes Additional attributes
196 */
197 $ignore_embed = (bool) \apply_filters( 'embed_privacy_ignore_embed', false, $content, $this->provider->get_title(), $this->provider->get_name(), $attributes );
198
199 if ( $ignore_embed ) {
200 return $content;
201 }
202
203 $attributes = \wp_parse_args( $attributes, [
204 'additional_checks' => [],
205 'elements' => [ 'embed', 'iframe', 'object' ],
206 'element_attribute' => 'src',
207 'height' => 0,
208 'ignore_aspect_ratio' => false,
209 'is_oembed' => false,
210 'regex' => Replacer::extend_pattern( $this->provider->get_pattern(), $this->provider ),
211 'strip_newlines' => ! \has_blocks( $content ),
212 'width' => 0,
213 ] );
214
215 if ( $attributes['is_oembed'] ) {
216 return Template::get( $this->provider, $content, $attributes );
217 }
218
219 \libxml_use_internal_errors( true );
220 $dom = new DOMDocument();
221 $character_replacements = self::get_character_replacements();
222 $dom->loadHTML(
223 '<html><meta charset="utf-8">' . \str_replace(
224 \array_keys( $character_replacements ),
225 \array_values( $character_replacements ),
226 $content
227 ) . '</html>',
228 \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD
229 );
230 $template_dom = new DOMDocument();
231 // detect domain if WordPress is installed on a sub domain
232 $host = \wp_parse_url( \home_url(), \PHP_URL_HOST );
233
234 if ( ! \filter_var( $host, \FILTER_VALIDATE_IP ) ) {
235 $host_array = \explode( '.', \str_replace( 'www.', '', $host ) );
236 $tld_count = \count( $host_array );
237
238 if ( $tld_count >= 3 && \strlen( $host_array[ $tld_count - 2 ] ) === 2 ) {
239 $host = \implode( '.', \array_splice( $host_array, $tld_count - 3, 3 ) );
240 }
241 else if ( $tld_count >= 2 ) {
242 $host = \implode( '.', \array_splice( $host_array, $tld_count - 2, $tld_count ) );
243 }
244 }
245
246 foreach ( $attributes['elements'] as $tag ) {
247 $replacements = [];
248
249 if ( $tag === 'object' ) {
250 $attributes['element_attribute'] = 'data';
251 }
252
253 /** @var \DOMElement $element */
254 foreach ( $dom->getElementsByTagName( $tag ) as $element ) {
255 if ( ! Embed_Privacy::get_instance()->run_checks( $attributes['additional_checks'], $element ) ) {
256 continue;
257 }
258
259 // ignore embeds from the same (sub-)domain
260 if ( \preg_match( '/https?:\/\/(.*\.)?' . \preg_quote( $host, '/' ) . '/', $element->getAttribute( $attributes['element_attribute'] ) ) ) {
261 continue;
262 }
263
264 if (
265 empty( $attributes['regex'] )
266 || ! \preg_match_all( $this->provider->get_pattern(), $element->getAttribute( $attributes['element_attribute'] ), $matches )
267 ) {
268 continue;
269 }
270
271 foreach ( $matches[0] as $matched_content ) {
272 /**
273 * Filter whether the replacement should take place for given matches.
274 *
275 * @since 1.10.9
276 *
277 * @param bool $should_replace Whether the replacement should take place
278 * @param string $matched_content Actual matched content
279 * @param \epiphyt\Embed_privacy\embed\Provider $provider Provider object
280 * @param string $content Current content
281 * @param mixed[] $attributes Current attributes
282 */
283 $should_replace = \apply_filters( 'embed_privacy_should_replace_match', true, $matched_content, $this->provider, $content, $attributes );
284
285 if ( ! $should_replace ) {
286 continue 2;
287 }
288 }
289
290 if ( $this->provider->is_unknown() ) {
291 $embedded_host = \wp_parse_url( $element->getAttribute( $attributes['element_attribute'] ), \PHP_URL_HOST );
292
293 // embeds with relative paths have no host
294 // and they are local by definition, so do nothing
295 // see https://github.com/epiphyt/embed-privacy/issues/27
296 if ( empty( $embedded_host ) ) {
297 return $content;
298 }
299
300 $this->provider->set_title( $embedded_host );
301 $this->provider->set_name( \sanitize_title( $embedded_host ) );
302
303 // check URL for available provider
304 foreach ( Providers::get_instance()->get_list() as $provider ) {
305 if (
306 $provider->is_matching( $element->getAttribute( $attributes['element_attribute'] ) )
307 && empty( $replacements )
308 ) {
309 continue 2;
310 }
311 }
312 }
313
314 /* translators: embed title */
315 $attributes['embed_title'] = $element->hasAttribute( 'title' ) ? $element->getAttribute( 'title' ) : '';
316 $attributes['embed_url'] = $element->getAttribute( $attributes['element_attribute'] );
317 $attributes['height'] = $element->hasAttribute( 'height' ) ? $element->getAttribute( 'height' ) : 0;
318 $attributes['width'] = $element->hasAttribute( 'width' ) ? $element->getAttribute( 'width' ) : 0;
319
320 // get overlay template as DOM element
321 $template_dom->loadHTML(
322 '<html><meta charset="utf-8">' . \str_replace( '%', '%_epi_', Template::get( $this->provider, $dom->saveHTML( $element ), $attributes ) ) . '</html>',
323 \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD
324 );
325 $overlay = null;
326
327 /** @var \DOMElement $div */
328 foreach ( $template_dom->getElementsByTagName( 'div' ) as $div ) {
329 if ( \stripos( $div->getAttribute( 'class' ), 'embed-privacy-container' ) !== false ) {
330 $overlay = $div;
331 break;
332 }
333 }
334
335 // store the elements to replace (see regressive loop down below)
336 if ( $overlay instanceof DOMNode || $overlay instanceof DOMElement ) {
337 $replacements[] = [
338 'element' => $element,
339 'replace' => $dom->importNode( $overlay, true ),
340 ];
341 }
342
343 // reset embed provider name
344 if ( $this->provider->is_unknown() ) {
345 $this->provider->set_name( '' );
346 $this->provider->set_title( '' );
347 }
348 }
349
350 if ( ! empty( $replacements ) ) {
351 $this->replacements = \array_merge( $this->replacements, $replacements );
352 Embed_Privacy::get_instance()->has_embed = true;
353 $elements = $dom->getElementsByTagName( $tag );
354 $i = $elements->length - 1;
355
356 // use regressive loop for replaceChild()
357 // see: https://www.php.net/manual/en/domnode.replacechild.php#50500
358 while ( $i > -1 ) {
359 $element = $elements->item( $i );
360
361 foreach ( $replacements as $replacement ) {
362 if ( $replacement['element'] === $element ) {
363 $element->parentNode->replaceChild( $replacement['replace'], $replacement['element'] ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
364 }
365 }
366
367 --$i;
368 }
369
370 $content = $dom->saveHTML( $dom->documentElement ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
371 }
372 }
373
374 \libxml_use_internal_errors( false );
375
376 $i = -1;
377
378 // embeds for other elements need to be handled manually
379 if (
380 empty( $this->replacements )
381 && ! empty( $attributes['regex'] )
382 && ! $this->provider->is_unknown()
383 && ! $this->provider->is_disabled()
384 && \preg_match_all( $attributes['regex'], $content, $matches ) >= 1
385 ) {
386 $ignored_attributes = [
387 'data-*',
388 'href',
389 ];
390
391 /**
392 * Filter ignored attributes for matches.
393 *
394 * You can use * as a wildcard, e.g. data-*
395 *
396 * @since 1.10.11
397 *
398 * @param string[] $ignored_attributes Current list of ignored attributes
399 * @param string[] $matches List of matched content
400 * @param \epiphyt\Embed_privacy\embed\Provider $provider Current provider
401 */
402 $ignored_attributes = (array) \apply_filters( 'embed_privacy_ignored_match_attributes', $ignored_attributes, $matches[0], $this->provider );
403
404 $ignored_attributes = \implode( '|', \array_map( static function( $item ) {
405 return \preg_quote( \trim( $item ), '/' );
406 }, $ignored_attributes ) );
407 // allow wildcard
408 $ignored_attributes = \str_replace( '\*', '(.*)', $ignored_attributes );
409
410 foreach ( $matches[0] as $matched_content ) {
411 ++$i;
412
413 if ( \str_contains( $matched_content, 'embed-privacy-' ) ) {
414 continue;
415 }
416
417 if ( isset( $matches['original_pattern'] ) ) {
418 if ( empty( $matches['original_pattern'][ $i ] ) ) {
419 continue;
420 }
421
422 // the original pattern must not be inside a href attribute
423 if ( \preg_match( '/(' . $ignored_attributes . ')="([^"]*)' . \preg_quote( $matches['original_pattern'][ $i ], '/' ) . '/', $matched_content ) ) {
424 continue;
425 }
426 }
427
428 // if the content contains an embed wrapper class, that means that the
429 // embed is broken
430 if (
431 $this->provider->is_system()
432 && \str_contains( $matched_content, 'class="wp-block-embed__wrapper' )
433 ) {
434 return $content;
435 }
436
437 /**
438 * Filter whether the replacement should take place for given matches.
439 *
440 * @since 1.10.9
441 *
442 * @param bool $should_replace Whether the replacement should take place
443 * @param string $matched_content Actual matched content
444 * @param \epiphyt\Embed_privacy\embed\Provider $provider Provider object
445 * @param string $content Current content
446 * @param mixed[] $attributes Current attributes
447 */
448 $should_replace = \apply_filters( 'embed_privacy_should_replace_match', true, $matched_content, $this->provider, $content, $attributes );
449
450 if ( $should_replace ) {
451 $content = \str_replace(
452 $matched_content,
453 Template::get(
454 $this->provider,
455 $matched_content,
456 $attributes
457 ),
458 $content
459 );
460 }
461 }
462 }
463
464 // decode to make sure there is nothing left encoded if replacements have been made
465 // otherwise, content is untouched by DOMDocument, and we don't need a decoding
466 // only required for WPBakery Page Builder
467 if ( ! empty( $this->replacements ) && \str_contains( 'vc_row', $content ) ) {
468 $content = \rawurldecode( $content );
469 }
470
471 return \str_replace(
472 \array_merge(
473 [
474 '<html><meta charset="utf-8">',
475 '</html>',
476 '%20data-epi-spacing%20',
477 '%_epi_20data-epi-spacing%_epi_20', // % has been replaced with %_epi_ after replacing spaces
478 ],
479 \array_values( $character_replacements )
480 ),
481 \array_merge(
482 [
483 '',
484 '',
485 ' ',
486 ' ',
487 ],
488 \array_keys( $character_replacements )
489 ),
490 $content
491 );
492 }
493
494 /**
495 * Set the provider for this overlay.
496 *
497 * @param string $content Content to get the provider from
498 * @param string $url URL to the embedded content
499 */
500 private function set_provider( $content, $url = '' ) {
501 $current_provider = null;
502 $providers = Providers::get_instance()->get_list();
503
504 foreach ( $providers as $provider ) {
505 if (
506 ! $provider->is_matching(
507 $content,
508 Replacer::extend_pattern( $provider->get_pattern(), $provider )
509 )
510 && ( empty( $url ) || ! $provider->is_matching( $url ) )
511 ) {
512 continue;
513 }
514
515 $current_provider = $provider;
516
517 /**
518 * Filter the overlay provider.
519 *
520 * @since 1.10.0
521 *
522 * @param \epiphyt\Embed_Privacy\embed\Provider $provider Current provider
523 * @param string $content Content to get the provider from
524 * @param string $url URL to the embedded content
525 */
526 $this->providers[] = \apply_filters( 'embed_privacy_overlay_provider', $current_provider, $content, $url );
527 }
528
529 // support unknown oEmbed provider
530 // see https://github.com/epiphyt/embed-privacy/issues/89
531 if ( $current_provider === null && ! empty( $url ) ) {
532 $parsed_url = \wp_parse_url( $url );
533 $provider = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
534 $current_provider = new Provider();
535 $current_provider->set_name( $provider );
536 $current_provider->set_title( $provider );
537 }
538
539 // unknown embeds
540 if ( $current_provider === null ) {
541 $current_provider = new Provider();
542 }
543
544 /**
545 * This filter is documented in inc/embed/class-replacement.php.
546 */
547 $this->providers[] = \apply_filters( 'embed_privacy_overlay_provider', $current_provider, $content, $url );
548 }
549 }
550