PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
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 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / modules / markdown / easy-markdown.php

easy-markdown.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at modules/markdown/easy-markdown.php

900 lines 30.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Plugin URI: https://automattic.com/
4 * Plugin Name: Easy Markdown
5 * Description: Write in Markdown, publish in WordPress
6 * Version: 0.1
7 * Author: Matt Wiebe
8 * Author URI: https://automattic.com/
9 * Text Domain: jetpack
10 *
11 * @package automattic/jetpack
12 */
13
14 /**
15 * Copyright (c) Automattic. All rights reserved.
16 *
17 * Released under the GPL license
18 * https://www.opensource.org/licenses/gpl-license.php
19 *
20 * This is an add-on for WordPress
21 * https://wordpress.org/
22 *
23 * **********************************************************************
24 * This program is free software; you can redistribute it and/or modify
25 * it under the terms of the GNU General Public License as published by
26 * the Free Software Foundation; either version 2 of the License, or
27 * (at your option) any later version.
28 *
29 * This program is distributed in the hope that it will be useful,
30 * but WITHOUT ANY WARRANTY; without even the implied warranty of
31 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
32 * GNU General Public License for more details.
33 * **********************************************************************
34 */
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit( 0 );
38 }
39
40 /**
41 * WPCom_Markdown class.
42 */
43 class WPCom_Markdown {
44
45 const POST_OPTION = 'wpcom_publish_posts_with_markdown';
46 const COMMENT_OPTION = 'wpcom_publish_comments_with_markdown';
47 const POST_TYPE_SUPPORT = 'wpcom-markdown';
48 const IS_MD_META = '_wpcom_is_markdown';
49
50 /**
51 * Our markdown parser.
52 *
53 * @var WPCom_GHF_Markdown_Parser
54 */
55 private static $parser;
56
57 /**
58 * An instance of the markdown class.
59 *
60 * @var WPCom_Markdown
61 */
62 private static $instance;
63
64 /**
65 * To ensure that our munged posts over xml-rpc are removed from the cache.
66 *
67 * @var array
68 */
69 public $posts_to_uncache = array();
70
71 /**
72 * Posts and parents to monitor.
73 *
74 * @var array
75 */
76 private $monitoring = array(
77 'post' => array(),
78 'parent' => array(),
79 );
80
81 /**
82 * Whether or not kses filters were removed. Only set if removal was attempted.
83 *
84 * @var ?bool
85 */
86 public $kses;
87
88 /**
89 * Yay singletons!
90 *
91 * @return object WPCom_Markdown instance
92 */
93 public static function get_instance() {
94 if ( ! self::$instance ) {
95 self::$instance = new self();
96 }
97 return self::$instance;
98 }
99
100 /**
101 * Kicks things off on `init` action
102 */
103 public function load() {
104 $this->add_default_post_type_support();
105 $this->maybe_load_actions_and_filters();
106 if ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) {
107 // phpcs:ignore WPCUT.SwitchBlog.SwitchBlog -- wpcom flags **every** use of switch_blog, apparently expecting valid instances to ignore or suppress the sniff.
108 add_action( 'switch_blog', array( $this, 'maybe_load_actions_and_filters' ), 10, 2 );
109 }
110 add_action( 'admin_init', array( $this, 'register_setting' ) );
111 add_action( 'admin_init', array( $this, 'maybe_unload_for_bulk_edit' ) );
112 if ( current_theme_supports( 'o2' ) || class_exists( 'P2' ) ) {
113 $this->add_o2_helpers();
114 }
115 }
116
117 /**
118 * If we're in a bulk edit session, unload so that we don't lose our markdown metadata
119 */
120 public function maybe_unload_for_bulk_edit() {
121 if ( isset( $_REQUEST['bulk_edit'] ) && $this->is_posting_enabled() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
122 $this->unload_markdown_for_posts();
123 }
124 }
125
126 /**
127 * Called on init and fires on switch_blog to decide if our actions and filters
128 * should be running.
129 *
130 * @param int|null $new_blog_id New blog ID.
131 * @param int|null $old_blog_id Old blog ID.
132 * @return null
133 */
134 public function maybe_load_actions_and_filters( $new_blog_id = null, $old_blog_id = null ) {
135
136 // When WP sites are being installed, the options table is not available yet.
137 if ( function_exists( 'wp_installing' ) && wp_installing() ) {
138 return;
139 }
140
141 // If this is a switch_to_blog call, and the blog isn't changing, we'll already be loaded.
142 if ( $new_blog_id && $new_blog_id === $old_blog_id ) {
143 return;
144 }
145
146 if ( $this->is_posting_enabled() ) {
147 $this->load_markdown_for_posts();
148 } else {
149 $this->unload_markdown_for_posts();
150 }
151
152 if ( $this->is_commenting_enabled() ) {
153 $this->load_markdown_for_comments();
154 } else {
155 $this->unload_markdown_for_comments();
156 }
157 }
158
159 /**
160 * Set up hooks for enabling Markdown conversion on posts
161 */
162 public function load_markdown_for_posts() {
163 add_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ), 10, 2 );
164 add_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
165 add_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
166 add_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10, 2 );
167 add_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10, 2 );
168 add_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10, 2 );
169 add_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10, 2 );
170 add_filter( '_wp_post_revision_fields', array( $this, 'wp_post_revision_fields' ) );
171 add_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
172 add_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
173 if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
174 $this->check_for_early_methods();
175 }
176 }
177
178 /**
179 * Removes hooks to disable Markdown conversion on posts
180 */
181 public function unload_markdown_for_posts() {
182 remove_filter( 'wp_kses_allowed_html', array( $this, 'wp_kses_allowed_html' ) );
183 remove_action( 'after_wp_tiny_mce', array( $this, 'after_wp_tiny_mce' ) );
184 remove_action( 'wp_insert_post', array( $this, 'wp_insert_post' ) );
185 remove_filter( 'wp_insert_post_data', array( $this, 'wp_insert_post_data' ), 10 );
186 remove_filter( 'edit_post_content', array( $this, 'edit_post_content' ), 10 );
187 remove_filter( 'edit_post_content_filtered', array( $this, 'edit_post_content_filtered' ), 10 );
188 remove_action( 'wp_restore_post_revision', array( $this, 'wp_restore_post_revision' ), 10 );
189 remove_filter( '_wp_post_revision_fields', array( $this, 'wp_post_revision_fields' ) );
190 remove_action( 'xmlrpc_call', array( $this, 'xmlrpc_actions' ) );
191 remove_filter( 'content_save_pre', array( $this, 'preserve_code_blocks' ), 1 );
192 }
193
194 /**
195 * Set up hooks for enabling Markdown conversion on comments
196 */
197 protected function load_markdown_for_comments() {
198 // Use priority 9 so that Markdown runs before KSES, which can clean up
199 // any munged HTML.
200 add_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
201 }
202
203 /**
204 * Removes hooks to disable Markdown conversion
205 */
206 protected function unload_markdown_for_comments() {
207 remove_filter( 'pre_comment_content', array( $this, 'pre_comment_content' ), 9 );
208 }
209
210 /**
211 * The o2 plugin does some of what we do. Let's take precedence.
212 */
213 public function add_o2_helpers() {
214 if ( $this->is_posting_enabled() ) {
215 add_filter( 'content_save_pre', array( $this, 'o2_escape_lists' ), 1 );
216 }
217
218 add_filter( 'o2_preview_post', array( $this, 'o2_preview_post' ) );
219 add_filter( 'o2_preview_comment', array( $this, 'o2_preview_comment' ) );
220
221 add_filter( 'wpcom_markdown_transform_pre', array( $this, 'o2_unescape_lists' ) );
222 add_filter( 'wpcom_untransformed_content', array( $this, 'o2_unescape_lists' ) );
223 }
224
225 /**
226 * If Markdown is enabled for posts on this blog, filter the text for o2 previews
227 *
228 * @param string $text Post text.
229 * @return string Post text transformed through the magic of Markdown
230 */
231 public function o2_preview_post( $text ) {
232 if ( $this->is_posting_enabled() ) {
233 $text = $this->transform( $text, array( 'unslash' => false ) );
234 }
235 return $text;
236 }
237
238 /**
239 * If Markdown is enabled for comments on this blog, filter the text for o2 previews
240 *
241 * @param string $text Comment text.
242 * @return string Comment text transformed through the magic of Markdown
243 */
244 public function o2_preview_comment( $text ) {
245 if ( $this->is_commenting_enabled() ) {
246 $text = $this->transform( $text, array( 'unslash' => false ) );
247 }
248 return $text;
249 }
250
251 /**
252 * Escapes lists so that o2 doesn't trounce them
253 *
254 * @param string $text Post/comment text.
255 * @return string Text escaped with HTML entity for asterisk.
256 */
257 public function o2_escape_lists( $text ) {
258 return preg_replace( '/^\\* /um', '&#42; ', $text );
259 }
260
261 /**
262 * Unescapes the token we inserted on o2_escape_lists
263 *
264 * @param string $text Post/comment text with HTML entities for asterisks.
265 * @return string Text with the HTML entity removed
266 */
267 public function o2_unescape_lists( $text ) {
268 return preg_replace( '/^[&]\#042; /um', '* ', $text );
269 }
270
271 /**
272 * Preserve code blocks from being munged by KSES before they have a chance
273 *
274 * @param string $text post content.
275 * @return string post content with code blocks escaped.
276 */
277 public function preserve_code_blocks( $text ) {
278 return $this->get_parser()->codeblock_preserve( $text );
279 }
280
281 /**
282 * Remove KSES if it's there. Store the result to manually invoke later if needed.
283 */
284 public function maybe_remove_kses() {
285 // Filters return true if they existed before you removed them.
286 if ( $this->is_posting_enabled() ) {
287 $this->kses = remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' ) && remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
288 }
289 }
290
291 /**
292 * Add our Writing and Discussion settings.
293 */
294 public function register_setting() {
295 add_settings_field( self::POST_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'post_field' ), 'writing' );
296 register_setting( 'writing', self::POST_OPTION, array( $this, 'sanitize_setting' ) );
297 add_settings_field( self::COMMENT_OPTION, __( 'Markdown', 'jetpack' ), array( $this, 'comment_field' ), 'discussion' );
298 register_setting( 'discussion', self::COMMENT_OPTION, array( $this, 'sanitize_setting' ) );
299 }
300
301 /**
302 * Sanitize setting. Don't really want to store "on" value, so we'll store "1" instead!
303 *
304 * @param string $input Value received by settings API via $_POST.
305 * @return bool Cast to boolean.
306 */
307 public function sanitize_setting( $input ) {
308 return (bool) $input;
309 }
310
311 /**
312 * Prints HTML for the Writing setting
313 */
314 public function post_field() {
315 printf(
316 '<label><input name="%1$s" id="%1$s" type="checkbox"%2$s /> %3$s</label><p class="description">%4$s</p>',
317 esc_attr( self::POST_OPTION ),
318 checked( $this->is_posting_enabled(), true, false ),
319 esc_html__( 'Use Markdown for posts and pages.', 'jetpack' ),
320 sprintf( '<a href="%s" data-target="wpcom-help-center">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
321 );
322 }
323
324 /**
325 * Prints HTML for the Discussion setting
326 */
327 public function comment_field() {
328 printf(
329 '<label><input name="%1$s" id="%1$s" type="checkbox"%2$s /> %3$s</label><p class="description">%4$s</p>',
330 esc_attr( self::COMMENT_OPTION ),
331 checked( $this->is_commenting_enabled(), true, false ),
332 esc_html__( 'Use Markdown for comments.', 'jetpack' ),
333 sprintf( '<a href="%s" data-target="wpcom-help-center">%s</a>', esc_url( $this->get_support_url() ), esc_html__( 'Learn more about Markdown.', 'jetpack' ) )
334 );
335 }
336
337 /**
338 * Get the support url for Markdown
339 *
340 * @uses apply_filters
341 * @return string support url
342 */
343 protected function get_support_url() {
344 /**
345 * Filter the Markdown support URL.
346 *
347 * @module markdown
348 *
349 * @since 2.8.0
350 *
351 * @param string $url Markdown support URL.
352 */
353 return apply_filters( 'easy_markdown_support_url', 'https://en.support.wordpress.com/markdown-quick-reference/' );
354 }
355
356 /**
357 * Is Mardown conversion for posts enabled?
358 *
359 * @return boolean
360 */
361 public function is_posting_enabled() {
362 return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::POST_OPTION, '' );
363 }
364
365 /**
366 * Is Markdown conversion for comments enabled?
367 *
368 * @return boolean
369 */
370 public function is_commenting_enabled() {
371 return (bool) Jetpack_Options::get_option_and_ensure_autoload( self::COMMENT_OPTION, '' );
372 }
373
374 /**
375 * Check if a $post_id has Markdown enabled
376 *
377 * @param int $post_id A post ID.
378 * @return boolean
379 */
380 public function is_markdown( $post_id ) {
381 return get_metadata( 'post', $post_id, self::IS_MD_META, true );
382 }
383
384 /**
385 * Set Markdown as enabled on a post_id. We skip over update_postmeta so we
386 * can sneakily set metadata on post revisions, which we need.
387 *
388 * @param int $post_id A post ID.
389 * @return bool The metadata was successfully set.
390 */
391 protected function set_as_markdown( $post_id ) {
392 return update_metadata( 'post', $post_id, self::IS_MD_META, true );
393 }
394
395 /**
396 * Get our Markdown parser object, optionally requiring all of our needed classes and
397 * instantiating our parser.
398 *
399 * @return object WPCom_GHF_Markdown_Parser instance.
400 */
401 public function get_parser() {
402
403 if ( ! self::$parser ) {
404 require_once JETPACK__PLUGIN_DIR . '/_inc/lib/markdown.php';
405 self::$parser = new WPCom_GHF_Markdown_Parser();
406 }
407
408 return self::$parser;
409 }
410
411 /**
412 * We don't want Markdown conversion all over the place.
413 */
414 public function add_default_post_type_support() {
415 add_post_type_support( 'post', self::POST_TYPE_SUPPORT );
416 add_post_type_support( 'page', self::POST_TYPE_SUPPORT );
417 add_post_type_support( 'revision', self::POST_TYPE_SUPPORT );
418 }
419
420 /**
421 * Figure out the post type of the post screen we're on
422 *
423 * @deprecated since 10.8
424 * @return string Current post_type
425 */
426 protected function get_post_screen_post_type() {
427 _deprecated_function( __METHOD__, 'jetpack-10.8', '' );
428
429 global $pagenow;
430 $post_type = filter_input( INPUT_GET, 'post_type', FILTER_UNSAFE_RAW );
431 $post_id = filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT );
432
433 if ( 'post-new.php' === $pagenow ) {
434 return ! empty( $post_type ) ? $post_type : 'post';
435 }
436
437 if ( $post_id ) {
438 $post_type = get_post_type( $post_id );
439 }
440
441 return ! empty( $post_type ) ? $post_type : 'post';
442 }
443
444 /**
445 * Swap post_content and post_content_filtered for editing
446 *
447 * @param string $content Post content.
448 * @param int $id post ID.
449 * @return string Swapped content
450 */
451 public function edit_post_content( $content, $id ) {
452 if ( $this->is_markdown( $id ) ) {
453 $post = get_post( $id );
454 if ( $post && ! empty( $post->post_content_filtered ) ) {
455 $post = $this->swap_for_editing( $post );
456 return $post->post_content;
457 }
458 }
459 return $content;
460 }
461
462 /**
463 * Swap post_content_filtered and post_content for editing
464 *
465 * @param string $content Post content_filtered.
466 * @param int $id post ID.
467 * @return string Swapped content
468 */
469 public function edit_post_content_filtered( $content, $id ) {
470 // if markdown was disabled, let's turn this off.
471 if ( ! $this->is_posting_enabled() && $this->is_markdown( $id ) ) {
472 $post = get_post( $id );
473 if ( $post && ! empty( $post->post_content_filtered ) ) {
474 $content = '';
475 }
476 }
477 return $content;
478 }
479
480 /**
481 * Some tags are allowed to have a 'markdown' attribute, allowing them to contain Markdown.
482 * We need to tell KSES about those tags.
483 *
484 * @param array $tags List of tags that KSES allows.
485 * @param string $context The context that KSES is allowing these tags.
486 * @return array The tags that KSES allows, with our extra 'markdown' parameter where necessary.
487 */
488 public function wp_kses_allowed_html( $tags, $context ) {
489 if ( 'post' !== $context ) {
490 return $tags;
491 }
492
493 $re = '/' . $this->get_parser()->contain_span_tags_re . '/';
494 foreach ( $tags as $tag => $attributes ) {
495
496 // In case other filters have changed the value to a non-array, we skip it.
497 if ( ! is_array( $attributes ) ) {
498 continue;
499 }
500
501 if ( preg_match( $re, $tag ) ) {
502 $attributes['markdown'] = true;
503 $tags[ $tag ] = $attributes;
504 }
505 }
506
507 return $tags;
508 }
509
510 /**
511 * TinyMCE needs to know not to strip the 'markdown' attribute. Unfortunately, it doesn't
512 * really offer a nice API for allowed attributes, so we have to manually add it
513 * to the schema instead.
514 */
515 public function after_wp_tiny_mce() {
516 ?>
517 <script type="text/javascript">
518 jQuery( function() {
519 ( 'undefined' !== typeof tinymce ) && tinymce.on( 'AddEditor', function( event ) {
520 event.editor.on( 'BeforeSetContent', function( event ) {
521 var editor = event.target;
522 Object.keys( editor.schema.elements ).forEach( function( key, index ) {
523 editor.schema.elements[ key ].attributes['markdown'] = {};
524 editor.schema.elements[ key ].attributesOrder.push( 'markdown' );
525 } );
526 } );
527 }, true );
528 } );
529 </script>
530 <?php
531 }
532
533 /**
534 * Magic happens here. Markdown is converted and stored on post_content. Original Markdown is stored
535 * in post_content_filtered so that we can continue editing as Markdown.
536 *
537 * @param array $post_data The post data that will be inserted into the DB. Slashed.
538 * @param array $postarr All the stuff that was in $_POST.
539 * @return array $post_data with post_content and post_content_filtered modified
540 */
541 public function wp_insert_post_data( $post_data, $postarr ) {
542 // $post_data array is slashed!
543 $post_id = $postarr['ID'] ?? false;
544 // bail early if markdown is disabled or this post type is unsupported.
545 if ( ! $this->is_posting_enabled() || ! post_type_supports( $post_data['post_type'], self::POST_TYPE_SUPPORT ) ) {
546 // it's disabled, but maybe this *was* a markdown post before.
547 if ( $this->is_markdown( $post_id ) && ! empty( $post_data['post_content_filtered'] ) ) {
548 $post_data['post_content_filtered'] = '';
549 }
550 // we have no context to determine supported post types in the `post_content_pre` hook,
551 // which already ran to sanitize code blocks. Undo that.
552 $post_data['post_content'] = $this->get_parser()->codeblock_restore( $post_data['post_content'] );
553 return $post_data;
554 }
555 // rejigger post_content and post_content_filtered
556 // revisions are already in the right place, except when we're restoring, but that's taken care of elsewhere
557 // also prevent quick edit feature from overriding already-saved markdown (issue https://github.com/Automattic/jetpack/issues/636).
558 if ( 'revision' !== $post_data['post_type'] && ! isset( $_POST['_inline_edit'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
559 /**
560 * Filter the original post content passed to Markdown.
561 *
562 * @module markdown
563 *
564 * @since 2.8.0
565 *
566 * @param string $post_data['post_content'] Untransformed post content.
567 */
568 $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
569 $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_id ) );
570 /** This filter is already documented in core/wp-includes/default-filters.php */
571 $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
572 } elseif ( str_starts_with( $post_data['post_name'], $post_data['post_parent'] . '-autosave' ) ) {
573 // autosaves for previews are weird.
574 /** This filter is already documented in modules/markdown/easy-markdown.php */
575 $post_data['post_content_filtered'] = apply_filters( 'wpcom_untransformed_content', $post_data['post_content'] );
576 $post_data['post_content'] = $this->transform( $post_data['post_content'], array( 'id' => $post_data['post_parent'] ) );
577 /** This filter is already documented in core/wp-includes/default-filters.php */
578 $post_data['post_content'] = apply_filters( 'content_save_pre', $post_data['post_content'] );
579 }
580
581 // set as markdown on the wp_insert_post hook later.
582 if ( $post_id ) {
583 $this->monitoring['post'][ $post_id ] = true;
584 } else {
585 $this->monitoring['content'] = wp_unslash( $post_data['post_content'] );
586 }
587 if ( 'revision' === $postarr['post_type'] && $this->is_markdown( $postarr['post_parent'] ) ) {
588 $this->monitoring['parent'][ $postarr['post_parent'] ] = true;
589 }
590
591 return $post_data;
592 }
593
594 /**
595 * Calls on wp_insert_post action, after wp_insert_post_data. This way we can
596 * still set postmeta on our revisions after it's all been deleted.
597 *
598 * @param int $post_id The post ID that has just been added/updated.
599 */
600 public function wp_insert_post( $post_id ) {
601 $post_parent = get_post_field( 'post_parent', $post_id );
602 // this didn't have an ID yet. Compare the content that was just saved.
603 if ( isset( $this->monitoring['content'] ) && get_post_field( 'post_content', $post_id ) === $this->monitoring['content'] ) {
604 unset( $this->monitoring['content'] );
605 $this->set_as_markdown( $post_id );
606 }
607 if ( isset( $this->monitoring['post'][ $post_id ] ) ) {
608 unset( $this->monitoring['post'][ $post_id ] );
609 $this->set_as_markdown( $post_id );
610 } elseif ( isset( $this->monitoring['parent'][ $post_parent ] ) ) {
611 unset( $this->monitoring['parent'][ $post_parent ] );
612 $this->set_as_markdown( $post_id );
613 }
614 }
615
616 /**
617 * Run a comment through Markdown. Easy peasy.
618 *
619 * @param string $content - the content.
620 * @return string
621 */
622 public function pre_comment_content( $content ) {
623 return $this->transform(
624 $content,
625 array(
626 'id' => $this->comment_hash( $content ),
627 )
628 );
629 }
630
631 /**
632 * Return a comment hash.
633 *
634 * @param string $content - the content of the comment.
635 */
636 protected function comment_hash( $content ) {
637 return 'c-' . substr( md5( $content ), 0, 8 );
638 }
639
640 /**
641 * Markdown conversion. Some DRYness for repetitive tasks.
642 *
643 * @param string $text Content to be run through Markdown.
644 * @param array $args Arguments, with keys:
645 * id: provide a string to prefix footnotes with a unique identifier
646 * unslash: when true, expects and returns slashed data
647 * decode_code_blocks: when true, assume that text in fenced code blocks is already
648 * HTML encoded and should be decoded before being passed to Markdown, which does
649 * its own encoding.
650 * @return string Markdown-processed content
651 */
652 public function transform( $text, $args = array() ) {
653 // If this contains Gutenberg content, let's keep it intact.
654 if ( has_blocks( $text ) ) {
655 return $text;
656 }
657
658 $args = wp_parse_args(
659 $args,
660 array(
661 'id' => false,
662 'unslash' => true,
663 'decode_code_blocks' => ! $this->get_parser()->use_code_shortcode,
664 )
665 );
666 // probably need to unslash.
667 if ( $args['unslash'] ) {
668 $text = wp_unslash( $text );
669 }
670
671 /**
672 * Filter the content to be run through Markdown, before it's transformed by Markdown.
673 *
674 * @module markdown
675 *
676 * @since 2.8.0
677 *
678 * @param string $text Content to be run through Markdown
679 * @param array $args Array of Markdown options.
680 */
681 $text = apply_filters( 'wpcom_markdown_transform_pre', $text, $args ) ?? '';
682 // ensure our paragraphs are separated.
683 $text = str_replace( array( '</p><p>', "</p>\n<p>" ), "</p>\n\n<p>", $text );
684 // visual editor likes to add <p>s. Buh-bye.
685 $text = $this->get_parser()->unp( $text );
686 // sometimes we get an encoded > at start of line, breaking blockquotes.
687 $text = preg_replace( '/^&gt;/m', '>', $text );
688 // prefixes are because we need to namespace footnotes by post_id.
689 $this->get_parser()->fn_id_prefix = $args['id'] ? $args['id'] . '-' : '';
690 // If we're not using the code shortcode, prevent over-encoding.
691 if ( $args['decode_code_blocks'] ) {
692 $text = $this->get_parser()->codeblock_restore( $text );
693 }
694 // Transform it!
695 $text = $this->get_parser()->transform( $text );
696 // Fix footnotes - kses doesn't like the : IDs it supplies.
697 $text = preg_replace( '/((id|href)="#?fn(ref)?):/', '$1-', $text );
698 // Markdown inserts extra spaces to make itself work. Buh-bye.
699 $text = rtrim( $text );
700 /**
701 * Filter the content to be run through Markdown, after it was transformed by Markdown.
702 *
703 * @module markdown
704 *
705 * @since 2.8.0
706 *
707 * @param string $text Content to be run through Markdown
708 * @param array $args Array of Markdown options.
709 */
710 $text = apply_filters( 'wpcom_markdown_transform_post', $text, $args );
711
712 // probably need to re-slash.
713 if ( $args['unslash'] ) {
714 $text = wp_slash( $text );
715 }
716
717 return $text;
718 }
719
720 /**
721 * Shows Markdown in the Revisions screen, and ensures that post_content_filtered
722 * is maintained on revisions
723 *
724 * @param array $fields Post fields pertinent to revisions.
725 */
726 public function wp_post_revision_fields( $fields ) {
727 $fields['post_content_filtered'] = __( 'Markdown content', 'jetpack' );
728 return $fields;
729 }
730
731 /**
732 * Do some song and dance to keep all post_content and post_content_filtered content
733 * in the expected place when a post revision is restored.
734 *
735 * @param int $post_id The post ID have a restore done to it.
736 * @param int $revision_id The revision ID being restored.
737 */
738 public function wp_restore_post_revision( $post_id, $revision_id ) {
739 if ( $this->is_markdown( $revision_id ) ) {
740 $revision = get_post( $revision_id, ARRAY_A );
741 $post = get_post( $post_id, ARRAY_A );
742 $post['post_content'] = $revision['post_content_filtered']; // Yes, we put it in post_content, because our wp_insert_post_data() expects that.
743 // set this flag so we can restore the post_content_filtered on the last revision later.
744 $this->monitoring['restore'] = true;
745 // let's not make a revision of our fixing update.
746 add_filter( 'wp_revisions_to_keep', '__return_false', 99 );
747 wp_update_post( $post );
748 $this->fix_latest_revision_on_restore( $post_id );
749 remove_filter( 'wp_revisions_to_keep', '__return_false', 99 );
750 }
751 }
752
753 /**
754 * We need to ensure the last revision has Markdown, not HTML in its post_content_filtered
755 * column after a restore.
756 *
757 * @param int $post_id The post ID that was just restored.
758 */
759 protected function fix_latest_revision_on_restore( $post_id ) {
760 global $wpdb;
761 $post = get_post( $post_id );
762 $last_revision = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $wpdb->posts WHERE post_type = 'revision' AND post_parent = %d ORDER BY ID DESC", $post->ID ) );
763 $last_revision->post_content_filtered = $post->post_content_filtered;
764 wp_insert_post( (array) $last_revision );
765 }
766
767 /**
768 * Kicks off magic for an XML-RPC session. We want to keep editing Markdown
769 * and publishing HTML.
770 *
771 * @param string $xmlrpc_method The current XML-RPC method.
772 */
773 public function xmlrpc_actions( $xmlrpc_method ) {
774 switch ( $xmlrpc_method ) {
775 case 'metaWeblog.getRecentPosts':
776 case 'wp.getPosts':
777 case 'wp.getPages':
778 add_action( 'parse_query', array( $this, 'make_filterable' ), 10, 1 );
779 break;
780 case 'wp.getPost':
781 $this->prime_post_cache();
782 break;
783 }
784 }
785
786 /**
787 * Function metaWeblog.getPost and wp.getPage fire xmlrpc_call action *after* get_post() is called.
788 * So, we have to detect those methods and prime the post cache early.
789 *
790 * @return null
791 */
792 protected function check_for_early_methods() {
793 $raw_post_data = file_get_contents( 'php://input' );
794 if ( ! str_contains( $raw_post_data, 'metaWeblog.getPost' )
795 && ! str_contains( $raw_post_data, 'wp.getPage' ) ) {
796 return;
797 }
798 include_once ABSPATH . WPINC . '/class-IXR.php';
799 $message = new IXR_Message( $raw_post_data );
800 $message->parse();
801 $post_id_position = 'metaWeblog.getPost' === $message->methodName ? 0 : 1; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
802 $this->prime_post_cache( $message->params[ $post_id_position ] ?? false );
803 }
804
805 /**
806 * Prime the post cache with swapped post_content. This is a sneaky way of getting around
807 * the fact that there are no good hooks to call on the *.getPost xmlrpc methods.
808 *
809 * @param bool $post_id - the post ID that we're priming.
810 */
811 private function prime_post_cache( $post_id = false ) {
812 global $wp_xmlrpc_server;
813 if ( ! $post_id ) {
814 if ( isset( $wp_xmlrpc_server->message->params[3] ) ) {
815 $post_id = $wp_xmlrpc_server->message->params[3];
816 } else {
817 return; // Exit early if we can't get a valid post_id
818 }
819 }
820
821 // prime the post cache.
822 if ( $this->is_markdown( $post_id ) ) {
823 $post = get_post( $post_id );
824 if ( ! empty( $post->post_content_filtered ) ) {
825 wp_cache_delete( $post->ID, 'posts' );
826 $post = $this->swap_for_editing( $post );
827 wp_cache_add( $post->ID, $post, 'posts' );
828 $this->posts_to_uncache[] = $post_id;
829 }
830 }
831 // uncache munged posts if using a persistent object cache.
832 if ( wp_using_ext_object_cache() ) {
833 add_action( 'shutdown', array( $this, 'uncache_munged_posts' ) );
834 }
835 }
836
837 /**
838 * Swaps `post_content_filtered` back to `post_content` for editing purposes.
839 *
840 * @param object $post WP_Post object.
841 * @return object WP_Post object with swapped `post_content_filtered` and `post_content`.
842 */
843 protected function swap_for_editing( $post ) {
844 $markdown = $post->post_content_filtered;
845 // unencode encoded code blocks.
846 $markdown = $this->get_parser()->codeblock_restore( $markdown );
847 // restore beginning of line blockquotes.
848 $markdown = preg_replace( '/^&gt; /m', '> ', $markdown );
849 $post->post_content_filtered = $post->post_content;
850 $post->post_content = $markdown;
851 return $post;
852 }
853
854 /**
855 * We munge the post cache to serve proper markdown content to XML-RPC clients.
856 * Uncache these after the XML-RPC session ends.
857 */
858 public function uncache_munged_posts() {
859 // $this context gets lost in testing sometimes. Weird.
860 foreach ( self::get_instance()->posts_to_uncache as $post_id ) {
861 wp_cache_delete( $post_id, 'posts' );
862 }
863 }
864
865 /**
866 * Since *.(get)?[Rr]ecentPosts calls get_posts with suppress filters on, we need to
867 * turn them back on so that we can swap things for editing.
868 *
869 * @param object $wp_query WP_Query object.
870 */
871 public function make_filterable( $wp_query ) {
872 $wp_query->set( 'suppress_filters', false );
873 add_action( 'the_posts', array( $this, 'the_posts' ), 10, 2 );
874 }
875
876 /**
877 * Swaps post_content and post_content_filtered for editing.
878 *
879 * @param array $posts Posts returned by the just-completed query.
880 * @return array Modified $posts
881 */
882 public function the_posts( $posts ) {
883 foreach ( $posts as $key => $post ) {
884 if ( $this->is_markdown( $post->ID ) && ! empty( $posts[ $key ]->post_content_filtered ) ) {
885 $markdown = $posts[ $key ]->post_content_filtered;
886 $posts[ $key ]->post_content_filtered = $posts[ $key ]->post_content;
887 $posts[ $key ]->post_content = $markdown;
888 }
889 }
890 return $posts;
891 }
892
893 /**
894 * Singleton silence is golden
895 */
896 private function __construct() {}
897 }
898
899 add_action( 'init', array( WPCom_Markdown::get_instance(), 'load' ) );
900