PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-comments / src / form / class-comment-form.php

class-comment-form.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-comments/src/form/class-comment-form.php

557 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The comment form.
4 *
5 * @package automattic/jetpack-comments
6 */
7
8 namespace Automattic\Jetpack\Comments;
9
10 use Automattic\Jetpack\Assets;
11
12 /**
13 * Replaces the core comment form, and accepts what it submits.
14 */
15 class Comment_Form {
16
17 const HANDLE = 'jetpack-comments';
18 const NONCE_ACTION = 'jetpack_comments_form';
19 const NONCE_NAME = 'jetpack_comments_form_nonce';
20
21 /**
22 * Singleton instance.
23 *
24 * @var Comment_Form|null
25 */
26 private static $instance = null;
27
28 /**
29 * Whether the settings blob has been printed.
30 *
31 * @var bool
32 */
33 private $settings_printed = false;
34
35 /**
36 * The form defaults last seen, for the must-log-in branch, which core fires with no arguments.
37 *
38 * @var array
39 */
40 private $defaults = array();
41
42 /**
43 * Register the form's hooks. Safe to call more than once.
44 *
45 * @return Comment_Form
46 */
47 public static function init() {
48 if ( null === self::$instance ) {
49 self::$instance = new self();
50 }
51
52 return self::$instance;
53 }
54
55 /**
56 * Take over the core comment form.
57 */
58 private function __construct() {
59 add_filter( 'comment_form_fields', array( $this, 'comment_form_fields' ) );
60 add_filter( 'comment_form_logged_in', array( $this, 'comment_form_logged_in' ) );
61 add_filter( 'comment_form_defaults', array( $this, 'comment_form_defaults' ), 20 );
62 // Past 10, where Jetpack Subscriptions adds its checkboxes, so this sees them before replacing the field.
63 add_filter( 'comment_form_submit_field', array( $this, 'render' ), 20, 2 );
64 add_action( 'comment_form_must_log_in_after', array( $this, 'render_must_log_in' ) );
65 add_filter( 'comment_reply_link', array( $this, 'comment_reply_link' ), 10, 4 );
66 add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
67 add_action( 'pre_comment_on_post', array( $this, 'verify_nonce' ) );
68 }
69
70 /**
71 * Keep Reply moving the form when the site requires registration.
72 *
73 * @param string $reply_link Markup for the reply link.
74 * @param array $args Reply link arguments.
75 * @param \WP_Comment $comment Comment being replied to.
76 * @param \WP_Post $post Post being commented on.
77 * @return string
78 */
79 public function comment_reply_link( $reply_link, $args, $comment, $post ) {
80 if ( ! get_option( 'comment_registration' ) || ! self::enabled_for_post_type() ) {
81 return $reply_link;
82 }
83
84 $comment = get_comment( $comment );
85 $post = get_post( $post );
86
87 if ( ! $comment instanceof \WP_Comment || ! $post instanceof \WP_Post ) {
88 return $reply_link;
89 }
90
91 $respond_id = esc_attr( $args['respond_id'] );
92 $reply_to = sprintf( $args['reply_to_text'], get_comment_author( $comment ) );
93
94 // A theme may put an icon inside its reply link.
95 $reply_text_html = array(
96 'svg' => array(
97 'class' => true,
98 'aria-hidden' => true,
99 'aria-labelledby' => true,
100 'role' => true,
101 'xmlns' => true,
102 'width' => true,
103 'height' => true,
104 'viewbox' => true,
105 ),
106 'use' => array(
107 'href' => true,
108 'xlink:href' => true,
109 ),
110 );
111
112 $link = sprintf(
113 '<a class="comment-reply-link" href="%s"%s onclick="return addComment.moveForm( \'%s-%d\', \'%d\', \'%s\', \'%d\' )">%s</a>',
114 esc_url( add_query_arg( 'replytocom', $comment->comment_ID . '#' . $respond_id ) ),
115 $args['show_reply_to_text'] ? '' : ' aria-label="' . esc_attr( $reply_to ) . '"',
116 esc_attr( $args['add_below'] ),
117 $comment->comment_ID,
118 $comment->comment_ID,
119 $respond_id,
120 $post->ID,
121 wp_kses( $args['show_reply_to_text'] ? $reply_to : $args['reply_text'], $reply_text_html )
122 );
123
124 return wp_kses( $args['before'], wp_kses_allowed_html( 'post' ) )
125 . $link
126 . wp_kses( $args['after'], wp_kses_allowed_html( 'post' ) );
127 }
128
129 /**
130 * Whether this form replaces core's for a post's type.
131 *
132 * @param int|null $post_id Post being commented on. Defaults to the current one.
133 * @return bool
134 */
135 public static function enabled_for_post_type( $post_id = null ) {
136 $post_type = $post_id ? get_post_type( $post_id ) : get_post_type();
137
138 /** This filter is documented in projects/plugins/jetpack/modules/comments/comments.php */
139 return (bool) apply_filters( 'jetpack_comment_form_enabled_for_' . $post_type, true );
140 }
141
142 /**
143 * Drop every field core would draw.
144 *
145 * @param array $fields Comment form fields, the textarea included.
146 * @return array
147 */
148 public function comment_form_fields( $fields ) {
149 return self::enabled_for_post_type() ? array() : $fields;
150 }
151
152 /**
153 * Suppress core's logged-in line.
154 *
155 * @param string $logged_in_as The "logged in as" markup.
156 * @return string
157 */
158 public function comment_form_logged_in( $logged_in_as ) {
159 return self::enabled_for_post_type() ? '' : $logged_in_as;
160 }
161
162 /**
163 * Set the form arguments the app reads back out.
164 *
165 * @param array $args Comment form arguments.
166 * @return array
167 */
168 public function comment_form_defaults( $args ) {
169 if ( ! self::enabled_for_post_type() ) {
170 return $args;
171 }
172
173 $defaults = array(
174 'logged_in_as' => '',
175 'comment_notes_before' => '',
176 'must_log_in' => '',
177 'label_submit' => _x( 'Comment', 'verb', 'jetpack-comments' ),
178 );
179
180 $greeting = get_option( 'highlander_comment_form_prompt' );
181 if ( is_string( $greeting ) && $greeting !== '' ) {
182 $defaults['title_reply'] = $greeting;
183 }
184
185 $this->defaults = array_merge( $args, $defaults );
186
187 return $this->defaults;
188 }
189
190 /**
191 * Replace the submit field with the app.
192 *
193 * @param string $submit_field The submit field markup this replaces.
194 * @param array $args Comment form arguments, after the theme's own.
195 * @return string
196 */
197 public function render( $submit_field, $args = array() ) {
198 if ( ! self::enabled_for_post_type() ) {
199 return $submit_field;
200 }
201
202 // The subscribe checkboxes the host drew: Jetpack's in this field, WordPress.com's from its own
203 // function. Drawn in the dialog under the host's names, so its gating and handlers still apply.
204 $drawn = $submit_field;
205 if ( function_exists( 'subscription_comment_form' ) ) {
206 // Echoed and caught: its stub declares no return value.
207 ob_start();
208 subscription_comment_form( self::post_id() );
209 $drawn .= (string) ob_get_clean();
210 }
211
212 $labels = array(
213 'subscribe_comments' => __( 'Notify me of new comments by email.', 'jetpack-comments' ),
214 'subscribe' => __( 'Notify me of new comments by email.', 'jetpack-comments' ),
215 'subscribe_blog' => sprintf(
216 /* translators: %s is the site's name. */
217 __( 'Subscribe to keep up with %s.', 'jetpack-comments' ),
218 get_bloginfo( 'name' )
219 ),
220 );
221
222 $args['subscriptions'] = array();
223 foreach ( $labels as $name => $label ) {
224 if ( preg_match( '/<input\b[^>]*\bname="' . $name . '"[^>]*>/', $drawn, $input ) ) {
225 $args['subscriptions'][] = array(
226 'name' => $name,
227 'label' => $label,
228 'checked' => false !== strpos( $input[0], 'checked' ),
229 );
230 }
231 }
232
233 // Fires after this filter, and would draw the subscribe options again below the form.
234 remove_action( 'comment_form', 'subscription_comment_form' );
235
236 $this->enqueue_assets( $args );
237
238 return $this->markup( $args );
239 }
240
241 /**
242 * Draw the app, and a form to hold it, on the must-log-in branch.
243 *
244 * @return void
245 */
246 public function render_must_log_in() {
247 if ( ! self::enabled_for_post_type() ) {
248 return;
249 }
250
251 $args = $this->defaults;
252
253 $this->enqueue_assets( $args );
254
255 printf(
256 '<form action="%s" method="post" id="commentform" class="comment-form">%s</form>',
257 esc_url( site_url( '/wp-comments-post.php' ) ),
258 $this->markup( $args ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built.
259 );
260 }
261
262 /**
263 * The app's mount point, holding a plain form until the script takes over, and the hidden fields both post with.
264 *
265 * @param array $args Comment form arguments.
266 * @return string
267 */
268 private function markup( $args = array() ) {
269 $post_id = self::post_id();
270 $permalink = get_permalink( $post_id );
271 $button = sprintf(
272 $args['submit_button'] ?? '<input name="%1$s" type="submit" id="%2$s" class="%3$s" value="%4$s" />',
273 esc_attr( $args['name_submit'] ?? 'submit' ),
274 esc_attr( $args['id_submit'] ?? 'submit' ),
275 esc_attr( $args['class_submit'] ?? 'submit' ),
276 esc_attr( $args['label_submit'] ?? _x( 'Comment', 'verb', 'jetpack-comments' ) )
277 );
278
279 // The classes come from the button template, not class_submit: on a block
280 // theme the Post Comments Form block bakes the theme's button classes into it.
281 $class = preg_match( '/\bclass="([^"]*)"/', $button, $match ) ? $match[1] : 'submit';
282
283 // Values belonging to this form rather than to the page it sits on.
284 $settings = array(
285 'postId' => $post_id,
286 'loginUrl' => wp_login_url( $permalink ),
287 // wp_logout_url() runs the URL through esc_html(), which encodes single quotes too.
288 // None on WordPress.com, where the site's session is the reader's whole WordPress.com login.
289 'logoutUrl' => is_user_logged_in() && ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) ? html_entity_decode( wp_logout_url( $permalink ), ENT_QUOTES ) : '',
290 'submit' => array(
291 'id' => $args['id_submit'] ?? 'submit',
292 'name' => $args['name_submit'] ?? 'submit',
293 'class' => $class,
294 // The block wraps its button the way the Buttons block does, so block-level button styles reach it.
295 'wrapClass' => false !== strpos( $class, 'wp-block-button__link' ) ? 'wp-block-button' : '',
296 'label' => $args['label_submit'] ?? _x( 'Comment', 'verb', 'jetpack-comments' ),
297 ),
298 'subscriptions' => $args['subscriptions'] ?? array(),
299 );
300
301 // Core's own fields and submit, for a page whose settings another release rendered or whose script never ran.
302 if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) {
303 $plain = '<p class="must-log-in">' . sprintf(
304 /* translators: %s is a link to the log-in page. */
305 esc_html__( 'You must be %s to post a comment.', 'jetpack-comments' ),
306 '<a href="' . esc_url( wp_login_url( $permalink ) ) . '">' . esc_html__( 'logged in', 'jetpack-comments' ) . '</a>'
307 ) . '</p>';
308 } else {
309 $required = (bool) get_option( 'require_name_email' );
310 $plain = '<p class="comment-form-comment"><label for="comment">' . esc_html_x( 'Comment', 'noun', 'jetpack-comments' ) . '</label>'
311 . '<textarea id="comment" name="comment" rows="4" required></textarea></p>';
312
313 if ( ! is_user_logged_in() ) {
314 $commenter = wp_get_current_commenter();
315 $fields = array(
316 'author' => array( __( 'Name', 'jetpack-comments' ), 'text', $commenter['comment_author'], $required ),
317 'email' => array( __( 'Email', 'jetpack-comments' ), 'email', $commenter['comment_author_email'], $required ),
318 'url' => array( __( 'Website', 'jetpack-comments' ), 'url', $commenter['comment_author_url'], false ),
319 );
320
321 foreach ( $fields as $name => list( $label, $type, $value, $is_required ) ) {
322 $plain .= sprintf(
323 '<p class="comment-form-%1$s"><label for="%1$s">%2$s</label><input id="%1$s" name="%1$s" type="%3$s" value="%4$s"%5$s /></p>',
324 $name,
325 esc_html( $label ),
326 $type,
327 esc_attr( $value ),
328 $is_required ? ' required' : ''
329 );
330 }
331 }
332
333 $plain .= sprintf( $args['submit_field'] ?? '<p class="form-submit">%1$s %2$s</p>', $button, '' );
334 }
335
336 return '<div class="jetpack-comments"'
337 . ' data-jetpack-comments="' . esc_attr( (string) wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP ) ) . '">'
338 . $plain
339 . '</div>'
340 . get_comment_id_fields( $post_id )
341 . wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME, false, false );
342 }
343
344 /**
345 * The post being commented on.
346 *
347 * @return int
348 */
349 public static function post_id() {
350 $post = get_post();
351
352 return $post ? $post->ID : 0;
353 }
354
355 /**
356 * Register the bundle, and the stylesheet on a singular view.
357 *
358 * @return void
359 */
360 public function register_assets() {
361 if ( wp_script_is( self::HANDLE, 'registered' ) ) {
362 return;
363 }
364
365 // The asset version is the script's hash, so a stylesheet-only change would ship under a cached URL.
366 $asset = include dirname( __DIR__, 2 ) . '/build/comments.asset.php';
367
368 Assets::register_script(
369 self::HANDLE,
370 '../../build/comments.js',
371 __FILE__,
372 array(
373 'in_footer' => true,
374 'strategy' => 'defer',
375 'version' => $asset['version'] . '-' . (string) filemtime( dirname( __DIR__, 2 ) . '/build/comments.css' ),
376 )
377 );
378
379 if ( is_singular() && comments_open() ) {
380 wp_enqueue_style( self::HANDLE );
381 add_action( 'wp_head', array( __CLASS__, 'print_noscript_style' ) );
382 }
383 }
384
385 /**
386 * Show the plain form where no script will ever replace it.
387 *
388 * @return void
389 */
390 public static function print_noscript_style() {
391 echo '<noscript><style>.jetpack-comments{visibility:visible!important}</style></noscript>';
392 }
393
394 /**
395 * Enqueue the bundle and hand it the settings for this form.
396 *
397 * @param array $args Comment form arguments.
398 * @return void
399 */
400 public function enqueue_assets( $args = array() ) {
401 $this->register_assets();
402
403 if ( ! $this->settings_printed ) {
404 $strings = array(
405 'reply' => _x( 'Reply', 'verb', 'jetpack-comments' ),
406 'commentLabel' => _x( 'Comment', 'noun', 'jetpack-comments' ),
407 'replyLabel' => _x( 'Reply', 'noun', 'jetpack-comments' ),
408 'placeholder' => __( 'Write a comment...', 'jetpack-comments' ),
409 'replyPlaceholder' => __( 'Write a reply...', 'jetpack-comments' ),
410 'name' => __( 'Name', 'jetpack-comments' ),
411 'email' => __( 'Email', 'jetpack-comments' ),
412 'emailHint' => __( 'Address never made public', 'jetpack-comments' ),
413 'emailHasAccount' => __( 'That email belongs to a WordPress.com account. Log in with WordPress.com to use it, or enter a different email.', 'jetpack-comments' ),
414 'website' => __( 'Website (optional)', 'jetpack-comments' ),
415 'createProfile' => __( 'Create a profile', 'jetpack-comments' ),
416 'intro' => __( 'Provide your name and email to leave a comment.', 'jetpack-comments' ),
417 'continueAsGuest' => __( 'Continue as a guest', 'jetpack-comments' ),
418 'postWithoutSaving' => __( 'No, thanks. I just want to post a comment', 'jetpack-comments' ),
419 'save' => __( 'Save', 'jetpack-comments' ),
420 'saveDetails' => __( 'Save my name, email, and website for the next time I comment.', 'jetpack-comments' ),
421 'close' => __( 'Close', 'jetpack-comments' ),
422 'options' => __( 'Options', 'jetpack-comments' ),
423 'changeDetails' => __( 'Change details', 'jetpack-comments' ),
424 'manageSubscriptions' => __( 'Manage subscription', 'jetpack-comments' ),
425 'mustLogIn' => __( 'You must be logged in to post a comment.', 'jetpack-comments' ),
426 'logIn' => __( 'Log in', 'jetpack-comments' ),
427 'logInWithWordPress' => __( 'Log in with WordPress.com', 'jetpack-comments' ),
428 'logOut' => __( 'Log out', 'jetpack-comments' ),
429 'addYourName' => __( 'Add your name', 'jetpack-comments' ),
430 'cancel' => __( 'Cancel', 'jetpack-comments' ),
431 'signInFailed' => __( 'We could not sign you in. Please try again.', 'jetpack-comments' ),
432 'signInRateLimited' => __( 'Too many sign-in attempts. Please wait a moment and try again.', 'jetpack-comments' ),
433 );
434
435 /**
436 * Filter the copy the comment form renders.
437 *
438 * @since 0.1.0
439 *
440 * @param array $strings Keyed by the name the app reads.
441 * @param array $args Comment form arguments.
442 */
443 $strings = apply_filters( 'jetpack_comments_strings', $strings, $args );
444 $lengths = wp_get_comment_fields_max_lengths();
445 $style = wp_styles()->query( self::HANDLE );
446
447 // Where a reader manages subscriptions: the Reader for a WordPress.com account, the
448 // email portal for anyone else. Only where the Newsletter offers them on this form;
449 // Simple stores an option's "off" as an empty string, Jetpack as 0.
450 $offered = false;
451 foreach ( array( 'stb_enabled', 'stc_enabled' ) as $option ) {
452 $offered = $offered || ! in_array( get_option( $option, 1 ), array( '', '0', 0 ), true );
453 }
454
455 $reader = 'https://wordpress.com/reader/subscriptions?s=' . rawurlencode( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
456 $manage = array(
457 'url' => '',
458 'byEmail' => true,
459 'signedInUrl' => '',
460 );
461
462 if ( $offered && ( function_exists( 'subscription_comment_form' ) || class_exists( 'Jetpack_Subscriptions' ) ) ) {
463 // On WordPress.com, a logged-in reader is a WordPress.com account.
464 $by_account = defined( 'IS_WPCOM' ) && IS_WPCOM && is_user_logged_in();
465 $manage = array(
466 'url' => $by_account ? $reader : 'https://subscribe.wordpress.com/',
467 'byEmail' => ! $by_account,
468 'signedInUrl' => $reader,
469 );
470 }
471
472 // Everything the app needs that only PHP knows.
473 $settings = array_merge(
474 array(
475 'version' => Comments::PACKAGE_VERSION,
476 // The dialog's shadow root links it again; page styles stop at that boundary.
477 // Decoded: WordPress.com's static-file filter joins its query with &amp;.
478 'styleUrl' => $style ? html_entity_decode( (string) add_query_arg( 'ver', $style->ver, $style->src ), ENT_QUOTES ) : '',
479 'requireNameEmail' => (bool) get_option( 'require_name_email' ),
480 'mustLogIn' => (bool) get_option( 'comment_registration' ) && ! is_user_logged_in(),
481 'maxLength' => isset( $lengths['comment_content'] ) ? (int) $lengths['comment_content'] : 65525,
482 'site' => array(
483 'name' => get_bloginfo( 'name' ),
484 'iconUrl' => (string) get_site_icon_url( 64 ),
485 ),
486 'manageSubscriptions' => $manage,
487 'strings' => $strings,
488 ),
489 Identity::settings()
490 );
491
492 wp_add_inline_script(
493 self::HANDLE,
494 'window.JetpackComments = ' . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
495 'before'
496 );
497 $this->settings_printed = true;
498 }
499
500 Assets::enqueue_script( self::HANDLE );
501 wp_enqueue_style( self::HANDLE );
502 }
503
504 /**
505 * Require a comment to arrive with a nonce this site issued.
506 *
507 * For a logged-out reader it is the same string for everybody, for up to 24
508 * hours, so it proves the sender loaded a page from this site and nothing more.
509 *
510 * @param int $comment_post_id The post being commented on.
511 * @return void
512 */
513 public function verify_nonce( $comment_post_id = 0 ) {
514 if ( ! self::enabled_for_post_type( $comment_post_id ) ) {
515 return;
516 }
517
518 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this is the nonce check.
519 $nonce = isset( $_POST[ self::NONCE_NAME ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_NAME ] ) ) : '';
520
521 if ( wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
522 return;
523 }
524
525 // A page cache can hand a logged-in reader a copy rendered for nobody, so
526 // the nonce they post is the anonymous one. wp_verify_nonce() reads the
527 // session token from the logged-in cookie, not the current user, so the
528 // cookie has to go too for the hash to match what a visitor was served.
529 if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
530 $user_id = get_current_user_id();
531 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Stashed and put back untouched.
532 $cookie = $_COOKIE[ LOGGED_IN_COOKIE ];
533
534 unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
535 wp_set_current_user( 0 );
536
537 $valid = (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION );
538
539 $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie;
540 wp_set_current_user( $user_id );
541
542 if ( $valid ) {
543 return;
544 }
545 }
546
547 wp_die(
548 esc_html__( 'Sorry, this comment could not be posted. Go back and try again.', 'jetpack-comments' ),
549 esc_html__( 'Comment Submission Failure', 'jetpack-comments' ),
550 array(
551 'response' => 403,
552 'back_link' => true,
553 )
554 );
555 }
556 }
557