From 1eda885719fd4002bb3b23e275612d3573f53f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Menrath?= Date: Sun, 8 Dec 2024 17:38:05 +0100 Subject: [PATCH] Draft logic of storing Event Sources as custom post type --- .../collection/class-event-sources.php | 118 ++++++++++++--- .../activitypub/model/class-event-source.php | 142 ++++++++++++++++++ includes/admin/class-event-sources.php | 85 ----------- includes/admin/class-settings-page.php | 67 +++++++++ includes/class-event-sources.php | 104 +++++++++++++ includes/class-settings.php | 10 ++ includes/table/class-event-sources.php | 83 +++++----- templates/event-sources.php | 11 +- templates/settings.php | 2 +- 9 files changed, 463 insertions(+), 159 deletions(-) delete mode 100644 includes/admin/class-event-sources.php diff --git a/includes/activitypub/collection/class-event-sources.php b/includes/activitypub/collection/class-event-sources.php index 2d29b03..72d3c4c 100644 --- a/includes/activitypub/collection/class-event-sources.php +++ b/includes/activitypub/collection/class-event-sources.php @@ -22,7 +22,7 @@ class Event_Sources { /** * The custom post type. */ - const POST_TYPE = 'Event_Bridge_For_ActivityPub_follow'; + const POST_TYPE = 'ebap_event_source'; /** * Register the post type used to store the external event sources (i.e., followed ActivityPub actors). @@ -47,7 +47,7 @@ class Event_Sources { \register_post_meta( self::POST_TYPE, - 'activitypub_inbox', + 'activitypub_actor_id', array( 'type' => 'string', 'single' => true, @@ -71,18 +71,6 @@ class Event_Sources { ) ); - \register_post_meta( - self::POST_TYPE, - 'activitypub_user_id', - array( - 'type' => 'string', - 'single' => false, - 'sanitize_callback' => function ( $value ) { - return esc_sql( $value ); - }, - ) - ); - \register_post_meta( self::POST_TYPE, 'activitypub_actor_json', @@ -94,12 +82,36 @@ class Event_Sources { }, ) ); + + \register_post_meta( + self::POST_TYPE, + 'event_source_active', + array( + 'type' => 'bool', + 'single' => true, + ) + ); + + \register_post_meta( + self::POST_TYPE, + 'event_source_utilize_announces', + array( + 'type' => 'string', + 'single' => true, + 'sanitize_callback' => function ( $value ) { + if ( 'same_origin' === $value ) { + return 'same_origin'; + } + return ''; + }, + ) + ); } /** * Add new Event Source. * - * @param string $actor The Actor ID. + * @param string $actor The Actor URL/ID. * * @return Event_Source|WP_Error The Followed (WP_Post array) or an WP_Error. */ @@ -143,22 +155,78 @@ class Event_Sources { * * @return array The Term list of Event Sources. */ - public static function get_all_followers() { + public static function get_event_sources() { $args = array( - 'nopaging' => true, + 'post_type' => self::POST_TYPE, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'DESC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'AND', + 'meta_query' => array( array( - 'key' => 'activitypub_inbox', - 'compare' => 'EXISTS', - ), - array( - 'key' => 'activitypub_actor_json', + 'key' => 'activitypub_actor_id', 'compare' => 'EXISTS', ), ), ); - return self::get_event_sources( null, null, null, $args ); + + $query = new WP_Query( $args ); + + $event_sources = array_map( + function ( $post ) { + return Event_Source::init_from_cpt( $post ); + }, + $query->get_posts() + ); + + return $event_sources; + } + + /** + * Get the Event Sources along with a total count for pagination purposes. + * + * @param int $number Maximum number of results to return. + * @param int $page Page number. + * @param array $args The WP_Query arguments. + * + * @return array { + * Data about the followers. + * + * @type array $followers List of `Follower` objects. + * @type int $total Total number of followers. + * } + */ + public static function get_event_sources_with_count( $number = -1, $page = null, $args = array() ) { + $defaults = array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => $number, + 'paged' => $page, + 'orderby' => 'ID', + 'order' => 'DESC', + ); + + $args = wp_parse_args( $args, $defaults ); + $query = new WP_Query( $args ); + $total = $query->found_posts; + $actors = array_map( + function ( $post ) { + return Event_Source::init_from_cpt( $post ); + }, + $query->get_posts() + ); + + return compact( 'actors', 'total' ); + } + + /** + * Remove a Follower. + * + * @param string $event_source The Actor URL. + * + * @return mixed True on success, false on failure. + */ + public static function remove( $event_source ) { + $post_id = Event_Source::get_wp_post_from_activitypub_actor_id( $event_source ); + return wp_delete_post( $post_id, true ); } } diff --git a/includes/activitypub/model/class-event-source.php b/includes/activitypub/model/class-event-source.php index c063090..d73e2ba 100644 --- a/includes/activitypub/model/class-event-source.php +++ b/includes/activitypub/model/class-event-source.php @@ -9,6 +9,7 @@ namespace Event_Bridge_For_ActivityPub\ActivityPub\Model; use Activitypub\Activity\Actor; +use Activitypub\Webfinger; use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources; use WP_Error; @@ -16,6 +17,15 @@ use WP_Error; * Event-Source (=ActivityPub Actor that is followed) model. */ class Event_Source extends Actor { + const ACTIVITYPUB_USER_HANDLE_REGEXP = '(?:([A-Za-z0-9_.-]+)@((?:[A-Za-z0-9_-]+\.)+[A-Za-z]+))'; + + /** + * The complete remote ActivityPub profile of the Event Source. + * + * @var int + */ + protected $_id; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore + /** * Get the Icon URL (Avatar). * @@ -35,6 +45,24 @@ class Event_Source extends Actor { return $icon; } + /** + * Get the WordPress post which stores the Event Source by the ActivityPub actor id of the event source. + * + * @param string $actor_id The ActivityPub actor ID. + * @return ?int The WordPress post ID if the actor is found, null if not. + */ + public static function get_wp_post_from_activitypub_actor_id( $actor_id ) { + global $wpdb; + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s AND post_type=%s", + esc_sql( $actor_id ), + Event_Sources::POST_TYPE + ) + ); + return $post_id ? intval( $post_id ) : null; + } + /** * Convert a Custom-Post-Type input to an \Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source. * @@ -53,7 +81,121 @@ class Event_Source extends Actor { $object->set_summary( $post->post_excerpt ); $object->set_published( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_date ) ) ); $object->set_updated( gmdate( 'Y-m-d H:i:s', strtotime( $post->post_modified ) ) ); + $thumbnail_id = get_post_thumbnail_id( $post ); + if ( $thumbnail_id ) { + $object->set_icon( + array( + 'type' => 'Image', + 'url' => wp_get_attachment_image_url( $thumbnail_id, 'thumbnail', true ), + ) + ); + } return $object; } + + /** + * Validate the current Event Source ActivityPub actor object. + * + * @return boolean True if the verification was successful. + */ + public function is_valid() { + // The minimum required attributes. + $required_attributes = array( + 'id', + 'preferredUsername', + 'inbox', + 'publicKey', + 'publicKeyPem', + ); + + foreach ( $required_attributes as $attribute ) { + if ( ! $this->get( $attribute ) ) { + return false; + } + } + + return true; + } + + /** + * Save the current Event Source object to Database within custom post type. + * + * @return int|WP_Error The post ID or an WP_Error. + */ + public function save() { + if ( ! $this->is_valid() ) { + return new WP_Error( 'activitypub_invalid_follower', __( 'Invalid Follower', 'activitypub' ), array( 'status' => 400 ) ); + } + + if ( ! $this->get__id() ) { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $post_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT ID FROM $wpdb->posts WHERE guid=%s", + esc_sql( $this->get_id() ) + ) + ); + + if ( $post_id ) { + $post = get_post( $post_id ); + $this->set__id( $post->ID ); + } + } + + $post_id = $this->get__id(); + + $args = array( + 'ID' => $post_id, + 'guid' => esc_url_raw( $this->get_id() ), + 'post_title' => wp_strip_all_tags( sanitize_text_field( $this->get_name() ) ), + 'post_author' => 0, + 'post_type' => Event_Sources::POST_TYPE, + 'post_name' => esc_url_raw( $this->get_id() ), + 'post_excerpt' => sanitize_text_field( wp_kses( $this->get_summary(), 'user_description' ) ), + 'post_status' => 'publish', + 'meta_input' => $this->get_post_meta_input(), + ); + + if ( ! empty( $post_id ) ) { + // If this is an update, prevent the "added" date from being overwritten by the current date. + $post = get_post( $post_id ); + $args['post_date'] = $post->post_date; + $args['post_date_gmt'] = $post->post_date_gmt; + } + + $post_id = wp_insert_post( $args ); + $this->_id = $post_id; + + // Abort if inserting or updating the post didn't work. + if ( 0 === $post_id || is_wp_error( $post_id ) ) { + return $post_id; + } + + // Delete old icon. + // Check if the post has a thumbnail. + $thumbnail_id = get_post_thumbnail_id( $post_id ); + + if ( $thumbnail_id ) { + // Remove the thumbnail from the post. + delete_post_thumbnail( $post_id ); + + // Delete the attachment (and its files) from the media library. + wp_delete_attachment( $thumbnail_id, true ); + } + + // Set new icon. + $icon = $this->get_icon(); + + if ( isset( $icon['url'] ) ) { + $image = media_sideload_image( $icon['url'], $post_id, null, 'id' ); + } + if ( isset( $image ) && ! is_wp_error( $image ) ) { + set_post_thumbnail( $post_id, $image ); + } + + return $post_id; + } } diff --git a/includes/admin/class-event-sources.php b/includes/admin/class-event-sources.php deleted file mode 100644 index bf14c3a..0000000 --- a/includes/admin/class-event-sources.php +++ /dev/null @@ -1,85 +0,0 @@ -retrieve(); - - wp_send_json_success(); - } -} diff --git a/includes/admin/class-settings-page.php b/includes/admin/class-settings-page.php index f53d379..a81252f 100644 --- a/includes/admin/class-settings-page.php +++ b/includes/admin/class-settings-page.php @@ -14,6 +14,10 @@ namespace Event_Bridge_For_ActivityPub\Admin; // Exit if accessed directly. defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore +use Activitypub\Webfinger; +use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source; +use Event_Bridge_For_ActivityPub\Event_Sources; +use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Source_Collection; use Event_Bridge_For_ActivityPub\Integrations\Event_Plugin; use Event_Bridge_For_ActivityPub\Setup; @@ -35,6 +39,10 @@ class Settings_Page { * @return void */ public static function admin_menu(): void { + add_action( + 'admin_init', + array( self::STATIC, 'maybe_add_event_source' ), + ); \add_options_page( 'Event Bridge for ActivityPub', __( 'Event Bridge for ActivityPub', 'event-bridge-for-activitypub' ), @@ -44,6 +52,65 @@ class Settings_Page { ); } + /** + * Checks whether the current request wants to add an event source (ActivityPub follow) and passed on to actual handler. + */ + public static function maybe_add_event_source() { + if ( ! isset( $_POST['event_bridge_for_activitypub_event_source'] ) ) { + return; + } + + // Check and verify request and check capabilities. + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'event-bridge-for-activitypub-event-sources-options' ) ) { + return; + } + + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $event_source = sanitize_text_field( $_POST['event_bridge_for_activitypub_event_source'] ); + + $actor_url = false; + + $url = wp_parse_url( $event_source ); + + if ( isset( $url['path'] ) && isset( $url['host'] ) && isset( $url['scheme'] ) ) { + $actor_url = $event_source; + } else { + if ( preg_match( '/^@?' . Event_Source::ACTIVITYPUB_USER_HANDLE_REGEXP . '$/i', $event_source ) ) { + $actor_url = Webfinger::resolve( $event_source ); + if ( is_wp_error( $actor_url ) ) { + return; + } + } else { + if ( ! isset( $url['path'] ) && isset( $url['host'] ) ) { + $actor_url = Event_Sources::get_application_actor( $url['host'] ); + } + if ( self::is_domain( $event_source ) ) { + $actor_url = Event_Sources::get_application_actor( $event_source ); + } + } + } + + if ( ! $actor_url ) { + return; + } + + Event_Source_Collection::add_event_source( $actor_url ); + } + + /** + * Check if a string is a valid domain name. + * + * @param string $domain The input string which might be a domain. + * @return bool + */ + private static function is_domain( $domain ): bool { + $pattern = '/^(?!\-)(?:(?:[a-zA-Z\d](?:[a-zA-Z\d\-]{0,61}[a-zA-Z\d])?)\.)+(?!\d+$)[a-zA-Z\d]{2,63}$/'; + return 1 === preg_match( $pattern, $domain ); + } + /** * Adds Link to the settings page in the plugin page. * It's called via apply_filter('plugin_action_links_' . PLUGIN_NAME). diff --git a/includes/class-event-sources.php b/includes/class-event-sources.php index 6b5c3b6..c5ea476 100644 --- a/includes/class-event-sources.php +++ b/includes/class-event-sources.php @@ -7,9 +7,12 @@ namespace Event_Bridge_For_ActivityPub; +use Activitypub\Activity\Extended_Object\Event; +use Activitypub\Collection\Actors; use Activitypub\Http; use Exception; +use function Activitypub\get_remote_metadata_by_actor; use function register_post_type; /** @@ -23,6 +26,15 @@ class Event_Sources { */ const POST_TYPE = 'event_bridge_follow'; + /** + * Constructor. + */ + public function __construct() { + \add_action( 'init', array( $this, 'register_post_meta' ) ); + + \add_action( 'activitypub_inbox', array( $this, 'handle_activitypub_inbox' ), 15, 3 ); + } + /** * Register the post type used to store the external event sources (i.e., followed ActivityPub actors). */ @@ -94,4 +106,96 @@ class Event_Sources { ) ); } + + /** + * Handle the ActivityPub Inbox. + * + * @param array $data The raw post data JSON object as an associative array. + * @param int $user_id The target user id. + * @param string $type The activity type. + */ + public static function handle_activitypub_inbox( $data, $user_id, $type ) { + if ( Actors::APPLICATION_USER_ID !== $user_id ) { + return; + } + + if ( ! in_array( $type, array( 'Create', 'Delete', 'Announce', 'Undo Announce' ), true ) ) { + return; + } + + $event = Event::init_from_array( $data ); + return $event; + } + + /** + * Get metadata of ActivityPub Actor by ID/URL. + * + * @param string $url The URL or ID of the ActivityPub actor. + */ + public static function get_metadata( $url ) { + if ( ! is_string( $url ) ) { + return array(); + } + + if ( false !== strpos( $url, '@' ) ) { + if ( false === strpos( $url, '/' ) && preg_match( '#^https?://#', $url, $m ) ) { + $url = substr( $url, strlen( $m[0] ) ); + } + } + return get_remote_metadata_by_actor( $url ); + } + + /** + * Respond to the Ajax request to fetch feeds + */ + public function ajax_fetch_events() { + if ( ! isset( $_POST['Event_Bridge_For_ActivityPub'] ) ) { + wp_send_json_error( 'missing-parameters' ); + } + + check_ajax_referer( 'fetch-events-' . sanitize_user( wp_unslash( $_POST['actor'] ) ) ); + + $actor = Actors::get_by_resource( sanitize_user( wp_unslash( $_POST['actor'] ) ) ); + if ( ! $actor ) { + wp_send_json_error( 'unknown-actor' ); + } + + $actor->retrieve(); + + wp_send_json_success(); + } + + /** + * Get the Application actor via FEP-2677. + * + * @param string $domain The domain without scheme. + * @return bool|string The URL/ID of the application actor, false if not found. + */ + public static function get_application_actor( $domain ) { + $result = wp_remote_get( 'https://' . $domain . '/.well-known/nodeinfo' ); + + if ( is_wp_error( $result ) ) { + return false; + } + + $body = wp_remote_retrieve_body( $result ); + + $nodeinfo = json_decode( $body, true ); + + // Check if 'links' exists and is an array. + if ( isset( $nodeinfo['links'] ) && is_array( $nodeinfo['links'] ) ) { + foreach ( $nodeinfo['links'] as $link ) { + // Check if this link matches the application actor rel. + if ( isset( $link['rel'] ) && 'https://www.w3.org/ns/activitystreams#Application' === $link['rel'] ) { + if ( is_string( $link['href'] ) ) { + return $link['href']; + } + break; + } + } + } + + // Return false if no application actor is found. + return false; + } } diff --git a/includes/class-settings.php b/includes/class-settings.php index 3b15a2c..b895e2a 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -93,6 +93,16 @@ class Settings { 'default' => EVENT_BRIDGE_FOR_ACTIVITYPUB_CUSTOM_SUMMARY, ) ); + + \register_setting( + 'event-bridge-for-activitypub-event-sources', + 'event_bridge_for_activitypub_event_sources_active', + array( + 'type' => 'boolean', + 'description' => \__( 'Whether the event sources feature is activated.', 'event-bridge-for-activitypub' ), + 'default' => 1, + ) + ); } /** diff --git a/includes/table/class-event-sources.php b/includes/table/class-event-sources.php index 200aebb..32e6794 100644 --- a/includes/table/class-event-sources.php +++ b/includes/table/class-event-sources.php @@ -8,8 +8,7 @@ namespace Event_Bridge_For_ActivityPub\Table; use WP_List_Table; -use Activitypub\Collection\Followers as FollowerCollection; -use Event_Bridge_For_ActivityPub\ActivityPub\Model\Event_Source; +use Event_Bridge_For_ActivityPub\ActivityPub\Collection\Event_Sources as Event_Sources_Collection; use function Activitypub\object_to_uri; @@ -18,7 +17,7 @@ if ( ! \class_exists( '\WP_List_Table' ) ) { } /** - * Followers Table-Class. + * Event Sources Table-Class. */ class Event_Sources extends WP_List_Table { /** @@ -42,9 +41,9 @@ class Event_Sources extends WP_List_Table { public function get_columns() { return array( 'cb' => '', - 'avatar' => \__( 'Avatar', 'event-bridge-for-activitypub' ), - 'post_title' => \__( 'Name', 'event-bridge-for-activitypub' ), - 'username' => \__( 'Username', 'event-bridge-for-activitypub' ), + 'icon' => \__( 'Icon', 'event-bridge-for-activitypub' ), + 'name' => \__( 'Name', 'event-bridge-for-activitypub' ), + 'active' => \__( 'Active', 'event-bridge-for-activitypub' ), 'url' => \__( 'URL', 'event-bridge-for-activitypub' ), 'published' => \__( 'Followed', 'event-bridge-for-activitypub' ), 'modified' => \__( 'Last updated', 'event-bridge-for-activitypub' ), @@ -58,7 +57,7 @@ class Event_Sources extends WP_List_Table { */ public function get_sortable_columns() { return array( - 'post_title' => array( 'post_title', true ), + 'name' => array( 'name', true ), 'modified' => array( 'modified', false ), 'published' => array( 'published', false ), ); @@ -96,22 +95,7 @@ class Event_Sources extends WP_List_Table { } // phpcs:enable WordPress.Security.NonceVerification.Recommended - $dummy_event_sources = array( - 'total' => 1, - 'actors' => array( - Event_Source::init_from_array( - array( - 'id' => 'https://graz.social/@linos', - 'url' => 'https://graz.social/@linos', - 'preferredUsername' => 'linos', - 'name' => 'André Menrath', - 'icon' => 'https://graz.social/system/accounts/avatars/000/000/001/original/fe1c795256720361.jpeg', - ) - ), - ), - ); - - $event_sources = $dummy_event_sources; + $event_sources = Event_Sources_Collection::get_event_sources_with_count($per_page, $page_num, $args ); $actors = $event_sources['actors']; $counter = $event_sources['total']; @@ -127,9 +111,9 @@ class Event_Sources extends WP_List_Table { foreach ( $actors as $actor ) { $item = array( 'icon' => esc_attr( $actor->get_icon_url() ), - 'post_title' => esc_attr( $actor->get_name() ), - 'username' => esc_attr( $actor->get_preferred_username() ), - 'url' => esc_attr( object_to_uri( $actor->get_url() ) ), + 'name' => esc_attr( $actor->get_name() ), + 'url' => esc_attr( object_to_uri( $actor->get_id() ) ), + 'active' => esc_attr( get_post_meta( $actor->get__id(), 'event_source_active', true) ), 'identifier' => esc_attr( $actor->get_id() ), 'published' => esc_attr( $actor->get_published() ), 'modified' => esc_attr( $actor->get_updated() ), @@ -170,7 +154,7 @@ class Event_Sources extends WP_List_Table { * @param array $item Item. * @return string */ - public function column_avatar( $item ) { + public function column_icon( $item ) { return sprintf( '', $item['icon'] @@ -198,14 +182,32 @@ class Event_Sources extends WP_List_Table { * @return string */ public function column_cb( $item ) { - return sprintf( '', esc_attr( $item['identifier'] ) ); + return sprintf( '', esc_attr( $item['identifier'] ) ); + } + + /** + * Column action. + * + * @param array $item Item. + * @return string + */ + public function column_active( $item ) { + if ( $item['active'] ) { + $action = 'true'; + } else { + $action = 'false'; + } + return sprintf( + '%s', + $action + ); } /** * Process action. */ public function process_action() { - if ( ! isset( $_REQUEST['followers'] ) || ! isset( $_REQUEST['_wpnonce'] ) ) { + if ( ! isset( $_REQUEST['event_sources'] ) || ! isset( $_REQUEST['_wpnonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ); @@ -213,28 +215,19 @@ class Event_Sources extends WP_List_Table { return; } - if ( ! current_user_can( 'edit_user', $this->user_id ) ) { + if ( ! current_user_can( 'manage_options' ) ) { return; } - $followers = $_REQUEST['followers']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $event_sources = $_REQUEST['event_sources']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput - if ( $this->current_action() === 'delete' ) { - if ( ! is_array( $followers ) ) { - $followers = array( $followers ); + if ( 'delete' === $this->current_action() ) { + if ( ! is_array( $event_sources ) ) { + $event_sources = array( $event_sources ); } - foreach ( $followers as $follower ) { - Event_Source::remove( $this->user_id, $follower ); + foreach ( $event_sources as $event_source ) { + Event_Sources_Collection::remove( $event_source ); } } } - - /** - * Returns user count. - * - * @return int - */ - public function get_user_count() { - return FollowerCollection::count_followers( $this->user_id ); - } } diff --git a/templates/event-sources.php b/templates/event-sources.php index 72e2fdc..c62912a 100644 --- a/templates/event-sources.php +++ b/templates/event-sources.php @@ -34,14 +34,19 @@ $table = new \Event_Bridge_For_ActivityPub\Table\Event_Sources();
- - + + prepare_items(); $table->search_box( 'Search', 'search' ); diff --git a/templates/settings.php b/templates/settings.php index 2c9c077..7ddfa95 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -144,7 +144,7 @@ $current_category_mapping = \get_option( 'event_bridge_for_activitypub_ev
-