diff --git a/.changeset/lucky-doors-applaud.md b/.changeset/lucky-doors-applaud.md new file mode 100644 index 000000000..1ca8845f8 --- /dev/null +++ b/.changeset/lucky-doors-applaud.md @@ -0,0 +1,6 @@ +--- +"@headstartwp/core": patch +"@headstartwp/next": patch +--- + +fix: make convertUrl and removeSourceUrl respect the original link's trailingslash diff --git a/.changeset/tiny-avocados-unite.md b/.changeset/tiny-avocados-unite.md new file mode 100644 index 000000000..ea0614c20 --- /dev/null +++ b/.changeset/tiny-avocados-unite.md @@ -0,0 +1,5 @@ +--- +"@headstartwp/headstartwp": patch +--- + +Fix redirect loop for invalid sitemap paths diff --git a/packages/core/src/utils/__tests__/removeSourceUrl.ts b/packages/core/src/utils/__tests__/removeSourceUrl.ts index 5fc1051da..233898ee3 100644 --- a/packages/core/src/utils/__tests__/removeSourceUrl.ts +++ b/packages/core/src/utils/__tests__/removeSourceUrl.ts @@ -1,7 +1,30 @@ import { removeSourceUrl } from '..'; describe('removeSourceUrl', () => { + it('returns empty string without nonEmptyLink', () => { + expect( + removeSourceUrl({ + link: 'https://test.com/test', + backendUrl: 'https://test.com/test', + nonEmptyLink: false, + }), + ).toBe(''); + }); it('removes source url', () => { + expect( + removeSourceUrl({ + link: 'https://test.com/test', + backendUrl: 'https://test.com/test', + }), + ).toBe('/'); + + expect( + removeSourceUrl({ + link: 'http://backendurl.com', + backendUrl: 'https://backendurl.com', + }), + ).toBe('/'); + expect( removeSourceUrl({ link: 'http://backendurl.com/', @@ -44,6 +67,13 @@ describe('removeSourceUrl', () => { }), ).toBe('/post-name-1?a=1&b=3&d=3'); + expect( + removeSourceUrl({ + link: 'http://backendurl.com/post-name-1?a=1&b=3&d=3/', + backendUrl: 'https://backendurl.com/', + }), + ).toBe('/post-name-1?a=1&b=3&d=3/'); + expect( removeSourceUrl({ link: 'http://backendurl.com/post-name-1#id', diff --git a/packages/core/src/utils/removeSourceUrl.ts b/packages/core/src/utils/removeSourceUrl.ts index 31dbc7acb..15f4c579b 100644 --- a/packages/core/src/utils/removeSourceUrl.ts +++ b/packages/core/src/utils/removeSourceUrl.ts @@ -15,6 +15,12 @@ export type removeSourceUrlType = { * The public url. Defaults to '/'. */ publicUrl?: string; + + /** + * If the removal of source url from link leads to a empty string, + * this setting control whether a '/' should be returned or the empty string + */ + nonEmptyLink?: boolean; }; /** @@ -25,17 +31,28 @@ export type removeSourceUrlType = { * * @see https://github.com/frontity/frontity/blob/dev/packages/components/link/utils.ts * + * @param props.link The link that might contain the sourceUrl + * @param props.backendUrl The source url + * @param props.publicUrl The public url + * @param props.nonEmptyLinks If the removal of source url from link leads to a empty string, + * this setting control whether a '/' should be returned or the empty string + * * @returns The URL without the Source URL. */ -export function removeSourceUrl({ link, backendUrl, publicUrl = '/' }: removeSourceUrlType) { - if (typeof link === 'undefined') { +export function removeSourceUrl({ + link: originalLink, + backendUrl, + publicUrl = '/', + nonEmptyLink = true, +}: removeSourceUrlType) { + if (typeof originalLink === 'undefined') { warn('link is undefined, double check if you are passing a valid value'); return ''; } if (typeof backendUrl === 'undefined') { warn('backendUrl is undefined, double check if you are passing a valid value'); - return link; + return originalLink; } // Ensure `sourceUrl` and `publicUrl` always include a trailing slash. All @@ -43,21 +60,33 @@ export function removeSourceUrl({ link, backendUrl, publicUrl = '/' }: removeSou const sourceUrl = backendUrl.replace(/\/?$/, '/'); const appUrl = publicUrl.replace(/\/?$/, '/'); - if (sourceUrl === '/' || link.startsWith('#')) { - return link; + if (sourceUrl === '/' || originalLink.startsWith('#')) { + return originalLink; } const { host: sourceHost, pathname: sourcePath } = new URL(sourceUrl); const { pathname: appPath } = new URL(appUrl, sourceUrl); + // we need to know if the original link has trailing slash or not + const hasTrailingSlash = /\/$/.test(originalLink); + const link = !hasTrailingSlash ? `${originalLink}/` : originalLink; const linkUrl = new URL(link, sourceUrl); // Compare just the host and the pathname. This way we ignore the protocol if // it doesn't match. if (linkUrl.host === sourceHost && linkUrl.pathname.startsWith(sourcePath)) { - return linkUrl.pathname.replace(sourcePath, appPath) + linkUrl.search + linkUrl.hash; + let transformedLink = + linkUrl.pathname.replace(sourcePath, appPath) + linkUrl.search + linkUrl.hash; + + transformedLink = hasTrailingSlash ? transformedLink : transformedLink.replace(/\/?$/, ''); + + if (nonEmptyLink && transformedLink === '') { + return '/'; + } + + return transformedLink; } // Do not change the link for other cases. - return link; + return originalLink; } diff --git a/packages/next/src/components/Yoast.tsx b/packages/next/src/components/Yoast.tsx index 8c7462d38..6cc0641a3 100644 --- a/packages/next/src/components/Yoast.tsx +++ b/packages/next/src/components/Yoast.tsx @@ -18,8 +18,10 @@ export function convertUrl(url: string, hostUrl: string, sourceUrl: string) { } return `${hostUrl}${removeSourceUrl({ - link: url.replace(/\/?$/, '/'), + link: url, + publicUrl: hostUrl, backendUrl: sourceUrl, + nonEmptyLink: false, })}`; } diff --git a/packages/next/src/components/__tests__/Yoast.tsx b/packages/next/src/components/__tests__/Yoast.tsx index 2405e164e..1a6c45312 100644 --- a/packages/next/src/components/__tests__/Yoast.tsx +++ b/packages/next/src/components/__tests__/Yoast.tsx @@ -1,16 +1,60 @@ import { convertUrl } from '../Yoast'; describe('convertUrl', () => { - it('root works without trailing slash', () => { + it('works without trainling slash', () => { expect( - convertUrl('https://test.com/test', 'https://test.test.com', 'https://test.com/test'), - ).toBe('https://test.test.com/'); + convertUrl( + 'https://backendurl.com/test', + 'https://publicurl.com', + 'https://backendurl.com/test', + ), + ).toBe('https://publicurl.com'); + + // a subsite on test.com/site1 + expect( + convertUrl( + 'https://backendurl.com/site1/post-name-1', + 'https://publicurl.com', + 'https://backendurl.com/site1', + ), + ).toBe('https://publicurl.com/post-name-1'); + + // front-end with subdomain + expect( + convertUrl( + 'https://backendurl.com/site1/post-name-1', + 'https://site1.publicurl.com', + 'https://backendurl.com/site1', + ), + ).toBe('https://site1.publicurl.com/post-name-1'); }); it('root works with trailing slash', () => { expect( - convertUrl('https://test.com/test/', 'https://test.test.com', 'https://test.com/test'), - ).toBe('https://test.test.com/'); + convertUrl( + 'https://backendurl.com/test/', + 'https://publicurl.com', + 'https://backendurl.com/test', + ), + ).toBe('https://publicurl.com/'); + + // a subsite on test.com/site1 + expect( + convertUrl( + 'https://backendurl.com/site1/post-name-1/', + 'https://publicurl.com', + 'https://backendurl.com/site1', + ), + ).toBe('https://publicurl.com/post-name-1/'); + + // front-end with subdomain + expect( + convertUrl( + 'https://backendurl.com/site1/post-name-1/', + 'https://site1.publicurl.com', + 'https://backendurl.com/site1', + ), + ).toBe('https://site1.publicurl.com/post-name-1/'); }); it('external url returns external url', () => { diff --git a/wp/headless-wp/composer.json b/wp/headless-wp/composer.json index 31a0e911e..5f0363e4d 100644 --- a/wp/headless-wp/composer.json +++ b/wp/headless-wp/composer.json @@ -10,7 +10,8 @@ } ], "require": { - "php": ">=8" + "php": ">=8", + "yoast/wordpress-seo": "^22.1" }, "repositories": [ { @@ -52,7 +53,13 @@ }, "config": { "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/installers": true + } + }, + "extra": { + "installer-paths": { + "vendor/{$name}/": ["type:wordpress-plugin"] } } } diff --git a/wp/headless-wp/composer.lock b/wp/headless-wp/composer.lock index 8e1c3d868..b128a4dd3 100644 --- a/wp/headless-wp/composer.lock +++ b/wp/headless-wp/composer.lock @@ -4,8 +4,227 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aaa9519b095e4c473378b4a5d151b7ec", - "packages": [], + "content-hash": "605e4a4bc9887f52888269f17af4203b", + "packages": [ + { + "name": "composer/installers", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/composer/installers.git", + "reference": "c29dc4b93137acb82734f672c37e029dfbd95b35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/installers/zipball/c29dc4b93137acb82734f672c37e029dfbd95b35", + "reference": "c29dc4b93137acb82734f672c37e029dfbd95b35", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "composer/composer": "1.6.* || ^2.0", + "composer/semver": "^1 || ^3", + "phpstan/phpstan": "^0.12.55", + "phpstan/phpstan-phpunit": "^0.12.16", + "symfony/phpunit-bridge": "^5.3", + "symfony/process": "^5" + }, + "type": "composer-plugin", + "extra": { + "class": "Composer\\Installers\\Plugin", + "branch-alias": { + "dev-main": "2.x-dev" + }, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "Composer\\Installers\\": "src/Composer/Installers" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kyle Robinson Young", + "email": "kyle@dontkry.com", + "homepage": "https://github.com/shama" + } + ], + "description": "A multi-framework Composer library installer", + "homepage": "https://composer.github.io/installers/", + "keywords": [ + "Dolibarr", + "Eliasis", + "Hurad", + "ImageCMS", + "Kanboard", + "Lan Management System", + "MODX Evo", + "MantisBT", + "Mautic", + "Maya", + "OXID", + "Plentymarkets", + "Porto", + "RadPHP", + "SMF", + "Starbug", + "Thelia", + "Whmcs", + "WolfCMS", + "agl", + "annotatecms", + "attogram", + "bitrix", + "cakephp", + "chef", + "cockpit", + "codeigniter", + "concrete5", + "croogo", + "dokuwiki", + "drupal", + "eZ Platform", + "elgg", + "expressionengine", + "fuelphp", + "grav", + "installer", + "itop", + "known", + "kohana", + "laravel", + "lavalite", + "lithium", + "magento", + "majima", + "mako", + "matomo", + "mediawiki", + "miaoxing", + "modulework", + "modx", + "moodle", + "osclass", + "pantheon", + "phpbb", + "piwik", + "ppi", + "processwire", + "puppet", + "pxcms", + "reindex", + "roundcube", + "shopware", + "silverstripe", + "sydes", + "sylius", + "tastyigniter", + "wordpress", + "yawik", + "zend", + "zikula" + ], + "support": { + "issues": "https://github.com/composer/installers/issues", + "source": "https://github.com/composer/installers/tree/v2.2.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2022-08-20T06:45:11+00:00" + }, + { + "name": "yoast/wordpress-seo", + "version": "22.1", + "source": { + "type": "git", + "url": "https://github.com/Yoast-dist/wordpress-seo.git", + "reference": "2b76a34e4289ed777faf38e09e71c0660719f389" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast-dist/wordpress-seo/zipball/2b76a34e4289ed777faf38e09e71c0660719f389", + "reference": "2b76a34e4289ed777faf38e09e71c0660719f389", + "shasum": "" + }, + "require": { + "composer/installers": "^1.12 || ^2.0", + "ext-filter": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "7.8.1", + "humbug/php-scoper": "^0.13.4", + "league/oauth2-client": "2.7.0", + "psr/container": "1.0.0", + "psr/log": "^1.0", + "symfony/config": "^3.4", + "symfony/dependency-injection": "^3.4", + "wordproof/wordpress-sdk": "1.3.5", + "yoast/wp-test-utils": "^1.2", + "yoast/yoastcs": "^3.0" + }, + "suggest": { + "ext-bcmath": "For more accurate calculations", + "ext-dom": "Improves image sitemap", + "ext-libxml": "Improves image sitemap", + "ext-mbstring": "For cyrillic support" + }, + "type": "wordpress-plugin", + "autoload": { + "classmap": [ + "admin/", + "inc/", + "vendor_prefixed/", + "src/", + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoa.st/1--" + } + ], + "description": "Improve your WordPress SEO: Write better content and have a fully optimized WordPress site using the Yoast SEO plugin.", + "homepage": "https://yoa.st/1ui", + "keywords": [ + "seo", + "wordpress" + ], + "support": { + "forum": "https://wordpress.org/support/plugin/wordpress-seo", + "issues": "https://github.com/Yoast/wordpress-seo/issues", + "security": "https://yoast.com/security-program/", + "source": "https://github.com/Yoast/wordpress-seo", + "wiki": "https://github.com/Yoast/wordpress-seo/wiki" + }, + "time": "2024-02-20T09:24:04+00:00" + } + ], "packages-dev": [ { "name": "10up/phpcs-composer", @@ -2990,5 +3209,5 @@ "php": ">=8" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/wp/headless-wp/includes/classes/Links.php b/wp/headless-wp/includes/classes/Links.php index 71c8817ae..cad8af844 100644 --- a/wp/headless-wp/includes/classes/Links.php +++ b/wp/headless-wp/includes/classes/Links.php @@ -19,7 +19,6 @@ class Links { * Set up any hooks */ public function register() { - add_action( 'template_redirect', [ $this, 'maybe_redirect_frontend' ] ); add_filter( 'rewrite_rules_array', [ $this, 'create_taxonomy_rewrites' ] ); } @@ -104,49 +103,4 @@ public function filter_home_url( $home_url, $path, $orig_scheme ) { // phpcs:ign return $url; } - - /** - * Redirect the WordPress frontend if the React website URL has been filled in and the user has selected to redirect the frontend - */ - public function maybe_redirect_frontend() { - // if request method is HEAD then the headless site is making a HEAD request to figure out redirects, so don't mess with redirects or home_url - if ( - isset( $_SERVER['REQUEST_METHOD'] ) && - 'HEAD' === sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) - ) { - return; - } - - if ( isset( $_SERVER['HTTP_X_WP_REDIRECT_CHECK'] ) ) { - return; - } - - global $wp; - - $site_url = \get_option( 'site_react_url' ); - - $should_redirect = ! is_admin() && ! is_preview() && ! is_robots() && ! is_feed() && ! empty( $site_url ); - $should_redirect = $should_redirect && true === Plugin::should_frontend_redirect(); - - /** - * Filter's whether the frontend should redirect to the react url - * - * @param array $should_redirect The default should redirect value. - */ - $should_redirect = apply_filters( 'tenup_headless_wp_frontend_should_redirect', $should_redirect ); - - if ( $should_redirect ) { - $url_request = $wp->request; - - // do not redirect for (missing) assets - if ( str_starts_with( $url_request, '/wp-content' ) || str_ends_with( $url_request, '.css' ) || str_ends_with( $url_request, '.js' ) ) { - return; - } - - // Redirect the frontend WordPress request to the React website URL. - // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect - \wp_redirect( trailingslashit( esc_url_raw( $site_url ) ) . $url_request, 301 ); - exit; - } - } } diff --git a/wp/headless-wp/includes/classes/Plugin.php b/wp/headless-wp/includes/classes/Plugin.php index 3e8b26406..a33e53970 100644 --- a/wp/headless-wp/includes/classes/Plugin.php +++ b/wp/headless-wp/includes/classes/Plugin.php @@ -51,6 +51,9 @@ public function register() { $search = new Search\Search(); $search->register(); + + $redirect = new Redirect(); + $redirect->register(); } /** diff --git a/wp/headless-wp/includes/classes/Redirect.php b/wp/headless-wp/includes/classes/Redirect.php new file mode 100644 index 000000000..b12e49164 --- /dev/null +++ b/wp/headless-wp/includes/classes/Redirect.php @@ -0,0 +1,72 @@ +request; + + // do not redirect for (missing) assets + if ( str_starts_with( $url_request, '/wp-content' ) || str_ends_with( $url_request, '.css' ) || str_ends_with( $url_request, '.js' ) ) { + return; + } + + // if any sitemap requests reaches this point then it is a 404 + if ( str_contains( $url_request, 'sitemap' ) && str_ends_with( $url_request, '.xml' ) ) { + // redirect to the homepage, otherwise users would see a HTTP 404 (not the 404 page on next.js) error on the browser. + \wp_redirect( trailingslashit( esc_url_raw( $site_url ) ), 307 ); + exit; + } + + // Redirect the frontend WordPress request to the React website URL. + // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect + \wp_redirect( trailingslashit( esc_url_raw( $site_url ) ) . $url_request, 301 ); + exit; + } + } +}