diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..49ff40e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +Fixes # + +## Proposed changes: + +* + +### Other information: + +- [ ] Have you written new tests for your changes, if applicable? + +## Testing instructions: + + + + + + +* Go to '..' +* + diff --git a/activitypub.php b/activitypub.php index 378e999..84460df 100644 --- a/activitypub.php +++ b/activitypub.php @@ -24,88 +24,80 @@ function init() { \defined( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS' ) || \define( 'ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS', 3 ); \defined( 'ACTIVITYPUB_HASHTAGS_REGEXP' ) || \define( 'ACTIVITYPUB_HASHTAGS_REGEXP', '(?:(?<=\s)|(?<=
)|(?<=
)|^)#([A-Za-z0-9_]+)(?:(?=\s|[[:punct:]]|$))' );
\defined( 'ACTIVITYPUB_USERNAME_REGEXP' ) || \define( 'ACTIVITYPUB_USERNAME_REGEXP', '(?:([A-Za-z0-9_-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))' );
- \defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "
[ap_title]
\n\n[ap_content]\n\n[ap_hashtags]
\n\n[ap_shortlink]
" ); - \defined( 'ACTIVITYPUB_SECURE_MODE' ) || \define( 'ACTIVITYPUB_SECURE_MODE', apply_filters( 'activitypub_secure_mode', $value = false ) ); + \defined( 'ACTIVITYPUB_CUSTOM_POST_CONTENT' ) || \define( 'ACTIVITYPUB_CUSTOM_POST_CONTENT', "[ap_title]\n\n[ap_content]\n\n[ap_hashtags]\n\n[ap_shortlink]" ); + \defined( 'ACTIVITYPUB_SECURE_MODE' ) || \define( 'ACTIVITYPUB_SECURE_MODE', apply_filters( 'activitypub_secure_mode', $value = false ) ); \define( 'ACTIVITYPUB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); \define( 'ACTIVITYPUB_PLUGIN_FILE', plugin_dir_path( __FILE__ ) . '/' . basename( __FILE__ ) ); - \define( 'ACTIVITYPUB_OBJECT', 'ACTIVITYPUB_OBJECT' ); - - require_once \dirname( __FILE__ ) . '/includes/table/class-followers.php'; - require_once \dirname( __FILE__ ) . '/includes/class-http.php'; - require_once \dirname( __FILE__ ) . '/includes/class-signature.php'; - require_once \dirname( __FILE__ ) . '/includes/class-webfinger.php'; - require_once \dirname( __FILE__ ) . '/includes/peer/class-followers.php'; - require_once \dirname( __FILE__ ) . '/includes/functions.php'; - - require_once \dirname( __FILE__ ) . '/includes/model/class-activity.php'; - require_once \dirname( __FILE__ ) . '/includes/model/class-post.php'; - require_once \dirname( __FILE__ ) . '/includes/model/class-user.php'; - require_once \dirname( __FILE__ ) . '/includes/model/class-follower.php'; - - require_once \dirname( __FILE__ ) . '/includes/class-migration.php'; Migration::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-activity-dispatcher.php'; Activity_Dispatcher::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-activitypub.php'; Activitypub::init(); - - require_once \dirname( __FILE__ ) . '/includes/collection/class-followers.php'; Collection\Followers::init(); // Configure the REST API route - require_once \dirname( __FILE__ ) . '/includes/rest/class-outbox.php'; Rest\Outbox::init(); - - require_once \dirname( __FILE__ ) . '/includes/rest/class-inbox.php'; Rest\Inbox::init(); - - require_once \dirname( __FILE__ ) . '/includes/rest/class-followers.php'; Rest\Followers::init(); - - require_once \dirname( __FILE__ ) . '/includes/rest/class-following.php'; Rest\Following::init(); - - require_once \dirname( __FILE__ ) . '/includes/rest/class-server.php'; - \Activitypub\Rest\Server::init(); - - require_once \dirname( __FILE__ ) . '/includes/rest/class-webfinger.php'; Rest\Webfinger::init(); - // load NodeInfo endpoints only if blog is public - if ( \get_option( 'blog_public', 1 ) ) { - require_once \dirname( __FILE__ ) . '/includes/rest/class-nodeinfo.php'; - Rest\NodeInfo::init(); - } - - require_once \dirname( __FILE__ ) . '/includes/class-admin.php'; Admin::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-hashtag.php'; Hashtag::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-shortcodes.php'; Shortcodes::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-mention.php'; Mention::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-debug.php'; Debug::init(); - - require_once \dirname( __FILE__ ) . '/includes/class-health-check.php'; Health_Check::init(); - - if ( \WP_DEBUG ) { - require_once \dirname( __FILE__ ) . '/includes/debug.php'; - } + Scheduler::init(); } \add_action( 'plugins_loaded', '\Activitypub\init' ); +/** + * Class Autoloader + */ +spl_autoload_register( + function ( $full_class ) { + $base_dir = \dirname( __FILE__ ) . '/includes/'; + $base = 'activitypub'; + + $class = strtolower( $full_class ); + + if ( strncmp( $class, $base, strlen( $base ) ) === 0 ) { + $class = str_replace( 'activitypub\\', '', $class ); + + if ( false !== strpos( $class, '\\' ) ) { + $parts = explode( '\\', $class ); + $class = array_pop( $parts ); + $sub_dir = implode( '/', $parts ); + $base_dir = $base_dir . $sub_dir . '/'; + } + + $filename = 'class-' . strtr( $class, '_', '-' ); + $file = $base_dir . $filename . '.php'; + + if ( file_exists( $file ) && is_readable( $file ) ) { + require_once $file; + } else { + // translators: %s is the class name + \wp_die( sprintf( esc_html__( 'Required class not found or not readable: %s', 'activitypub' ), esc_html( $full_class ) ) ); + } + } + } +); + +require_once \dirname( __FILE__ ) . '/includes/functions.php'; + +// load NodeInfo endpoints only if blog is public +if ( \get_option( 'blog_public', 1 ) ) { + Rest\NodeInfo::init(); +} + +$debug_file = \dirname( __FILE__ ) . '/includes/debug.php'; +if ( \WP_DEBUG && file_exists( $debug_file ) && is_readable( $debug_file ) ) { + require_once $debug_file; +} + /** * Add plugin settings link */ @@ -118,34 +110,32 @@ function plugin_settings_link( $actions ) { return \array_merge( $settings_link, $actions ); } -\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), '\Activitypub\plugin_settings_link' ); +\add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), __NAMESPACE__ . '\plugin_settings_link' ); -/** - * Add rewrite rules - */ -function add_rewrite_rules() { - if ( ! \class_exists( 'Webfinger' ) ) { - \add_rewrite_rule( '^.well-known/webfinger', 'index.php?rest_route=/activitypub/1.0/webfinger', 'top' ); - } +\register_activation_hook( + __FILE__, + array( + __NAMESPACE__ . '\Activitypub', + 'activate', + ) +); - if ( ! \class_exists( 'Nodeinfo' ) || ! (bool) \get_option( 'blog_public', 1 ) ) { - \add_rewrite_rule( '^.well-known/nodeinfo', 'index.php?rest_route=/activitypub/1.0/nodeinfo/discovery', 'top' ); - \add_rewrite_rule( '^.well-known/x-nodeinfo2', 'index.php?rest_route=/activitypub/1.0/nodeinfo2', 'top' ); - } +\register_deactivation_hook( + __FILE__, + array( + __NAMESPACE__ . '\Activitypub', + 'deactivate', + ) +); - \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); -} -\add_action( 'init', '\Activitypub\add_rewrite_rules', 1 ); +register_uninstall_hook( + __FILE__, + array( + __NAMESPACE__ . '\Activitypub', + 'uninstall', + ) +); -/** - * Flush rewrite rules; - */ -function flush_rewrite_rules() { - \Activitypub\add_rewrite_rules(); - \flush_rewrite_rules(); -} -\register_activation_hook( __FILE__, '\Activitypub\flush_rewrite_rules' ); -\register_deactivation_hook( __FILE__, '\flush_rewrite_rules' ); /** * Only load code that needs BuddyPress to run once BP is loaded and initialized. diff --git a/composer.json b/composer.json index 67bd2d3..b0117c4 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "squizlabs/php_codesniffer": "3.*", "wp-coding-standards/wpcs": "*", "yoast/phpunit-polyfills": "^1.0", - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1" + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0" }, "config": { "allow-plugins": true diff --git a/includes/class-activity-dispatcher.php b/includes/class-activity-dispatcher.php index 422240d..ee435a7 100644 --- a/includes/class-activity-dispatcher.php +++ b/includes/class-activity-dispatcher.php @@ -5,6 +5,8 @@ use Activitypub\Model\Post; use Activitypub\Model\Activity; use Activitypub\Collection\Followers; +use function Activitypub\safe_remote_post; + /** * ActivityPub Activity_Dispatcher Class * @@ -37,7 +39,7 @@ class Activity_Dispatcher { /** * Send "update" activities. * - * @param Activitypub\Model\Post $activitypub_post + * @param Activitypub\Model\Post $activitypub_post The ActivityPub Post. */ public static function send_update_activity( Post $activitypub_post ) { self::send_activity( $activitypub_post, 'Update' ); @@ -46,23 +48,23 @@ class Activity_Dispatcher { /** * Send "delete" activities. * - * @param Activitypub\Model\Post $activitypub_post + * @param Activitypub\Model\Post $activitypub_post The ActivityPub Post. */ public static function send_delete_activity( Post $activitypub_post ) { self::send_activity( $activitypub_post, 'Delete' ); } /** - * Undocumented function + * Send Activities to followers and mentioned users. * - * @param Activitypub\Model\Post $activitypub_post - * @param [type] $activity_type + * @param Activitypub\Model\Post $activitypub_post The ActivityPub Post. + * @param string $activity_type The Activity-Type. * * @return void */ public static function send_activity( Post $activitypub_post, $activity_type ) { // check if a migration is needed before sending new posts - \Activitypub\Migration::maybe_migrate(); + Migration::maybe_migrate(); // get latest version of post $user_id = $activitypub_post->get_post_author(); @@ -79,7 +81,7 @@ class Activity_Dispatcher { foreach ( $inboxes as $inbox ) { $activity = $activitypub_activity->to_json(); - \Activitypub\safe_remote_post( $inbox, $activity, $user_id ); + safe_remote_post( $inbox, $activity, $user_id ); } } } diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index f7885cd..9fed0c0 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -24,9 +24,40 @@ class Activitypub { \add_post_type_support( $post_type, 'activitypub' ); } - \add_action( 'transition_post_status', array( self::class, 'schedule_post_activity' ), 33, 3 ); \add_action( 'wp_trash_post', array( self::class, 'trash_post' ), 1 ); \add_action( 'untrash_post', array( self::class, 'untrash_post' ), 1 ); + + \add_action( 'init', array( self::class, 'add_rewrite_rules' ) ); + } + + /** + * Activation Hook + * + * @return void + */ + public static function activate() { + self::flush_rewrite_rules(); + + Scheduler::register_schedules(); + } + + /** + * Deactivation Hook + * + * @return void + */ + public static function deactivate() { + self::flush_rewrite_rules(); + + Scheduler::deregister_schedules(); + } + + /** + * Uninstall Hook + * + * @return void + */ + public static function uninstall() { } /** @@ -100,36 +131,6 @@ class Activitypub { return $vars; } - /** - * Schedule Activities. - * - * @param string $new_status New post status. - * @param string $old_status Old post status. - * @param WP_Post $post Post object. - */ - public static function schedule_post_activity( $new_status, $old_status, $post ) { - // Do not send activities if post is password protected. - if ( \post_password_required( $post ) ) { - return; - } - - // Check if post-type supports ActivityPub. - $post_types = \get_post_types_by_support( 'activitypub' ); - if ( ! \in_array( $post->post_type, $post_types, true ) ) { - return; - } - - $activitypub_post = new \Activitypub\Model\Post( $post ); - - if ( 'publish' === $new_status && 'publish' !== $old_status ) { - \wp_schedule_single_event( \time(), 'activitypub_send_create_activity', array( $activitypub_post ) ); - } elseif ( 'publish' === $new_status ) { - \wp_schedule_single_event( \time(), 'activitypub_send_update_activity', array( $activitypub_post ) ); - } elseif ( 'trash' === $new_status ) { - \wp_schedule_single_event( \time(), 'activitypub_send_delete_activity', array( $activitypub_post ) ); - } - } - /** * Replaces the default avatar. * @@ -148,7 +149,14 @@ class Activitypub { } $allowed_comment_types = \apply_filters( 'get_avatar_comment_types', array( 'comment' ) ); - if ( ! empty( $id_or_email->comment_type ) && ! \in_array( $id_or_email->comment_type, (array) $allowed_comment_types, true ) ) { + if ( + ! empty( $id_or_email->comment_type ) && + ! \in_array( + $id_or_email->comment_type, + (array) $allowed_comment_types, + true + ) + ) { $args['url'] = false; /** This filter is documented in wp-includes/link-template.php */ return \apply_filters( 'get_avatar_data', $args, $id_or_email ); @@ -193,7 +201,12 @@ class Activitypub { * @return void */ public static function trash_post( $post_id ) { - \add_post_meta( $post_id, 'activitypub_canonical_url', \get_permalink( $post_id ), true ); + \add_post_meta( + $post_id, + 'activitypub_canonical_url', + \get_permalink( $post_id ), + true + ); } /** @@ -206,4 +219,40 @@ class Activitypub { public static function untrash_post( $post_id ) { \delete_post_meta( $post_id, 'activitypub_canonical_url' ); } + + /** + * Add rewrite rules + */ + public static function add_rewrite_rules() { + if ( ! \class_exists( 'Webfinger' ) ) { + \add_rewrite_rule( + '^.well-known/webfinger', + 'index.php?rest_route=/activitypub/1.0/webfinger', + 'top' + ); + } + + if ( ! \class_exists( 'Nodeinfo' ) && true === (bool) \get_option( 'blog_public', 1 ) ) { + \add_rewrite_rule( + '^.well-known/nodeinfo', + 'index.php?rest_route=/activitypub/1.0/nodeinfo/discovery', + 'top' + ); + \add_rewrite_rule( + '^.well-known/x-nodeinfo2', + 'index.php?rest_route=/activitypub/1.0/nodeinfo2', + 'top' + ); + } + + \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); + } + + /** + * Flush rewrite rules; + */ + public static function flush_rewrite_rules() { + self::add_rewrite_rules(); + \flush_rewrite_rules(); + } } diff --git a/includes/class-migration.php b/includes/class-migration.php index 1e8c44b..7e7cd9a 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -1,9 +1,18 @@ upsert(); - $result = wp_set_object_terms( $user_id, $follower->get_actor(), self::TAXONOMY, true ); + $result = wp_set_object_terms( $user_id, $follower->get_actor(), Followers::TAXONOMY, true ); } } } diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php new file mode 100644 index 0000000..a79044e --- /dev/null +++ b/includes/class-scheduler.php @@ -0,0 +1,138 @@ +post_type, $post_types, true ) ) { + return; + } + + $activitypub_post = new Post( $post ); + + if ( 'publish' === $new_status && 'publish' !== $old_status ) { + \wp_schedule_single_event( + \time(), + 'activitypub_send_create_activity', + array( $activitypub_post ) + ); + } elseif ( 'publish' === $new_status ) { + \wp_schedule_single_event( + \time(), + 'activitypub_send_update_activity', + array( $activitypub_post ) + ); + } elseif ( 'trash' === $new_status ) { + \wp_schedule_single_event( + \time(), + 'activitypub_send_delete_activity', + array( $activitypub_post ) + ); + } + } + + /** + * Update followers + * + * @return void + */ + public static function update_followers() { + $followers = Followers::get_outdated_followers(); + + foreach ( $followers as $follower ) { + $meta = get_remote_metadata_by_actor( $follower->get_actor() ); + + if ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) { + $follower->set_error( $meta ); + } else { + $follower->from_meta( $meta ); + } + + $follower->update(); + } + } + + /** + * Cleanup followers + * + * @return void + */ + public static function cleanup_followers() { + $followers = Followers::get_faulty_followers(); + + foreach ( $followers as $follower ) { + $meta = get_remote_metadata_by_actor( $follower->get_actor() ); + + if ( is_tombstone( $meta ) ) { + $follower->delete(); + } elseif ( empty( $meta ) || ! is_array( $meta ) || is_wp_error( $meta ) ) { + if ( 5 <= $follower->count_errors() ) { + $follower->delete(); + } else { + $follower->set_error( $meta ); + $follower->update(); + } + } else { + $follower->reset_errors(); + } + } + } +} diff --git a/includes/class-shortcodes.php b/includes/class-shortcodes.php index 7289808..f56d483 100644 --- a/includes/class-shortcodes.php +++ b/includes/class-shortcodes.php @@ -3,14 +3,12 @@ namespace Activitypub; class Shortcodes { /** - * Class constructor, registering WordPress then shortcodes - * - * @param WP_Post $post A WordPress Post Object + * Initialize the class, registering WordPress hooks */ public static function init() { - foreach ( get_class_methods( 'Activitypub\Shortcodes' ) as $shortcode ) { + foreach ( get_class_methods( self::class ) as $shortcode ) { if ( 'init' !== $shortcode ) { - add_shortcode( 'ap_' . $shortcode, array( 'Activitypub\Shortcodes', $shortcode ) ); + add_shortcode( 'ap_' . $shortcode, array( self::class, $shortcode ) ); } } } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index c6c6223..8a271f6 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -143,7 +143,7 @@ class Followers { 'single' => true, 'sanitize_callback' => function( $value ) { if ( ! is_numeric( $value ) && (int) $value !== $value ) { - $value = strtotime( 'now' ); + $value = \time(); } return $value; @@ -186,7 +186,7 @@ class Followers { } /** - * Handles "Unfollow" requests + * Handle "Unfollow" requests * * @param array $object The JSON "Undo" Activity * @param int $user_id The ID of the ID of the WordPress User @@ -202,7 +202,7 @@ class Followers { } /** - * Add a new Follower + * Add new Follower * * @param int $user_id The ID of the WordPress User * @param string $actor The Actor URL @@ -316,7 +316,7 @@ class Followers { * * @return array The Term list of Followers, the format depends on $output */ - public static function get_followers( $user_id, $output = ARRAY_N, $number = null, $offset = null, $args = array() ) { + public static function get_followers( $user_id, $number = null, $offset = null, $args = array() ) { $defaults = array( 'taxonomy' => self::TAXONOMY, 'hide_empty' => false, @@ -329,25 +329,13 @@ class Followers { $args = wp_parse_args( $args, $defaults ); $terms = new WP_Term_Query( $args ); - $items = array(); - // change output format - switch ( $output ) { - case ACTIVITYPUB_OBJECT: - foreach ( $terms->get_terms() as $follower ) { - $items[] = new Follower( $follower->name ); // phpcs:ignore - } - return $items; - case OBJECT: - return $terms->get_terms(); - case ARRAY_N: - default: - foreach ( $terms->get_terms() as $follower ) { - $items[] = $follower->name; // phpcs:ignore - } - return $items; + foreach ( $terms->get_terms() as $follower ) { + $items[] = new Follower( $follower->name ); // phpcs:ignore } + + return $items; } /** @@ -404,4 +392,70 @@ class Followers { return array_filter( $results ); } + + /** + * Get all Followers that have not been updated for a given time + * + * @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT. + * @param int $number Limits the result. + * @param int $older_than The time in seconds. + * + * @return mixed The Term list of Followers, the format depends on $output. + */ + public static function get_outdated_followers( $number = 50, $older_than = 604800 ) { + $args = array( + 'taxonomy' => self::TAXONOMY, + 'number' => $number, + 'meta_key' => 'updated_at', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + 'meta_query' => array( + array( + 'key' => 'updated_at', + 'value' => time() - $older_than, + 'type' => 'numeric', + 'compare' => '<=', + ), + ), + ); + + $terms = new WP_Term_Query( $args ); + $items = array(); + + foreach ( $terms->get_terms() as $follower ) { + $items[] = new Follower( $follower->name ); // phpcs:ignore + } + + return $items; + } + + /** + * Get all Followers that had errors + * + * @param enum $output The output format, supported ARRAY_N, OBJECT and ACTIVITYPUB_OBJECT + * @param integer $number The number of Followers to return. + * + * @return mixed The Term list of Followers, the format depends on $output. + */ + public static function get_faulty_followers( $number = 10 ) { + $args = array( + 'taxonomy' => self::TAXONOMY, + 'number' => $number, + 'meta_query' => array( + array( + 'key' => 'errors', + 'compare' => 'EXISTS', + ), + ), + ); + + $terms = new WP_Term_Query( $args ); + $items = array(); + + foreach ( $terms->get_terms() as $follower ) { + $items[] = new Follower( $follower->name ); // phpcs:ignore + } + + return $items; + } } diff --git a/includes/debug.php b/includes/debug.php index a5683f4..d42b2a9 100644 --- a/includes/debug.php +++ b/includes/debug.php @@ -6,7 +6,8 @@ namespace Activitypub; * * @param array $r Array of HTTP request args. * @param string $url The request URL. - * @return array $args Array or string of HTTP request arguments. + * + * @return array Array or string of HTTP request arguments. */ function allow_localhost( $r, $url ) { $r['reject_unsafe_urls'] = false; diff --git a/includes/functions.php b/includes/functions.php index 9743b44..18e5c46 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -43,9 +43,9 @@ function safe_remote_get( $url ) { /** * Returns a users WebFinger "resource" * - * @param int $user_id + * @param int $user_id The User-ID. * - * @return string The user-resource + * @return string The User-Resource. */ function get_webfinger_resource( $user_id ) { return Webfinger::get_user_resource( $user_id ); @@ -113,29 +113,24 @@ function get_remote_metadata_by_actor( $actor ) { return $metadata; } -function get_identifier_settings( $user_id ) { - ?> -- - | -
-
|
-
---|