├── .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[] = '
'; 136 | $lines[] = $post_content; 137 | $lines[] = '
'; 138 | } 139 | 140 | // TODO: generate my own markup so i can include u-photo 141 | foreach ( array( 'photo', 'video', 'audio' ) as $field ) { 142 | if ( isset( $_FILES[ $field ] ) || isset( $props[ $field ] ) ) { 143 | $lines[] = '[gallery size=full columns=1]'; 144 | break; 145 | } 146 | } 147 | return implode( "\n", $lines ); 148 | } 149 | 150 | /** 151 | * Generates and returns a string h-event. 152 | */ 153 | private static function generate_event( $input ) { 154 | $props = mp_get( $input, 'replace', mp_get( $input, 'properties' ) ); 155 | $lines[] = '
'; 156 | 157 | if ( isset( $props['name'] ) ) { 158 | $lines[] = '

' . $props['name'][0] . '

'; 159 | } 160 | 161 | $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[] = '
'; 190 | return implode( "\n", $lines ); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /includes/compat-functions.php: -------------------------------------------------------------------------------- 1 | post_modified : $post->post_date; 35 | if ( empty( $time ) || '0000-00-00 00:00:00' === $time ) { 36 | return false; 37 | } 38 | return date_create_from_format( 'Y-m-d H:i:s', $time, wp_timezone() ); 39 | } 40 | } 41 | 42 | if ( ! function_exists( 'wp_timezone_string' ) ) { 43 | /** 44 | * Retrieves the timezone from site settings as a string. 45 | * 46 | * Uses the `timezone_string` option to get a proper timezone if available, 47 | * otherwise falls back to an offset. 48 | * 49 | * @since 5.3.0 - backported into Micropub 50 | * 51 | * @return string PHP timezone string or a ±HH:MM offset. 52 | */ 53 | function wp_timezone_string() { 54 | $timezone_string = get_option( 'timezone_string' ); 55 | if ( $timezone_string ) { 56 | return $timezone_string; 57 | } 58 | $offset = (float) get_option( 'gmt_offset' ); 59 | $hours = (int) $offset; 60 | $minutes = ( $offset - $hours ); 61 | $sign = ( $offset < 0 ) ? '-' : '+'; 62 | $abs_hour = abs( $hours ); 63 | $abs_mins = abs( $minutes * 60 ); 64 | $tz_offset = sprintf( '%s%02d:%02d', $sign, $abs_hour, $abs_mins ); 65 | return $tz_offset; 66 | } 67 | } 68 | 69 | if ( ! function_exists( 'wp_timezone' ) ) { 70 | /** 71 | * Retrieves the timezone from site settings as a `DateTimeZone` object. 72 | * 73 | * Timezone can be based on a PHP timezone string or a ±HH:MM offset. 74 | * 75 | * @since 5.3.0 - backported into Simple Location 76 | * 77 | * @return DateTimeZone Timezone object. 78 | */ 79 | function wp_timezone() { 80 | return new DateTimeZone( wp_timezone_string() ); 81 | } 82 | } 83 | 84 | // Polyfill for pre-PHP 7.3. 85 | if ( ! function_exists( 'array_key_first' ) ) { 86 | function array_key_first( array $arr ) { 87 | foreach ( $arr as $key => $unused ) { 88 | return $key; 89 | } 90 | return null; 91 | } 92 | } 93 | 94 | // Polyfill for pre-PHP 7.3. 95 | if ( ! function_exists( 'array_key_last' ) ) { 96 | function array_key_last( $a ) { 97 | if ( ! is_array( $a ) || empty( $a ) ) { 98 | return null; 99 | } 100 | return array_keys( $a )[ count( $a ) - 1 ]; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /includes/functions.php: -------------------------------------------------------------------------------- 1 | $value ) { 13 | if ( 'HTTP_' === substr( $name, 0, 5 ) ) { 14 | $headers[ str_replace( ' ', '-', strtolower( str_replace( '_', ' ', substr( $name, 5 ) ) ) ) ] = $value; 15 | } elseif ( 'CONTENT_TYPE' === $name ) { 16 | $headers['content-type'] = $value; 17 | } elseif ( 'CONTENT_LENGTH' === $name ) { 18 | $headers['content-length'] = $value; 19 | } 20 | } 21 | return $headers; 22 | } 23 | } 24 | 25 | if ( ! function_exists( 'mp_get' ) ) { 26 | function mp_get( $data, $key, $def = array(), $index = false ) { 27 | $return = $def; 28 | if ( is_array( $data ) && isset( $data[ $key ] ) ) { 29 | $return = $data[ $key ]; 30 | } 31 | if ( $index && wp_is_numeric_array( $return ) && ! empty( $return ) ) { 32 | $return = $return[0]; 33 | } 34 | return $return; 35 | } 36 | } 37 | 38 | if ( ! function_exists( 'mp_filter' ) ) { 39 | // Searches for partial matches in an array of strings 40 | function mp_filter( $a, $filter ) { 41 | return array_values( 42 | array_filter( 43 | $a, 44 | function ( $value ) use ( $filter ) { 45 | return ( false !== stripos( $value, $filter ) ); 46 | } 47 | ) 48 | ); 49 | } 50 | } 51 | 52 | if ( ! function_exists( 'micropub_get_response' ) ) { 53 | function micropub_get_response() { 54 | return apply_filters( 'indieauth_response', null ); 55 | } 56 | } 57 | 58 | if ( ! function_exists( 'is_micropub_post' ) ) { 59 | function is_micropub_post( $post = null ) { 60 | $post = get_post( $post ); 61 | if ( ! $post ) { 62 | return false; 63 | } 64 | $response = get_post_meta( $post->ID, 'micropub_version', true ); 65 | if ( $response ) { 66 | return true; 67 | } 68 | $response = get_post_meta( $post->ID, 'micropub_auth_response', true ); 69 | if ( ! $response ) { 70 | return false; 71 | } 72 | return true; 73 | } 74 | } 75 | 76 | if ( ! function_exists( 'micropub_get_client_info' ) ) { 77 | function micropub_get_client_info( $post = null ) { 78 | $post = get_post( $post ); 79 | if ( ! $post ) { 80 | return false; 81 | } 82 | $response = get_post_meta( $post->ID, 'micropub_auth_response', true ); 83 | if ( empty( $response ) ) { 84 | return ''; 85 | } 86 | if ( class_exists( 'IndieAuth_Client_Taxonomy' ) ) { 87 | if ( array_key_exists( 'client_uid', $response ) ) { 88 | return IndieAuth_Client_Taxonomy::get_client( $response['client_uid'] ); 89 | } 90 | if ( array_key_exists( 'client_id', $response ) ) { 91 | $return = IndieAuth_Client_Taxonomy::get_client( $response['client_id'] ); 92 | if ( ! is_wp_error( $return ) ) { 93 | return $return; 94 | } 95 | } 96 | } 97 | 98 | return array_filter( 99 | array( 100 | 'client_id' => $response['client_id'], 101 | 'name' => mp_get( $response, 'client_name', null ), 102 | 'icon' => mp_get( $response, 'client_icon', null ), 103 | ) 104 | ); 105 | } 106 | } 107 | 108 | if ( ! function_exists( 'micropub_client_info' ) ) { 109 | function micropub_client_info( $post = null, $args = null ) { 110 | $client = micropub_get_client_info( $post ); 111 | $defaults = array( 112 | 'size' => 15, 113 | 'class' => 'micropub-client', 114 | 'container' => 'div', 115 | ); 116 | 117 | $args = wp_parse_args( $args, $defaults ); 118 | if ( is_wp_error( $client ) || empty( $client ) ) { 119 | return ''; 120 | } 121 | if ( array_key_exists( 'icon', $client ) ) { 122 | $props = array( 123 | 'src' => $client['icon'], 124 | 'height' => $args['size'], 125 | 'width' => $args['size'], 126 | 'title' => $client['name'], 127 | ); 128 | 129 | $text = ' $value ) { 131 | $text .= ' ' . esc_attr( $key ) . '="' . esc_attr( $value ) . '"'; 132 | } 133 | $text .= ' />'; 134 | } elseif ( array_key_exists( 'name', $client ) ) { 135 | $text = esc_html( $client['name'] ); 136 | } else { 137 | $text = 'Unknown Client'; 138 | } 139 | 140 | if ( array_key_exists( 'id', $client ) ) { 141 | printf( '<%1$s class="%2$s">%4$s', esc_attr( $args['container'] ), esc_attr( $args['class'] ), esc_url( get_term_link( $client['id'] ), 'indieauth_client' ), wp_kses_post( $text ) ); 142 | } else { 143 | printf( '<%1$s class="%1$s">%2$s', esc_attr( $args['container'] ), esc_attr( $args['class'] ), wp_kses_post( $text ) ); 144 | } 145 | } 146 | } 147 | 148 | if ( ! function_exists( 'micropub_get_scopes' ) ) { 149 | function micropub_get_scopes() { 150 | return apply_filters( 'indieauth_scopes', null ); 151 | } 152 | } 153 | 154 | if ( ! function_exists( 'micropub_get_post_datetime' ) ) { 155 | function micropub_get_post_datetime( $post = null, $field = 'date', $timezone = null ) { 156 | $post = get_post( $post ); 157 | if ( ! $post ) { 158 | return false; 159 | } 160 | 161 | $time = ( 'modified' === $field ) ? $post->post_modified_gmt : $post->post_date_gmt; 162 | if ( empty( $time ) || '0000-00-00 00:00:00' === $time ) { 163 | return false; 164 | } 165 | 166 | $datetime = date_create_immutable_from_format( 'Y-m-d H:i:s', $time, new DateTimeZone( 'UTC' ) ); 167 | 168 | if ( is_null( $timezone ) ) { 169 | $timezone = get_post_meta( $post->ID, 'geo_timezone', true ); 170 | } 171 | 172 | if ( $timezone ) { 173 | $timezone = new DateTimeZone( $timezone ); 174 | } else { 175 | $timezone = wp_timezone(); 176 | } 177 | return $datetime->setTimezone( $timezone ); 178 | } 179 | } 180 | 181 | if ( ! function_exists( 'get_micropub_error' ) ) { 182 | function get_micropub_error( $obj ) { 183 | if ( is_array( $obj ) ) { 184 | // When checking the result of wp_remote_post 185 | if ( isset( $obj['body'] ) ) { 186 | $body = json_decode( $obj['body'], true ); 187 | if ( isset( $body['error'] ) ) { 188 | return new WP_Micropub_Error( 189 | $body['error'], 190 | isset( $body['error_description'] ) ? $body['error_description'] : null, 191 | $obj['response']['code'] 192 | ); 193 | } 194 | } 195 | } elseif ( is_object( $obj ) && 'WP_Micropub_Error' === get_class( $obj ) ) { 196 | $data = $obj->get_data(); 197 | if ( isset( $data['error'] ) ) { 198 | return $obj; 199 | } 200 | } 201 | return false; 202 | } 203 | } 204 | 205 | if ( ! function_exists( 'is_micropub_error' ) ) { 206 | function is_micropub_error( $obj ) { 207 | return ( $obj instanceof WP_Micropub_Error ); 208 | } 209 | } 210 | 211 | if ( ! function_exists( 'micropub_wp_error' ) ) { 212 | // Converts WP_Error into Micropub Error 213 | function micropub_wp_error( $error ) { 214 | if ( is_wp_error( $error ) ) { 215 | $data = $error->get_error_data(); 216 | $status = isset( $data['status'] ) ? $data['status'] : 200; 217 | if ( is_array( $data ) ) { 218 | unset( $data['status'] ); 219 | } 220 | return new WP_Micropub_Error( $error->get_error_code(), $error->get_error_message(), $status, $data ); 221 | } 222 | return null; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /micropub.php: -------------------------------------------------------------------------------- 1 | Micropub server. 6 | * Protocol spec: Micropub Living Standard 7 | * Author: IndieWeb WordPress Outreach Club 8 | * Requires at least: 4.9.9 9 | * Requires PHP: 5.6 10 | * Requires Plugins: indieauth 11 | * Author URI: https://indieweb.org/WordPress_Outreach_Club 12 | * Text Domain: micropub 13 | * License: CC0 14 | * License URI: http://creativecommons.org/publicdomain/zero/1.0/ 15 | * Version: 2.4.0 16 | */ 17 | 18 | /* See README for supported filters and actions. 19 | * Example command lines for testing: 20 | * Form-encoded: 21 | * curl -i -H 'Authorization: Bearer ...' -F h=entry -F name=foo -F content=bar \ 22 | * -F photo=@gallery/snarfed.gif 'http://localhost/wp-json/micropub/1.0/endpoint' 23 | * JSON: 24 | * curl -v -d @body.json -H 'Content-Type: application/json' 'http://localhost/w/?micropub=endpoint' 25 | * 26 | */ 27 | 28 | if ( ! defined( 'MICROPUB_NAMESPACE' ) ) { 29 | define( 'MICROPUB_NAMESPACE', 'micropub/1.0' ); 30 | } 31 | 32 | if ( ! defined( 'MICROPUB_DISABLE_NAG' ) ) { 33 | define( 'MICROPUB_DISABLE_NAG', 0 ); 34 | } 35 | 36 | // For debugging purposes this will set all Micropub posts to Draft 37 | if ( ! defined( 'MICROPUB_DRAFT_MODE' ) ) { 38 | define( 'MICROPUB_DRAFT_MODE', '0' ); 39 | } 40 | 41 | if ( class_exists( 'IndieAuth_Plugin' ) ) { 42 | 43 | // Global Functions 44 | require_once plugin_dir_path( __FILE__ ) . 'includes/functions.php'; 45 | 46 | // Compatibility Functions with Newer WordPress Versions 47 | require_once plugin_dir_path( __FILE__ ) . 'includes/compat-functions.php'; 48 | 49 | // Error Handling Class 50 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-micropub-error.php'; 51 | 52 | // Endpoint Base Class. 53 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-micropub-base.php'; 54 | 55 | // Media Endpoint and Handling Functions 56 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-micropub-media.php'; 57 | 58 | // Server Functions 59 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-micropub-endpoint.php'; 60 | 61 | // Render Functions 62 | require_once plugin_dir_path( __FILE__ ) . 'includes/class-micropub-render.php'; 63 | 64 | } else { 65 | 66 | add_action( 'admin_notices', 'micropub_indieauth_not_installed_notice' ); 67 | } 68 | function micropub_not_ssl_notice() { 69 | if ( is_ssl() || MICROPUB_DISABLE_NAG ) { 70 | return; 71 | } 72 | ?> 73 |
74 |

For security reasons you should use Micropub only on an HTTPS domain.

75 |
76 | 'Version' ) )['Version']; 82 | } 83 | 84 | function micropub_indieauth_not_installed_notice() { 85 | ?> 86 |
87 |

To use Micropub, you must have IndieAuth support. Please install the IndieAuth plugin.

88 |
89 | 9 | 10 | 11 | ./tests/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Micropub === 2 | Contributors: indieweb, snarfed, dshanske 3 | Tags: micropub, publish, indieweb, microformats 4 | Requires at least: 4.9.9 5 | Tested up to: 6.5.2 6 | Stable tag: 2.4.0 7 | Requires PHP: 7.2 8 | License: CC0 9 | License URI: http://creativecommons.org/publicdomain/zero/1.0/ 10 | 11 | Allows you to publish to your site using [Micropub](http://micropub.net/) clients. 12 | 13 | == Description == 14 | 15 | Micropub is an open API standard that is used to create posts on your site using third-party clients. Web apps and native apps (e.g. iPhone, Android) can use Micropub to post short notes, photos, events or other posts to your own site, similar to a Twitter client posting to Twitter.com. Requires the IndieAuth plugin for authentication. 16 | 17 | Once you've installed and activated the plugin, try a client such as [Quill](http://quill.p3k.io/) to create a new post on your site. It walks you through the steps and helps you troubleshoot if you run into any problems. A list of known Micropub clients are available [here](https://indieweb.org/Micropub/Clients) 18 | 19 | Supports the full [Micropub spec](https://micropub.spec.indieweb.org/) 20 | 21 | As this allows the creation of posts without entering the WordPress admin, it is not subject to any Gutenberg compatibility concerns per se. Posts created will not have Gutenberg blocks as they were not created with Gutenberg, but otherwise there should be no issues at this time. 22 | 23 | Available in the WordPress plugin directory at [wordpress.org/plugins/micropub](https://wordpress.org/plugins/micropub/). 24 | 25 | == License == 26 | 27 | This project is placed in the public domain. You may also use it under the [CC0 license](http://creativecommons.org/publicdomain/zero/1.0/). 28 | 29 | == WordPress details == 30 | 31 | = Filters and hooks = 32 | Adds ten filters: 33 | 34 | `before_micropub( $input )` 35 | 36 | Called before handling a Micropub request. Returns `$input`, possibly modified. 37 | 38 | `micropub_post_content( $post_content, $input )` 39 | 40 | Called during the handling of a Micropub request. The content generation function is attached to this filter by default. Returns `$post_content`, possibly modified. 41 | 42 | `micropub_post_type( $post_type = 'post', $input )` 43 | 44 | Called during the creation of a Micropub post. This defaults to post, but allows for setting Micropub posts to a custom post type. 45 | 46 | `micropub_tax_input( $tax_input, $input )` 47 | 48 | Called during the creation of a Micropub post. This defaults to nothing but allows for a Micropub post to set a custom taxonomy. 49 | 50 | `micropub_syndicate-to( $synd_urls, $user_id, $input )` 51 | 52 | Called to generate the list of `syndicate-to` targets to return in response to a query. Returns `$synd_urls`, an array, possibly modified. This filter is empty by default 53 | 54 | `micropub_query( $resp, $input )` 55 | 56 | Allows you to replace a query response with your own customized version to add additional information 57 | 58 | `micropub_suggest_title( $mf2 )` 59 | 60 | Allows a suggested title to be generated. This can be used either to generate the post slug or for individuals who want to use it to set a WordPress title 61 | 62 | `indieauth_scopes( $scopes )` 63 | 64 | This returns scopes from a plugin implementing IndieAuth. This filter is empty by default. 65 | 66 | `indieauth_response( $response )` 67 | 68 | This returns the token auth response from a plugin implementing IndieAuth. This filter is empty by default. 69 | 70 | `pre_insert_micropub_post( $args )` 71 | 72 | This filters the arguments sent to wp_insert_post just prior to its insertion. If the ID key is set, then this will short-circuit the insertion to allow for custom database coding. 73 | 74 | ...and two hooks: 75 | 76 | `after_micropub( $input, $wp_args = null)` 77 | 78 | Called after handling a Micropub request. Not called if the request fails (ie doesn't return HTTP 2xx). 79 | 80 | `micropub_syndication( $ID, $syndicate_to )` 81 | 82 | 83 | Called only if there are syndication targets $syndicate_to for post $ID. $syndicate_to will be an array of UIDs that are verified as one or more of the UIDs added using the `micropub_syndicate-to` filter. 84 | 85 | Arguments: 86 | 87 | * `$input`: associative array, the Micropub request in [JSON format](http://micropub.net/draft/index.html#json-syntax). If the request was form-encoded or a multipart file upload, it's converted to JSON format. 88 | * `$wp_args`: optional associative array. For creates and updates, this is the arguments passed to `wp_insert_post` or `wp_update_post`. For deletes and undeletes, `args['ID']` contains the post id to be (un)deleted. Null for queries. 89 | 90 | = Other = 91 | 92 | Stores [microformats2](http://microformats.org/wiki/microformats2) properties in [post metadata](http://codex.wordpress.org/Function_Reference/post_meta_Function_Examples) with keys prefixed by `mf2_`. [Details here.](https://indiewebcamp.com/WordPress_Data#Microformats_data) All values are arrays; use `unserialize()` to deserialize them. 93 | 94 | Does *not* support multithreading. PHP doesn't really either, so it generally won't matter, but just for the record. 95 | 96 | Supports Stable Extensions to Micropub: 97 | 98 | * [Post Status](https://indieweb.org/Micropub-extensions#Post_Status) - Either `published` or `draft` 99 | * [Visibility](https://indieweb.org/Micropub-extensions#Visibility) - Either `public` or `private`. 100 | * [Query for Category/Tag List](https://indieweb.org/Micropub-extensions#Query_for_Category.2FTag_List) - Supports querying for categories and tags. 101 | * [Slug](https://indieweb.org/Micropub-extensions#Slug) - Custom slug. 102 | * [Query for Post List](https://indieweb.org/Micropub-extensions#Query_for_Post_List) - Supports query for the last x number of posts. 103 | 104 | Supports Proposed Extensions to Micropub: 105 | 106 | * [Limit Parameter for Query](https://github.com/indieweb/micropub-extensions/issues/35) - Supports adding limit to any query designed to return a list of options to limit it to that number. 107 | * [Offset Parameter for Query](https://github.com/indieweb/micropub-extensions/issues/36) - Supports adding offset to any query. Must be used with limit. 108 | * [Filter Parameter for Query](https://github.com/indieweb/micropub-extensions/issues/34) - Supported for the Category/Tag List query. 109 | * [Location Visiblity](https://github.com/indieweb/micropub-extensions/issues/16) - Either `public`, `private`, or `protected` 110 | * [Query for Supported Queries](https://github.com/indieweb/micropub-extensions/issues/7) - Returns a list of query parameters the endpoint supports 111 | * [Query for Supported Properties](https://github.com/indieweb/micropub-extensions/issues/8) - Returns a list of which supported experimental properties the endpoint supports so the client can choose to hide unsupported ones. 112 | * [Discovery of Media Endpoint using Link Rel](https://github.com/indieweb/micropub-extensions/issues/15) - Adds a link header for the media endpoint 113 | * [Supports extended GEO URIs](https://github.com/indieweb/micropub-extensions/issues/32) - Supports adding arbitrary parameters to the GEO URI. Micropub converts this into an mf2 object. Supported as built into the Indigenous client. 114 | * [Supports deleting uploaded media](https://github.com/indieweb/micropub-extensions/issues/30) - Supports action=delete&url=url on the media endpoint to delete files. 115 | * [Supports querying for media on the media endpoint](https://github.com/indieweb/micropub-extensions/issues/14) and [optional URL parameter for same]((https://github.com/indieweb/micropub-extensions/issues/37)) 116 | * [Supports filtering media queries by mime-type](https://github.com/indieweb/micropub-extensions/issues/45) 117 | * [Return Visibility in q=config](https://github.com/indieweb/micropub-extensions/issues/8#issuecomment-536301952) 118 | 119 | Deprecated Extensions still Supported: 120 | 121 | * [Last Media Uploaded](https://github.com/indieweb/micropub-extensions/issues/10) - Supports querying for the last image uploaded ...set to within the last hour. This was superseded by supporting `q=source&limit=1` on the media endpoint. 122 | 123 | Extensions Supported by Other Plugins: 124 | 125 | * [Query for Location](https://github.com/indieweb/micropub-extensions/issues/6) - Suported by Simple Location if installed. 126 | 127 | If an experimental property is not set to one of the noted options, the plugin will return HTTP 400 with body: 128 | 129 | { 130 | "error": "invalid_request", 131 | } 132 | 133 | WordPress has a [whitelist of file extensions that it allows in uploads](https://codex.wordpress.org/Uploading_Files#About_Uploading_Files_on_Dashboard). If you upload a file in a Micropub extension that doesn't have an allowed extension, the plugin will return HTTP 400 with body: 134 | 135 | { 136 | "error": "invalid_request", 137 | "error_description": "Sorry, this file is not permitted for security reasons." 138 | } 139 | 140 | 141 | == Authentication and authorization == 142 | 143 | For reasons of security it is recommended that you only use this plugin on sites that implement HTTPS. Authentication is not built into this plugin. 144 | 145 | In order to use this, the IndieAuth plugin is required. Other plugins may be written in future as alternatives and will be noted if they exist. 146 | 147 | == Installation == 148 | 149 | Install the IndieAuth plugin from the WordPress plugin directory, then install this plugin. No setup needed. 150 | 151 | == Configuration Options == 152 | 153 | These configuration options can be enabled by adding them to your wp-config.php 154 | 155 | * `define('MICROPUB_NAMESPACE', 'micropub/1.0' )` - By default the namespace for micropub is micropub/1.0. This would allow you to change this for your endpoint 156 | * `define('MICROPUB_DISABLE_NAG', 1 )` - Disable notices for insecure sites 157 | * `define('MICROPUB_DRAFT_MODE', 1 )` - Override default post status and set to draft for debugging purposes. 158 | 159 | == Frequently Asked Questions == 160 | 161 | = I am experiencing issues in logging in with IndieAuth. = 162 | 163 | There are a series of troubleshooting steps in the IndieAuth plugin for this. The most common problem involves the token not being passed due the configuration of your hosting provider. 164 | 165 | == Upgrade Notice == 166 | 167 | 168 | = Version 2.4.0 = 169 | 170 | The following option and setting was removed from the plugin as the IndieAuth plugin now allows the creation of a draft token to satisfy this need. Being as this was the only setting 171 | the entire settings page was removed. 172 | 173 | * `micropub_default_post_status` - if set, Micropub posts will be set to this status by default( publish, draft, or private ). 174 | 175 | The older MICROPUB_DRAFT_MODE config override remains in place for now. 176 | 177 | Static rendering for newer posts has been replaced by dynamic render. This means that if you disable this plugin, it will not render the microformats on newer posts. This is using the same code used for static 178 | rendering but future enhancements are planned. 179 | 180 | = Version 2.2.3 = 181 | The Micropub plugin will no longer store published, updated, summary, or name options. These will be derived from the WordPress post properties they are mapped to and returned on query. 182 | 183 | = Version 2.2.0 = 184 | 185 | The Micropub plugin will no longer function without the IndieAuth plugin installed. 186 | 187 | = Version 2.0.0 = 188 | 189 | This version changes the Micropub endpoint URL as it now uses the REST API. You may have to update any third-parties that have cached this info. 190 | 191 | == Screenshots == 192 | 193 | None. 194 | 195 | == Development == 196 | 197 | The canonical repo is http://github.com/indieweb/wordpress-micropub . Feedback and pull requests are welcome! 198 | 199 | To add a new release to the WordPress plugin directory, tag it with the version number and push the tag. It will automatically deploy. 200 | 201 | To set up your local environment to run the unit tests and set up PHPCodesniffer to test adherence to [WordPress Coding Standards](https://make.wordpress.org/core/handbook/coding-standards/php/) and [PHP Compatibility](https://github.com/wimg/PHPCompatibility): 202 | 203 | 1. Install [Composer](https://getcomposer.org). Composer is only used for development and is not required to run the plugin. 204 | 1. Run `composer install` which will install PHP Codesniffer, PHPUnit, the standards required, and all dependencies. 205 | 206 | To configure PHPUnit 207 | 208 | 1. Install and start MySQL. (You may already have it.) 209 | 1. Run `./bin/install-wp-tests.sh wordpress_micropub_test root '' localhost` to download WordPress and [its unit test library](https://develop.svn.wordpress.org/trunk/tests/phpunit/), into your systems tmp directory by default, and create a MySQL db to test against. [Background here](http://wp-cli.org/docs/plugin-unit-tests/). Feel free to use a MySQL user other than `root`. You can set the `WP_CORE_DIR` and `WP_TESTS_DIR` environment variables to change where WordPress and its test library are installed. For example, I put them both in the repo dir. 210 | 1. Open `wordpress-tests-lib/wp-tests-config.php` and add a slash to the end of the ABSPATH value. No clue why it leaves off the slash; it doesn't work without it. 211 | 1. Run `phpunit` in the repo root dir. If you set `WP_CORE_DIR` and `WP_TESTS_DIR` above, you'll need to set them for this too. You should see output like this: 212 | 213 | 214 | Installing... 215 | ... 216 | 1 / 1 (100%) 217 | Time: 703 ms, Memory: 33.75Mb 218 | OK (1 test, 3 assertions) 219 | 220 | To set up PHPCodesniffer to test adherence to [WordPress Coding Standards](https://make.wordpress.org/core/handbook/coding-standards/php/) and [PHP 5.6 Compatibility](https://github.com/wimg/PHPCompatibility): 221 | 222 | 1. To list coding standard issues in a file, run `composer phpcs` 223 | 1. If you want to try to automatically fix issues, run `composer phpcbf``. 224 | 225 | To automatically convert the readme.txt file to readme.md, you may, if you have installed composer as noted in the previous section, enter `composer update-readme` to have the .txt file converted 226 | into markdown and saved to readme.md. 227 | 228 | == Changelog == 229 | 230 | = 2.4.0 (2024-06-13) = 231 | * Remove sole setting as no longer needed(see upgrade notice) 232 | * Remove settings page as no more settings. 233 | * Bump minimum PHP version to PHP7.2 234 | * Require IndieAuth plugin as a dependency 235 | * Switch to dynamic from static rendering on posts...markup will no longer be placed inside the content block but dynamically added. 236 | 237 | = 2.3.3 (2023-03-10) = 238 | * Stop including visible text in reply contexts since they go inside since they go inside e-content, which webmention recipients use as the reply text. 239 | * Fix undeclared variables 240 | 241 | = 2.3.2 (2022-06-22 ) = 242 | * Update readme 243 | * Fix client name bug 244 | 245 | = 2.3.1 (2021-12-25 ) = 246 | * Made one little mistake. 247 | 248 | = 2.3.0 (2021-12-25 ) = 249 | * Sanitize media endpoint queries 250 | * Add mime_type filter for media queries 251 | * Update media endpoint query response 252 | * Set client application taxonomy id if present 253 | * Add display functions for showing the client or returning the client data which will work with or without the client application taxonomy added in Indieauth 254 | * Normalize JSON inputs to ensure no errors 255 | * Add support for Visibility config return https://github.com/indieweb/micropub-extensions/issues/8#issuecomment-536301952 256 | * Sets `_edit_last` property when a post is updated. 257 | 258 | = 2.2.5 (2021-09-22 ) = 259 | * Update readme links 260 | * Add filter to allow custom database insert. 261 | * Latitude and longitude properties are now converted into a location property. 262 | * Introduce new function to simplify returning a properly set datetime with timezone 263 | * Media Endpoint now supports a delete action. 264 | * New query unit test revealed bug in new q=source&url= query previously introduced. 265 | * Update media response to now just include published, updated, created, and mime_type for now. 266 | 267 | = 2.2.4 (2021-05-06 ) = 268 | * Add published date to return from q=source on media endpoint 269 | 270 | = 2.2.3 (2020-09-09 ) = 271 | * Deduplicated endpoint test code from endpoint and media endpoint classes. 272 | * Removed error suppression revealing several notices that had been hidden. Fixed warning notices. 273 | * Abstract request for scope and response into functions to avoid calling the actual filter as this may be deprecated in future. 274 | * Switch check in permissions to whether a user was logged in. 275 | * Published, updated, name, and summary properties are no longer stored in post meta. When queried, they will be pulled from the equivalent WordPress properties. Content should be as well, however as content in the post includes rendered microformats we need to store the pure version. Might address this in a future version. 276 | * As timezone is not stored in the WordPress timestamp, store the timezone offset for the post in meta instead. 277 | * Sideload and set featured images if featured property is set. 278 | 279 | = 2.2.2 (2020-08-23 ) = 280 | * Fixed and updated testing environment 281 | * Fixed failing tests as a result of update to testing environment 282 | * Change return response code based on spec update from 401 to 403 283 | 284 | = 2.2.1 (2020-07-31 ) = 285 | * Change category query parameter from search to filter per decision at Micropub Popup Session 286 | * Fix permissions for Media Endpoint to match Endpoint 287 | * For source query on both media and micropub endpoint support offset parameter 288 | 289 | = 2.2.0 (2020-07-25 ) = 290 | * Deprecate MICROPUB_LOCAL_AUTH, MICROPUB_AUTHENTICATION_ENDPOINT and MICROPUB_TOKEN_ENDPOINT constants. 291 | * Remove IndieAuth Client code, will now require the IndieAuth or other plugin that does not yet exist. 292 | 293 | = 2.1.0 (2020-02-06 ) = 294 | * Fix bug where timezone meta key was always set to website timezone instead of provided one 295 | * Fix issue where title and caption were not being set for images by adopting code from WordPress core 296 | * Remove post scope 297 | * Add support for draft scope 298 | * Improve permission handling by ensuring someone cannot edit another users posts unless they have that capability 299 | * Fix issue with date rendering in events 300 | * return URL in response to creating a post 301 | * introduce two new filters to filter the post type and the taxonomy input for posts 302 | 303 | = 2.0.11 (2019-05-25) = 304 | * Fix issues with empty variables 305 | * Update last media query to limit itself to last hour 306 | * Undelete is now part of delete scope as there is no undelete scope 307 | * Address issue where properties in upload are single property arrays 308 | 309 | = 2.0.10 (2019-04-13) = 310 | * Fix issue with media not being attached to post 311 | 312 | = 2.0.9 (2019-03-25) = 313 | * Add filter `micropub_suggest_title` and related function to generate slugs 314 | * Map updated property to WordPress modified property 315 | * Add meta key to micropub uploaded media so it can be queried 316 | * Add last and source queries for media endpoint 317 | * Set up return function for media that returns attachment metadata for now 318 | 319 | = 2.0.8 (2019-03-08) = 320 | * Parse geo URI into h-geo or h-card object 321 | 322 | = 2.0.7 (2019-02-18) = 323 | * Update geo storage to fix accuracy storage as well as allow for name parameter and future parameters to be passed. Indigenous for Android now supports passing this 324 | 325 | = 2.0.6 (2018-12-30) = 326 | * Adjust query filter to allow for new properties to be added by query 327 | * Add Gutenberg information into README 328 | 329 | = 2.0.5 (2018-11-23) = 330 | * Move syndication trigger to after micropub hook in order to ensure final version is rendered before sending syndication 331 | * Add settings UI for alternate authorization endpoint and token endpoint which will be hidden if Indieauth plugin is enabled 332 | 333 | = 2.0.4 (2018-11-17) = 334 | * Issues raised on prior release. 335 | * Removed generating debug messages when the data is empty 336 | 337 | = 2.0.3 (2018-11-17) = 338 | * Fix issue where the after_micropub action could not see form encoded files by adding them as properties on upload 339 | * Fix issue in previous release where did not account for a null request sent by wpcli 340 | * Add search parameter to category 341 | * Wrap category query in categories key to be consistent with other query parameters 342 | * If a URL is not provided to the query source parameter it will return the last 10 posts or more/less with an optional parameter 343 | * Micropub query filter now called after default parameters are added rather than before so it can modify the defaults rather than replacing them. 344 | * Micropub config query now returns a list of supported mp parameters and supported q query parameters 345 | * Micropub media endpoint config query now returns an empty array indicating that it has no configuration parameters yet 346 | 347 | = 2.0.2 (2018-11-12) = 348 | * Fix issue with built-in auth and update compatibility testing 349 | * Add experimental endpoint discovery option(https://indieweb.org/micropub_media_endpoint#Discovery_via_link_rel) 350 | 351 | = 2.0.1 (2018-11-04) = 352 | * Move authorization code later in load to resolve conflict 353 | 354 | = 2.0.0 (2018-10-22) = 355 | * Split plugin into files by functionality 356 | * Change authorization to integrate with WordPress mechanisms for login 357 | * Reject where the URL cannot be matched with a user account 358 | * Rewrite using REST API 359 | * Use `indieauth_scopes` and `indieauth_response` originally added for IndieAuth integration to be used by built in auth as well 360 | * Improve handling of access tokens in headers to cover additional cases 361 | * Add Media Endpoint 362 | * Improve error handling 363 | * Ensure compliance with Micropub spec 364 | * Update composer dependencies and include PHPUnit as a development dependency 365 | * Add nag notice for http domains and the option to diable with a setting 366 | * Load auth later in init sequence to avoid conflict 367 | 368 | = 1.4.3 (2018-05-27) = 369 | * Change scopes to filter 370 | * Get token response when IndieAuth plugin is installed 371 | 372 | = 1.4.2 (2018-04-19) = 373 | * Enforce scopes 374 | 375 | = 1.4.1 (2018-04-15) = 376 | * Version bump due some individuals not getting template file 377 | 378 | = 1.4 (2018-04-08) = 379 | * Separate functions that generate headers into micropub and IndieAuth 380 | * Add support for an option now used by the IndieAuth plugin to set alternate token and authorization endpoints 381 | * MICROPUB_LOCAL_AUTH configuration option adjusted to reflect that this disables the plugin built in authentication. This can hand it back to WordPress or allow another plugin to take over 382 | * MICROPUB_LOCAL_AUTH now disables adding auth headers to the page. 383 | * Fix post status issue by checking for valid defaults 384 | * Add configuration option under writing settings to set default post status 385 | * Add `micropub_syndication` hook that only fires on a request to syndicate to make it easier for third-party plugins to hook in 386 | 387 | = 1.3 (2017-12-31) = 388 | * Saves access token response in a post meta field `micropub_auth_response`. 389 | * Bug fix for `post_date_gmt` 390 | * Store timezone from published in arguments passed to micropub filter 391 | * Correctly handle published times that are in a different timezone than the site. 392 | * Set minimum version to PHP 5.3 393 | * Adhere to WordPress Coding Standards 394 | * Add `micropub_query` filter 395 | * Support Nested Properties in Content Generation 396 | * Deprecate `MICROPUB_DRAFT_MODE` configuration option in favor of setting option 397 | * Remove post content generation override in case of microformats2 capable theme or Post Kinds plugin installed 398 | * Introduce `micropub_post_content` filter to which post content generation is attached so that a theme or plugin can modify/remove the post generation as needed 399 | 400 | = 1.2 (2017-06-25) = 401 | * Support [OwnYourSwarm](https://ownyourswarm.p3k.io/)'s [custom `checkin` microformats2 property](https://ownyourswarm.p3k.io/docs#checkins), including auto-generating content if necessary. 402 | * Support `u-bookmark-of`. 403 | 404 | = 1.1 (2017-03-30) = 405 | * Support [`h-adr`](http://microformats.org/wiki/h-adr), [`h-geo`](http://microformats.org/wiki/h-geo), and plain text values for [`p-location`](http://microformats.org/wiki/h-event#p-location). 406 | * Bug fix for create/update with `content[html]`. 407 | 408 | = 1.0.1 = 409 | * Remove accidental dependence on PHP 5.3 (#46). 410 | 411 | = 1.0 = 412 | Substantial update. Supports [full W3C Micropub spec](https://www.w3.org/TR/micropub/), except for optional 413 | media endpoint. 414 | 415 | * Change `mf2_*` post meta format from multiple separate values to single array value that can be deserialized with `unserialize`. 416 | * Change the `before_micropub` filter's signature from `( $wp_args )` to `( $input )` (microformats2 associative array). 417 | * Change the `after_micropub` hook's signature changed from `( $post_id )` to `( $input, $wp_args )` (microformats2 associative array, WordPress post args). 418 | * Post content will not be automatically marked up if theme supports microformats2 or [Post Kinds plugin](https://wordpress.org/plugins/indieweb-post-kinds/) is enabled. 419 | * Add PHP Codesniffer File. 420 | 421 | = 0.4 = 422 | * Store all properties in post meta except those in a blacklist. 423 | * Support setting authentication and token endpoint in wp-config by setting `MICROPUB_AUTHENTICATION_ENDPOINT` and `MICROPUB_TOKEN_ENDPOINT`. 424 | * Support setting all micropub posts to draft in wp-config for testing by setting `MICROPUB_DRAFT_MODE` in wp-config. 425 | * Support using local auth to authenticate as opposed to IndieAuth as by setting `MICROPUB_LOCAL_AUTH` in wp-config. 426 | * Set content to summary if no content provided. 427 | * Support querying for syndicate-to and future query options. 428 | 429 | = 0.3 = 430 | * Use the specific WordPress user whose URL matches the access token, if possible. 431 | * Set `post_date_gmt` as well as `post_date`. 432 | 433 | = 0.2 = 434 | * Support more Micropub properties: `photo`, `like-of`, `repost-of`, `in-reply-to`, `rsvp`, `location`, `category`, `h=event`. 435 | * Check but don't require access tokens on localhost. 436 | * Better error handling. 437 | 438 | = 0.1 = 439 | Initial release. 440 | -------------------------------------------------------------------------------- /templates/micropub-post-status-setting.php: -------------------------------------------------------------------------------- 1 |
2 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /templates/micropub-settings.php: -------------------------------------------------------------------------------- 1 |
2 |

Micropub Options

3 |
4 | 11 |
12 |
13 | 14 | --------------------------------------------------------------------------------