PluginProbe
Embed Privacy / 1.11.2
Embed Privacy v1.11.2
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.2, at inc/embed/class-replacement.php

554 lines 16.7 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', $this->provider->is_disabled(), $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 ( $this->provider->is_unknown() ) {
265 $embedded_host = \wp_parse_url( $element->getAttribute( $attributes['element_attribute'] ), \PHP_URL_HOST );
266
267 // embeds with relative paths have no host
268 // and they are local by definition, so do nothing
269 // see https://github.com/epiphyt/embed-privacy/issues/27
270 if ( empty( $embedded_host ) ) {
271 return $content;
272 }
273
274 $this->provider->set_title( $embedded_host );
275 $this->provider->set_name( \sanitize_title( $embedded_host ) );
276
277 // check URL for available provider
278 foreach ( Providers::get_instance()->get_list() as $provider ) {
279 if (
280 $provider->is_matching( $element->getAttribute( $attributes['element_attribute'] ) )
281 && empty( $replacements )
282 ) {
283 continue 2;
284 }
285 }
286 }
287 else {
288 if (
289 empty( $attributes['regex'] )
290 || ! \preg_match_all( $this->provider->get_pattern(), $element->getAttribute( $attributes['element_attribute'] ), $matches )
291 ) {
292 continue;
293 }
294
295 foreach ( $matches[0] as $matched_content ) {
296 /**
297 * Filter whether the replacement should take place for given matches.
298 *
299 * @since 1.10.9
300 *
301 * @param bool $should_replace Whether the replacement should take place
302 * @param string $matched_content Actual matched content
303 * @param \epiphyt\Embed_privacy\embed\Provider $provider Provider object
304 * @param string $content Current content
305 * @param mixed[] $attributes Current attributes
306 */
307 $should_replace = \apply_filters( 'embed_privacy_should_replace_match', true, $matched_content, $this->provider, $content, $attributes );
308
309 if ( ! $should_replace ) {
310 continue 2;
311 }
312 }
313 }
314
315 /* translators: embed title */
316 $attributes['embed_title'] = $element->hasAttribute( 'title' ) ? $element->getAttribute( 'title' ) : '';
317 $attributes['embed_url'] = $element->getAttribute( $attributes['element_attribute'] );
318 $attributes['height'] = $element->hasAttribute( 'height' ) ? $element->getAttribute( 'height' ) : 0;
319 $attributes['width'] = $element->hasAttribute( 'width' ) ? $element->getAttribute( 'width' ) : 0;
320
321 // get overlay template as DOM element
322 $template_dom->loadHTML(
323 '<html><meta charset="utf-8">' . \str_replace( '%', '%_epi_', Template::get( $this->provider, $dom->saveHTML( $element ), $attributes ) ) . '</html>',
324 \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD
325 );
326 $overlay = null;
327
328 /** @var \DOMElement $div */
329 foreach ( $template_dom->getElementsByTagName( 'div' ) as $div ) {
330 if ( \stripos( $div->getAttribute( 'class' ), 'embed-privacy-container' ) !== false ) {
331 $overlay = $div;
332 break;
333 }
334 }
335
336 // store the elements to replace (see regressive loop down below)
337 if ( $overlay instanceof DOMNode || $overlay instanceof DOMElement ) {
338 $replacements[] = [
339 'element' => $element,
340 'replace' => $dom->importNode( $overlay, true ),
341 ];
342 }
343
344 // reset embed provider name
345 if ( $this->provider->is_unknown() ) {
346 $this->provider->set_name( '' );
347 $this->provider->set_title( '' );
348 }
349 }
350
351 if ( ! empty( $replacements ) ) {
352 $this->replacements = \array_merge( $this->replacements, $replacements );
353 Embed_Privacy::get_instance()->has_embed = true;
354 $elements = $dom->getElementsByTagName( $tag );
355 $i = $elements->length - 1;
356
357 // use regressive loop for replaceChild()
358 // see: https://www.php.net/manual/en/domnode.replacechild.php#50500
359 while ( $i > -1 ) {
360 $element = $elements->item( $i );
361
362 foreach ( $replacements as $replacement ) {
363 if ( $replacement['element'] === $element ) {
364 $element->parentNode->replaceChild( $replacement['replace'], $replacement['element'] ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
365 }
366 }
367
368 --$i;
369 }
370
371 $content = $dom->saveHTML( $dom->documentElement ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
372 }
373 }
374
375 \libxml_use_internal_errors( false );
376
377 $i = -1;
378
379 // embeds for other elements need to be handled manually
380 if (
381 empty( $this->replacements )
382 && ! empty( $attributes['regex'] )
383 && ! $this->provider->is_unknown()
384 && ! $this->provider->is_disabled()
385 && \preg_match_all( $attributes['regex'], $content, $matches ) >= 1
386 ) {
387 $ignored_attributes = [
388 'data-*',
389 'href',
390 ];
391
392 /**
393 * Filter ignored attributes for matches.
394 *
395 * You can use * as a wildcard, e.g. data-*
396 *
397 * @since 1.10.11
398 *
399 * @param string[] $ignored_attributes Current list of ignored attributes
400 * @param string[] $matches List of matched content
401 * @param \epiphyt\Embed_privacy\embed\Provider $provider Current provider
402 */
403 $ignored_attributes = (array) \apply_filters( 'embed_privacy_ignored_match_attributes', $ignored_attributes, $matches[0], $this->provider );
404
405 $ignored_attributes = \implode( '|', \array_map( static function( $item ) {
406 return \preg_quote( \trim( $item ), '/' );
407 }, $ignored_attributes ) );
408 // allow wildcard
409 $ignored_attributes = \str_replace( '\*', '(.*)', $ignored_attributes );
410
411 foreach ( $matches[0] as $matched_content ) {
412 ++$i;
413
414 if ( \str_contains( $matched_content, 'embed-privacy-' ) ) {
415 continue;
416 }
417
418 if ( isset( $matches['original_pattern'] ) ) {
419 if ( empty( $matches['original_pattern'][ $i ] ) ) {
420 continue;
421 }
422
423 // the original pattern must not be inside a href attribute
424 if ( \preg_match( '/(' . $ignored_attributes . ')="([^"]*)' . \preg_quote( $matches['original_pattern'][ $i ], '/' ) . '/', $matched_content ) ) {
425 continue;
426 }
427 }
428
429 // if the content contains an embed wrapper class, that means that the
430 // embed is broken
431 if (
432 $this->provider->is_system()
433 && \str_contains( $matched_content, 'class="wp-block-embed__wrapper' )
434 ) {
435 return $content;
436 }
437
438 /**
439 * Filter whether the replacement should take place for given matches.
440 *
441 * @since 1.10.9
442 *
443 * @param bool $should_replace Whether the replacement should take place
444 * @param string $matched_content Actual matched content
445 * @param \epiphyt\Embed_privacy\embed\Provider $provider Provider object
446 * @param string $content Current content
447 * @param mixed[] $attributes Current attributes
448 */
449 $should_replace = \apply_filters( 'embed_privacy_should_replace_match', true, $matched_content, $this->provider, $content, $attributes );
450
451 if ( $should_replace ) {
452 $content = \str_replace(
453 $matched_content,
454 Template::get(
455 $this->provider,
456 $matched_content,
457 $attributes
458 ),
459 $content
460 );
461 }
462 }
463 }
464
465 // decode to make sure there is nothing left encoded if replacements have been made
466 // otherwise, content is untouched by DOMDocument, and we don't need a decoding
467 // only required for WPBakery Page Builder
468 if ( ! empty( $this->replacements ) && \str_contains( 'vc_row', $content ) ) {
469 $content = \rawurldecode( $content );
470 }
471
472 return \str_replace(
473 \array_merge(
474 [
475 '<html><meta charset="utf-8">',
476 '</html>',
477 '%20data-epi-spacing%20',
478 '"data-epi-spacing%20',
479 '%_epi_20data-epi-spacing%_epi_20', // % has been replaced with %_epi_ after replacing spaces
480 ],
481 \array_values( $character_replacements )
482 ),
483 \array_merge(
484 [
485 '',
486 '',
487 ' ',
488 '" ',
489 ' ',
490 ],
491 \array_keys( $character_replacements )
492 ),
493 $content
494 );
495 }
496
497 /**
498 * Set the provider for this overlay.
499 *
500 * @param string $content Content to get the provider from
501 * @param string $url URL to the embedded content
502 */
503 private function set_provider( $content, $url = '' ) {
504 $current_provider = null;
505 $providers = Providers::get_instance()->get_list();
506
507 foreach ( $providers as $provider ) {
508 if (
509 ! $provider->is_matching(
510 $content,
511 Replacer::extend_pattern( $provider->get_pattern(), $provider )
512 )
513 && ( empty( $url ) || ! $provider->is_matching( $url ) )
514 ) {
515 continue;
516 }
517
518 $current_provider = $provider;
519
520 /**
521 * Filter the overlay provider.
522 *
523 * @since 1.10.0
524 *
525 * @param \epiphyt\Embed_Privacy\embed\Provider $provider Current provider
526 * @param string $content Content to get the provider from
527 * @param string $url URL to the embedded content
528 */
529 $this->providers[] = \apply_filters( 'embed_privacy_overlay_provider', $current_provider, $content, $url );
530 }
531
532 // support unknown oEmbed provider
533 // see https://github.com/epiphyt/embed-privacy/issues/89
534 if ( $current_provider === null && ! empty( $url ) ) {
535 $parsed_url = \wp_parse_url( $url );
536 $provider = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
537 $current_provider = new Provider();
538 $current_provider->set_name( $provider );
539 $current_provider->set_title( $provider );
540 }
541
542 // unknown embeds
543 if ( $current_provider === null ) {
544 $current_provider = new Provider();
545 $current_provider->set_pattern( \preg_quote( $url, '/' ) );
546 }
547
548 /**
549 * This filter is documented in inc/embed/class-replacement.php.
550 */
551 $this->providers[] = \apply_filters( 'embed_privacy_overlay_provider', $current_provider, $content, $url );
552 }
553 }
554