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

521 lines 15.9 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 /**
18 * Script and style handle.
19 */
20 const HANDLE = 'jetpack-comments';
21
22 /**
23 * Nonce action guarding a comment submission.
24 */
25 const NONCE_ACTION = 'jetpack_comments_form';
26
27 /**
28 * POST field carrying the nonce.
29 */
30 const NONCE_NAME = 'jetpack_comments_form_nonce';
31
32 /**
33 * Colour schemes the form can be drawn in.
34 */
35 const COLOR_SCHEMES = array( 'transparent', 'light', 'dark' );
36
37 /**
38 * Colour scheme used when the site has not chosen one.
39 */
40 const DEFAULT_COLOR_SCHEME = 'transparent';
41
42 /**
43 * Singleton instance.
44 *
45 * @var Comment_Form|null
46 */
47 private static $instance = null;
48
49 /**
50 * Whether the settings blob has been printed.
51 *
52 * @var bool
53 */
54 private $settings_printed = false;
55
56 /**
57 * Register the form's hooks. Safe to call more than once.
58 *
59 * @return Comment_Form
60 */
61 public static function init() {
62 if ( null === self::$instance ) {
63 self::$instance = new self();
64 }
65
66 return self::$instance;
67 }
68
69 /**
70 * Take over the core comment form.
71 */
72 private function __construct() {
73 add_filter( 'comment_form_fields', array( $this, 'comment_form_fields' ) );
74 add_filter( 'comment_form_logged_in', array( $this, 'comment_form_logged_in' ) );
75 add_filter( 'comment_form_defaults', array( $this, 'comment_form_defaults' ), 20 );
76
77 // Past 10, where Jetpack Subscriptions adds its checkboxes: this replaces
78 // the field wholesale, so it has to see what everyone else has added.
79 add_filter( 'comment_form_submit_field', array( $this, 'render' ), 20, 2 );
80
81 add_action( 'comment_form_must_log_in_after', array( $this, 'render_must_log_in' ) );
82
83 add_filter( 'comment_reply_link', array( $this, 'comment_reply_link' ), 10, 4 );
84
85 add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
86 add_action( 'pre_comment_on_post', array( $this, 'verify_nonce' ) );
87 }
88
89 /**
90 * Keep Reply moving the form when the site requires registration.
91 *
92 * @param string $reply_link Markup for the reply link.
93 * @param array $args Reply link arguments.
94 * @param \WP_Comment $comment Comment being replied to.
95 * @param \WP_Post $post Post being commented on.
96 * @return string
97 */
98 public function comment_reply_link( $reply_link, $args, $comment, $post ) {
99 if ( ! get_option( 'comment_registration' ) || ! self::enabled_for_post_type() ) {
100 return $reply_link;
101 }
102
103 $comment = get_comment( $comment );
104 $post = get_post( $post );
105
106 if ( ! $comment instanceof \WP_Comment || ! $post instanceof \WP_Post ) {
107 return $reply_link;
108 }
109
110 $respond_id = esc_attr( $args['respond_id'] );
111 $reply_url = esc_url( add_query_arg( 'replytocom', $comment->comment_ID . '#' . $respond_id ) );
112
113 $reply_to = sprintf( $args['reply_to_text'], get_comment_author( $comment ) );
114
115 $link = sprintf(
116 '<a class="comment-reply-link" href="%s"%s onclick="return addComment.moveForm( \'%s-%d\', \'%d\', \'%s\', \'%d\' )">%s</a>',
117 $reply_url,
118 $args['show_reply_to_text'] ? '' : ' aria-label="' . esc_attr( $reply_to ) . '"',
119 esc_attr( $args['add_below'] ),
120 $comment->comment_ID,
121 $comment->comment_ID,
122 $respond_id,
123 $post->ID,
124 wp_kses( $args['show_reply_to_text'] ? $reply_to : $args['reply_text'], self::reply_text_html() )
125 );
126
127 return wp_kses( $args['before'], wp_kses_allowed_html( 'post' ) )
128 . $link
129 . wp_kses( $args['after'], wp_kses_allowed_html( 'post' ) );
130 }
131
132 /**
133 * Markup a theme may put inside its reply link, such as an icon.
134 *
135 * @return array
136 */
137 private static function reply_text_html() {
138 return array(
139 'svg' => array(
140 'class' => true,
141 'aria-hidden' => true,
142 'aria-labelledby' => true,
143 'role' => true,
144 'xmlns' => true,
145 'width' => true,
146 'height' => true,
147 'viewbox' => true,
148 ),
149 'use' => array(
150 'href' => true,
151 'xlink:href' => true,
152 ),
153 );
154 }
155
156 /**
157 * Whether this form should replace core's for a post's type.
158 *
159 * @param int|null $post_id Post being commented on. Defaults to the current one.
160 * @return bool
161 */
162 public static function enabled_for_post_type( $post_id = null ) {
163 $post_type = $post_id ? get_post_type( $post_id ) : get_post_type();
164
165 /** This filter is documented in projects/plugins/jetpack/modules/comments/comments.php */
166 return (bool) apply_filters( 'jetpack_comment_form_enabled_for_' . $post_type, true );
167 }
168
169 /**
170 * Drop every field core would draw, so the app can draw its own.
171 *
172 * @param array $fields Comment form fields, the textarea included.
173 * @return array
174 */
175 public function comment_form_fields( $fields ) {
176 return self::enabled_for_post_type() ? array() : $fields;
177 }
178
179 /**
180 * Suppress core's logged-in line, which the app draws itself.
181 *
182 * @param string $logged_in_as The "logged in as" markup.
183 * @return string
184 */
185 public function comment_form_logged_in( $logged_in_as ) {
186 return self::enabled_for_post_type() ? '' : $logged_in_as;
187 }
188
189 /**
190 * Set the form arguments the app reads back out.
191 *
192 * @param array $args Comment form arguments.
193 * @return array
194 */
195 public function comment_form_defaults( $args ) {
196 if ( ! self::enabled_for_post_type() ) {
197 return $args;
198 }
199
200 $defaults = array(
201 'logged_in_as' => '',
202 'comment_notes_before' => '',
203 'must_log_in' => '',
204 'label_submit' => _x( 'Comment', 'verb', 'jetpack-comments' ),
205 );
206
207 $greeting = get_option( 'highlander_comment_form_prompt' );
208 if ( is_string( $greeting ) && $greeting !== '' ) {
209 $defaults['title_reply'] = $greeting;
210 }
211
212 return array_merge( $args, $defaults );
213 }
214
215 /**
216 * Replace the submit field with the app.
217 *
218 * @param string $submit_field The submit field markup this replaces.
219 * @param array $args Comment form arguments, after the theme's own.
220 * @return string
221 */
222 public function render( $submit_field, $args = array() ) {
223 if ( ! self::enabled_for_post_type() ) {
224 return $submit_field;
225 }
226
227 // Fires after this filter, and would draw a subscribe option this form has no room for.
228 remove_action( 'comment_form', 'subscription_comment_form' );
229
230 $this->enqueue_assets( $args );
231
232 return $this->markup( $args );
233 }
234
235 /**
236 * Draw the app, and a form to hold it, on the must-log-in branch.
237 *
238 * @return void
239 */
240 public function render_must_log_in() {
241 if ( ! self::enabled_for_post_type() ) {
242 return;
243 }
244
245 $this->enqueue_assets();
246
247 printf(
248 '<form action="%s" method="post" id="commentform" class="comment-form">%s</form>',
249 esc_url( site_url( '/wp-comments-post.php' ) ),
250 $this->markup() // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped as it is built.
251 );
252 }
253
254 /**
255 * The app's mount point, and the hidden fields it posts with.
256 *
257 * @param array $args Comment form arguments.
258 * @return string
259 */
260 private function markup( $args = array() ) {
261 return '<div class="jetpack-comments ' . esc_attr( self::color_scheme() ) . '"'
262 . ' data-jetpack-comments="' . esc_attr(
263 (string) wp_json_encode(
264 self::form_settings( $args ),
265 JSON_UNESCAPED_SLASHES | JSON_HEX_AMP
266 )
267 ) . '"></div>'
268 . '<div class="jetpack-comments__fields">'
269 . get_comment_id_fields( self::post_id() )
270 . wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME, false, false )
271 . '</div>';
272 }
273
274 /**
275 * The post being commented on.
276 *
277 * @return int
278 */
279 private static function post_id() {
280 $post = get_post();
281
282 return $post ? $post->ID : 0;
283 }
284
285 /**
286 * The colour scheme the site has chosen.
287 *
288 * @return string
289 */
290 private static function color_scheme() {
291 $scheme = get_option( 'jetpack_comment_form_color_scheme', self::DEFAULT_COLOR_SCHEME );
292
293 return in_array( $scheme, self::COLOR_SCHEMES, true ) ? $scheme : self::DEFAULT_COLOR_SCHEME;
294 }
295
296 /**
297 * Register the bundle, and the stylesheet on a singular view.
298 *
299 * @return void
300 */
301 public function register_assets() {
302 if ( wp_script_is( self::HANDLE, 'registered' ) ) {
303 return;
304 }
305
306 Assets::register_script(
307 self::HANDLE,
308 '../../build/comments.js',
309 __FILE__,
310 array(
311 'in_footer' => true,
312 'strategy' => 'defer',
313 )
314 );
315
316 if ( is_singular() && comments_open() ) {
317 wp_enqueue_style( self::HANDLE );
318 }
319 }
320
321 /**
322 * Enqueue the bundle and hand it the settings for this form.
323 *
324 * @param array $args Comment form arguments.
325 * @return void
326 */
327 public function enqueue_assets( $args = array() ) {
328 $this->register_assets();
329
330 if ( ! $this->settings_printed ) {
331 wp_add_inline_script(
332 self::HANDLE,
333 'window.JetpackComments = ' . wp_json_encode(
334 $this->settings( $args ),
335 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
336 ) . ';',
337 'before'
338 );
339 $this->settings_printed = true;
340 }
341
342 Assets::enqueue_script( self::HANDLE );
343 wp_enqueue_style( self::HANDLE );
344 }
345
346 /**
347 * Everything the app needs that only PHP knows.
348 *
349 * @param array $args Comment form arguments.
350 * @return array
351 */
352 private function settings( $args ) {
353 $lengths = wp_get_comment_fields_max_lengths();
354
355 return array_merge(
356 array(
357 'requireNameEmail' => (bool) get_option( 'require_name_email' ),
358 'showCookiesConsent' => (bool) get_option( 'show_comments_cookies_opt_in' ),
359 'mustLogIn' => (bool) get_option( 'comment_registration' ) && ! is_user_logged_in(),
360 'maxLength' => isset( $lengths['comment_content'] ) ? (int) $lengths['comment_content'] : 65525,
361 'strings' => self::strings( $args ),
362 ),
363 Identity::settings()
364 );
365 }
366
367 /**
368 * Values belonging to one form, rather than to the page it sits on.
369 *
370 * @param array $args Comment form arguments.
371 * @return array
372 */
373 private static function form_settings( $args ) {
374 $post_id = self::post_id();
375 $permalink = get_permalink( $post_id );
376
377 $settings = array(
378 'postId' => $post_id,
379 'loginUrl' => wp_login_url( $permalink ),
380 'logoutUrl' => '',
381 'submitId' => $args['id_submit'] ?? 'submit',
382 'submitName' => $args['name_submit'] ?? 'submit',
383 'submitLabel' => $args['label_submit'] ?? _x( 'Comment', 'verb', 'jetpack-comments' ),
384 );
385
386 if ( is_user_logged_in() ) {
387 // wp_logout_url() runs the URL through esc_html(), which encodes single quotes too.
388 $settings['logoutUrl'] = html_entity_decode( wp_logout_url( $permalink ), ENT_QUOTES );
389 }
390
391 return $settings;
392 }
393
394 /**
395 * The copy the app renders.
396 *
397 * @param array $args Comment form arguments.
398 * @return array
399 */
400 private static function strings( $args ) {
401 $strings = array(
402 'reply' => _x( 'Reply', 'verb', 'jetpack-comments' ),
403 'commentLabel' => _x( 'Comment', 'noun', 'jetpack-comments' ),
404 'replyLabel' => _x( 'Reply', 'noun', 'jetpack-comments' ),
405 'placeholder' => __( 'Write a comment...', 'jetpack-comments' ),
406 'replyPlaceholder' => __( 'Write a reply...', 'jetpack-comments' ),
407 'name' => __( 'Name', 'jetpack-comments' ),
408 'email' => __( 'Email', 'jetpack-comments' ),
409 'emailPlaceholder' => __( 'Email (Address never made public)', 'jetpack-comments' ),
410 'website' => __( 'Website', 'jetpack-comments' ),
411 'websitePlaceholder' => __( 'Website (Optional)', 'jetpack-comments' ),
412 'guestPrompt' => __( 'Leave a comment.', 'jetpack-comments' ),
413 'mustLogInPrompt' => __( 'Log in to leave a comment.', 'jetpack-comments' ),
414 'logIn' => __( 'Log in', 'jetpack-comments' ),
415 'guestPromptRequired' => __( 'Provide your name and email to leave a comment.', 'jetpack-comments' ),
416 'saveDetails' => __( 'Save my name, email, and website in this browser for the next time I comment.', 'jetpack-comments' ),
417 'logOut' => __( 'Log out', 'jetpack-comments' ),
418 'logInOrProvide' => __( 'Log in or provide your name and email to leave a comment.', 'jetpack-comments' ),
419 'logInOrProvideReply' => __( 'Log in or provide your name and email to leave a reply.', 'jetpack-comments' ),
420 'logInOptional' => __( 'Leave a comment. (log in optional)', 'jetpack-comments' ),
421 'logInOptionalReply' => __( 'Leave a reply. (log in optional)', 'jetpack-comments' ),
422 'logInToReply' => __( 'Log in to leave a reply.', 'jetpack-comments' ),
423 /* translators: %1$s is the commenter's name, %2$s the provider (WordPress.com, Google, Facebook). The line ends before a "Log out" button. */
424 'signedInAs' => __( '%1$s - Logged in via %2$s -', 'jetpack-comments' ),
425 'cancel' => __( 'Cancel', 'jetpack-comments' ),
426 'settings' => __( 'Settings', 'jetpack-comments' ),
427 'close' => __( 'Close', 'jetpack-comments' ),
428 'providers' => array(
429 'wordpress' => __( 'WordPress.com', 'jetpack-comments' ),
430 'google' => __( 'Google', 'jetpack-comments' ),
431 'facebook' => __( 'Facebook', 'jetpack-comments' ),
432 'mail' => __( 'Email', 'jetpack-comments' ),
433 ),
434 'signInFailed' => __( 'We could not sign you in. Please try again.', 'jetpack-comments' ),
435 'signInRateLimited' => __( 'Too many sign-in attempts. Please wait a moment and try again.', 'jetpack-comments' ),
436 );
437
438 /**
439 * Filter the copy the comment form renders.
440 *
441 * @since 0.1.0
442 *
443 * @param array $strings Keyed by the name the app reads.
444 * @param array $args Comment form arguments.
445 */
446 return apply_filters( 'jetpack_comments_strings', $strings, $args );
447 }
448
449 /**
450 * Require a comment to arrive with a nonce this site issued.
451 *
452 * Worth being plain about the strength of this. For a logged-in reader the
453 * nonce is tied to their session and is real CSRF cover. For a logged-out one
454 * it is the same string for everybody, for up to 24 hours, so it proves the
455 * sender loaded a page from this site and nothing more.
456 *
457 * @param int $comment_post_id The post being commented on.
458 * @return void
459 */
460 public function verify_nonce( $comment_post_id = 0 ) {
461 if ( ! self::enabled_for_post_type( $comment_post_id ) ) {
462 return;
463 }
464
465 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this is the nonce check.
466 $nonce = isset( $_POST[ self::NONCE_NAME ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_NAME ] ) ) : '';
467
468 if ( wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
469 return;
470 }
471
472 if ( self::verify_logged_out_nonce( $nonce ) ) {
473 return;
474 }
475
476 wp_die(
477 esc_html__( 'Sorry, this comment could not be posted. Go back and try again.', 'jetpack-comments' ),
478 esc_html__( 'Comment Submission Failure', 'jetpack-comments' ),
479 array(
480 'response' => 403,
481 'back_link' => true,
482 )
483 );
484 }
485
486 /**
487 * Check a nonce against the one a logged-out reader would have been given.
488 *
489 * A page cache can hand a logged-in reader a copy rendered for nobody, so the
490 * nonce they post is the anonymous one. wp_verify_nonce() hashes the user ID
491 * together with wp_get_session_token(), and that token is read from the
492 * logged-in cookie rather than from the current user, so clearing the user is
493 * not enough on its own: the cookie has to go too, or the hash still carries
494 * their session and can never match what an anonymous visitor was served.
495 *
496 * @param string $nonce The nonce submitted with the comment.
497 * @return bool
498 */
499 private static function verify_logged_out_nonce( $nonce ) {
500 if ( ! defined( 'LOGGED_IN_COOKIE' ) || ! isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) {
501 // Nothing to strip, so the check above already ran as this reader.
502 return false;
503 }
504
505 $user_id = get_current_user_id();
506
507 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Stashed and put back untouched, for core to read as it would have.
508 $cookie = $_COOKIE[ LOGGED_IN_COOKIE ];
509
510 unset( $_COOKIE[ LOGGED_IN_COOKIE ] );
511 wp_set_current_user( 0 );
512
513 $valid = (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION );
514
515 $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie;
516 wp_set_current_user( $user_id );
517
518 return $valid;
519 }
520 }
521