' . esc_html( $error_message ) . '' ); ?>
51 |' . $display_url . '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
72 |%2$s', esc_url( $url ), esc_html__( 'Authorize Access', 'share-on-mastodon' ) ); ?> 281 | 285 |
286 |287 | %2$s', 290 | esc_url( 291 | add_query_arg( 292 | array( 293 | 'page' => 'share-on-mastodon', // phpcs:ignore WordPress.Arrays.MultipleStatementAlignment.DoubleArrowNotAligned 294 | 'action' => 'revoke', // phpcs:ignore WordPress.Arrays.MultipleStatementAlignment.LongIndexSpaceBeforeDoubleArrow 295 | '_wpnonce' => wp_create_nonce( 'share-on-mastodon:token:revoke' ), 296 | ), 297 | admin_url( 'options-general.php' ) 298 | ) 299 | ), 300 | esc_html__( 'Revoke Access', 'share-on-mastodon' ) 301 | ); 302 | ?> 303 |
304 | 309 | 310 | 315 | 316 | 325 | 352 | 357 | 387 | 392 | 456 | 461 | 476 | 477 | 499 | 504 |386 | 387 | ' . $display_url . '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> 388 | 389 | 390 |
391 | ID, '_share_on_mastodon_error', true ); 394 | 395 | if ( '' !== $error_message ) : 396 | ?> 397 |398 | post_type ) && ! in_array( $current_screen->post_type, $options['post_types'], true ) ) ) { 459 | // Only load JS for actually supported post types. 460 | return; 461 | } 462 | 463 | global $post; 464 | 465 | // Enqueue CSS and JS. 466 | wp_enqueue_style( 'share-on-mastodon', plugins_url( '/assets/share-on-mastodon.css', __DIR__ ), array(), Share_On_Mastodon::PLUGIN_VERSION ); 467 | wp_enqueue_script( 'share-on-mastodon', plugins_url( '/assets/share-on-mastodon.js', __DIR__ ), array(), Share_On_Mastodon::PLUGIN_VERSION, false ); 468 | wp_localize_script( 469 | 'share-on-mastodon', 470 | 'share_on_mastodon_obj', 471 | array( 472 | 'message' => esc_attr__( 'Forget this URL?', 'share-on-mastodon' ), // Confirmation message. 473 | 'post_id' => ! empty( $post->ID ) ? $post->ID : 0, // Pass current post ID to JS. 474 | 'nonce' => wp_create_nonce( basename( __FILE__ ) ), 475 | 'ajaxurl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ), 476 | 'custom_status_field' => ! empty( $options['custom_status_field'] ) ? '1' : '0', 477 | 'content_warning' => ! empty( $options['content_warning'] ) ? '1' : '0', 478 | ) 479 | ); 480 | } 481 | 482 | /** 483 | * Determines if a post should, in fact, be shared. 484 | * 485 | * @param \WP_Post $post Post object. 486 | * @return bool If the post should be shared. 487 | */ 488 | protected function is_valid( $post ) { 489 | if ( 'publish' !== $post->post_status ) { 490 | // Status is something other than `publish`. 491 | debug_log( '[Share on Mastodon] Post not public.' ); 492 | return false; 493 | } 494 | 495 | if ( post_password_required( $post ) ) { 496 | // Post is password-protected. 497 | debug_log( '[Share on Mastodon] Post password-protected.' ); 498 | return false; 499 | } 500 | 501 | $options = get_options( $post->post_author ); 502 | 503 | if ( ! in_array( $post->post_type, (array) $options['post_types'], true ) ) { 504 | // Unsupported post type. 505 | debug_log( '[Share on Mastodon] Unsupported post type.' ); 506 | return false; 507 | } 508 | 509 | if ( '' !== get_post_meta( $post->ID, '_share_on_mastodon_url', true ) ) { 510 | // Was shared before (and not "unlinked"). 511 | debug_log( '[Share on Mastodon] Post shared before.' ); 512 | return false; 513 | } 514 | 515 | if ( is_older_than( DAY_IN_SECONDS / 2, $post ) && '1' !== get_post_meta( $post->ID, '_share_on_mastodon', true ) ) { 516 | // Unless the box was ticked explicitly, we won't share "older" posts. Since v0.13.0, sharing "older" posts 517 | // is "opt-in," always. 518 | debug_log( '[Share on Mastodon] Preventing older post from being shared automatically.' ); 519 | return false; 520 | } 521 | 522 | $is_enabled = false; 523 | 524 | if ( '1' === get_post_meta( $post->ID, '_share_on_mastodon', true ) ) { 525 | // Sharing was enabled for this post. 526 | $is_enabled = true; 527 | } 528 | 529 | // That's not it, though; we have a setting that enables posts to be shared nevertheless. 530 | if ( ! empty( $options['share_always'] ) ) { 531 | $is_enabled = true; 532 | } 533 | 534 | // We let developers override `$is_enabled` through a callback function. 535 | return apply_filters( 'share_on_mastodon_enabled', $is_enabled, $post->ID ); 536 | } 537 | 538 | /** 539 | * Parses `%title%`, etc. template tags. 540 | * 541 | * @param string $status Mastodon status, or template. 542 | * @param int $post_id Post ID. 543 | * @return string Parsed status. 544 | */ 545 | protected function parse_status( $status, $post_id ) { 546 | // Fill out title and tags. 547 | $status = str_replace( '%title%', get_the_title( $post_id ), $status ); 548 | $status = str_replace( '%tags%', $this->get_tags( $post_id ), $status ); 549 | 550 | // Estimate a max length of sorts. 551 | $max_length = mb_strlen( str_replace( array( '%excerpt%', '%permalink%' ), '', $status ) ); 552 | $max_length = max( 0, 450 - $max_length ); // For a possible permalink, and then some. 553 | 554 | $status = str_replace( '%excerpt%', $this->get_excerpt( $post_id, $max_length ), $status ); 555 | 556 | $status = preg_replace( '~(\r\n){2,}~', "\r\n\r\n", $status ); // We should have normalized line endings by now. 557 | $status = sanitize_textarea_field( $status ); // Strips HTML and whatnot. 558 | 559 | // Add the (escaped) URL after the everything else has been sanitized, so as not to garble permalinks with 560 | // multi-byte characters in them. 561 | $status = str_replace( '%permalink%', esc_url_raw( get_permalink( $post_id ) ), $status ); 562 | 563 | return $status; 564 | } 565 | 566 | /** 567 | * Returns a post's excerpt, but limited to approx. 125 characters. 568 | * 569 | * @param int $post_id Post ID. 570 | * @param int $max_length Estimated maximum length. 571 | * @return string (Possibly shortened) excerpt. 572 | */ 573 | protected function get_excerpt( $post_id, $max_length = 125 ) { 574 | if ( 0 === $max_length ) { 575 | // Nothing to do. 576 | return ''; 577 | } 578 | 579 | // Grab the default `excerpt_more`. 580 | $excerpt_more = apply_filters( 'excerpt_more', ' […]' ); 581 | 582 | // The excerpt as generated by WordPress. 583 | $orig = apply_filters( 'the_excerpt', get_the_excerpt( $post_id ) ); 584 | 585 | // Trim off the `excerpt_more` string. 586 | $excerpt = preg_replace( "~$excerpt_more$~", '', $orig ); 587 | 588 | $excerpt = wp_strip_all_tags( $orig ); // Just in case a site owner's allowing HTML in their excerpts or something. 589 | $excerpt = html_entity_decode( $orig, ENT_QUOTES | ENT_HTML5, get_bloginfo( 'charset' ) ); // Prevent special characters from messing things up. 590 | 591 | $shortened = mb_substr( $excerpt, 0, apply_filters( 'share_on_mastodon_excerpt_length', $max_length ) ); 592 | $shortened = trim( $shortened ); 593 | 594 | if ( $shortened === $excerpt ) { 595 | // Might as well done nothing. 596 | return $orig; 597 | } elseif ( ctype_punct( mb_substr( $shortened, -1 ) ) ) { 598 | // Final char is a "punctuation" character. 599 | $shortened .= ' …'; 600 | } else { 601 | $shortened .= '…'; 602 | } 603 | 604 | return $shortened; 605 | } 606 | 607 | /** 608 | * Returns a post's tags as a string of space-separated hashtags. 609 | * 610 | * @param int $post_id Post ID. 611 | * @return string Hashtag string. 612 | */ 613 | protected function get_tags( $post_id ) { 614 | $hashtags = ''; 615 | $tags = get_the_tags( $post_id ); 616 | 617 | if ( $tags && ! is_wp_error( $tags ) ) { 618 | foreach ( $tags as $tag ) { 619 | $tag_name = $tag->name; 620 | 621 | if ( preg_match( '/(\s|-)+/', $tag_name ) ) { 622 | // Try to "CamelCase" multi-word tags. 623 | $tag_name = preg_replace( '~(\s|-)+~', ' ', $tag_name ); 624 | $tag_name = explode( ' ', $tag_name ); 625 | $tag_name = implode( '', array_map( 'ucfirst', $tag_name ) ); 626 | } 627 | 628 | $hashtags .= '#' . $tag_name . ' '; 629 | } 630 | } 631 | 632 | return trim( $hashtags ); 633 | } 634 | 635 | /** 636 | * Checks for a Mastodon instance and auth token. 637 | * 638 | * @param \WP_Post $post Post object. 639 | * @return bool Whether auth access was set up okay. 640 | */ 641 | protected function setup_completed( $post ) { 642 | $options = get_options( $post->post_author ); 643 | 644 | if ( empty( $options['mastodon_host'] ) ) { 645 | return false; 646 | } 647 | 648 | if ( ! wp_http_validate_url( $options['mastodon_host'] ) ) { 649 | return false; 650 | } 651 | 652 | if ( empty( $options['mastodon_access_token'] ) ) { 653 | return false; 654 | } 655 | 656 | return true; 657 | } 658 | 659 | /** 660 | * Checks whether the current request was initiated by the block editor. 661 | * 662 | * @return bool Whether the current request was initiated by the block editor. 663 | */ 664 | protected function is_gutenberg() { 665 | if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) { 666 | // Not a REST request. 667 | return false; 668 | } 669 | 670 | if ( wp_doing_cron() ) { 671 | return false; 672 | } 673 | 674 | $nonce = null; 675 | 676 | if ( isset( $_REQUEST['_wpnonce'] ) ) { 677 | $nonce = $_REQUEST['_wpnonce']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 678 | } elseif ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) { 679 | $nonce = $_SERVER['HTTP_X_WP_NONCE']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 680 | } 681 | 682 | if ( null === $nonce ) { 683 | return false; 684 | } 685 | 686 | // Check the nonce. 687 | return wp_verify_nonce( $nonce, 'wp_rest' ); 688 | } 689 | } 690 | -------------------------------------------------------------------------------- /includes/class-share-on-mastodon.php: -------------------------------------------------------------------------------- 1 | plugin_options = new Plugin_Options(); 54 | $this->plugin_options->register(); 55 | 56 | $this->post_handler = new Post_Handler(); 57 | $this->post_handler->register(); 58 | 59 | // Main plugin hooks. 60 | register_deactivation_hook( dirname( __DIR__ ) . '/share-on-mastodon.php', array( $this, 'deactivate' ) ); 61 | 62 | add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); 63 | add_action( 'init', array( $this, 'init' ) ); 64 | 65 | $options = get_options(); 66 | 67 | if ( ! empty( $options['micropub_compat'] ) ) { 68 | Micropub_Compat::register(); 69 | } 70 | 71 | if ( ! empty( $options['syn_links_compat'] ) ) { 72 | Syn_Links_Compat::register(); 73 | } 74 | 75 | Block_Editor::register(); 76 | } 77 | 78 | /** 79 | * Ensures cron job is scheduled, and, if needed, kicks off database migrations. 80 | */ 81 | public function init() { 82 | // Schedule a daily cron job. 83 | if ( false === wp_next_scheduled( 'share_on_mastodon_verify_token' ) ) { 84 | wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'share_on_mastodon_verify_token' ); 85 | } 86 | 87 | if ( get_option( 'share_on_mastodon_db_version' ) !== self::DB_VERSION ) { 88 | $this->migrate(); 89 | } 90 | } 91 | 92 | /** 93 | * Runs on deactivation. 94 | */ 95 | public function deactivate() { 96 | wp_clear_scheduled_hook( 'share_on_mastodon_verify_token' ); 97 | } 98 | 99 | /** 100 | * Enables localization. 101 | */ 102 | public function load_textdomain() { 103 | load_plugin_textdomain( 'share-on-mastodon', false, basename( dirname( __DIR__ ) ) . '/languages' ); 104 | } 105 | 106 | /** 107 | * Returns `Post_Handler` instance. 108 | * 109 | * @return Post_Handler This plugin's `Post_Handler` instance. 110 | */ 111 | public function get_post_handler() { 112 | return $this->post_handler; 113 | } 114 | 115 | /** 116 | * Returns `Plugin_Options` instance. 117 | * 118 | * @return Plugin_Options This plugin's `Plugin_Options` instance. 119 | */ 120 | public function get_plugin_options() { 121 | return $this->plugin_options; 122 | } 123 | 124 | /** 125 | * Returns `Plugin_Options` instance. 126 | * 127 | * @return Plugin_Options This plugin's `Plugin_Options` instance. 128 | */ 129 | public function get_options_handler() { 130 | _deprecated_function( __METHOD__, '0.19.0', '\Share_On_Mastodon\Share_On_Mastodon::get_plugin_options' ); 131 | 132 | return $this->plugin_options; 133 | } 134 | 135 | /** 136 | * Performs the necessary database migrations, if applicable. 137 | */ 138 | protected function migrate() { 139 | if ( ! function_exists( '\\dbDelta' ) ) { 140 | require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 141 | } 142 | 143 | ob_start(); 144 | include __DIR__ . '/database/schema.php'; 145 | $sql = ob_get_clean(); 146 | 147 | dbDelta( $sql ); 148 | 149 | update_option( 'share_on_mastodon_db_version', self::DB_VERSION, 'no' ); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /includes/class-syn-links-compat.php: -------------------------------------------------------------------------------- 1 | 12 | CREATE TABLE ( 13 | id mediumint(9) UNSIGNED NOT NULL AUTO_INCREMENT, 14 | host varchar(191) NOT NULL, 15 | client_name varchar(191), 16 | website varchar(191), 17 | scopes varchar(191), 18 | redirect_uris text, 19 | client_id varchar(191) NOT NULL, 20 | client_secret varchar(191) NOT NULL, 21 | vapid_key varchar(191), 22 | client_token varchar(191), 23 | created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL, 24 | modified_at datetime, 25 | PRIMARY KEY (id) 26 | ) get_charset_collate(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>; 27 | -------------------------------------------------------------------------------- /includes/functions.php: -------------------------------------------------------------------------------- 1 | get_plugin_options() 35 | ->get_options(); 36 | 37 | return apply_filters( 'share_on_mastodon_options', $options, $user_id ); 38 | } 39 | 40 | /** 41 | * Tries to convert an attachment URL into a post ID. 42 | * 43 | * Mostly lifted from core. The main difference is this function will also match URLs whose filename part probably 44 | * should include `-scaled`. 45 | * 46 | * @param string $url The URL to resolve. 47 | * @return int The found post ID, or 0 on failure. 48 | */ 49 | function attachment_url_to_postid( $url ) { 50 | global $wpdb; 51 | 52 | $dir = wp_get_upload_dir(); 53 | $path = $url; 54 | 55 | $site_url = wp_parse_url( $dir['url'] ); 56 | $image_path = wp_parse_url( $path ); 57 | 58 | // Force the protocols to match if needed. 59 | if ( isset( $image_path['scheme'] ) && ( $image_path['scheme'] !== $site_url['scheme'] ) ) { 60 | $path = str_replace( $image_path['scheme'], $site_url['scheme'], $path ); 61 | } 62 | 63 | if ( str_starts_with( $path, $dir['baseurl'] . '/' ) ) { 64 | $path = substr( $path, strlen( $dir['baseurl'] . '/' ) ); 65 | } 66 | 67 | $filename = pathinfo( $path, PATHINFO_FILENAME ); // The bit before the (last) file extension (if any). 68 | 69 | $sql = $wpdb->prepare( 70 | "SELECT post_id, meta_value FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value REGEXP %s", 71 | str_replace( $filename, "$filename(-scaled)*", $path ) // This is really the only change here. 72 | ); 73 | 74 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared 75 | $results = $wpdb->get_results( $sql ); 76 | $post_id = null; 77 | 78 | if ( $results ) { 79 | // Use the first available result, but prefer a case-sensitive match, if exists. 80 | $post_id = reset( $results )->post_id; 81 | 82 | if ( count( $results ) > 1 ) { 83 | foreach ( $results as $result ) { 84 | if ( $path === $result->meta_value ) { 85 | $post_id = $result->post_id; 86 | break; 87 | } 88 | } 89 | } 90 | } 91 | 92 | return (int) $post_id; 93 | } 94 | 95 | /** 96 | * Determines whether a post is older than a certain number of seconds. 97 | * 98 | * @param int $seconds Minimum "age," in seconds. 99 | * @param int|\WP_Post $post Post ID or object. Defaults to global `$post`. 100 | * @return bool True if the post exists and is older than `$seconds`, false otherwise. 101 | */ 102 | function is_older_than( $seconds, $post = null ) { 103 | $post_time = get_post_time( 'U', true, $post ); 104 | 105 | if ( false === $post_time ) { 106 | return false; 107 | } 108 | 109 | if ( $post_time >= time() - $seconds ) { 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | -------------------------------------------------------------------------------- /languages/share-on-mastodon.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2024 Jan Boddez 2 | # This file is distributed under the GNU General Public License v3. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: Share on Mastodon 0.19.0\n" 6 | "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/share-on-mastodon\n" 7 | "Last-Translator: FULL NAME