| @@ -15,8 +15,9 @@ | ||
| 15 | 15 | |
| 16 | 16 | use WP_Post; |
| 17 | 17 | use WP_Error; |
| 18 | 18 | |
| 19 | +use const Parsely\PARSELY_CACHE_GROUP; | |
| 19 | 20 | use const Parsely\PARSELY_FILE; |
| 20 | 21 | |
| 21 | 22 | /** |
| 22 | 23 | * Utils Class. |
| @@ -21,8 +22,16 @@ | ||
| 21 | 22 | /** |
| 22 | 23 | * Utils Class. |
| 23 | 24 | * |
| 24 | 25 | * @since 3.17.0 |
| 26 | + * | |
| 27 | + * @phpstan-type ItmParams array{ | |
| 28 | + * campaign: string, | |
| 29 | + * source?: string, | |
| 30 | + * medium?: string, | |
| 31 | + * content?: string, | |
| 32 | + * term?: string, | |
| 33 | + * } | |
| 25 | 34 | */ |
| 26 | 35 | class Utils { |
| 27 | 36 | const DATE_UTC_FORMAT = 'Y-m-d'; |
| 28 | 37 | const WP_DATE_TIME_FORMAT = 'Y-m-d H:i:s'; |
| @@ -270,9 +279,9 @@ | ||
| 270 | 279 | |
| 271 | 280 | return esc_html( |
| 272 | 281 | sprintf( |
| 273 | 282 | /* translators: 1: Number of seconds */ |
| 274 | - __( '%1$d sec.', 'wp-parsely' ), | |
| 283 | + __( '%1$ds', 'wp-parsely' ), | |
| 275 | 284 | round( $seconds ) |
| 276 | 285 | ) |
| 277 | 286 | ); |
| 278 | 287 | } |
| @@ -410,18 +419,24 @@ | ||
| 410 | 419 | |
| 411 | 420 | /** |
| 412 | 421 | * Returns the post ID for the passed URL. |
| 413 | 422 | * |
| 423 | + * Posts the current user can neither view publicly nor read resolve to 0. | |
| 424 | + * | |
| 414 | 425 | * @since 3.16.0 |
| 415 | 426 | * @since 3.18.0 Moved from `Models/class-smart-link.php`. |
| 427 | + * @since 3.24.2 Resolves only to posts visible to the current user, and | |
| 428 | + * matches slugs only of this site's URLs, unambiguously. | |
| 416 | 429 | * |
| 417 | 430 | * @param string $url The URL to get the post ID for. |
| 418 | 431 | * @return int The post ID of the URL, 0 if not found. |
| 419 | 432 | */ |
| 420 | 433 | public static function get_post_id_by_url( string $url ): int { |
| 421 | - $cache = wp_cache_get( $url, 'wp_parsely_smart_link_url_to_postid' ); | |
| 422 | - if ( is_integer( $cache ) ) { | |
| 423 | - return $cache; | |
| 434 | + $cache_key = sprintf( 'url-to-postid-%s', hash( 'sha256', $url ) ); | |
| 435 | + $cache = wp_cache_get( $cache_key, PARSELY_CACHE_GROUP ); | |
| 436 | + | |
| 437 | + if ( false !== $cache && is_numeric( $cache ) ) { | |
| 438 | + return self::get_visible_post_id( (int) $cache ); | |
| 424 | 439 | } |
| 425 | 440 | |
| 426 | 441 | if ( function_exists( 'wpcom_vip_url_to_postid' ) ) { |
| 427 | 442 | $post_id = wpcom_vip_url_to_postid( $url ); |
| @@ -427,16 +442,21 @@ | ||
| 427 | 442 | $post_id = wpcom_vip_url_to_postid( $url ); |
| 428 | 443 | } else { |
| 429 | 444 | // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid |
| 430 | 445 | $post_id = url_to_postid( $url ); |
| 431 | - wp_cache_set( $url, $post_id, 'wp_parsely_smart_link_url_to_postid' ); | |
| 446 | + wp_cache_set( $cache_key, $post_id, PARSELY_CACHE_GROUP, WEEK_IN_SECONDS ); | |
| 432 | 447 | } |
| 433 | 448 | |
| 434 | 449 | // A post ID was found, return it. |
| 435 | 450 | if ( 0 !== $post_id ) { |
| 436 | - return $post_id; | |
| 451 | + return self::get_visible_post_id( $post_id ); | |
| 437 | 452 | } |
| 438 | 453 | |
| 454 | + // A slug can only identify a post in this site's URLs. | |
| 455 | + if ( ! self::is_site_url( $url ) ) { | |
| 456 | + return 0; | |
| 457 | + } | |
| 458 | + | |
| 439 | 459 | // No post ID was found, try to find it from the slug. |
| 440 | 460 | $clean_url = preg_replace( '/\?.*$/', '', $url ); // Remove the query string from the URL. |
| 441 | 461 | if ( null === $clean_url ) { |
| 442 | 462 | $clean_url = $url; |
| @@ -442,8 +462,43 @@ | ||
| 442 | 462 | $clean_url = $url; |
| 443 | 463 | } |
| 444 | 464 | $post_slug = basename( $clean_url ); |
| 445 | 465 | |
| 466 | + // Public posts take precedence over other posts with the same slug. | |
| 467 | + $post_ids = array_values( | |
| 468 | + array_filter( | |
| 469 | + self::get_top_level_post_ids_by_slug( $post_slug, array( 'publish', 'inherit' ) ), | |
| 470 | + 'is_post_publicly_viewable' | |
| 471 | + ) | |
| 472 | + ); | |
| 473 | + | |
| 474 | + // Visibility is checked on return, so the resolution can be cached for all users. | |
| 475 | + if ( 0 === count( $post_ids ) ) { | |
| 476 | + $post_ids = self::get_top_level_post_ids_by_slug( $post_slug, array( 'any' ) ); | |
| 477 | + } | |
| 478 | + | |
| 479 | + // A slug shared by several posts identifies none of them. | |
| 480 | + if ( 1 === count( $post_ids ) ) { | |
| 481 | + wp_cache_set( $cache_key, $post_ids[0], PARSELY_CACHE_GROUP, WEEK_IN_SECONDS ); | |
| 482 | + return self::get_visible_post_id( $post_ids[0] ); | |
| 483 | + } | |
| 484 | + | |
| 485 | + return 0; | |
| 486 | + } | |
| 487 | + | |
| 488 | + /** | |
| 489 | + * Returns up to 2 IDs of top-level posts of public post types with the | |
| 490 | + * passed slug, enough to tell whether the slug is ambiguous. | |
| 491 | + * | |
| 492 | + * Like get_page_by_path() given a single slug, child posts don't match. | |
| 493 | + * | |
| 494 | + * @since 3.24.2 | |
| 495 | + * | |
| 496 | + * @param string $slug The post slug. | |
| 497 | + * @param array<string> $post_statuses The post statuses to match. | |
| 498 | + * @return array<int> The post IDs. | |
| 499 | + */ | |
| 500 | + private static function get_top_level_post_ids_by_slug( string $slug, array $post_statuses ): array { | |
| 446 | 501 | $public_post_types = get_post_types( |
| 447 | 502 | array( |
| 448 | 503 | 'public' => true, |
| 449 | 504 | 'show_in_rest' => true, |
| @@ -448,14 +503,129 @@ | ||
| 448 | 503 | 'public' => true, |
| 449 | 504 | 'show_in_rest' => true, |
| 450 | 505 | ) |
| 451 | 506 | ); |
| 452 | - $post = get_page_by_path( $post_slug, OBJECT, array_keys( $public_post_types ) ); | |
| 453 | 507 | |
| 454 | - if ( null !== $post ) { | |
| 455 | - wp_cache_set( $url, $post->ID, 'wp_parsely_smart_link_url_to_postid' ); | |
| 456 | - return $post->ID; | |
| 508 | + $query = new \WP_Query( | |
| 509 | + array( | |
| 510 | + 'name' => $slug, | |
| 511 | + 'post_type' => array_keys( $public_post_types ), | |
| 512 | + 'post_status' => $post_statuses, | |
| 513 | + 'post_parent' => 0, | |
| 514 | + 'fields' => 'ids', | |
| 515 | + 'posts_per_page' => 2, | |
| 516 | + 'no_found_rows' => true, | |
| 517 | + ) | |
| 518 | + ); | |
| 519 | + | |
| 520 | + $post_ids = array(); | |
| 521 | + foreach ( $query->posts as $post_id ) { | |
| 522 | + if ( is_int( $post_id ) ) { | |
| 523 | + $post_ids[] = $post_id; | |
| 524 | + } | |
| 457 | 525 | } |
| 458 | 526 | |
| 527 | + return $post_ids; | |
| 528 | + } | |
| 529 | + | |
| 530 | + /** | |
| 531 | + * Returns the passed post ID, or 0 if the current user can neither view | |
| 532 | + * the post publicly nor read it. | |
| 533 | + * | |
| 534 | + * @since 3.24.2 | |
| 535 | + * | |
| 536 | + * @param int $post_id The post ID. | |
| 537 | + * @return int The post ID, or 0. | |
| 538 | + */ | |
| 539 | + private static function get_visible_post_id( int $post_id ): int { | |
| 540 | + if ( is_post_publicly_viewable( $post_id ) || current_user_can( 'read_post', $post_id ) ) { | |
| 541 | + return $post_id; | |
| 542 | + } | |
| 543 | + | |
| 459 | 544 | return 0; |
| 545 | + } | |
| 546 | + | |
| 547 | + /** | |
| 548 | + * Returns whether the passed URL is relative, or has the host of the home | |
| 549 | + * URL, the site URL, the Site ID or the canonical URL domain. | |
| 550 | + * | |
| 551 | + * A leading `www.` is ignored, as `url_to_postid()` does. | |
| 552 | + * | |
| 553 | + * @since 3.24.2 | |
| 554 | + * | |
| 555 | + * @param string $url The URL to check. | |
| 556 | + * @return bool Whether the URL belongs to this site. | |
| 557 | + */ | |
| 558 | + private static function is_site_url( string $url ): bool { | |
| 559 | + $url_host = wp_parse_url( $url, PHP_URL_HOST ); | |
| 560 | + | |
| 561 | + if ( ! is_string( $url_host ) ) { | |
| 562 | + return true; | |
| 563 | + } | |
| 564 | + | |
| 565 | + $site_addresses = array( | |
| 566 | + home_url(), | |
| 567 | + site_url(), | |
| 568 | + \Parsely\get_parsely()->get_site_id(), | |
| 569 | + apply_filters( 'wp_parsely_canonical_url_domain', null ), | |
| 570 | + ); | |
| 571 | + | |
| 572 | + foreach ( $site_addresses as $site_address ) { | |
| 573 | + if ( ! is_string( $site_address ) || '' === trim( $site_address ) ) { | |
| 574 | + continue; | |
| 575 | + } | |
| 576 | + | |
| 577 | + // Domain-only addresses need a scheme to be parsed. | |
| 578 | + $site_host = wp_parse_url( | |
| 579 | + 'http://' . preg_replace( '#^https?://#i', '', trim( $site_address ) ), | |
| 580 | + PHP_URL_HOST | |
| 581 | + ); | |
| 582 | + | |
| 583 | + if ( is_string( $site_host ) && self::strip_www( $site_host ) === self::strip_www( $url_host ) ) { | |
| 584 | + return true; | |
| 585 | + } | |
| 586 | + } | |
| 587 | + | |
| 588 | + return false; | |
| 589 | + } | |
| 590 | + | |
| 591 | + /** | |
| 592 | + * Lowercases a host and removes its leading `www.`. | |
| 593 | + * | |
| 594 | + * @since 3.24.2 | |
| 595 | + * | |
| 596 | + * @param string $host The host. | |
| 597 | + * @return string The host, without its leading `www.`. | |
| 598 | + */ | |
| 599 | + private static function strip_www( string $host ): string { | |
| 600 | + return (string) preg_replace( '/^www\./', '', strtolower( $host ) ); | |
| 601 | + } | |
| 602 | + | |
| 603 | + /** | |
| 604 | + * Appends ITM parameters to a URL. | |
| 605 | + * | |
| 606 | + * @since 3.19.0 | |
| 607 | + * | |
| 608 | + * @param string $url The URL to append the ITM parameters to. | |
| 609 | + * @param ItmParams $params The ITM parameters to append. | |
| 610 | + * @return string The URL with the ITM parameters appended. | |
| 611 | + */ | |
| 612 | + public static function append_itm_params( string $url, $params ): string { | |
| 613 | + // Convert the params array to the correct format. | |
| 614 | + $mapping = array( | |
| 615 | + 'campaign' => 'itm_campaign', | |
| 616 | + 'source' => 'itm_source', | |
| 617 | + 'medium' => 'itm_medium', | |
| 618 | + 'content' => 'itm_content', | |
| 619 | + 'term' => 'itm_term', | |
| 620 | + ); | |
| 621 | + | |
| 622 | + $itm_params = array(); | |
| 623 | + foreach ( $params as $key => $value ) { | |
| 624 | + if ( array_key_exists( $key, $mapping ) ) { | |
| 625 | + $itm_params[ $mapping[ $key ] ] = $value; | |
| 626 | + } | |
| 627 | + } | |
| 628 | + | |
| 629 | + return add_query_arg( $itm_params, $url ); | |
| 460 | 630 | } |
| 461 | 631 | } |