From 4e6b70c2deced3c8927477da4a30b21b48fdfecb Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Sun, 23 Jun 2024 18:08:49 -0500 Subject: [PATCH 01/14] Add `api/v1/accounts/update_credentials` route --- includes/class-mastodon-api.php | 253 +++++++++++++++++++++++++++++--- 1 file changed, 232 insertions(+), 21 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 7bb721fc..6eee5007 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1369,9 +1369,210 @@ public function add_rest_routes() { ) ); + register_rest_route( + self::PREFIX, + 'api/v1/accounts/update_credentials', + array( + 'methods' => array( 'PATCH', 'POST', 'OPTIONS' ), + 'callback' => array( $this, 'api_update_credentials' ), + 'permission_callback' => $this->required_scope( 'write:accounts' ), + 'args' => array( + 'display_name' => array( + 'type' => 'string', + 'description' => 'The name to display in the user’s profile.', + ), + 'note' => array( + 'type' => 'string', + 'description' => 'A new biography for the user.', + ), + 'avatar' => array( + 'type' => 'binary', + 'description' => 'A base64 encoded image to display as the user’s avatar.', + ), + 'header' => array( + 'type' => 'binary', + 'description' => 'A base64 encoded image to display as the user’s header image.', + ), + 'source' => array( + 'type' => 'string', + 'description' => 'The application name of the client that created the credentials being updated.', + ), + ), + ) + ); + do_action( 'mastodon_api_register_rest_routes', $this ); } + private function get_body_from_php_input() { + // A helpful shim in case this is PHP <=5.6 when php://input could only be accessed once + static $input; + if ( ! isset( $input ) ) { + $input = file_get_contents( 'php://input' ); + } + + return $input; + } + + /** + * Get file upload from PATCH request. + * + * @param string $key The form field name of the file input. + * @return array|false Array of file data similar to a key in $_FILES, or false if no file found. + */ + private function get_patch_upload( $key ) { + if ( 'PATCH' !== $_SERVER['REQUEST_METHOD'] ) { + return false; + } + + $raw_data = $this->get_body_from_php_input(); + if ( empty( $raw_data ) ) { + return false; + } + + $content_type = isset( $_SERVER['CONTENT_TYPE'] ) ? $_SERVER['CONTENT_TYPE'] : ''; + if ( ! preg_match( '/boundary=(.*)$/', $content_type, $matches ) ) { + return false; + } + $boundary = $matches[1]; + + $parts = array_slice( explode( '--' . $boundary, $raw_data ), 1, -1 ); + foreach ( $parts as $part ) { + if ( false === strpos( $part, 'name="' . $key . '"' ) ) { + continue; + } + + list( $header_raw, $file_content ) = explode( "\r\n\r\n", $part, 2 ); + $headers = array(); + foreach ( explode( "\r\n", $header_raw ) as $line ) { + if ( false !== strpos( $line, ': ' ) ) { + list( $name, $value ) = explode( ': ', $line ); + $headers[ $name ] = $value; + } + } + + if ( ! preg_match( '/filename="([^"]+)"/', $headers['Content-Disposition'], $matches ) ) { + return false; + } + $file_name = $matches[1]; + + // require the file needed fo wp_tempnam + require_once ABSPATH . 'wp-admin/includes/file.php'; + $tmp_name = wp_tempnam( 'patch_upload_' . $key ); + file_put_contents( $tmp_name, $file_content ); + + return array( + 'name' => $file_name, + 'type' => isset( $headers['Content-Type'] ) ? $headers['Content-Type'] : 'application/octet-stream', + 'size' => strlen( $file_content ), + 'tmp_name' => $tmp_name, + 'error' => UPLOAD_ERR_OK, + ); + } + + return false; + } + + /** + * Get data from a PATCH request. + * + * This function handles different content types: + * - application/x-www-form-urlencoded + * - application/json + * - multipart/form-data + * + * @return array The merged array of request data. + */ + private function get_patch_data() { + if ( 'PATCH' !== $_SERVER['REQUEST_METHOD'] ) { + return $_REQUEST; + } + + $content_type = isset( $_SERVER['CONTENT_TYPE'] ) ? $_SERVER['CONTENT_TYPE'] : ''; + $input = $this->get_body_from_php_input(); + $data = array(); + + if ( strpos( $content_type, 'application/x-www-form-urlencoded' ) !== false ) { + parse_str( $input, $data ); + } elseif ( strpos( $content_type, 'application/json' ) !== false ) { + $json_data = json_decode( $input, true ); + if ( is_array( $json_data ) ) { + $data = $json_data; + } + } elseif ( strpos( $content_type, 'multipart/form-data' ) !== false ) { + $boundary = substr( $input, 0, strpos( $input, "\r\n" ) ); + if ( empty( $boundary ) ) { + // get boundary from $content_type + $boundary = substr( $content_type, strpos( $content_type, 'boundary=' ) + 9 ); + // remove double quotes + $boundary = str_replace( '\"', '', $boundary ); + } + if ( empty( $boundary ) ) { + return $_REQUEST; + } + $parts = array_slice( explode( $boundary, $input ), 1, -1 ); + + foreach ( $parts as $part ) { + if ( strpos( $part, 'filename=' ) !== false ) { + // This is a file upload, handle separately + continue; + } + + if ( preg_match( '/name="([^"]+)"/', $part, $matches ) ) { + $name = $matches[1]; + $value = substr( $part, strpos( $part, "\r\n\r\n" ) + 4, -2 ); + $data[ $name ] = $value; + } + } + } + + return array_merge( $_REQUEST, $data ); + } + + public function api_update_credentials( $request ) { + $token = $this->oauth->get_token(); + $user = get_userdata( $token['user_id'] ); + if ( ! $user ) { + return new \WP_Error( 'user-not-found', 'User not found', array( 'status' => 404 ) ); + } + + // handle avatar + $avatar = $this->get_patch_upload( 'avatar' ); + if ( $avatar ) { + $avatar = $this->handle_upload( $avatar ); + } + if ( is_wp_error( $avatar ) ) { + return $avatar; + } + + // same for header + $header = $this->get_patch_upload( 'header' ); + if ( $header ) { + $header = $this->handle_upload( $header ); + } + if ( is_wp_error( $header ) ) { + return $header; + } + + // now populate the params - get_patch_data is unsanitized but the get_param request methods run that + $request->set_body_params( $this->get_patch_data() ); + $request->sanitize_params(); + + $data = array( + 'avatar' => $avatar, + 'header' => $header, + 'display_name' => $request->get_param( 'display_name' ), + 'note' => $request->get_param( 'note' ), + 'fields_attributes' => $request->get_param( 'fields_attributes' ), + ); + $data = array_filter( $data ); + + do_action( 'mastodon_api_update_credentials', $user, $data ); + + // return HTTP 200 response + return rest_ensure_response( [] ); + } + public function query_vars( $query_vars ) { $query_vars[] = 'enable-mastodon-apps'; return $query_vars; @@ -1741,47 +1942,57 @@ public function api_get_media( WP_REST_Request $request ) { */ public function api_post_media( WP_REST_Request $request ) { $media = $request->get_file_params(); - if ( empty( $media ) ) { + if ( empty( $media ) || empty( $media['file'] ) ) { return new \WP_Error( 'mastodon_api_post_media', 'Media is empty', array( 'status' => 422 ) ); } + $attachment_id = $this->handle_upload( $media['file'] ); + $request->set_param( 'post_id', $attachment_id ); + + $description = $request->get_param( 'description' ); + if ( $description ) { + wp_update_post( + array( + 'ID' => $attachment_id, + 'post_excerpt' => $description, + ) + ); + } + + return rest_ensure_response( $this->api_get_media( $request ) ); + } + + /** + * Handle the upload of a media file. + * + * @param array $media The media file data. + * @return int|\WP_Error The attachment ID or a WP_Error object on failure. + */ + private function handle_upload( $media ) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; - if ( ! isset( $media['file']['name'] ) || false === strpos( $media['file']['name'], '.' ) ) { - switch ( $media['file']['type'] ) { + if ( ! isset( $media['name'] ) || false === strpos( $media['name'], '.' ) ) { + switch ( $media['type'] ) { case 'image/png': - $media['file']['name'] = 'image.png'; + $media['name'] = 'image.png'; break; case 'image/jpeg': - $media['file']['name'] = 'image.jpg'; + $media['name'] = 'image.jpg'; break; case 'image/gif': - $media['file']['name'] = 'image.gif'; + $media['name'] = 'image.gif'; break; } } - $attachment_id = \media_handle_sideload( $media['file'] ); + $attachment_id = \media_handle_sideload( $media ); if ( is_wp_error( $attachment_id ) ) { return new \WP_Error( 'mastodon_api_post_media', $attachment_id->get_error_message(), array( 'status' => 422 ) ); } wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, get_attached_file( $attachment_id ) ) ); - - $request->set_param( 'post_id', $attachment_id ); - - $description = $request->get_param( 'description' ); - if ( $description ) { - wp_update_post( - array( - 'ID' => $attachment_id, - 'post_excerpt' => $description, - ) - ); - } - - return rest_ensure_response( $this->api_get_media( $request ) ); + return $attachment_id; } /** From 936c328c9be35bb162889c26a00f0d0835f6451e Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Sun, 23 Jun 2024 22:41:37 -0500 Subject: [PATCH 02/14] Better data handling, lint fixes --- includes/class-mastodon-api.php | 207 +++++++++++++++++--------------- 1 file changed, 109 insertions(+), 98 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 6eee5007..580b28e1 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1373,7 +1373,7 @@ public function add_rest_routes() { self::PREFIX, 'api/v1/accounts/update_credentials', array( - 'methods' => array( 'PATCH', 'POST', 'OPTIONS' ), + 'methods' => array( 'PATCH', 'OPTIONS' ), 'callback' => array( $this, 'api_update_credentials' ), 'permission_callback' => $this->required_scope( 'write:accounts' ), 'args' => array( @@ -1404,8 +1404,12 @@ public function add_rest_routes() { do_action( 'mastodon_api_register_rest_routes', $this ); } - private function get_body_from_php_input() { - // A helpful shim in case this is PHP <=5.6 when php://input could only be accessed once + /** + * Get the body from php://input. + * We don't use $request->get_body() because its data is mangled. + */ + private static function get_body_from_php_input() { + // A helpful shim in case this is PHP <=5.6 when php://input could only be accessed once. static $input; if ( ! isset( $input ) ) { $input = file_get_contents( 'php://input' ); @@ -1415,63 +1419,67 @@ private function get_body_from_php_input() { } /** - * Get file upload from PATCH request. - * - * @param string $key The form field name of the file input. - * @return array|false Array of file data similar to a key in $_FILES, or false if no file found. - */ - private function get_patch_upload( $key ) { - if ( 'PATCH' !== $_SERVER['REQUEST_METHOD'] ) { - return false; - } - - $raw_data = $this->get_body_from_php_input(); - if ( empty( $raw_data ) ) { - return false; - } - - $content_type = isset( $_SERVER['CONTENT_TYPE'] ) ? $_SERVER['CONTENT_TYPE'] : ''; - if ( ! preg_match( '/boundary=(.*)$/', $content_type, $matches ) ) { - return false; - } - $boundary = $matches[1]; - - $parts = array_slice( explode( '--' . $boundary, $raw_data ), 1, -1 ); - foreach ( $parts as $part ) { - if ( false === strpos( $part, 'name="' . $key . '"' ) ) { - continue; - } - - list( $header_raw, $file_content ) = explode( "\r\n\r\n", $part, 2 ); - $headers = array(); - foreach ( explode( "\r\n", $header_raw ) as $line ) { - if ( false !== strpos( $line, ': ' ) ) { - list( $name, $value ) = explode( ': ', $line ); - $headers[ $name ] = $value; - } - } - - if ( ! preg_match( '/filename="([^"]+)"/', $headers['Content-Disposition'], $matches ) ) { + * Get file upload from PATCH request. + * + * @param string $key The form field name of the file input. + * @param WP_REST_Request $request The request object. + * @return array|false Array of file data similar to a key in $_FILES, or false if no file found. + */ + private function get_patch_upload( $key, $request ) { + if ( 'PATCH' !== $request->get_method() ) { + return false; + } + + $raw_data = self::get_body_from_php_input(); + if ( empty( $raw_data ) ) { return false; - } - $file_name = $matches[1]; + } + + $content_type = $request->get_content_type(); + if ( ! $content_type || empty( $content_type['parameters'] ) ) { + return false; + } + if ( ! preg_match( '/boundary="?([^";]+)"?/', $content_type['parameters'], $matches ) ) { + return false; + } + $boundary = $matches[1]; + + $parts = array_slice( explode( '--' . $boundary, $raw_data ), 1, -1 ); + foreach ( $parts as $part ) { + if ( false === strpos( $part, 'name="' . $key . '"' ) ) { + continue; + } - // require the file needed fo wp_tempnam - require_once ABSPATH . 'wp-admin/includes/file.php'; - $tmp_name = wp_tempnam( 'patch_upload_' . $key ); - file_put_contents( $tmp_name, $file_content ); + list( $header_raw, $file_content ) = explode( "\r\n\r\n", $part, 2 ); + $headers = array(); + foreach ( explode( "\r\n", $header_raw ) as $line ) { + if ( false !== strpos( $line, ': ' ) ) { + list( $name, $value ) = explode( ': ', $line ); + $headers[ $name ] = $value; + } + } - return array( - 'name' => $file_name, - 'type' => isset( $headers['Content-Type'] ) ? $headers['Content-Type'] : 'application/octet-stream', - 'size' => strlen( $file_content ), - 'tmp_name' => $tmp_name, - 'error' => UPLOAD_ERR_OK, - ); + if ( ! preg_match( '/filename="([^"]+)"/', $headers['Content-Disposition'], $matches ) ) { + return false; + } + $file_name = $matches[1]; + + // require the file needed fo wp_tempnam. + require_once ABSPATH . 'wp-admin/includes/file.php'; + $tmp_name = wp_tempnam( 'patch_upload_' . $key ); + file_put_contents( $tmp_name, $file_content ); + + return array( + 'name' => $file_name, + 'type' => isset( $headers['Content-Type'] ) ? $headers['Content-Type'] : 'application/octet-stream', + 'size' => strlen( $file_content ), + 'tmp_name' => $tmp_name, + 'error' => UPLOAD_ERR_OK, + ); } - return false; - } + return false; + } /** * Get data from a PATCH request. @@ -1481,52 +1489,55 @@ private function get_patch_upload( $key ) { * - application/json * - multipart/form-data * + * @param WP_REST_Request $request The request object. * @return array The merged array of request data. */ - private function get_patch_data() { - if ( 'PATCH' !== $_SERVER['REQUEST_METHOD'] ) { - return $_REQUEST; + private function get_patch_data( $request ) { + $data = array(); + if ( 'PATCH' !== $request->get_method() ) { + return $data; } - $content_type = isset( $_SERVER['CONTENT_TYPE'] ) ? $_SERVER['CONTENT_TYPE'] : ''; - $input = $this->get_body_from_php_input(); - $data = array(); + $content_type = $request->get_content_type(); + if ( ! $content_type ) { + return $data; + } - if ( strpos( $content_type, 'application/x-www-form-urlencoded' ) !== false ) { - parse_str( $input, $data ); - } elseif ( strpos( $content_type, 'application/json' ) !== false ) { - $json_data = json_decode( $input, true ); - if ( is_array( $json_data ) ) { - $data = $json_data; - } - } elseif ( strpos( $content_type, 'multipart/form-data' ) !== false ) { - $boundary = substr( $input, 0, strpos( $input, "\r\n" ) ); - if ( empty( $boundary ) ) { - // get boundary from $content_type - $boundary = substr( $content_type, strpos( $content_type, 'boundary=' ) + 9 ); - // remove double quotes - $boundary = str_replace( '\"', '', $boundary ); - } - if ( empty( $boundary ) ) { - return $_REQUEST; - } - $parts = array_slice( explode( $boundary, $input ), 1, -1 ); + $input = self::get_body_from_php_input(); - foreach ( $parts as $part ) { - if ( strpos( $part, 'filename=' ) !== false ) { - // This is a file upload, handle separately - continue; + switch ( $content_type['value'] ) { + case 'application/x-www-form-urlencoded': + parse_str( $input, $data ); + break; + case 'application/json': + $json_data = json_decode( $input, true ); + if ( is_array( $json_data ) ) { + $data = $json_data; + } + break; + case 'multipart/form-data': + $boundary = preg_match( '/boundary="?([^";]+)"?/', $content_type['parameters'], $matches ) ? $matches[1] : null; + if ( empty( $boundary ) ) { + return $data; } + $parts = array_slice( explode( $boundary, $input ), 1, -1 ); - if ( preg_match( '/name="([^"]+)"/', $part, $matches ) ) { - $name = $matches[1]; - $value = substr( $part, strpos( $part, "\r\n\r\n" ) + 4, -2 ); - $data[ $name ] = $value; + foreach ( $parts as $part ) { + if ( strpos( $part, 'filename=' ) !== false ) { + // This is a file upload, handle separately. + continue; + } + + if ( preg_match( '/name="([^"]+)"/', $part, $matches ) ) { + $name = $matches[1]; + $value = substr( $part, strpos( $part, "\r\n\r\n" ) + 4, -2 ); + $data[ $name ] = $value; + } } - } + break; } - return array_merge( $_REQUEST, $data ); + return $data; } public function api_update_credentials( $request ) { @@ -1536,8 +1547,8 @@ public function api_update_credentials( $request ) { return new \WP_Error( 'user-not-found', 'User not found', array( 'status' => 404 ) ); } - // handle avatar - $avatar = $this->get_patch_upload( 'avatar' ); + // handle avatar. + $avatar = $this->get_patch_upload( 'avatar', $request ); if ( $avatar ) { $avatar = $this->handle_upload( $avatar ); } @@ -1545,8 +1556,8 @@ public function api_update_credentials( $request ) { return $avatar; } - // same for header - $header = $this->get_patch_upload( 'header' ); + // same for header. + $header = $this->get_patch_upload( 'header', $request ); if ( $header ) { $header = $this->handle_upload( $header ); } @@ -1554,8 +1565,8 @@ public function api_update_credentials( $request ) { return $header; } - // now populate the params - get_patch_data is unsanitized but the get_param request methods run that - $request->set_body_params( $this->get_patch_data() ); + // now populate the params - get_patch_data is unsanitized but the get_param request methods run that. + $request->set_body_params( $this->get_patch_data( $request ) ); $request->sanitize_params(); $data = array( @@ -1569,8 +1580,8 @@ public function api_update_credentials( $request ) { do_action( 'mastodon_api_update_credentials', $user, $data ); - // return HTTP 200 response - return rest_ensure_response( [] ); + // return HTTP 200 response. + return new \WP_REST_Response( null, 200 ); } public function query_vars( $query_vars ) { From 53696436443b00f8f503ba0cecfed2b585b5c7bc Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Tue, 25 Jun 2024 16:03:04 -0500 Subject: [PATCH 03/14] Better user_id handling to allow Blog account support --- includes/class-mastodon-api.php | 12 ++++++++---- includes/handler/class-status.php | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 580b28e1..d63c4d96 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1542,11 +1542,13 @@ private function get_patch_data( $request ) { public function api_update_credentials( $request ) { $token = $this->oauth->get_token(); - $user = get_userdata( $token['user_id'] ); + $user = get_user_by( 'id', $token['user_id'] ); if ( ! $user ) { return new \WP_Error( 'user-not-found', 'User not found', array( 'status' => 404 ) ); } + $user_id = $this->get_user_id_from_request( $request ); + // handle avatar. $avatar = $this->get_patch_upload( 'avatar', $request ); if ( $avatar ) { @@ -1578,10 +1580,12 @@ public function api_update_credentials( $request ) { ); $data = array_filter( $data ); - do_action( 'mastodon_api_update_credentials', $user, $data ); + do_action( 'mastodon_api_update_credentials', $user_id, $data ); - // return HTTP 200 response. - return new \WP_REST_Response( null, 200 ); + // if we set this earlier it gets cleared out by `$request->sanitize_params()`. + $request->set_param( 'user_id', (int) $user->ID ); + // Return the account. + return $this->api_account( $request ); } public function query_vars( $query_vars ) { diff --git a/includes/handler/class-status.php b/includes/handler/class-status.php index 12db768e..75403644 100644 --- a/includes/handler/class-status.php +++ b/includes/handler/class-status.php @@ -131,7 +131,8 @@ public function api_status( ?Status_Entity $status, int $object_id, array $data if ( isset( $data['comment'] ) && $data['comment'] instanceof \WP_Comment ) { $comment = $data['comment']; - $account = apply_filters( 'mastodon_api_account', null, $comment->user_id, null, $comment ); + $user_id = apply_filters( 'mastodon_api_mapback_user_id', (int) $comment->user_id ); + $account = apply_filters( 'mastodon_api_account', null, $user_id, null, $comment ); if ( ! ( $account instanceof \Enable_Mastodon_Apps\Entity\Account ) ) { return $status; } @@ -148,8 +149,9 @@ public function api_status( ?Status_Entity $status, int $object_id, array $data $status->in_reply_to_id = strval( $comment->comment_post_ID ); } } elseif ( $post instanceof \WP_Post ) { + $user_id = apply_filters( 'mastodon_api_mapback_user_id', (int) $post->post_author ); // Documented in class-mastodon-api.php. - $account = apply_filters( 'mastodon_api_account', null, $post->post_author, null, $post ); + $account = apply_filters( 'mastodon_api_account', null, $user_id, null, $post ); if ( ! ( $account instanceof \Enable_Mastodon_Apps\Entity\Account ) ) { return $status; From ae0f6c90432a85b0679714f22fc98a342103ebb5 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Tue, 25 Jun 2024 21:35:47 -0500 Subject: [PATCH 04/14] proper user handling while editing --- includes/class-mastodon-api.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index d63c4d96..124a7377 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1547,8 +1547,6 @@ public function api_update_credentials( $request ) { return new \WP_Error( 'user-not-found', 'User not found', array( 'status' => 404 ) ); } - $user_id = $this->get_user_id_from_request( $request ); - // handle avatar. $avatar = $this->get_patch_upload( 'avatar', $request ); if ( $avatar ) { @@ -1579,11 +1577,12 @@ public function api_update_credentials( $request ) { 'fields_attributes' => $request->get_param( 'fields_attributes' ), ); $data = array_filter( $data ); + $user_id = (int) $user->ID; do_action( 'mastodon_api_update_credentials', $user_id, $data ); // if we set this earlier it gets cleared out by `$request->sanitize_params()`. - $request->set_param( 'user_id', (int) $user->ID ); + $request->set_param( 'user_id', $user_id ); // Return the account. return $this->api_account( $request ); } From 72537e7ced5a86d6012306d7d51ca1b036eff00c Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Wed, 26 Jun 2024 14:42:40 -0500 Subject: [PATCH 05/14] Handle display_name and note internally for WP Users --- includes/class-mastodon-api.php | 59 +++++++++++++------ .../oauth2/class-authenticate-handler.php | 2 +- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 124a7377..5a9ba938 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1378,24 +1378,22 @@ public function add_rest_routes() { 'permission_callback' => $this->required_scope( 'write:accounts' ), 'args' => array( 'display_name' => array( - 'type' => 'string', - 'description' => 'The name to display in the user’s profile.', + 'type' => 'string', + 'description' => 'The name to display in the user’s profile.', + 'sanitize_callback' => 'sanitize_text_field', + 'required' => false, ), 'note' => array( - 'type' => 'string', - 'description' => 'A new biography for the user.', - ), - 'avatar' => array( - 'type' => 'binary', - 'description' => 'A base64 encoded image to display as the user’s avatar.', - ), - 'header' => array( - 'type' => 'binary', - 'description' => 'A base64 encoded image to display as the user’s header image.', + 'type' => 'string', + 'description' => 'A new biography for the user.', + 'sanitize_callback' => 'sanitize_text_field', + 'required' => false, ), - 'source' => array( - 'type' => 'string', - 'description' => 'The application name of the client that created the credentials being updated.', + 'fields_attributes' => array( + 'type' => 'object', + 'description' => 'A list of custom fields to update.', + + 'required' => false, ), ), ) @@ -1565,13 +1563,13 @@ public function api_update_credentials( $request ) { return $header; } - // now populate the params - get_patch_data is unsanitized but the get_param request methods run that. + // now populate the params - get_patch_data is unsanitized so we re-run request param sanitization. $request->set_body_params( $this->get_patch_data( $request ) ); $request->sanitize_params(); $data = array( - 'avatar' => $avatar, - 'header' => $header, + 'avatar' => $avatar, // Attachment ID. + 'header' => $header, // Attachment ID. 'display_name' => $request->get_param( 'display_name' ), 'note' => $request->get_param( 'note' ), 'fields_attributes' => $request->get_param( 'fields_attributes' ), @@ -1579,10 +1577,33 @@ public function api_update_credentials( $request ) { $data = array_filter( $data ); $user_id = (int) $user->ID; - do_action( 'mastodon_api_update_credentials', $user_id, $data ); + /** + * An action for clients to hook into for setting user profile data. + * + * @param array $data User attributes requested to update. Only keys requested for update will be present. + * Keys: avatar(attachment_id)|header(attachment_id)|display_name(string)|note(string)|fields_attributes(hash) + * If your plugin acts on data and you don't want this plugin to runs it own update, + * remove the keys from the array. + * @param int $user_id The user_id to act on. + */ + $data = apply_filters( 'mastodon_api_update_credentials', $data, $user_id ); + + // Update the user with any available data for fields we support (just display_name and note currently). + if ( isset( $data['display_name'] ) ) { + wp_update_user( + array( + 'ID' => $user_id, + 'display_name' => $data['display_name'], + ) + ); + } + if ( isset( $data['note'] ) ) { + update_user_meta( $user_id, 'description', $data['note'] ); + } // if we set this earlier it gets cleared out by `$request->sanitize_params()`. $request->set_param( 'user_id', $user_id ); + // Return the account. return $this->api_account( $request ); } diff --git a/includes/oauth2/class-authenticate-handler.php b/includes/oauth2/class-authenticate-handler.php index da6f97ed..91a28a49 100644 --- a/includes/oauth2/class-authenticate-handler.php +++ b/includes/oauth2/class-authenticate-handler.php @@ -105,7 +105,7 @@ private function render_no_permission_screen( $data ) { private function render_consent_screen( $data ) { $scope_explanations = array( 'read' => __( 'Read information from your account, for example read your statuses.', 'enable-mastodon-apps' ), - 'write' => __( 'Write information to your account, for example post a status on your behalf.', 'enable-mastodon-apps' ), + 'write' => __( 'Write information to your account, for example post a status on your behalf, or edit your profile.', 'enable-mastodon-apps' ), 'follow' => __( 'Follow other accounts using your account.', 'enable-mastodon-apps' ), 'push' => __( 'Subscribe to push events for your account.', 'enable-mastodon-apps' ), ); From 588815ae7ae2aa6b2aabaac3ecbf783803649bb2 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Wed, 26 Jun 2024 14:57:53 -0500 Subject: [PATCH 06/14] lint nom --- includes/class-mastodon-api.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 5a9ba938..be7b120c 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1377,23 +1377,23 @@ public function add_rest_routes() { 'callback' => array( $this, 'api_update_credentials' ), 'permission_callback' => $this->required_scope( 'write:accounts' ), 'args' => array( - 'display_name' => array( + 'display_name' => array( 'type' => 'string', 'description' => 'The name to display in the user’s profile.', 'sanitize_callback' => 'sanitize_text_field', - 'required' => false, + 'required' => false, ), - 'note' => array( + 'note' => array( 'type' => 'string', 'description' => 'A new biography for the user.', 'sanitize_callback' => 'sanitize_text_field', - 'required' => false, + 'required' => false, ), 'fields_attributes' => array( - 'type' => 'object', - 'description' => 'A list of custom fields to update.', + 'type' => 'object', + 'description' => 'A list of custom fields to update.', - 'required' => false, + 'required' => false, ), ), ) From ba71f0492a5673f07893dc632e8fde2f480a56de Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Wed, 26 Jun 2024 15:22:44 -0500 Subject: [PATCH 07/14] ensure that user_ids only work for users of this site --- includes/class-mastodon-api.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index be7b120c..9d9c85a1 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -2661,6 +2661,10 @@ public function api_account_statuses( $request ) { public function api_account( $request ) { $user_id = $this->get_user_id_from_request( $request ); + if ( is_multisite() && ! is_user_member_of_blog( $user_id ) ) { + return new \WP_Error( 'mastodon_api_account', 'Record not found', array( 'status' => 404 ) ); + } + /** * Modify the account data returned for `/api/account/{user_id}` requests. * From 55c9d3c1641627a645c38ceffac4b61473a19e99 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Wed, 26 Jun 2024 16:45:13 -0500 Subject: [PATCH 08/14] skip redundant `is_multisite` check props @akirk Co-authored-by: Alex Kirk --- includes/class-mastodon-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 9d9c85a1..b644c141 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -2661,7 +2661,7 @@ public function api_account_statuses( $request ) { public function api_account( $request ) { $user_id = $this->get_user_id_from_request( $request ); - if ( is_multisite() && ! is_user_member_of_blog( $user_id ) ) { + if ( ! is_user_member_of_blog( $user_id ) ) { return new \WP_Error( 'mastodon_api_account', 'Record not found', array( 'status' => 404 ) ); } From 291699af6982c95635d72f80522c621bb9954138 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Wed, 26 Jun 2024 16:52:57 -0500 Subject: [PATCH 09/14] changes not needed --- includes/handler/class-status.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/includes/handler/class-status.php b/includes/handler/class-status.php index 75403644..12db768e 100644 --- a/includes/handler/class-status.php +++ b/includes/handler/class-status.php @@ -131,8 +131,7 @@ public function api_status( ?Status_Entity $status, int $object_id, array $data if ( isset( $data['comment'] ) && $data['comment'] instanceof \WP_Comment ) { $comment = $data['comment']; - $user_id = apply_filters( 'mastodon_api_mapback_user_id', (int) $comment->user_id ); - $account = apply_filters( 'mastodon_api_account', null, $user_id, null, $comment ); + $account = apply_filters( 'mastodon_api_account', null, $comment->user_id, null, $comment ); if ( ! ( $account instanceof \Enable_Mastodon_Apps\Entity\Account ) ) { return $status; } @@ -149,9 +148,8 @@ public function api_status( ?Status_Entity $status, int $object_id, array $data $status->in_reply_to_id = strval( $comment->comment_post_ID ); } } elseif ( $post instanceof \WP_Post ) { - $user_id = apply_filters( 'mastodon_api_mapback_user_id', (int) $post->post_author ); // Documented in class-mastodon-api.php. - $account = apply_filters( 'mastodon_api_account', null, $user_id, null, $post ); + $account = apply_filters( 'mastodon_api_account', null, $post->post_author, null, $post ); if ( ! ( $account instanceof \Enable_Mastodon_Apps\Entity\Account ) ) { return $status; From 752b1088c59ad4c8eed8a3833e733c7112ef97c8 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Thu, 27 Jun 2024 08:19:23 -0500 Subject: [PATCH 10/14] add update_credentials to rewrites --- includes/class-mastodon-api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index b644c141..f7e8a77e 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -196,6 +196,7 @@ public function rewrite_rules() { array( 'api/v1/accounts/relationships', 'api/v1/accounts/verify_credentials', + 'api/v1/accounts/update_credentials', 'api/v1/accounts/familiar_followers', 'api/v1/accounts/search', 'api/v1/accounts/lookup', From e682a199a9b157722005c74f4fe77ca041061f0c Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Thu, 27 Jun 2024 08:25:07 -0500 Subject: [PATCH 11/14] lint noms --- enable-mastodon-apps.php | 1 + includes/class-mastodon-api.php | 10 +++++----- includes/class-mastodon-app.php | 2 +- includes/entity/class-entity.php | 1 + 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/enable-mastodon-apps.php b/enable-mastodon-apps.php index db753854..a9eecfc8 100644 --- a/enable-mastodon-apps.php +++ b/enable-mastodon-apps.php @@ -14,6 +14,7 @@ */ namespace Enable_Mastodon_Apps; + use OAuth2; defined( 'ABSPATH' ) || exit; diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index f7e8a77e..11e8c95e 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -2775,12 +2775,12 @@ public static function remap_comment_id( $comment_id ) { public static function remap_user_id( $user_id ) { $user_id = apply_filters( 'mastodon_api_canonical_user_id', $user_id ); - $term = get_term_by( 'name', $user_id, \Enable_Mastodon_Apps\Mastodon_API::REMAP_TAXONOMY ); + $term = get_term_by( 'name', $user_id, self::REMAP_TAXONOMY ); $remote_user_id = 0; if ( $term ) { $remote_user_id = $term->term_id; } else { - $term = wp_insert_term( $user_id, \Enable_Mastodon_Apps\Mastodon_API::REMAP_TAXONOMY ); + $term = wp_insert_term( $user_id, self::REMAP_TAXONOMY ); if ( is_wp_error( $term ) ) { return $remote_user_id; } @@ -2791,12 +2791,12 @@ public static function remap_user_id( $user_id ) { } public static function remap_url( $url ) { - $term = get_term_by( 'name', $url, \Enable_Mastodon_Apps\Mastodon_API::REMAP_TAXONOMY ); + $term = get_term_by( 'name', $url, self::REMAP_TAXONOMY ); $remapped_id = 0; if ( $term ) { $remapped_id = $term->term_id; } else { - $term = wp_insert_term( $url, \Enable_Mastodon_Apps\Mastodon_API::REMAP_TAXONOMY ); + $term = wp_insert_term( $url, self::REMAP_TAXONOMY ); if ( ! is_wp_error( $term ) ) { $remapped_id = $term['term_id']; } @@ -2829,7 +2829,7 @@ public function api_nodeinfo() { global $wp_version; $software = array( 'name' => $this->software_string(), - 'version' => Mastodon_API::VERSION, + 'version' => self::VERSION, ); $software = apply_filters( 'mastodon_api_nodeinfo_software', $software ); $ret = array( diff --git a/includes/class-mastodon-app.php b/includes/class-mastodon-app.php index a8b30053..02a33ad8 100644 --- a/includes/class-mastodon-app.php +++ b/includes/class-mastodon-app.php @@ -204,7 +204,7 @@ public function was_used( $request, $additional_debug_data = array() ) { } public static function set_current_app( $client_id, $request ) { - self::$current_app = Mastodon_App::get_by_client_id( $client_id ); + self::$current_app = self::get_by_client_id( $client_id ); self::$current_app->was_used( $request ); return self::$current_app; } diff --git a/includes/entity/class-entity.php b/includes/entity/class-entity.php index b074723a..009fa9c2 100644 --- a/includes/entity/class-entity.php +++ b/includes/entity/class-entity.php @@ -6,6 +6,7 @@ */ namespace Enable_Mastodon_Apps\Entity; + use Enable_Mastodon_Apps\Mastodon_API; /** From 5123b6b6fc1e45f6b1b9caa6164d06080c594c21 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Thu, 18 Jul 2024 16:23:14 -0500 Subject: [PATCH 12/14] some phpcs ignores --- includes/class-mastodon-api.php | 2 +- templates/debug.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 11e8c95e..3b90c773 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1411,7 +1411,7 @@ private static function get_body_from_php_input() { // A helpful shim in case this is PHP <=5.6 when php://input could only be accessed once. static $input; if ( ! isset( $input ) ) { - $input = file_get_contents( 'php://input' ); + $input = file_get_contents( 'php://input' ); //phcs:ignore } return $input; diff --git a/templates/debug.php b/templates/debug.php index 6dc93c57..bbcac2ad 100644 --- a/templates/debug.php +++ b/templates/debug.php @@ -173,7 +173,7 @@ foreach ( $tokens as $token => $data ) { $user = 'app-level'; if ( $data['user_id'] ) { - $userdata = get_user_by( 'ID', $data['user_id'] ); + $userdata = get_user_by( 'ID', $data['user_id'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited if ( $userdata ) { if ( is_wp_error( $userdata ) ) { $user = $userdata->get_error_message(); From 5b500e665471f404570a1a749530917140a1ef8f Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Tue, 30 Jul 2024 13:39:32 -0500 Subject: [PATCH 13/14] phpcs: much more pedantic than I --- includes/class-mastodon-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 7edf5bca..8b1682a8 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1412,7 +1412,7 @@ private static function get_body_from_php_input() { // A helpful shim in case this is PHP <=5.6 when php://input could only be accessed once. static $input; if ( ! isset( $input ) ) { - $input = file_get_contents( 'php://input' ); //phcs:ignore + $input = file_get_contents( 'php://input' ); // phcs:ignore . } return $input; From 14699a8734479588fe4ec7bca8e57b2a939afc03 Mon Sep 17 00:00:00 2001 From: Matt Wiebe Date: Tue, 30 Jul 2024 14:33:55 -0500 Subject: [PATCH 14/14] use WP_Filesystem for putting file --- includes/class-mastodon-api.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/includes/class-mastodon-api.php b/includes/class-mastodon-api.php index 8b1682a8..f7a43fca 100644 --- a/includes/class-mastodon-api.php +++ b/includes/class-mastodon-api.php @@ -1467,7 +1467,13 @@ private function get_patch_upload( $key, $request ) { // require the file needed fo wp_tempnam. require_once ABSPATH . 'wp-admin/includes/file.php'; $tmp_name = wp_tempnam( 'patch_upload_' . $key ); - file_put_contents( $tmp_name, $file_content ); + // Use WP_Filesystem abstraction. + global $wp_filesystem; + if ( ! $wp_filesystem ) { + // init if not yet done. + WP_Filesystem(); + } + $wp_filesystem->put_contents( $tmp_name, $file_content ); return array( 'name' => $file_name,