PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 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 All 507 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-beta, at jetpack_vendor/automattic/jetpack-comments/src/form/class-comment-form.php

561 lines 20.0 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 'logoutUrl' => is_user_logged_in() ? html_entity_decode( wp_logout_url( $permalink ), ENT_QUOTES ) : '',
289 'submit' => array(
290 'id' => $args['id_submit'] ?? 'submit',
291 'name' => $args['name_submit'] ?? 'submit',
292 'class' => $class,
293 // The block wraps its button the way the Buttons block does, so block-level button styles reach it.
294 'wrapClass' => false !== strpos( $class, 'wp-block-button__link' ) ? 'wp-block-button' : '',
295 'label' => $args['label_submit'] ?? _x( 'Comment', 'verb', 'jetpack-comments' ),
296 ),
297 'subscriptions' => $args['subscriptions'] ?? array(),
298 );
299
300 // Core's own fields and submit, for a page whose settings another release rendered or whose script never ran.
301 if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) {
302 $plain = '<p class="must-log-in">' . sprintf(
303 /* translators: %s is a link to the log-in page. */
304 esc_html__( 'You must be %s to post a comment.', 'jetpack-comments' ),
305 '<a href="' . esc_url( wp_login_url( $permalink ) ) . '">' . esc_html__( 'logged in', 'jetpack-comments' ) . '</a>'
306 ) . '</p>';
307 } else {
308 $required = (bool) get_option( 'require_name_email' );
309 $plain = '<p class="comment-form-comment"><label for="comment">' . esc_html_x( 'Comment', 'noun', 'jetpack-comments' ) . '</label>'
310 . '<textarea id="comment" name="comment" rows="4" required></textarea></p>';
311
312 if ( ! is_user_logged_in() ) {
313 $commenter = wp_get_current_commenter();
314 $fields = array(
315 'author' => array( __( 'Name', 'jetpack-comments' ), 'text', $commenter['comment_author'], $required ),
316 'email' => array( __( 'Email', 'jetpack-comments' ), 'email', $commenter['comment_author_email'], $required ),
317 'url' => array( __( 'Website', 'jetpack-comments' ), 'url', $commenter['comment_author_url'], false ),
318 );
319
320 foreach ( $fields as $name => list( $label, $type, $value, $is_required ) ) {
321 $plain .= sprintf(
322 '<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>',
323 $name,
324 esc_html( $label ),
325 $type,
326 esc_attr( $value ),
327 $is_required ? ' required' : ''
328 );
329 }
330 }
331
332 $plain .= sprintf( $args['submit_field'] ?? '<p class="form-submit">%1$s %2$s</p>', $button, '' );
333 }
334
335 return '<div class="jetpack-comments"'
336 . ' data-jetpack-comments="' . esc_attr( (string) wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP ) ) . '">'
337 . $plain
338 . '</div>'
339 . get_comment_id_fields( $post_id )
340 . wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME, false, false );
341 }
342
343 /**
344 * The post being commented on.
345 *
346 * @return int
347 */
348 public static function post_id() {
349 $post = get_post();
350
351 return $post ? $post->ID : 0;
352 }
353
354 /**
355 * Register the bundle, and the stylesheet on a singular view.
356 *
357 * @return void
358 */
359 public function register_assets() {
360 if ( wp_script_is( self::HANDLE, 'registered' ) ) {
361 return;
362 }
363
364 // The asset version is the script's hash, so a stylesheet-only change would ship under a cached URL.
365 $asset = include dirname( __DIR__, 2 ) . '/build/comments.asset.php';
366
367 Assets::register_script(
368 self::HANDLE,
369 '../../build/comments.js',
370 __FILE__,
371 array(
372 'in_footer' => true,
373 'strategy' => 'defer',
374 'version' => $asset['version'] . '-' . (string) filemtime( dirname( __DIR__, 2 ) . '/build/comments.css' ),
375 )
376 );
377
378 if ( is_singular() && comments_open() ) {
379 wp_enqueue_style( self::HANDLE );
380 add_action( 'wp_head', array( __CLASS__, 'print_noscript_style' ) );
381 }
382 }
383
384 /**
385 * Show the plain form where no script will ever replace it.
386 *
387 * @return void
388 */
389 public static function print_noscript_style() {
390 echo '<noscript><style>.jetpack-comments{visibility:visible!important}</style></noscript>';
391 }
392
393 /**
394 * Enqueue the bundle and hand it the settings for this form.
395 *
396 * @param array $args Comment form arguments.
397 * @return void
398 */
399 public function enqueue_assets( $args = array() ) {
400 $this->register_assets();
401
402 if ( ! $this->settings_printed ) {
403 $strings = array(
404 'reply' => _x( 'Reply', 'verb', 'jetpack-comments' ),
405 'blockTools' => __( 'Block tools', 'jetpack-comments' ),
406 'addBlock' => __( 'Add block', 'jetpack-comments' ),
407 'commentLabel' => _x( 'Comment', 'noun', 'jetpack-comments' ),
408 'replyLabel' => _x( 'Reply', 'noun', 'jetpack-comments' ),
409 /* translators: The empty comment box's placeholder. The form adds "..." after it. */
410 'placeholder' => __( 'Write a comment', 'jetpack-comments' ),
411 /* translators: The empty reply box's placeholder. The form adds "..." after it. */
412 'replyPlaceholder' => __( 'Write a reply', 'jetpack-comments' ),
413 'name' => __( 'Name', 'jetpack-comments' ),
414 'email' => __( 'Email', 'jetpack-comments' ),
415 'emailHint' => __( 'Address never made public', 'jetpack-comments' ),
416 'emailHasAccount' => __( 'That email belongs to a WordPress.com account. Log in with WordPress.com to use it, or enter a different email.', 'jetpack-comments' ),
417 'website' => __( 'Website (optional)', 'jetpack-comments' ),
418 'intro' => __( 'Enter your name and email to comment.', 'jetpack-comments' ),
419 'continueAsGuest' => __( 'Continue as a guest', 'jetpack-comments' ),
420 'postWithoutSaving' => __( 'No, thanks. I just want to post a comment', 'jetpack-comments' ),
421 'save' => __( 'Save', 'jetpack-comments' ),
422 'saveDetails' => __( 'Save my name, email, and website in this browser for the next time I comment.', 'jetpack-comments' ),
423 'close' => __( 'Close', 'jetpack-comments' ),
424 'options' => __( 'Options', 'jetpack-comments' ),
425 'manageSubscriptions' => __( 'Manage subscription', 'jetpack-comments' ),
426 'mustLogIn' => __( 'You must be logged in to post a comment.', 'jetpack-comments' ),
427 'logIn' => __( 'Log in', 'jetpack-comments' ),
428 'logInWithWordPress' => __( 'Log in with WordPress.com', 'jetpack-comments' ),
429 'logOut' => __( 'Log out', 'jetpack-comments' ),
430 'addYourName' => __( 'Add your name', 'jetpack-comments' ),
431 'cancel' => __( 'Cancel', 'jetpack-comments' ),
432 'signInFailed' => __( 'We could not sign you in. Please try again.', 'jetpack-comments' ),
433 'tooLong' => __( 'This comment is too long to post. Shorten it to send it.', 'jetpack-comments' ),
434 'signInRateLimited' => __( 'Too many sign-in attempts. Please wait a moment and try again.', 'jetpack-comments' ),
435 );
436
437 /**
438 * Filter the copy the comment form renders.
439 *
440 * @since 0.1.0
441 *
442 * @param array $strings Keyed by the name the app reads.
443 * @param array $args Comment form arguments.
444 */
445 $strings = apply_filters( 'jetpack_comments_strings', $strings, $args );
446 $lengths = wp_get_comment_fields_max_lengths();
447 $style = wp_styles()->query( self::HANDLE );
448
449 // Where a reader manages subscriptions: the Reader for a WordPress.com account, the
450 // email portal for anyone else. Only where the Newsletter offers them on this form;
451 // Simple stores an option's "off" as an empty string, Jetpack as 0.
452 $offered = false;
453 foreach ( array( 'stb_enabled', 'stc_enabled' ) as $option ) {
454 $offered = $offered || ! in_array( get_option( $option, 1 ), array( '', '0', 0 ), true );
455 }
456
457 $reader = 'https://wordpress.com/reader/subscriptions?s=' . rawurlencode( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
458 $manage = array(
459 'url' => '',
460 'byEmail' => true,
461 'signedInUrl' => '',
462 );
463
464 if ( $offered && ( function_exists( 'subscription_comment_form' ) || class_exists( 'Jetpack_Subscriptions' ) ) ) {
465 // On WordPress.com, a logged-in reader is a WordPress.com account.
466 $by_account = defined( 'IS_WPCOM' ) && IS_WPCOM && is_user_logged_in();
467 $manage = array(
468 'url' => $by_account ? $reader : 'https://subscribe.wordpress.com/',
469 'byEmail' => ! $by_account,
470 'signedInUrl' => $reader,
471 );
472 }
473
474 // Everything the app needs that only PHP knows.
475 $settings = array_merge(
476 array(
477 'version' => Comments::PACKAGE_VERSION,
478 // The dialog's shadow root links it again; page styles stop at that boundary.
479 // Decoded: WordPress.com's static-file filter joins its query with &amp;.
480 'styleUrl' => $style ? html_entity_decode( (string) add_query_arg( 'ver', $style->ver, $style->src ), ENT_QUOTES ) : '',
481 'requireNameEmail' => (bool) get_option( 'require_name_email' ),
482 'mustLogIn' => (bool) get_option( 'comment_registration' ) && ! is_user_logged_in(),
483 'maxLength' => isset( $lengths['comment_content'] ) ? (int) $lengths['comment_content'] : 65525,
484 'blocks' => Block_Editor::is_enabled(),
485 'editorLocale' => Block_Editor::is_enabled() ? Block_Editor::locale_data() : (object) array(),
486 'site' => array(
487 'name' => get_bloginfo( 'name' ),
488 'iconUrl' => (string) get_site_icon_url( 64 ),
489 ),
490 'manageSubscriptions' => $manage,
491 'strings' => $strings,
492 ),
493 Identity::settings()
494 );
495
496 wp_add_inline_script(
497 self::HANDLE,
498 'window.JetpackComments = ' . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
499 'before'
500 );
501 $this->settings_printed = true;
502 }
503
504 Assets::enqueue_script( self::HANDLE );
505 wp_enqueue_style( self::HANDLE );
506 }
507
508 /**
509 * Require a comment to arrive with a nonce this site issued.
510 *
511 * For a logged-out reader it is the same string for everybody, for up to 24
512 * hours, so it proves the sender loaded a page from this site and nothing more.
513 *
514 * @param int $comment_post_id The post being commented on.
515 * @return void
516 */
517 public function verify_nonce( $comment_post_id = 0 ) {
518 if ( ! self::enabled_for_post_type( $comment_post_id ) ) {
519 return;
520 }
521
522 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this is the nonce check.
523 $nonce = isset( $_POST[ self::NONCE_NAME ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_NAME ] ) ) : '';
524
525 if ( wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
526 return;
527 }
528
529 // A page cache can hand a logged-in reader a copy rendered for nobody, so
530 // the nonce they post is the anonymous one. wp_verify_nonce() reads the
531 // session token from the logged-in cookie, not the current user, so the
532 // cookie has to go too for the hash to match what a visitor was served.
533 if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
534 $user_id = get_current_user_id();
535 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Stashed and put back untouched.
536 $cookie = $_COOKIE[ LOGGED_IN_COOKIE ];
537
538 unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
539 wp_set_current_user( 0 );
540
541 $valid = (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION );
542
543 $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie;
544 wp_set_current_user( $user_id );
545
546 if ( $valid ) {
547 return;
548 }
549 }
550
551 wp_die(
552 esc_html__( 'Sorry, this comment could not be posted. Go back and try again.', 'jetpack-comments' ),
553 esc_html__( 'Comment Submission Failure', 'jetpack-comments' ),
554 array(
555 'response' => 403,
556 'back_link' => true,
557 )
558 );
559 }
560 }
561