├── .distignore ├── Gruntfile.js ├── includes ├── class-micropub-base.php ├── class-micropub-endpoint.php ├── class-micropub-error.php ├── class-micropub-media.php ├── class-micropub-render.php ├── compat-functions.php └── functions.php ├── micropub.php ├── package.json ├── phpunit.xml.dist ├── readme.txt └── templates ├── micropub-post-status-setting.php └── micropub-settings.php /.distignore: -------------------------------------------------------------------------------- 1 | /.wordpress-org 2 | /.git 3 | /.github 4 | /bin 5 | /vendor 6 | /tests 7 | readme.md 8 | package.json 9 | composer.lock 10 | phpunit.xml 11 | phpcs.xml 12 | README.md 13 | readme.md 14 | .travis.yml 15 | .distignore 16 | .gitignore 17 | .svnignore 18 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | // Project configuration. 3 | grunt.initConfig( 4 | { 5 | wp_readme_to_markdown: { 6 | target: { 7 | files: { 8 | 'readme.md': 'readme.txt' 9 | } 10 | } 11 | } 12 | } 13 | ); 14 | 15 | grunt.loadNpmTasks( 'grunt-wp-readme-to-markdown' ); 16 | // Default task(s). 17 | grunt.registerTask( 'default', ['wp_readme_to_markdown'] ); 18 | }; 19 | -------------------------------------------------------------------------------- /includes/class-micropub-base.php: -------------------------------------------------------------------------------- 1 | ' . PHP_EOL, static::get_rel(), static::get_endpoint() ); 36 | } 37 | 38 | public static function header( $header, $value ) { 39 | header( $header . ': ' . $value, false ); 40 | } 41 | 42 | /** 43 | * The autodicovery http-header 44 | */ 45 | public static function http_header() { 46 | static::header( 'Link', sprintf( '<%1$s>; rel="%2$s"', static::get_endpoint(), static::get_rel() ) ); 47 | } 48 | 49 | /** 50 | * Generates webfinger/host-meta links 51 | */ 52 | public static function jrd_links( $links ) { 53 | $links['links'][] = array( 54 | 'rel' => static::get_rel(), 55 | 'href' => static::get_endpoint(), 56 | ); 57 | return $links; 58 | } 59 | 60 | 61 | public static function return_micropub_error( $response, $handler, $request ) { 62 | if ( static::get_route() !== $request->get_route() ) { 63 | return $response; 64 | } 65 | if ( is_wp_error( $response ) ) { 66 | return micropub_wp_error( $response ); 67 | } 68 | return $response; 69 | } 70 | 71 | public static function log_error( $message, $name = 'Micropub' ) { 72 | if ( empty( $message ) || defined( 'DIR_TESTDATA' ) ) { 73 | return false; 74 | } 75 | if ( is_array( $message ) || is_object( $message ) ) { 76 | $message = wp_json_encode( $message ); 77 | } 78 | 79 | return error_log( sprintf( '%1$s: %2$s', $name, $message ) ); // phpcs:ignore 80 | } 81 | 82 | public static function get( $a, $key, $args = array() ) { 83 | if ( is_array( $a ) ) { 84 | return isset( $a[ $key ] ) ? $a[ $key ] : $args; 85 | } 86 | return $args; 87 | } 88 | 89 | public static function load_auth() { 90 | // Check if logged in 91 | if ( ! is_user_logged_in() ) { 92 | return new WP_Error( 'forbidden', 'Unauthorized', array( 'status' => 403 ) ); 93 | } 94 | 95 | static::$micropub_auth_response = micropub_get_response(); 96 | static::$scopes = micropub_get_scopes(); 97 | 98 | // If there is no auth response this is cookie authentication which should be rejected 99 | // https://www.w3.org/TR/micropub/#authentication-and-authorization - Requests must be authenticated by token 100 | if ( empty( static::$micropub_auth_response ) ) { 101 | return new WP_Error( 'unauthorized', 'Cookie Authentication is not permitted', array( 'status' => 401 ) ); 102 | } 103 | return true; 104 | } 105 | 106 | public static function check_query_permissions( $request ) { 107 | $auth = self::load_auth(); 108 | if ( is_wp_error( $auth ) ) { 109 | return $auth; 110 | } 111 | $query = $request->get_param( 'q' ); 112 | if ( ! $query ) { 113 | return new WP_Error( 'invalid_request', 'Missing Query Parameter', array( 'status' => 400 ) ); 114 | } 115 | 116 | return true; 117 | } 118 | 119 | protected static function check_error( $result ) { 120 | if ( ! $result ) { 121 | return new WP_Micropub_Error( 'invalid_request', $result, 400 ); 122 | } elseif ( is_wp_error( $result ) ) { 123 | return micropub_wp_error( $result ); 124 | } 125 | return $result; 126 | } 127 | 128 | /** 129 | * Returns the mf2 properties for a post. 130 | */ 131 | public static function get_mf2( $post_id = null ) { 132 | $mf2 = array(); 133 | $post = get_post( $post_id ); 134 | 135 | foreach ( get_post_meta( $post_id ) as $field => $val ) { 136 | $val = maybe_unserialize( $val[0] ); 137 | if ( 'mf2_type' === $field ) { 138 | $mf2['type'] = $val; 139 | } elseif ( 'mf2_' === substr( $field, 0, 4 ) ) { 140 | $mf2['properties'][ substr( $field, 4 ) ] = $val; 141 | } 142 | } 143 | 144 | // Time Information 145 | $published = micropub_get_post_datetime( $post ); 146 | $updated = micropub_get_post_datetime( $post, 'modified' ); 147 | $mf2['properties']['published'] = array( $published->format( DATE_W3C ) ); 148 | if ( $published->getTimestamp() !== $updated->getTimestamp() ) { 149 | $mf2['properties']['updated'] = array( $updated->format( DATE_W3C ) ); 150 | } 151 | 152 | if ( ! empty( $post->post_title ) ) { 153 | $mf2['properties']['name'] = array( $post->post_title ); 154 | } 155 | 156 | if ( ! empty( $post->post_excerpt ) ) { 157 | $mf2['properties']['summary'] = array( htmlspecialchars_decode( $post->post_excerpt ) ); 158 | } 159 | if ( ! array_key_exists( 'content', $mf2['properties'] ) && ! empty( $post->post_content ) ) { 160 | $mf2['properties']['content'] = array( htmlspecialchars_decode( $post->post_content ) ); 161 | } 162 | 163 | return $mf2; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /includes/class-micropub-endpoint.php: -------------------------------------------------------------------------------- 1 | WP_REST_Server::CREATABLE, 54 | 'callback' => array( static::class, 'post_handler' ), 55 | 'permission_callback' => array( static::class, 'check_create_permissions' ), 56 | 57 | ), 58 | array( 59 | 'methods' => WP_REST_Server::READABLE, 60 | 'callback' => array( static::class, 'query_handler' ), 61 | 'permission_callback' => array( static::class, 'check_query_permissions' ), 62 | 63 | ), 64 | ) 65 | ); 66 | } 67 | 68 | public static function check_create_permissions( $request ) { 69 | $auth = self::load_auth(); 70 | if ( is_wp_error( $auth ) ) { 71 | return $auth; 72 | } 73 | 74 | $action = $request->get_param( 'action' ); 75 | $action = $action ? $action : 'create'; 76 | $permission = self::check_action( $action ); 77 | 78 | if ( is_micropub_error( $permission ) ) { 79 | return $permission->to_wp_error(); 80 | } 81 | 82 | return $permission; 83 | } 84 | 85 | 86 | /** 87 | * Parse the micropub request and render the document 88 | * 89 | * @param WP_REST_Request $request WordPress request 90 | * 91 | * @uses apply_filter() Calls 'before_micropub' on the default request 92 | */ 93 | protected static function load_input( $request ) { 94 | $content_type = $request->get_content_type(); 95 | $content_type = mp_get( $content_type, 'value', 'application/x-www-form-urlencoded' ); 96 | 97 | if ( 'GET' === $request->get_method() ) { 98 | static::$input = $request->get_query_params(); 99 | } elseif ( 'application/json' === $content_type ) { 100 | static::$input = self::normalize_json( $request->get_json_params() ); 101 | } elseif ( ! $content_type || 102 | 'application/x-www-form-urlencoded' === $content_type || 103 | 'multipart/form-data' === $content_type ) { 104 | static::$input = self::form_to_json( $request->get_body_params() ); 105 | static::$files = $request->get_file_params(); 106 | } else { 107 | return new WP_Micropub_Error( 'invalid_request', 'Unsupported Content Type: ' . $content_type, 400 ); 108 | } 109 | if ( empty( static::$input ) ) { 110 | return new WP_Micropub_Error( 'invalid_request', 'No input provided', 400 ); 111 | } 112 | if ( WP_DEBUG ) { 113 | if ( ! empty( static::$files ) ) { 114 | static::log_error( array_keys( static::$files ), 'Micropub File Parameters' ); 115 | } 116 | static::log_error( static::$input, 'Micropub Input' ); 117 | } 118 | 119 | if ( isset( static::$input['properties'] ) ) { 120 | $properties = static::$input['properties']; 121 | if ( isset( $properties['location'] ) ) { 122 | static::$input['properties']['location'] = self::parse_geo_uri( $properties['location'][0] ); 123 | } elseif ( isset( $properties['latitude'] ) && isset( $properties['longitude'] ) ) { 124 | // Convert latitude and longitude properties to an h-geo with altitude if present. 125 | static::$input['properties']['location'] = array( 126 | 'type' => array( 'h-geo' ), 127 | 'properties' => array( 128 | 'latitude' => $properties['latitude'], 129 | 'longitude' => $properties['longitude'], 130 | ), 131 | ); 132 | if ( isset( $properties['altitude'] ) ) { 133 | static::$input['properties']['location']['properties']['altitude'] = $properties['altitude']; 134 | unset( static::$input['properties']['altitude'] ); 135 | } 136 | unset( static::$input['properties']['latitude'] ); 137 | unset( static::$input['properties']['longitude'] ); 138 | } 139 | 140 | if ( isset( $properties['checkin'] ) ) { 141 | static::$input['properties']['checkin'] = self::parse_geo_uri( $properties['checkin'][0] ); 142 | } 143 | } 144 | 145 | static::$input = apply_filters( 'before_micropub', static::$input ); 146 | } 147 | 148 | /** 149 | * Check action and match to scope 150 | * 151 | * @param string $action 152 | * 153 | * @return boolean|WP_Micropub_Error 154 | **/ 155 | protected static function check_action( $action ) { 156 | switch ( $action ) { 157 | case 'delete': 158 | case 'undelete': 159 | $return = current_user_can( 'delete_posts' ); 160 | break; 161 | case 'update': 162 | $return = current_user_can( 'edit_published_posts' ); 163 | break; 164 | case 'create': 165 | $return = current_user_can( 'edit_posts' ); 166 | break; 167 | default: 168 | return new WP_Micropub_Error( 'invalid_request', 'Unknown Action', 400 ); 169 | } 170 | if ( $return ) { 171 | return true; 172 | } 173 | return new WP_Micropub_Error( 'insufficient_scope', sprintf( 'insufficient to %1$s posts', $action ), 403, static::$scopes ); 174 | } 175 | 176 | 177 | /** 178 | * Parse the micropub request and render the document 179 | * 180 | * @param WP_REST_Request $request. 181 | */ 182 | public static function post_handler( $request ) { 183 | $user_id = get_current_user_id(); 184 | $response = new WP_REST_Response(); 185 | $load = static::load_input( $request ); 186 | if ( is_micropub_error( $load ) ) { 187 | return $load; 188 | } 189 | 190 | $action = mp_get( static::$input, 'action', 'create' ); 191 | $url = mp_get( static::$input, 'url' ); 192 | 193 | // check that we support all requested syndication targets 194 | $synd_supported = self::get_syndicate_targets( $user_id ); 195 | $uids = array(); 196 | foreach ( $synd_supported as $syn ) { 197 | $uids[] = mp_get( $syn, 'uid' ); 198 | } 199 | 200 | $properties = mp_get( static::$input, 'properties' ); 201 | $synd_requested = mp_get( $properties, 'mp-syndicate-to' ); 202 | $unknown = array_diff( $synd_requested, $uids ); 203 | 204 | if ( $unknown ) { 205 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'Unknown mp-syndicate-to targets: %1$s', implode( ', ', $unknown ) ), 400 ); 206 | } 207 | // For all actions other than creation a url is required 208 | if ( ! $url && 'create' !== $action ) { 209 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'URL is Required for %1$s action', $action ), 400 ); 210 | } 211 | switch ( $action ) { 212 | case 'create': 213 | $args = static::create( $user_id ); 214 | if ( ! is_micropub_error( $args ) ) { 215 | $response->set_status( 201 ); 216 | $response->header( 'Location', get_permalink( $args['ID'] ) ); 217 | } 218 | break; 219 | case 'update': 220 | $args = static::update( static::$input ); 221 | break; 222 | case 'delete': 223 | $post_id = url_to_postid( $url ); 224 | $args = get_post( $post_id, ARRAY_A ); 225 | if ( ! $args ) { 226 | return new WP_Micropub_Error( 'invalid_request', sprintf( '%1$s not found', $url ), 400 ); 227 | } 228 | static::check_error( wp_trash_post( $args['ID'] ) ); 229 | break; 230 | case 'undelete': 231 | $found = false; 232 | // url_to_postid() doesn't support posts in trash, so look for 233 | // it ourselves, manually. 234 | // here's another, more complicated way that customizes WP_Query: 235 | // https://gist.github.com/peterwilsoncc/bb40e52cae7faa0e6efc 236 | foreach ( get_posts( 237 | array( 238 | 'post_status' => 'trash', 239 | 'fields' => 'ids', 240 | ) 241 | ) as $post_id ) { 242 | if ( get_the_guid( $post_id ) === $url ) { 243 | wp_untrash_post( $post_id ); 244 | wp_publish_post( $post_id ); 245 | $found = true; 246 | $args = array( 'ID' => $post_id ); 247 | } 248 | } 249 | if ( ! $found ) { 250 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'deleted post %1$s not found', $url ), 400 ); 251 | } 252 | break; 253 | default: 254 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'unknown action %1$s', $action ), 400 ); 255 | } 256 | if ( is_micropub_error( $args ) ) { 257 | return $args; 258 | } 259 | do_action( 'after_micropub', static::$input, $args ); 260 | 261 | if ( ! empty( $synd_requested ) ) { 262 | do_action( 'micropub_syndication', $args['ID'], $synd_requested ); 263 | } 264 | 265 | $response->set_data( $args ); 266 | return $response; 267 | } 268 | 269 | private static function get_syndicate_targets( $user_id, $input = null ) { 270 | return apply_filters( 'micropub_syndicate-to', array(), $user_id, $input ); 271 | } 272 | 273 | /** 274 | * Handle queries to the micropub endpoint 275 | * 276 | * @param WP_REST_Request $request 277 | */ 278 | public static function query_handler( $request ) { 279 | $user_id = get_current_user_id(); 280 | static::load_input( $request ); 281 | 282 | switch ( static::$input['q'] ) { 283 | case 'config': 284 | $resp = array( 285 | 'syndicate-to' => static::get_syndicate_targets( $user_id, static::$input ), 286 | 'media-endpoint' => rest_url( static::get_namespace() . '/media' ), 287 | // Support returning visibility properties in q=config https://github.com/indieweb/micropub-extensions/issues/8#issuecomment-536301952 288 | 'visibility' => array( 'public', 'private' ), 289 | 'mp' => array( 290 | 'slug', 291 | 'syndicate-to', 292 | ), // List of supported mp parameters 293 | 'q' => array( 294 | 'config', 295 | 'syndicate-to', 296 | 'category', 297 | 'source', 298 | ), // List of supported query parameters https://github.com/indieweb/micropub-extensions/issues/7 299 | 'properties' => array( 300 | 'location-visibility', 301 | ), // List of support properties https://github.com/indieweb/micropub-extensions/issues/8 302 | ); 303 | break; 304 | case 'syndicate-to': 305 | // return syndication targets with filter 306 | $resp = array( 'syndicate-to' => static::get_syndicate_targets( $user_id, static::$input ) ); 307 | break; 308 | case 'category': 309 | // https://github.com/indieweb/micropub-extensions/issues/5 310 | $resp = array_merge( 311 | get_tags( array( 'fields' => 'names' ) ), 312 | get_terms( 313 | array( 314 | 'taxonomy' => 'category', 315 | 'fields' => 'names', 316 | ) 317 | ) 318 | ); 319 | if ( array_key_exists( 'filter', static::$input ) ) { 320 | $filter = static::$input['filter']; 321 | $resp = mp_filter( $resp, $filter ); 322 | } 323 | $resp = array( 'categories' => $resp ); 324 | break; 325 | case 'source': 326 | if ( array_key_exists( 'url', static::$input ) ) { 327 | $post_id = url_to_postid( static::$input['url'] ); 328 | if ( ! $post_id ) { 329 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'not found: %1$s', static::$input['url'] ), 400 ); 330 | } 331 | $resp = self::query( $post_id ); 332 | } else { 333 | $args = array( 334 | 'posts_per_page' => mp_get( static::$input, 'limit', 10 ), 335 | 'fields' => 'ids', 336 | ); 337 | if ( array_key_exists( 'offset', static::$input ) ) { 338 | $args['offset'] = mp_get( static::$input, 'offset' ); 339 | } 340 | 341 | if ( array_key_exists( 'visibility', static::$input ) ) { 342 | $visibilitylist = array( array( 'private' ), array( 'public' ) ); 343 | if ( ! in_array( static::$input['visibility'], $visibilitylist, true ) ) { 344 | // Returning null will cause the server to return a 400 error 345 | return null; 346 | } 347 | if ( array( 'private' ) === static::$input['visibility'] ) { 348 | if ( user_can( $user_id, 'read_private_posts' ) ) { 349 | $args['post-status'] = 'private'; 350 | } 351 | } 352 | } elseif ( array_key_exists( 'post-status', static::$input ) ) { 353 | // According to the proposed specification these are the only two properties supported. 354 | // https://indieweb.org/Micropub-extensions#Post_Status 355 | // For now these are the only two we will support even though WordPress defaults to 8 and allows custom 356 | // But makes it easy to change 357 | 358 | // Map published to the WordPress property publish. 359 | if ( 'published' === mp_get( static::$input, 'post-status' ) ) { 360 | $args['post-status'] = 'publish'; 361 | } elseif ( 'draft' === mp_get( static::$input, 'post-status' ) ) { 362 | $args['post-status'] = 'draft'; 363 | } 364 | } 365 | $posts = get_posts( $args ); 366 | $resp = array(); 367 | foreach ( $posts as $post ) { 368 | $resp[] = self::query( $post ); 369 | } 370 | $resp = array( 'items' => $resp ); 371 | } 372 | 373 | break; 374 | default: 375 | $resp = new WP_Micropub_Error( 'invalid_request', 'unknown query', 400, static::$input ); 376 | } 377 | $resp = apply_filters( 'micropub_query', $resp, static::$input ); 378 | if ( is_wp_error( $resp ) ) { 379 | return $resp; 380 | } 381 | do_action( 'after_micropub', static::$input, null ); 382 | return new WP_REST_Response( $resp, 200 ); 383 | } 384 | 385 | /* Query a format. 386 | * 387 | * @param int $post_id Post ID 388 | * 389 | * @return array MF2 Formatted Array 390 | */ 391 | public static function query( $post_id ) { 392 | $resp = static::get_mf2( $post_id ); 393 | 394 | $props = mp_get( static::$input, 'properties' ); 395 | 396 | if ( $props ) { 397 | if ( ! is_array( $props ) ) { 398 | $props = array( $props ); 399 | } 400 | $resp = array( 401 | 'properties' => array_intersect_key( 402 | $resp['properties'], 403 | array_flip( $props ) 404 | ), 405 | ); 406 | } 407 | 408 | return $resp; 409 | } 410 | 411 | /* 412 | * Insert Post 413 | * 414 | */ 415 | private static function insert_post( &$args ) { 416 | 417 | /** 418 | * This filters arguments before inserting into the Post Table. 419 | * If $args['ID'] is set, this will short circuit insertion to allow for custom database insertion. 420 | */ 421 | $args = apply_filters( 'pre_insert_micropub_post', $args ); 422 | if ( array_key_exists( 'ID', $args ) ) { 423 | return; 424 | } 425 | kses_remove_filters(); // prevent sanitizing HTML tags in post_content 426 | $args['ID'] = static::check_error( wp_insert_post( $args, true ) ); 427 | 428 | // Set Client Application Taxonomy if available. 429 | if ( $args['ID'] && array_key_exists( 'client_uid', static::$micropub_auth_response ) ) { 430 | wp_set_object_terms( $args['ID'], array( static::$micropub_auth_response['client_uid'] ), 'indieauth_client' ); 431 | } 432 | 433 | $args['post_url'] = get_permalink( $args['ID'] ); 434 | kses_init_filters(); 435 | } 436 | 437 | /* 438 | * Handle a create request. 439 | */ 440 | private static function create( $user_id ) { 441 | $args = static::mp_to_wp( static::$input ); 442 | 443 | // Allow Filtering of Post Type 444 | $args['post_type'] = apply_filters( 'micropub_post_type', 'post', static::$input ); 445 | 446 | // Allow filtering of Tax Input 447 | $args['tax_input'] = apply_filters( 'micropub_tax_input', null, static::$input ); 448 | 449 | $args = static::store_micropub_auth_response( $args ); 450 | 451 | $post_content = mp_get( $args, 'post_content', '' ); 452 | $post_content = apply_filters( 'micropub_post_content', $post_content, static::$input ); 453 | if ( $post_content ) { 454 | $args['post_content'] = $post_content; 455 | } 456 | 457 | $args = static::store_mf2( $args ); 458 | $args = static::store_geodata( $args ); 459 | if ( is_micropub_error( $args ) ) { 460 | return $args; 461 | } 462 | 463 | if ( $user_id ) { 464 | $args['post_author'] = $user_id; 465 | } 466 | 467 | // If the current user cannot publish posts then post status is always draft 468 | if ( ! user_can( $user_id, 'publish_posts' ) && user_can( $user_id, 'edit_posts' ) ) { 469 | $args['post_status'] = 'draft'; 470 | } else { 471 | $args['post_status'] = static::post_status( static::$input ); 472 | } 473 | if ( ! $args['post_status'] ) { 474 | return new WP_Micropub_Error( 'invalid_request', 'Invalid Post Status', 400 ); 475 | } 476 | if ( WP_DEBUG ) { 477 | static::log_error( $args, 'wp_insert_post with args' ); 478 | } 479 | 480 | static::insert_post( $args ); 481 | 482 | static::default_file_handler( $args['ID'] ); 483 | return $args; 484 | } 485 | 486 | /* 487 | * Update Post 488 | * 489 | */ 490 | private static function update_post( &$args ) { 491 | kses_remove_filters(); // prevent sanitizing HTML tags in post_content 492 | $args['ID'] = static::check_error( wp_update_post( $args, true ) ); 493 | $args['post_url'] = get_permalink( $args['ID'] ); 494 | kses_init_filters(); 495 | } 496 | 497 | /* 498 | * Handle an update request. 499 | * 500 | * This really needs a db transaction! But we can't assume the underlying 501 | * MySQL db is InnoDB and supports transactions. :( 502 | */ 503 | private static function update( $input ) { 504 | $post_id = url_to_postid( $input['url'] ); 505 | $args = get_post( $post_id, ARRAY_A ); 506 | if ( ! $args ) { 507 | return new WP_Micropub_Error( 'invalid_request', sprintf( '%1$s not found', $input['url'] ), 400 ); 508 | } 509 | 510 | // add 511 | $add = mp_get( $input, 'add', false ); 512 | if ( $add ) { 513 | if ( ! is_array( $add ) ) { 514 | return new WP_Micropub_Error( 'invalid_request', 'add must be an object', 400 ); 515 | } 516 | if ( array_diff( array_keys( $add ), array( 'category', 'syndication' ) ) ) { 517 | return new WP_Micropub_Error( 'invalid_request', 'can only add to category and syndication; other properties not supported', 400 ); 518 | } 519 | $add_args = static::mp_to_wp( array( 'properties' => $add ) ); 520 | if ( $add_args['tags_input'] ) { 521 | // i tried wp_add_post_tags here, but it didn't work 522 | $args['tags_input'] = array_merge( 523 | $args['tags_input'] ? $args['tags_input'] : array(), 524 | $add_args['tags_input'] 525 | ); 526 | } 527 | if ( $add_args['post_category'] ) { 528 | // i tried wp_set_post_categories here, but it didn't work 529 | $args['post_category'] = array_merge( 530 | $args['post_category'] ? $args['post_category'] : array(), 531 | $add_args['post_category'] 532 | ); 533 | } 534 | } 535 | // Delete was moved to before replace in versions greater than 1.4.3 due to the fact that all items should be removed before replacement 536 | // delete 537 | $delete = mp_get( $input, 'delete', false ); 538 | if ( $delete ) { 539 | if ( is_assoc_array( $delete ) ) { 540 | if ( array_diff( array_keys( $delete ), array( 'category', 'syndication' ) ) ) { 541 | return new WP_Micropub_Error( 'invalid_request', 'can only delete individual values from category and syndication; other properties not supported', 400 ); 542 | } 543 | $delete_args = static::mp_to_wp( array( 'properties' => $delete ) ); 544 | if ( $delete_args['tags_input'] ) { 545 | $args['tags_input'] = array_diff( 546 | $args['tags_input'] ? $args['tags_input'] : array(), 547 | $delete_args['tags_input'] 548 | ); 549 | } 550 | if ( $delete_args['post_category'] ) { 551 | $args['post_category'] = array_diff( 552 | $args['post_category'] ? $args['post_category'] : array(), 553 | $delete_args['post_category'] 554 | ); 555 | } 556 | } elseif ( wp_is_numeric_array( $delete ) ) { 557 | $delete = array_flip( $delete ); 558 | if ( array_key_exists( 'category', $delete ) ) { 559 | wp_delete_object_term_relationships( $post_id, array( 'post_tag', 'category' ) ); 560 | unset( $args['tags_input'] ); 561 | unset( $args['post_category'] ); 562 | } 563 | $delete = static::mp_to_wp( array( 'properties' => $delete ) ); 564 | if ( ! empty( $delete ) && is_assoc_array( $delete ) ) { 565 | foreach ( $delete as $name => $_ ) { 566 | $args[ $name ] = null; 567 | } 568 | } 569 | } else { 570 | return new WP_Micropub_Error( 'invalid_request', 'delete must be an array or object', 400 ); 571 | } 572 | } 573 | 574 | // replace 575 | $replace = mp_get( $input, 'replace', false ); 576 | if ( $replace ) { 577 | if ( ! is_array( $replace ) ) { 578 | return new WP_Micropub_Error( 'invalid_request', 'replace must be an object', 400 ); 579 | } 580 | foreach ( static::mp_to_wp( array( 'properties' => $replace ) ) 581 | as $name => $val ) { 582 | $args[ $name ] = $val; 583 | } 584 | } 585 | 586 | // tell WordPress to preserve published date explicitly, otherwise 587 | // wp_update_post sets it to the current time 588 | $args['edit_date'] = true; 589 | 590 | /* Filter Post Content 591 | * Post Content is initially generated from content properties in the mp_to_wp function however this function is called 592 | * multiple times for replace and delete 593 | */ 594 | $post_content = mp_get( $args, 'post_content', '' ); 595 | $post_content = apply_filters( 'micropub_post_content', $post_content, static::$input ); 596 | if ( $post_content ) { 597 | $args['post_content'] = $post_content; 598 | } 599 | 600 | // Store metadata from Microformats Properties 601 | $args = static::store_mf2( $args ); 602 | $args = static::store_geodata( $args ); 603 | 604 | if ( 0 !== get_current_user_id() ) { 605 | if ( ! array_key_exists( 'meta_input', $args ) ) { 606 | $args['meta_input'] = array(); 607 | } 608 | 609 | $args['meta_input']['_edit_last'] = get_current_user_id(); 610 | } 611 | 612 | if ( WP_DEBUG ) { 613 | static::log_error( $args, 'wp_update_post with args' ); 614 | } 615 | 616 | static::update_post( $args ); 617 | 618 | static::default_file_handler( $post_id ); 619 | return $args; 620 | } 621 | 622 | private static function default_post_status() { 623 | return MICROPUB_DRAFT_MODE ? 'draft' : 'publish'; 624 | } 625 | 626 | private static function post_status( $mf2 ) { 627 | $props = $mf2['properties']; 628 | // If both are not set immediately return 629 | if ( ! isset( $props['post-status'] ) && ! isset( $props['visibility'] ) ) { 630 | return self::default_post_status(); 631 | } 632 | if ( isset( $props['visibility'] ) ) { 633 | $visibilitylist = array( array( 'private' ), array( 'public' ) ); 634 | if ( ! in_array( $props['visibility'], $visibilitylist, true ) ) { 635 | // Returning null will cause the server to return a 400 error 636 | return null; 637 | } 638 | if ( array( 'private' ) === $props['visibility'] ) { 639 | return 'private'; 640 | } 641 | } 642 | if ( isset( $props['post-status'] ) ) { 643 | // According to the proposed specification these are the only two properties supported. 644 | // https://indieweb.org/Micropub-extensions#Post_Status 645 | // For now these are the only two we will support even though WordPress defaults to 8 and allows custom 646 | // But makes it easy to change 647 | $statuslist = array( array( 'published' ), array( 'draft' ) ); 648 | if ( ! in_array( $props['post-status'], $statuslist, true ) ) { 649 | // Returning null will cause the server to return a 400 error 650 | return null; 651 | } 652 | // Map published to the WordPress property publish. 653 | if ( array( 'published' ) === $props['post-status'] ) { 654 | return 'publish'; 655 | } 656 | return 'draft'; 657 | } 658 | 659 | // If visibility is public and no post-status is specified, 660 | // return the default post status value. 661 | return self::default_post_status(); 662 | } 663 | 664 | /** 665 | * Generates a suggestion for a title based on mf2 properties. 666 | * This can be used to generate a post slug 667 | * $mf2 MF2 Properties 668 | * 669 | */ 670 | private static function suggest_post_title( $mf2 ) { 671 | $props = mp_get( $mf2, 'properties' ); 672 | if ( isset( $props['name'] ) ) { 673 | return $props['name']; 674 | } 675 | return apply_filters( 'micropub_suggest_title', '', $props ); 676 | } 677 | 678 | /** 679 | * Converts Micropub create, update, or delete request to args for WordPress 680 | * wp_insert_post() or wp_update_post(). 681 | * 682 | * For updates, reads the existing post and starts with its data: 683 | * 'replace' properties are replaced 684 | * 'add' properties are added. the new value in $args will contain both the 685 | * existing and new values. 686 | * 'delete' properties are set to NULL 687 | * 688 | * Uses $input, so load_input() must be called before this. 689 | */ 690 | private static function mp_to_wp( $mf2 ) { 691 | $props = mp_get( $mf2, 'properties' ); 692 | $args = array(); 693 | 694 | foreach ( array( 695 | 'mp-slug' => 'post_name', 696 | 'name' => 'post_title', 697 | 'summary' => 'post_excerpt', 698 | ) as $mf => $wp ) { 699 | if ( isset( $props[ $mf ] ) ) { 700 | $args[ $wp ] = static::get( $props[ $mf ], 0 ); 701 | } 702 | } 703 | 704 | // perform these functions only for creates 705 | if ( ! isset( $args['ID'] ) && ! isset( $args['post_name'] ) ) { 706 | $slug = static::suggest_post_title( $mf2 ); 707 | if ( ! empty( $slug ) ) { 708 | $args['post_name'] = $slug; 709 | } 710 | } 711 | if ( isset( $args['post_name'] ) ) { 712 | if ( is_array( $args['post_name'] ) ) { 713 | $args['post_name'] = array_key_first( $args['post_name'] ); 714 | } 715 | $args['post_name'] = sanitize_title( $args['post_name'] ); 716 | } 717 | 718 | if ( isset( $props['published'] ) ) { 719 | $date = new DateTime( $props['published'][0] ); 720 | // If for whatever reason the date cannot be parsed do not include one which defaults to now 721 | if ( $date ) { 722 | $wptz = wp_timezone(); 723 | $tz = $date->getTimezone(); 724 | $date->setTimeZone( $wptz ); 725 | // Pass this argument to the filter for use 726 | $args['timezone'] = $tz->getName(); 727 | $args['post_date'] = $date->format( 'Y-m-d H:i:s' ); 728 | $date->setTimeZone( new DateTimeZone( 'GMT' ) ); 729 | $args['post_date_gmt'] = $date->format( 'Y-m-d H:i:s' ); 730 | } 731 | } 732 | 733 | if ( isset( $props['updated'] ) ) { 734 | $date = new DateTime( $props['updated'][0] ); 735 | // If for whatever reason the date cannot be parsed do not include one which defaults to now 736 | if ( $date ) { 737 | $wptz = wp_timezone(); 738 | $date->setTimeZone( $wptz ); 739 | $tz = $date->getTimezone(); 740 | // Pass this argument to the filter for use 741 | $args['timezone'] = $tz->getName(); 742 | $args['post_modified'] = $date->format( 'Y-m-d H:i:s' ); 743 | $date->setTimeZone( new DateTimeZone( 'GMT' ) ); 744 | $args['post_modified_gmt'] = $date->format( 'Y-m-d H:i:s' ); 745 | } 746 | } 747 | 748 | // Map micropub categories to WordPress categories if they exist, otherwise 749 | // to WordPress tags. 750 | if ( isset( $props['category'] ) && is_array( $props['category'] ) ) { 751 | $args['post_category'] = array(); 752 | $args['tags_input'] = array(); 753 | foreach ( $props['category'] as $mp_cat ) { 754 | $wp_cat = get_category_by_slug( $mp_cat ); 755 | if ( $wp_cat ) { 756 | $args['post_category'][] = $wp_cat->term_id; 757 | } else { 758 | $args['tags_input'][] = $mp_cat; 759 | } 760 | } 761 | } 762 | if ( isset( $props['content'] ) ) { 763 | $content = $props['content'][0]; 764 | if ( is_array( $content ) ) { 765 | $args['post_content'] = $content['html'] ? $content['html'] : 766 | htmlspecialchars( $content['value'] ); 767 | } elseif ( $content ) { 768 | $args['post_content'] = htmlspecialchars( $content ); 769 | } 770 | } 771 | return $args; 772 | } 773 | 774 | /** 775 | * Handles Photo Upload. 776 | * 777 | */ 778 | public static function default_file_handler( $post_id ) { 779 | foreach ( array( 'photo', 'video', 'audio', 'featured' ) as $field ) { 780 | $props = mp_get( static::$input, 'properties' ); 781 | $att_ids = array(); 782 | 783 | if ( isset( static::$files[ $field ] ) || isset( $props[ $field ] ) ) { 784 | if ( isset( static::$files[ $field ] ) ) { 785 | $files = static::$files[ $field ]; 786 | if ( is_array( $files['name'] ) ) { 787 | $files = Micropub_Media::file_array( $files ); 788 | foreach ( $files as $file ) { 789 | $att_ids[] = static::check_error( 790 | Micropub_Media::media_handle_upload( $file, $post_id ) 791 | ); 792 | } 793 | } else { 794 | $att_ids[] = static::check_error( 795 | Micropub_Media::media_handle_upload( $files, $post_id ) 796 | ); 797 | } 798 | } elseif ( isset( $props[ $field ] ) ) { 799 | foreach ( $props[ $field ] as $val ) { 800 | $url = is_array( $val ) ? $val['value'] : $val; 801 | $desc = is_array( $val ) ? $val['alt'] : null; 802 | $att_ids[] = static::check_error( 803 | Micropub_Media::media_sideload_url( 804 | $url, 805 | $post_id, 806 | $desc 807 | ) 808 | ); 809 | } 810 | } 811 | 812 | $att_urls = array(); 813 | foreach ( $att_ids as $id ) { 814 | if ( is_micropub_error( $id ) ) { 815 | return $id; 816 | } 817 | // There should only be one of these. 818 | if ( 'featured' === $field ) { 819 | set_post_thumbnail( $post_id, $id ); 820 | } 821 | $att_urls[] = wp_get_attachment_url( $id ); 822 | } 823 | // Add to the input so will be visible to the after_micropub action 824 | if ( ! isset( static::$input['properties'][ $field ] ) ) { 825 | static::$input['properties'][ $field ] = $att_urls; 826 | } else { 827 | static::$input['properties'][ $field ] = array_merge( static::$input['properties'][ $field ], $att_urls ); 828 | } 829 | add_post_meta( $post_id, 'mf2_' . $field, $att_urls, true ); 830 | } 831 | } 832 | } 833 | 834 | /** 835 | * Stores geodata in WordPress format. 836 | * 837 | * Reads from the location and checkin properties. checkin isn't an official 838 | * mf2 property yet, but OwnYourSwarm sends it: 839 | * https://ownyourswarm.p3k.io/docs#checkins 840 | * 841 | * WordPress geo data is stored in post meta: geo_address (free text), 842 | * geo_latitude, geo_longitude, and geo_public: 843 | * https://codex.wordpress.org/Geodata 844 | * It is noted that should the HTML5 style geolocation properties of altitude, accuracy, speed, and heading are 845 | * used they would use the same geo prefix. Simple Location stores these when available using accuracy to estimate 846 | * map zoom when displayed. 847 | */ 848 | public static function store_geodata( $args ) { 849 | $properties = static::get( static::$input, 'properties' ); 850 | $location = static::get( $properties, 'location', static::get( $properties, 'checkin' ) ); 851 | $location = static::get( $location, 0, $location ); 852 | 853 | // Location-visibility is an experimental property https://indieweb.org/Micropub-extensions#Location_Visibility 854 | // It attempts to mimic the geo_public property 855 | $visibility = static::get( $properties, 'location-visibility', null ); 856 | if ( $visibility ) { 857 | $visibility = array_pop( $visibility ); 858 | if ( ! isset( $args['meta_input'] ) ) { 859 | $args['meta_input'] = array(); 860 | } 861 | switch ( $visibility ) { 862 | // Currently supported by https://github.com/dshanske/simple-location as part of the Geodata store noted in codex link above 863 | // Public indicates coordinates, map, and textual description displayed 864 | case 'public': 865 | $args['meta_input']['geo_public'] = 1; 866 | break; 867 | // Private indicates no display 868 | case 'private': 869 | $args['meta_input']['geo_public'] = 0; 870 | break; 871 | // Protected which is not in the original geodata spec is used by Simple Location to indicate textual description only 872 | case 'protected': 873 | $args['meta_input']['geo_public'] = 2; 874 | break; 875 | default: 876 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'unsupported location visibility %1$s', $visibility ), 400 ); 877 | 878 | } 879 | } 880 | if ( $location ) { 881 | if ( ! isset( $args['meta_input'] ) ) { 882 | $args['meta_input'] = array(); 883 | } 884 | if ( is_array( $location ) ) { 885 | $props = $location['properties']; 886 | if ( isset( $props['geo'] ) ) { 887 | if ( array_key_exists( 'label', $props ) ) { 888 | $args['meta_input']['geo_address'] = $props['label'][0]; 889 | } 890 | $props = $props['geo'][0]['properties']; 891 | } else { 892 | $parts = array( 893 | mp_get( $props, 'name', array(), true ), 894 | mp_get( $props, 'street-address', array(), true ), 895 | mp_get( $props, 'locality', array(), true ), 896 | mp_get( $props, 'region', array(), true ), 897 | mp_get( $props, 'postal-code', array(), true ), 898 | mp_get( $props, 'country-name', array(), true ), 899 | ); 900 | $parts = array_filter( $parts ); 901 | if ( ! empty( $parts ) ) { 902 | $args['meta_input']['geo_address'] = implode( 903 | ', ', 904 | array_filter( 905 | $parts, 906 | function ( $v ) { 907 | return $v; 908 | } 909 | ) 910 | ); 911 | } 912 | } 913 | foreach ( array( 'latitude', 'longitude', 'altitude', 'accuracy' ) as $property ) { 914 | if ( array_key_exists( $property, $props ) ) { 915 | $args['meta_input'][ 'geo_' . $property ] = $props[ $property ][0]; 916 | } 917 | } 918 | } elseif ( 'http' !== substr( $location, 0, 4 ) ) { 919 | $args['meta_input']['geo_address'] = $location; 920 | } 921 | } 922 | return $args; 923 | } 924 | 925 | /** 926 | * Parse a GEO URI into an mf2 object for storage 927 | */ 928 | public static function parse_geo_uri( $uri ) { 929 | if ( ! is_string( $uri ) ) { 930 | return $uri; 931 | } 932 | // Ensure this is a geo uri 933 | if ( 'geo:' !== substr( $uri, 0, 4 ) ) { 934 | return $uri; 935 | } 936 | $properties = array(); 937 | // Geo URI format: 938 | // http://en.wikipedia.org/wiki/Geo_URI#Example 939 | // https://indieweb.org/Micropub#h-entry 940 | // 941 | // e.g. geo:37.786971,-122.399677;u=35 942 | $geo = str_replace( 'geo:', '', urldecode( $uri ) ); 943 | $geo = explode( ';', $geo ); 944 | $coords = explode( ',', $geo[0] ); 945 | $properties['latitude'] = array( trim( $coords[0] ) ); 946 | $properties['longitude'] = array( trim( $coords[1] ) ); 947 | // Geo URI optionally allows for altitude to be stored as a third csv 948 | if ( isset( $coords[2] ) ) { 949 | $properties['altitude'] = array( trim( $coords[2] ) ); 950 | } 951 | // Store additional parameters 952 | array_shift( $geo ); // Remove coordinates to check for other parameters 953 | foreach ( $geo as $g ) { 954 | $g = explode( '=', $g ); 955 | if ( 'u' === $g[0] ) { 956 | $g[0] = 'accuracy'; 957 | } 958 | $properties[ $g[0] ] = array( $g[1] ); 959 | } 960 | // If geo URI is overloaded h-card... e.g. geo:37.786971,-122.399677;u=35;h=card;name=Home;url=https://example.com 961 | if ( array_key_exists( 'h', $properties ) ) { 962 | $type = array( 'h-' . $properties['h'][0] ); 963 | unset( $properties['h'] ); 964 | } else { 965 | $diff = array_diff( 966 | array_keys( $properties ), 967 | array( 'longitude', 'latitude', 'altitude', 'accuracy' ) 968 | ); 969 | // If empty that means this is a geo 970 | if ( empty( $diff ) ) { 971 | $type = array( 'h-geo' ); 972 | } else { 973 | $type = array( 'h-card' ); 974 | } 975 | } 976 | 977 | return array( 978 | 'type' => $type, 979 | 'properties' => array_filter( $properties ), 980 | ); 981 | } 982 | 983 | /** 984 | * Store the return of the authorization endpoint as post metadata. 985 | */ 986 | public static function store_micropub_auth_response( $args ) { 987 | $micropub_auth_response = static::$micropub_auth_response; 988 | if ( $micropub_auth_response || ( is_assoc_array( $micropub_auth_response ) ) ) { 989 | $args['meta_input'] = mp_get( $args, 'meta_input' ); 990 | $args['meta_input']['micropub_auth_response'] = wp_array_slice_assoc( $micropub_auth_response, array( 'client_id', 'client_name', 'client_icon', 'uuid' ) ); 991 | $args['meta_input']['micropub_version']['version'] = micropub_get_plugin_version(); 992 | } 993 | return $args; 994 | } 995 | 996 | /** 997 | * Store properties as post metadata. Details: 998 | * https://indiewebcamp.com/WordPress_Data#Microformats_data 999 | * 1000 | * Uses $input, so load_input() must be called before this. 1001 | * 1002 | * If the request is a create, this populates $args['meta_input']. If the 1003 | * request is an update, it changes the post meta values in the db directly. 1004 | */ 1005 | public static function store_mf2( $args ) { 1006 | // Properties that map to WordPress properties. 1007 | $excludes = array( 'name', 'published', 'updated', 'summary', 'content', 'visibility' ); 1008 | $props = mp_get( static::$input, 'properties', false ); 1009 | if ( ! isset( $args['ID'] ) && $props ) { 1010 | $args['meta_input'] = mp_get( $args, 'meta_input' ); 1011 | $type = mp_get( static::$input, 'type' ); 1012 | if ( $type ) { 1013 | $args['meta_input']['mf2_type'] = $type; 1014 | } 1015 | if ( isset( $args['timezone'] ) ) { 1016 | $args['meta_input']['geo_timezone'] = $args['timezone']; 1017 | } 1018 | foreach ( $props as $key => $val ) { 1019 | // mp- entries are commands not properties and are therefore not stored. 1020 | if ( 'mp-' !== substr( $key, 0, 3 ) && ! in_array( $key, $excludes, true ) ) { 1021 | $args['meta_input'][ 'mf2_' . $key ] = $val; 1022 | } 1023 | } 1024 | return $args; 1025 | } 1026 | 1027 | $replace = static::get( static::$input, 'replace', null ); 1028 | if ( $replace ) { 1029 | foreach ( $replace as $prop => $val ) { 1030 | update_post_meta( $args['ID'], 'mf2_' . $prop, $val ); 1031 | } 1032 | } 1033 | 1034 | $meta = get_post_meta( $args['ID'] ); 1035 | $add = static::get( static::$input, 'add', null ); 1036 | if ( $add ) { 1037 | foreach ( $add as $prop => $val ) { 1038 | $key = 'mf2_' . $prop; 1039 | if ( array_key_exists( $key, $meta ) ) { 1040 | $cur = $meta[ $key ][0] ? unserialize( $meta[ $key ][0] ) : array(); 1041 | update_post_meta( $args['ID'], $key, array_merge( $cur, $val ) ); 1042 | } else { 1043 | update_post_meta( $args['ID'], $key, $val ); 1044 | } 1045 | } 1046 | } 1047 | 1048 | $delete = static::get( static::$input, 'delete', null ); 1049 | if ( $delete ) { 1050 | if ( is_assoc_array( $delete ) ) { 1051 | foreach ( $delete as $prop => $to_delete ) { 1052 | $key = 'mf2_' . $prop; 1053 | if ( isset( $meta[ $key ] ) ) { 1054 | $existing = unserialize( $meta[ $key ][0] ); 1055 | update_post_meta( 1056 | $args['ID'], 1057 | $key, 1058 | array_diff( $existing, $to_delete ) 1059 | ); 1060 | } 1061 | } 1062 | } else { 1063 | foreach ( $delete as $_ => $prop ) { 1064 | delete_post_meta( $args['ID'], 'mf2_' . $prop ); 1065 | if ( 'location' === $prop ) { 1066 | delete_post_meta( $args['ID'], 'geo_latitude' ); 1067 | delete_post_meta( $args['ID'], 'geo_longitude' ); 1068 | } 1069 | } 1070 | } 1071 | } 1072 | 1073 | return $args; 1074 | } 1075 | 1076 | /* Takes form encoded input and converts to json encoded input */ 1077 | public static function form_to_json( $data ) { 1078 | $input = array(); 1079 | foreach ( $data as $key => $val ) { 1080 | if ( 'action' === $key || 'url' === $key ) { 1081 | $input[ $key ] = $val; 1082 | } elseif ( 'h' === $key ) { 1083 | $input['type'] = array( 'h-' . $val ); 1084 | } elseif ( 'access_token' === $key ) { 1085 | continue; 1086 | } else { 1087 | $input['properties'] = mp_get( $input, 'properties' ); 1088 | $input['properties'][ $key ] = 1089 | ( is_array( $val ) && wp_is_numeric_array( $val ) ) 1090 | ? $val : array( $val ); 1091 | } 1092 | } 1093 | return $input; 1094 | } 1095 | 1096 | /* Ensures JSON is compliant with the Micropub JSON Syntax */ 1097 | public static function normalize_json( $data ) { 1098 | if ( ! array_key_exists( 'properties', $data ) ) { 1099 | return $data; 1100 | } 1101 | foreach ( $data['properties'] as $key => $value ) { 1102 | if ( ! is_array( $value ) ) { 1103 | $data['properties'][ $key ] = array( $value ); 1104 | } 1105 | } 1106 | return $data; 1107 | } 1108 | } 1109 | -------------------------------------------------------------------------------- /includes/class-micropub-error.php: -------------------------------------------------------------------------------- 1 | set_status( $code ); 7 | $data = array( 8 | 'error' => $error, 9 | 'error_description' => $error_description, 10 | 'data' => $debug, 11 | ); 12 | $data = array_filter( $data ); 13 | $this->set_data( $data ); 14 | if ( WP_DEBUG && ! defined( 'DIR_TESTDATA' ) ) { 15 | error_log( $this->to_log() ); // phpcs:ignore 16 | } 17 | } 18 | 19 | public function set_debug( $a ) { 20 | $data = $this->get_data(); 21 | $this->set_data( array_merge( $data, $a ) ); 22 | } 23 | 24 | public function to_wp_error() { 25 | $data = $this->get_data(); 26 | $status = $this->get_status(); 27 | return new WP_Error( 28 | $data['error'], 29 | $data['error_description'], 30 | array( 31 | 'status' => $status, 32 | 'data' => mp_get( $data, 'data' ), 33 | ) 34 | ); 35 | } 36 | 37 | public function to_log() { 38 | $data = $this->get_data(); 39 | $status = $this->get_status(); 40 | $debug = mp_get( $data, 'debug', array() ); 41 | return sprintf( 'Micropub Error: %1$s %2$s - %3$s', $status, $data['error'], $data['error_description'], wp_json_encode( $debug ) ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /includes/class-micropub-media.php: -------------------------------------------------------------------------------- 1 | WP_REST_Server::CREATABLE, 39 | 'callback' => array( static::class, 'post_handler' ), 40 | 'permission_callback' => array( static::class, 'check_create_permissions' ), 41 | ), 42 | array( 43 | 'methods' => WP_REST_Server::READABLE, 44 | 'callback' => array( static::class, 'query_handler' ), 45 | 'permission_callback' => array( static::class, 'check_query_permissions' ), 46 | ), 47 | ) 48 | ); 49 | } 50 | 51 | public static function check_create_permissions( $request ) { 52 | $auth = self::load_auth(); 53 | if ( is_wp_error( $auth ) ) { 54 | return $auth; 55 | } 56 | 57 | if ( ! current_user_can( 'upload_files' ) ) { 58 | $error = new WP_Micropub_Error( 'insufficient_scope', 'You do not have permission to create or upload media', 403 ); 59 | return $error->to_wp_error(); 60 | } 61 | return true; 62 | } 63 | 64 | // Based on WP_REST_Attachments_Controller function of the same name 65 | // TODO: Hook main endpoint functionality into and extend to use this class 66 | 67 | public static function upload_from_file( $files, $name = null, $headers = array() ) { 68 | 69 | if ( empty( $files ) ) { 70 | return new WP_Micropub_Error( 'invalid_request', 'No data supplied', 400 ); 71 | } 72 | 73 | // Pass off to WP to handle the actual upload. 74 | $overrides = array( 75 | 'test_form' => false, 76 | ); 77 | 78 | // Verify hash, if given. 79 | if ( ! empty( $headers['content_md5'] ) ) { 80 | $content_md5 = array_shift( $headers['content_md5'] ); 81 | $expected = trim( $content_md5 ); 82 | $actual = md5_file( $files['file']['tmp_name'] ); 83 | 84 | if ( $expected !== $actual ) { 85 | return new WP_Micropub_Error( 'invalid_request', 'Content hash did not match expected.', 412 ); 86 | } 87 | } 88 | 89 | // Bypasses is_uploaded_file() when running unit tests. 90 | if ( defined( 'DIR_TESTDATA' ) && DIR_TESTDATA ) { 91 | $overrides['action'] = 'wp_handle_mock_upload'; 92 | } 93 | 94 | /** Include admin functions to get access to wp_handle_upload() */ 95 | require_once ABSPATH . 'wp-admin/includes/file.php'; 96 | 97 | if ( $name && isset( $files[ $name ] ) && is_array( $files[ $name ] ) ) { 98 | $files = $files[ $name ]; 99 | } 100 | 101 | foreach ( $files as $key => $value ) { 102 | if ( is_array( $value ) ) { 103 | $files[ $key ] = array_shift( $value ); 104 | } 105 | } 106 | 107 | $file = wp_handle_upload( $files, $overrides ); 108 | 109 | if ( isset( $file['error'] ) ) { 110 | error_log( wp_json_encode( $file['error'] ) ); 111 | return new WP_Micropub_Error( 'invalid_request', $file['error'], 500, $files ); 112 | } 113 | 114 | return $file; 115 | } 116 | 117 | // Takes an array of files and converts it for use with wp_handle_upload 118 | public static function file_array( $files ) { 119 | if ( ! is_array( $files['name'] ) ) { 120 | return $files; 121 | } 122 | $count = count( $files['name'] ); 123 | $newfiles = array(); 124 | for ( $i = 0; $i < $count; ++$i ) { 125 | $newfiles[] = array( 126 | 'name' => $files['name'][ $i ], 127 | 'tmp_name' => $files['tmp_name'][ $i ], 128 | 'size' => $files['size'][ $i ], 129 | ); 130 | } 131 | return $newfiles; 132 | } 133 | 134 | public static function upload_from_url( $url ) { 135 | if ( ! wp_http_validate_url( $url ) ) { 136 | return new WP_Micropub_Error( 'invalid_request', 'Invalid Media URL', 400 ); 137 | } 138 | require_once ABSPATH . 'wp-admin/includes/file.php'; 139 | $tmp = download_url( $url ); 140 | if ( is_wp_error( $tmp ) ) { 141 | return new WP_Micropub_Error( 'invalid_request', $tmp->get_message(), 400 ); 142 | } 143 | $file_array = array( 144 | 'name' => basename( wp_parse_url( $url, PHP_URL_PATH ) ), 145 | 'tmp_name' => $tmp, 146 | 'error' => 0, 147 | 'size' => filesize( $tmp ), 148 | ); 149 | $overrides = array( 150 | /* 151 | * Tells WordPress to not look for the POST form fields that would 152 | * normally be present, default is true, we downloaded the file from 153 | * a remote server, so there will be no form fields. 154 | */ 155 | 'test_form' => false, 156 | 157 | // Setting this to false lets WordPress allow empty files, not recommended. 158 | 'test_size' => true, 159 | 160 | // A properly uploaded file will pass this test. There should be no reason to override this one. 161 | 'test_upload' => true, 162 | ); 163 | // Move the temporary file into the uploads directory. 164 | $file = wp_handle_sideload( $file_array, $overrides ); 165 | if ( isset( $file['error'] ) ) { 166 | return new WP_Micropub_Error( 'invalid_request', $file['error'], 500 ); 167 | } 168 | return $file; 169 | } 170 | 171 | protected static function insert_attachment( $file, $post_id = 0, $title = null ) { 172 | $args = array( 173 | 'post_mime_type' => $file['type'], 174 | 'guid' => $file['url'], 175 | 'post_parent' => $post_id, 176 | 'meta_input' => array( 177 | '_micropub_upload' => 1, 178 | ), 179 | ); 180 | 181 | // Include image functions to get access to wp_read_image_metadata 182 | require_once ABSPATH . 'wp-admin/includes/image.php'; 183 | 184 | // Use image exif/iptc data for title and caption defaults if possible. 185 | // This is copied from the REST API Attachment upload controller code 186 | // FIXME: It probably should work for audio and video as well but as Core does not do that it is fine for now 187 | $image_meta = wp_read_image_metadata( $file['file'] ); 188 | 189 | if ( $image_meta ) { 190 | if ( trim( $image_meta['title'] ) && ! is_numeric( sanitize_title( $image_meta['title'] ) ) ) { 191 | $args['post_title'] = $image_meta['title']; 192 | } 193 | if ( trim( $image_meta['caption'] ) ) { 194 | $args['post_excerpt'] = $image_meta['caption']; 195 | } 196 | } 197 | if ( empty( $args['post_title'] ) ) { 198 | $args['post_title'] = preg_replace( '/\.[^.]+$/', '', wp_basename( $file['file'] ) ); 199 | } 200 | 201 | $id = wp_insert_attachment( $args, $file['file'], 0, true ); 202 | 203 | if ( is_wp_error( $id ) ) { 204 | if ( 'db_update_error' === $id->get_error_code() ) { 205 | return new WP_Micropub_Error( 'invalid_request', 'Database Error On Upload', 500 ); 206 | } else { 207 | return new WP_Micropub_Error( 'invalid_request', $id->get_error_message(), 400 ); 208 | } 209 | } 210 | 211 | // Set Client Application Taxonomy if available. 212 | if ( $id && array_key_exists( 'client_uid', static::$micropub_auth_response ) ) { 213 | wp_set_object_terms( $id, array( static::$micropub_auth_response['client_uid'] ), 'indieauth_client' ); 214 | } 215 | 216 | // Include admin functions to get access to wp_generate_attachment_metadata(). These functions are included here 217 | // as these functions are not normally loaded externally as is the practice in similar areas of WordPress. 218 | require_once ABSPATH . 'wp-admin/includes/admin.php'; 219 | 220 | wp_update_attachment_metadata( $id, wp_generate_attachment_metadata( $id, $file['file'] ) ); 221 | 222 | return $id; 223 | } 224 | 225 | public static function attach_media( $attachment_id, $post_id ) { 226 | $post = array( 227 | 'ID' => $attachment_id, 228 | 'post_parent' => $post_id, 229 | ); 230 | return wp_update_post( $post, true ); 231 | } 232 | 233 | /* 234 | * Returns information about an attachment 235 | */ 236 | private static function return_media_data( $attachment_id ) { 237 | $published = micropub_get_post_datetime( $attachment_id ); 238 | $metadata = wp_get_attachment_metadata( $attachment_id ); 239 | 240 | $data = array( 241 | 'url' => wp_get_attachment_image_url( $attachment_id, 'full' ), 242 | 'published' => $published->format( DATE_W3C ), 243 | 'mime_type' => get_post_mime_type( $attachment_id ), 244 | ); 245 | 246 | if ( array_key_exists( 'width', $metadata ) ) { 247 | $data['width'] = $metadata['width']; 248 | } 249 | 250 | if ( array_key_exists( 'height', $metadata ) ) { 251 | $data['height'] = $metadata['height']; 252 | } 253 | 254 | $created = null; 255 | // Created is added by the Simple Location plugin and includes the full timezone if it can find it. 256 | if ( array_key_exists( 'created', $metadata ) ) { 257 | $created = new DateTime( $metadata['created'] ); 258 | /** created_timestamp is the default created timestamp in all WordPress installations. It has no timezone offset so it is often output incorrectly. 259 | * See https://core.trac.wordpress.org/ticket/49413 260 | **/ 261 | } elseif ( array_key_exists( 'created_timestamp', $metadata ) && 0 !== $metadata['created_timestamp'] ) { 262 | $created = new DateTime(); 263 | $created->setTimestamp( $metadata['created_timestamp'] ); 264 | $created->setTimezone( wp_timezone() ); 265 | } 266 | if ( $created ) { 267 | $data['created'] = $created->format( DATE_W3C ); 268 | } 269 | 270 | // Only video or audio would have album art. Video uses the term poster, audio has no term, but using for both in the interest of simplicity. 271 | if ( has_post_thumbnail( $attachment_id ) ) { 272 | $data['poster'] = wp_get_attachment_url( get_post_thumbnail_id( $attachment_id ) ); 273 | } 274 | 275 | if ( wp_attachment_is( 'image', $attachment_id ) ) { 276 | // Return the thumbnail size present as a default. 277 | $data['thumbnail'] = wp_get_attachment_image_url( $attachment_id ); 278 | } 279 | 280 | return array_filter( $data ); 281 | } 282 | 283 | // Handles requests to the Media Endpoint 284 | public static function post_handler( $request ) { 285 | $params = $request->get_params(); 286 | if ( array_key_exists( 'action', $params ) ) { 287 | return self::action_handler( $params ); 288 | } 289 | 290 | return self::upload_handler( $request ); 291 | } 292 | 293 | public static function action_handler( $params ) { 294 | switch ( $params['action'] ) { 295 | case 'delete': 296 | if ( ! array_key_exists( 'url', $params ) ) { 297 | return new WP_Micropub_Error( 'invalid_request', 'Missing Parameter: url', 400 ); 298 | } 299 | $url = esc_url_raw( $params['url'] ); 300 | $attachment_id = attachment_url_to_postid( $url ); 301 | if ( $attachment_id ) { 302 | if ( ! current_user_can( 'delete_post', $attachment_id ) ) { 303 | $error = new WP_Micropub_Error( 'insufficient_scope', 'You do not have permission to delete media', 403 ); 304 | return $error->to_wp_error(); 305 | } 306 | $response = wp_delete_attachment( $attachment_id, true ); 307 | if ( $response ) { 308 | return new WP_REST_Response( 309 | $response, 310 | 200 311 | ); 312 | } 313 | } 314 | return new WP_Micropub_Error( 'invalid_request', 'Unable to Delete File', 400 ); 315 | default: 316 | return new WP_Micropub_Error( 'invalid_request', 'No Action Handler for This Action', 400 ); 317 | } 318 | } 319 | 320 | public static function upload_handler( $request ) { 321 | // Get the file via $_FILES 322 | $files = $request->get_file_params(); 323 | $headers = $request->get_headers(); 324 | if ( empty( $files ) ) { 325 | return new WP_Micropub_Error( 'invalid_request', 'No Files Attached', 400 ); 326 | } else { 327 | $file = self::upload_from_file( $files, 'file', $headers ); 328 | } 329 | 330 | if ( is_micropub_error( $file ) ) { 331 | return $file; 332 | } 333 | $title = $request->get_param( 'name' ); 334 | $id = self::insert_attachment( $file, 0, $title ); 335 | 336 | $url = wp_get_attachment_url( $id ); 337 | $data = self::return_media_data( $id ); 338 | add_post_meta( $id, 'micropub_auth_response', static::$micropub_auth_response ); 339 | $data['url'] = $url; 340 | $data['id'] = $id; 341 | $response = new WP_REST_Response( 342 | $data, 343 | 201, 344 | array( 345 | 'Location' => $url, 346 | ) 347 | ); 348 | return $response; 349 | } 350 | 351 | // Responds to queries to the media endpoint 352 | public static function query_handler( $request ) { 353 | $params = $request->get_query_params(); 354 | if ( array_key_exists( 'q', $params ) ) { 355 | switch ( sanitize_key( $params['q'] ) ) { 356 | case 'config': 357 | return new WP_REST_Response( 358 | array( 359 | 'q' => array( 360 | 'last', 361 | 'source', 362 | ), 363 | 'properties' => array( 364 | 'url', 365 | 'limit', 366 | 'offset', 367 | 'mime_type', 368 | ), 369 | ), 370 | 200 371 | ); 372 | case 'last': 373 | $attachments = get_posts( 374 | array( 375 | 'post_type' => 'attachment', 376 | 'fields' => 'ids', 377 | 'posts_per_page' => 10, 378 | 'post_parent' => 0, 379 | 'order' => 'DESC', 380 | 'date_query' => array( 381 | 'after' => '1 hour ago', 382 | ), 383 | ) 384 | ); 385 | if ( is_array( $attachments ) ) { 386 | foreach ( $attachments as $attachment ) { 387 | $datetime = micropub_get_post_datetime( $attachment ); 388 | if ( wp_attachment_is( 'image', $attachment ) ) { 389 | return self::return_media_data( $attachment ); 390 | } 391 | } 392 | } 393 | return array(); 394 | case 'source': 395 | if ( array_key_exists( 'url', $params ) ) { 396 | $attachment_id = attachment_url_to_postid( esc_url( $params['url'] ) ); 397 | if ( ! $attachment_id ) { 398 | return new WP_Micropub_Error( 'invalid_request', sprintf( 'not found: %1$s', $params['url'] ), 400 ); 399 | } 400 | $resp = self::return_media_data( $attachment_id ); 401 | } else { 402 | $numberposts = (int) mp_get( $params, 'limit', 10 ); 403 | $args = array( 404 | 'posts_per_page' => $numberposts, 405 | 'post_type' => 'attachment', 406 | 'post_parent' => 0, 407 | 'fields' => 'ids', 408 | 'order' => 'DESC', 409 | ); 410 | if ( array_key_exists( 'offset', $params ) ) { 411 | $args['offset'] = (int) mp_get( $params, 'offset' ); 412 | } 413 | 414 | if ( array_key_exists( 'mime_type', $params ) ) { 415 | $args['post_mime_type'] = sanitize_mime_type( $params['mime_type'] ); 416 | } 417 | $attachments = get_posts( $args ); 418 | $resp = array(); 419 | foreach ( $attachments as $attachment ) { 420 | $resp[] = self::return_media_data( $attachment ); 421 | } 422 | $resp = array( 'items' => $resp ); 423 | } 424 | return $resp; 425 | } 426 | } 427 | 428 | if ( is_micropub_error( $permission ) ) { 429 | return $permission; 430 | } 431 | return new WP_Micropub_Error( 'invalid_request', 'unknown query', 400, $request->get_query_params() ); 432 | } 433 | 434 | public static function media_sideload_url( $url, $post_id = 0, $title = null ) { 435 | // Check to see if URL is already in the media library 436 | $id = attachment_url_to_postid( $url ); 437 | if ( $id ) { 438 | // Attach media to post 439 | wp_update_post( 440 | array( 441 | 'ID' => $id, 442 | 'post_parent' => $post_id, 443 | ) 444 | ); 445 | return $id; 446 | } 447 | 448 | $file = self::upload_from_url( $url ); 449 | if ( is_micropub_error( $file ) ) { 450 | return $file; 451 | } 452 | 453 | return self::insert_attachment( $file, $post_id, $title ); 454 | } 455 | 456 | public static function media_handle_upload( $file, $post_id = 0 ) { 457 | $file = self::upload_from_file( $file ); 458 | if ( is_micropub_error( $file ) ) { 459 | return $file; 460 | } 461 | 462 | return self::insert_attachment( $file, $post_id ); 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /includes/class-micropub-render.php: -------------------------------------------------------------------------------- 1 | ID, 'micropub_version', true ); 38 | if ( ! $version ) { 39 | $should = false; 40 | } elseif ( get_post_meta( $post->ID, 'mf2_content', true ) ) { 41 | $should = false; 42 | } else { 43 | $should = true; 44 | } 45 | } 46 | return apply_filters( 'micropub_dynamic_render', $should, $post ); 47 | } 48 | 49 | /** 50 | * Generates and returns a post_content string suitable for wp_insert_post() 51 | * and friends. 52 | */ 53 | public static function generate_post_content( $post_content, $input ) { 54 | $props = mp_get( $input, 'properties' ); 55 | $lines = array(); 56 | 57 | $verbs = array( 58 | 'like-of' => 'Likes', 59 | 'repost-of' => 'Reposted', 60 | 'in-reply-to' => 'In reply to', 61 | 'bookmark-of' => 'Bookmarked', 62 | 'follow-of' => 'Follows', 63 | ); 64 | 65 | // interactions 66 | foreach ( array_keys( $verbs ) as $prop ) { 67 | if ( ! isset( $props[ $prop ] ) ) { 68 | continue; 69 | } 70 | 71 | if ( wp_is_numeric_array( $props[ $prop ] ) ) { 72 | $val = $props[ $prop ][0]; 73 | } else { 74 | $val = $props[ $prop ]; 75 | } 76 | if ( $val ) { 77 | // Supports nested properties by turning single value properties into nested 78 | // https://micropub.net/draft/#nested-microformats-objects 79 | if ( is_string( $val ) ) { 80 | $val = array( 81 | 'url' => $val, 82 | ); 83 | } 84 | if ( ! isset( $val['name'] ) && isset( $val['url'] ) ) { 85 | $val['name'] = $val['url']; 86 | } 87 | if ( isset( $val['url'] ) ) { 88 | if ( 'in-reply-to' === $prop && ! isset( $props['rsvp'] ) ) { 89 | // Special case replies. Don't include any visible text, 90 | // since it goes inside e-content, which webmention 91 | // recipients use as the reply text. 92 | $lines[] = sprintf( 93 | '', 94 | $prop, 95 | $val['url'] 96 | ); 97 | } else { 98 | $lines[] = sprintf( 99 | '
%1s %4s.
', 100 | $verbs[ $prop ], 101 | $prop, 102 | $val['url'], 103 | $val['name'] 104 | ); 105 | } 106 | } 107 | } 108 | } 109 | 110 | $checkin = isset( $props['checkin'] ); 111 | if ( $checkin ) { 112 | $checkin = wp_is_numeric_array( $props['checkin'] ) ? $props['checkin'][0] : $props['checkin']; 113 | $name = $checkin['properties']['name'][0]; 114 | $urls = $checkin['properties']['url']; 115 | $lines[] = 'Checked into ' . $name . '.
'; 117 | } 118 | 119 | if ( isset( $props['rsvp'] ) ) { 120 | $lines[] = 'RSVPs ' . $props['rsvp'][0] . '.
'; 122 | } 123 | 124 | // event 125 | if ( array( 'h-event' ) === mp_get( $input, 'type' ) ) { 126 | $lines[] = static::generate_event( $input ); 127 | } 128 | 129 | // If there is no content use the summary property as content 130 | if ( empty( $post_content ) && isset( $props['summary'] ) ) { 131 | $post_content = $props['summary'][0]; 132 | } 133 | 134 | if ( ! empty( $post_content ) ) { 135 | $lines[] = ''; 162 | $times = array(); 163 | foreach ( array( 'start', 'end' ) as $cls ) { 164 | if ( isset( $props[ $cls ][0] ) ) { 165 | $datetime = new DateTimeImmutable( $props[ $cls ][0] ); 166 | $times[] = ''; 168 | } 169 | } 170 | $lines[] = implode( "\nto\n", $times ); 171 | 172 | if ( isset( $props['location'] ) && 'geo:' !== substr( $props['location'][0], 0, 4 ) ) { 173 | $lines[] = 'at ' . 174 | $props['location'][0] . ''; 175 | } 176 | 177 | end( $lines ); 178 | $lines[ key( $lines ) ] .= '.'; 179 | $lines[] = '
'; 180 | 181 | if ( isset( $props['summary'] ) ) { 182 | $lines[] = '' . urldecode( $props['summary'][0] ) . '
'; 183 | } 184 | 185 | if ( isset( $props['description'] ) ) { 186 | $lines[] = '' . urldecode( $props['description'][0] ) . '
'; 187 | } 188 | 189 | $lines[] = 'For security reasons you should use Micropub only on an HTTPS domain.
75 |To use Micropub, you must have IndieAuth support. Please install the IndieAuth plugin.
88 |