You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
618 lines
20 KiB
618 lines
20 KiB
<?php |
|
/** |
|
* Wrapper for fetching data from mobilizon instances |
|
* |
|
* @link https://graz.social/@linos |
|
* @since 1.0.0 |
|
* |
|
* @package Mobilizon_Mirror |
|
* @subpackage Mobilizon_Mirror/includes |
|
*/ |
|
|
|
/** |
|
* |
|
* This class defines all code necessary to fetch data from mobilizon |
|
* |
|
* @since 1.0.0 |
|
* @package Mobilizon_Mirror |
|
* @subpackage Mobilizon_Mirror/includes |
|
* @author André Menrath <andre.menrath@posteo.de> |
|
*/ |
|
class Mobilizon_Mirror_API { |
|
/** |
|
* The ID of this plugin. |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @var string $plugin_name The ID of this plugin. |
|
*/ |
|
private $plugin_name; |
|
|
|
/** |
|
* Initialize the class and set its properties. |
|
* |
|
* @since 1.0.0 |
|
* @param string $plugin_name The name of this plugin. |
|
*/ |
|
public function __construct( $plugin_name ) { |
|
$this->plugin_name = $plugin_name; |
|
} |
|
|
|
/** |
|
* Adds https:// protokoll if no url scheme is present |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param string $url The URL which is processed. |
|
*/ |
|
private function addhttp( $url ) { |
|
if ( ! preg_match( '~^(?:f|ht)tps?://~i', $url ) ) { |
|
$url = 'https://' . $url; |
|
} |
|
return $url; |
|
} |
|
|
|
/** |
|
* Adds https:// protokoll if no url scheme is present |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param string $base_url the domain/hostname/url of the mobilizon instance. |
|
* @param string $query the graphql query string see https://framagit.org/framasoft/mobilizon/-/blob/master/schema.graphql. |
|
*/ |
|
private function do_mobilizon_query( $base_url, $query ) { |
|
// Get API-endpoint from Instance URL. |
|
$base_url = rtrim( $base_url, '/' ); |
|
$url_array = array( $base_url, 'api' ); |
|
$endpoint = implode( '/', $url_array ); |
|
$endpoint = $this->addhttp( $endpoint ); |
|
|
|
// Define default GraphQL headers. |
|
$headers = array( 'Content-Type: application/json', 'User-Agent: Minimal GraphQL client' ); |
|
$body = array( 'query' => $query ); |
|
$args = array( |
|
'body' => $body, |
|
'headers' => $headers, |
|
); |
|
|
|
// Send HTTP-Query and return the response. |
|
return( wp_remote_post( $endpoint, $args ) ); |
|
} |
|
|
|
/** |
|
* Inserts or updates a new post of post type mobilizon_events |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param array $event Contains all the WordPress default and metadata fields for a post. |
|
* @return mixed $post_id Is the post_id as int on success, else WP_Error or 0. |
|
*/ |
|
private function insert_or_update_mobilizon_event_as_post( $event ) { |
|
require_once ABSPATH . 'wp-admin/includes/media.php'; |
|
require_once ABSPATH . 'wp-admin/includes/file.php'; |
|
require_once ABSPATH . 'wp-admin/includes/image.php'; |
|
$my_post = array( |
|
'post_title' => $event['title'], |
|
'post_date_gmt' => str_replace( 'T', ' ', str_replace( 'Z', '', $event['insertedAt'] ) ), |
|
'post_modified_gmt' => str_replace( 'T', ' ', str_replace( 'Z', '', $event['updatedAt'] ) ), |
|
'post_content' => $event['description'], |
|
'post_status' => 'publish', |
|
'post_type' => 'mobilizon_event', |
|
'post_status' => ( 'PUBLIC' === $event['visibility'] ) ? 'publish' : 'private', |
|
); |
|
|
|
if ( isset( $event['post_id'] ) ) { |
|
// In this case it is an updated event from Mobilizon. |
|
$my_post['ID'] = $event['post_id']; |
|
// Update the post! |
|
$post_id = wp_update_post( $my_post ); |
|
} else { |
|
// Else it is a new Event from Mobilizon. Save it locally. |
|
$post_id = wp_insert_post( $my_post ); |
|
} |
|
|
|
// Abort if inserting or updating the post didn't work. |
|
if ( 0 === $post_id || is_wp_error( $post_id ) ) { |
|
return $post_id; |
|
} |
|
|
|
// Set Mobilizon tags as WordPress post tags |
|
wp_set_post_tags( $post_id, array_column( $event['tags'], 'title' ) ); |
|
|
|
// Set the federated group name of Mobilizon as term |
|
wp_set_object_terms( $post_id, $event['attributedTo']['preferredUsername'], 'mobilizon_group'); |
|
|
|
/** |
|
* Some notes on the bedingsOn and endsOn keys: |
|
* |
|
* The `DateTime` scalar type represents a date and time in the UTC |
|
* timezone. The DateTime appears in a JSON response as an ISO8601 formatted |
|
* string, including UTC timezone ("Z"). The parsed date and time string will |
|
* be converted to UTC if there is an offset. |
|
*/ |
|
update_post_meta( $post_id, 'beginsOn', $event['beginsOn'] ); |
|
update_post_meta( $post_id, 'endsOn', $event['endsOn'] ); |
|
update_post_meta( $post_id, 'onlineAddress', $event['onlineAddress'] ); |
|
update_post_meta( $post_id, 'organizerName', ( isset( $event['organizerActor']['name'] ) ) ? $event['organizerActor']['name'] : $event['attributedTo']['name'] ); |
|
update_post_meta( $post_id, 'organizerURL', ( isset( $event['organizerActor']['url'] ) ) ? $event['organizerActor']['url'] : $event['attributedTo']['url'] ); |
|
update_post_meta( $post_id, 'street', ( isset( $event['physicalAddress']['street'] ) ) ? $event['physicalAddress']['street'] : '' ); |
|
update_post_meta( $post_id, 'place', ( isset( $event['physicalAddress']['description'] ) ) ? $event['physicalAddress']['description'] : '' ); |
|
update_post_meta( $post_id, 'city', ( isset( $event['physicalAddress']['locality'] ) ) ? $event['physicalAddress']['locality'] : '' ); |
|
update_post_meta( $post_id, 'postalCode', ( isset( $event['physicalAddress']['postalCode'] ) ) ? $event['physicalAddress']['postalCode'] : '' ); |
|
update_post_meta( $post_id, 'updatedAt', $event['updatedAt'] ); |
|
update_post_meta( $post_id, 'url', $event['url'] ); |
|
update_post_meta( $post_id, 'uuid', $event['uuid'] ); |
|
update_post_meta( $post_id, 'status', $event['status'] ); |
|
if ( isset( $event['picture']['url'] ) ) { |
|
$image = media_sideload_image( $event['picture']['url'], $post_id, $event['picture']['alt'], 'id' ); |
|
if ( ! is_wp_error( $image ) ) { |
|
set_post_thumbnail( $post_id, $image ); |
|
} |
|
} |
|
return $post_id; |
|
} |
|
|
|
/** |
|
* Sets up a query to fetch an list of events from the set mobilizon server, not the details yet |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
*/ |
|
private function mobilizon_set_up_event_list_query() { |
|
$group_names = get_option( $this->plugin_name )['group_names']; |
|
$date_now = gmdate( 'c' ); |
|
$query = 'query {'; |
|
foreach ( $group_names as $group_name ) { |
|
$query .= "${group_name}: group(preferredUsername: \"${group_name}\") { |
|
organizedEvents(afterDatetime: \"{$date_now}\") { |
|
elements { |
|
uuid, |
|
updatedAt, |
|
} |
|
} |
|
}"; |
|
} |
|
$query .= '}'; |
|
return $query; |
|
} |
|
|
|
/** |
|
* Sanitizes a mobilizon event (don't trust any third party server) |
|
* And perform some checks on the data as well! |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param array $array Contains the event infomrations. |
|
* @return array $array Sanitzed version. |
|
*/ |
|
private function recursive_sanitize_event( $array ) { |
|
foreach ( $array as $key => &$value ) { |
|
if ( is_array( $value ) ) { |
|
$value = $this->recursive_sanitize_event( $value ); |
|
} else { |
|
if ( 'url' === $key || 'onlineAddress' === $key ) { |
|
$value = esc_url_raw( $value ); |
|
} elseif ( 'description' === $key ) { |
|
$value = wp_kses_post( $value ); |
|
} else { |
|
$value = sanitize_text_field( $value ); |
|
} |
|
} |
|
} |
|
return $array; |
|
} |
|
|
|
/** |
|
* Checks and sanitizes mobilizon event (don't trust any third party server) |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param mixed $event Raw version. |
|
* @return mixed $event Event is returned if checks are passed, false if not! |
|
*/ |
|
private function check_and_sanitize_mobilizon_event( $event ) { |
|
// Check the responded event from the moblizon server. |
|
if ( ! is_array( $event ) ) { |
|
return false; |
|
} |
|
|
|
// Check if all the requeset fields are set. |
|
$required_fields = array( |
|
'uuid', |
|
'updatedAt', |
|
'insertedAt', |
|
'title', |
|
'attributedTo', |
|
'url', |
|
'beginsOn', |
|
'status', |
|
'visibility', |
|
'tags', |
|
); |
|
foreach ( $required_fields as $required_field ) { |
|
if ( ! isset( $event[ $required_field ] ) ) { |
|
log( 'Could not mirror event because field ' . $required_field . ' was missing' ); |
|
return false; |
|
} |
|
} |
|
// Check if uuid is a valid v4 UUID. |
|
$uuid_v4 = '/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i'; |
|
if ( ! preg_match( $uuid_v4, $event['uuid'] ) ) { |
|
return false; |
|
} |
|
|
|
// TODO: check the dates and maybe already process them here from "2022-01-23T08:39:12Z" format to "2022-01-23 08:39:12" at the moment it is done in funciton insert_or_update_mobilizon_event_as_post! |
|
|
|
// Check the tags! |
|
if ( ! isset( $event['tags'] ) || ! is_array( $event['tags'] ) ) { |
|
return false; |
|
} |
|
foreach ( $event['tags'] as $tag ) { |
|
if ( ! isset( $tag['title'] ) ) { |
|
return false; |
|
} |
|
} |
|
|
|
// Sanitize everything! |
|
$event = $this->recursive_sanitize_event( $event ); |
|
|
|
return $event; |
|
} |
|
|
|
/** |
|
* Gets an event and all its details from an mobilizon server by the events uuid |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param string $uuid See definition at https://de.wikipedia.org/wiki/Universally_Unique_Identifier. |
|
*/ |
|
private function get_mobilizon_event( $uuid ) { |
|
$query = "query { |
|
event(uuid: \"${uuid}\") { |
|
uuid, |
|
insertedAt, |
|
updatedAt, |
|
title, |
|
organizerActor { |
|
name, |
|
url |
|
}, |
|
attributedTo { |
|
name, |
|
url, |
|
preferredUsername |
|
}, |
|
url, |
|
beginsOn, |
|
endsOn, |
|
description, |
|
onlineAddress, |
|
status, |
|
visibility, |
|
picture { |
|
url, |
|
alt |
|
}, |
|
tags { |
|
slug, |
|
title |
|
}, |
|
physicalAddress { |
|
street, |
|
description, |
|
locality, |
|
postalCode |
|
} |
|
} |
|
}"; |
|
|
|
// Execute the event query to the mobilizon instance. |
|
$response = $this->do_mobilizon_query( esc_url( get_option( $this->plugin_name )['instance_url'] ), $query ); |
|
|
|
// Check if the HTTP-Query was successful. |
|
if ( wp_remote_retrieve_response_code( $response ) !== 200 ) { |
|
return false; |
|
} |
|
|
|
$event = json_decode( wp_remote_retrieve_body( $response ), true )['data']['event']; |
|
$event = $this->check_and_sanitize_mobilizon_event( $event ); |
|
return $event; |
|
} |
|
|
|
/** |
|
* Gets a list of all local posts of post_type 'mobilizon_event' |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @return array keys: 'uuid', values: subarray with keys 'udpatedAt' and 'post_id' |
|
*/ |
|
private function get_local_mobilizon_events() { |
|
$args = array( |
|
'numberposts' => -1, |
|
'post_type' => 'mobilizon_event', |
|
); |
|
$the_query = new WP_Query( $args ); |
|
$local_events = array(); |
|
|
|
if ( $the_query->have_posts() ) : |
|
while ( $the_query->have_posts() ) : |
|
$the_query->the_post(); |
|
$local_events[ get_post_meta( get_the_ID(), 'uuid', true ) ] = |
|
array( |
|
'updatedAt' => get_post_meta( get_the_ID(), 'updatedAt', true ), // TODO Replace this with post_modified. |
|
'post_id' => get_the_ID(), |
|
); |
|
endwhile; |
|
endif; |
|
|
|
wp_reset_postdata(); |
|
return $local_events; |
|
} |
|
|
|
/** |
|
* This functions validates the response scheme from the mobilizon event query |
|
* |
|
* It should like like this, for example: |
|
* { |
|
* "data": { |
|
* "groupname1": { |
|
* "organizedEvents": { |
|
* "elements": [ |
|
* { |
|
* "updatedAt": "2021-12-29T08:36:27Z", |
|
* "uuid": "6e76dcf9-1618-4728-bc05-e572503156c0" |
|
* }, |
|
* { |
|
* "updatedAt": "2022-01-22T09:58:28Z", |
|
* "uuid": "02560cac-9524-46c1-95c6-aae18c984004" |
|
* } |
|
* ] |
|
* } |
|
* }, |
|
* "groupname2": { |
|
* "organizedEvents": { |
|
* "elements": [ |
|
* { |
|
* "updatedAt": "2022-01-04T09:03:19Z", |
|
* "uuid": "e2b1e563-18ba-4fdf-8643-0a08638dc7fe" |
|
* } |
|
* ] |
|
* } |
|
* } |
|
* } |
|
* }' |
|
* |
|
* @since 1.1.0 |
|
* @access private |
|
* @param mixed $value the decoded json response |
|
* @return boolean If the validation passed or not. |
|
*/ |
|
function validate_mobilizon_event_list_response ( $value ) { |
|
$group_names_regex = implode( '|', get_option( $this->plugin_name )['group_names'] ); |
|
$args = array( |
|
'type' => 'object', |
|
'additionalProperties' => false, |
|
'required' => true, |
|
'properties' => array( |
|
'data' => array( |
|
'type' => 'object', |
|
'additionalProperties' => false, |
|
'required' => true, |
|
'patternProperties' => array( |
|
$group_names_regex => array( |
|
'type' => 'object', |
|
'additionalProperties' => false, |
|
'required' => true, |
|
'properties' => array( |
|
'organizedEvents' => array( |
|
'type' => 'object', |
|
'additionalProperties' => false, |
|
'required' => true, |
|
'properties' => array( |
|
'elements' => array( |
|
'type' => array( |
|
'type' => 'array', |
|
'items' => array( |
|
'type' => 'object', |
|
'properties' => array( |
|
'updatedAt' => array( |
|
'type' => 'string', |
|
'format' => 'date-time', |
|
'required' => true, |
|
), |
|
'uuid' => array( |
|
'type' => 'string', |
|
'format' => 'uuid', |
|
'required' => true, |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
), |
|
); |
|
$param = "Mobilizon response with the event list is not valid!"; |
|
return rest_validate_value_from_schema( $value, $args, $param ); |
|
} |
|
|
|
/** |
|
* Gets a list of all remote events from the mobilizon server and group as set up in the options. |
|
* |
|
* The value 'updatedAt' represents a date and time in the UTCtimezone. |
|
* It is an ISO8601 formatted string, including UTC timezone ("Z"). |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @return array keys: 'uuid', values: 'updatedAt' |
|
*/ |
|
private function get_remote_mobilizon_events() { |
|
// Get the query for all events (uuid, updatedAT). |
|
$query = $this->mobilizon_set_up_event_list_query(); |
|
|
|
// Execute the event query to the mobilizon instance. |
|
$response = $this->do_mobilizon_query( esc_url( get_option( $this->plugin_name )['instance_url'] ), $query ); |
|
|
|
// Check if the HTTP-Query was successful. |
|
if ( wp_remote_retrieve_response_code( $response ) !== 200 ) { |
|
return false; |
|
} |
|
|
|
// Extract the events as an array from the query's response body. |
|
$body = json_decode( wp_remote_retrieve_body( $response ), true, 7 ); |
|
|
|
// Validate response we got, its a remote server: don't trust it! |
|
if ( is_wp_error( $this->validate_mobilizon_event_list_response( $body ) ) ) { |
|
return false; |
|
} |
|
|
|
// The response is still nested multiple times, but at this state we don't need to proccess by group. |
|
$uuid_list = []; |
|
$updated_list = []; |
|
foreach ( $body['data'] as $events_per_group ) { |
|
$uuid_list = array_merge( array_column( $events_per_group['organizedEvents']['elements'], 'uuid' ), $uuid_list ); |
|
$updated_list = array_merge( array_column( $events_per_group['organizedEvents']['elements'], 'updatedAt' ), $updated_list ); |
|
} |
|
|
|
// Remote Event List by uuid as key: |
|
// The uuid serves as the array key, the updatedAt's are directly in the values! |
|
return array_combine( $uuid_list, $updated_list ); |
|
} |
|
|
|
/** |
|
* Compares list of remote event with list of local events and insert, update or delete events local ones |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param array $local local_events. |
|
* @param array $remote remote_events. |
|
*/ |
|
private function update_mobilizon_events( $local, $remote ) { |
|
require_once ABSPATH . 'wp-admin/includes/media.php'; |
|
require_once ABSPATH . 'wp-admin/includes/file.php'; |
|
require_once ABSPATH . 'wp-admin/includes/image.php'; |
|
|
|
// Get events which uuids are not in remote but on local. |
|
$to_delete = array_keys( array_diff_key( $local, $remote ) ); |
|
|
|
// Delete this events from WordPress. |
|
foreach ( $to_delete as $uuid ) { |
|
$post_id = $local[ $uuid ]['post_id']; |
|
wp_delete_attachment( get_post_thumbnail_id( $local[ $uuid ]['post_id'] ), true ); |
|
if ( wp_delete_post( $post_id, true ) ) { |
|
unset( $local [ $uuid ] ); |
|
} else { |
|
// Maybe the transient with the local mirrored events got currupted |
|
// This should not happen anyway... |
|
delete_transient( 'mobilizon_mirror_cached_events_list' ); |
|
log( 'Mobilizon Mirror failed to delete an event, please report. ID: ' . $post_id . ', uuid: ' . $uuid ); |
|
} |
|
} |
|
|
|
// Get events which uuids are in remote but not in local. |
|
$to_add = array_keys( array_diff_key( $remote, $local ) ); |
|
// Insert the new events locally in wordress. |
|
foreach ( $to_add as $uuid ) { |
|
// Fetch event details from the remote mobilizon server, = false when no error. |
|
$event_details = $this->get_mobilizon_event( $uuid ); |
|
// Skip this event, if there was an error getting it from remote. |
|
if ( ! $event_details ) { |
|
continue; |
|
} |
|
// If there was success go ahead an insert it locally. |
|
$post_id = $this->insert_or_update_mobilizon_event_as_post( $event_details ); |
|
if ( 0 !== $post_id && ! is_wp_error( $post_id ) ) { |
|
// Update the local event list, if the insertion was successfull. |
|
$local[ $uuid ] = array( |
|
'post_id' => $post_id, |
|
'updatedAt' => $remote[ $uuid ], |
|
); |
|
} |
|
} |
|
|
|
// Get events with same uuids but different updatedAt values. |
|
$to_update = array_keys( array_diff_assoc( array_intersect_key( $remote, $local ), array_combine( array_keys( $local ), array_column( $local, 'updatedAt' ) ) ) ); |
|
// Update the local events. |
|
foreach ( $to_update as $uuid ) { |
|
// Attachment will also be fetched again, so delete the old one! |
|
wp_delete_attachment( get_post_thumbnail_id( $local[ $uuid ]['post_id'] ), true ); |
|
// Fetch event details again from the remote mobilizon server. |
|
$event_details = $this->get_mobilizon_event( $uuid ); |
|
// Skip this event, if there was an error getting it from remote. |
|
if ( ! $event_details ) { |
|
continue; |
|
} |
|
// Insert the existing local post id, so that we update in the next step. |
|
$event_details['post_id'] = $local[ $uuid ]['post_id']; |
|
$post_id = $this->insert_or_update_mobilizon_event_as_post( $event_details ); |
|
|
|
if ( 0 !== $post_id && ! is_wp_error( $post_id ) ) { |
|
// update the local event list, if the update was successfull. |
|
$local[ $uuid ] = array( |
|
'post_id' => $post_id, |
|
'updatedAt' => $remote[ $uuid ], |
|
); |
|
} |
|
} |
|
|
|
// Update the transient with the local mobilizon events, but only if anything has changed! |
|
if ( count( $to_delete ) + count( $to_add ) + count( $to_update ) !== 0 ) { |
|
delete_transient( 'mobilizon_mirror_cached_events_list' ); |
|
set_transient( 'mobilizon_mirror_cached_events_list', $local ); |
|
} |
|
} |
|
|
|
|
|
/** |
|
* This is the main function which takes care of mirroring the mobilizon events on WordPress |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
*/ |
|
public function refresh_mobilizon_events() { |
|
// Return if relevant options are not set! |
|
if ( '' === get_option( $this->plugin_name )['group_name'] || '' === get_option( $this->plugin_name )['instance_url'] ) { |
|
return; |
|
} |
|
|
|
// Get list of local events. |
|
if ( get_transient( 'mobilizon_mirror_cached_events_list' ) ) { |
|
$local_events = get_transient( 'mobilizon_mirror_cached_events_list' ); |
|
} else { |
|
$local_events = $this->get_local_mobilizon_events(); |
|
set_transient( 'mobilizon_mirror_cached_events_list', $local_events ); |
|
} |
|
|
|
// Get list of remote events. |
|
$remote_events = $this->get_remote_mobilizon_events(); |
|
|
|
// Result is an array with the remote events (may be empty if no events scheduled) if the response of the server was successful. |
|
if ( is_array( $remote_events ) ) { |
|
// Compare remote with local and insert, update or delete events. |
|
$this->update_mobilizon_events( $local_events, $remote_events ); |
|
} |
|
} |
|
|
|
/** |
|
* Add the custom interval how often the events are synced |
|
* |
|
* @since 1.0.0 |
|
* @access private |
|
* @param array $schedules Is used/needed for the WordPress scheduling system. |
|
* @return array $schedules Is used/needed for the WordPress scheduling system. |
|
*/ |
|
public function mobilizon_mirror_add_minitly( $schedules ) { |
|
// Add a 'weekly' schedule to the existing set! |
|
$schedules['mobilizon_mirror_refresh_interval'] = array( |
|
// TODO: Make this a user setting! 60sec are min, max could be up to an hour? |
|
'interval' => 120, |
|
'display' => esc_html__( 'Every two minutes', 'mobilizon-mirror' ), |
|
); |
|
return $schedules; |
|
} |
|
}
|
|
|