PluginProbe
Embed Privacy / trunk
Embed Privacy vtrunk
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 trunk, at inc/embed/class-replacement.php

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