├── .editorconfig ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── behat-test.yml │ ├── integration-tests.yml │ └── unit-tests.yml ├── .gitignore ├── .phpcs.xml.dist ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── behat.yml ├── bin └── install-wp-tests.sh ├── composer.json ├── features ├── find-domains.feature ├── insert-redirect.feature └── testing.feature ├── includes ├── class-capability.php ├── class-list-redirects.php ├── class-lookup.php ├── class-post-type.php ├── class-utils.php ├── class-wpcom-legacy-redirector-cli.php ├── class-wpcom-legacy-redirector-ui.php └── class-wpcom-legacy-redirector.php ├── js └── admin-add-redirects.js ├── phpunit-integration.xml.dist ├── phpunit.xml.dist ├── tests ├── Behat │ └── FeatureContext.php ├── Integration │ ├── CapabilityTest.php │ ├── LookupTest.php │ ├── PostTypeTest.php │ ├── RedirectsTest.php │ ├── TestCase.php │ └── bootstrap.php └── Unit │ ├── CapabilityTest.php │ ├── CliTest.php │ ├── MonkeyStubs.php │ ├── PreservableParamsTest.php │ ├── RedirectsTest.php │ ├── UtilsTest.php │ └── bootstrap.php ├── wpcom-helper.php └── wpcom-legacy-redirector.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # It is based on https://core.trac.wordpress.org/browser/trunk/.editorconfig 3 | # See https://editorconfig.org for more information about the standard. 4 | 5 | # WordPress Coding Standards 6 | # https://make.wordpress.org/core/handbook/coding-standards/ 7 | 8 | root = true 9 | 10 | [*] 11 | charset = utf-8 12 | end_of_line = lf 13 | insert_final_newline = true 14 | trim_trailing_whitespace = true 15 | indent_style = tab 16 | 17 | [{*.yml,*.feature}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | 24 | [*.txt] 25 | end_of_line = crlf 26 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # The following teams will get auto-tagged for a review. 2 | # See https://docs.github.com/en/enterprise/2.15/user/articles/about-code-owners 3 | * @Automattic/redirector 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "composer" # See documentation for possible values 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "daily" 10 | # Add assignees 11 | assignees: 12 | - "GaryJones" 13 | - "ovidiul" 14 | # Prefix all commit messages with "Composer" 15 | # include a list of updated dependencies 16 | commit-message: 17 | prefix: "Composer" 18 | include: "scope" 19 | # Specify labels for Composer pull requests 20 | labels: 21 | - "Type: maintenance" 22 | -------------------------------------------------------------------------------- /.github/workflows/behat-test.yml: -------------------------------------------------------------------------------- 1 | name: Behat Testing 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/behat-test.yml' 7 | - '**.php' 8 | - '**.feature' 9 | - 'behat.yml' 10 | - 'composer.json' 11 | pull_request: 12 | paths: 13 | - '.github/workflows/behat-test.yml' 14 | - '**.php' 15 | - '**.feature' 16 | - 'behat.yml' 17 | - 'composer.json' 18 | types: 19 | - opened 20 | - reopened 21 | - synchronize 22 | 23 | workflow_dispatch: 24 | 25 | jobs: 26 | behat: 27 | uses: automattic/wpvip-plugins-.github/.github/workflows/reusable-behat-test.yml@trunk 28 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: WP ${{ matrix.wordpress }} on PHP ${{ matrix.php }} 8 | runs-on: ubuntu-latest 9 | continue-on-error: ${{ matrix.allowed_failure }} 10 | 11 | env: 12 | WP_VERSION: ${{ matrix.wordpress }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | # Check lowest supported WP version, with the lowest supported PHP. 18 | - wordpress: '5.9' 19 | php: '7.4' 20 | allowed_failure: false 21 | # Check latest WP with the highest supported PHP. 22 | - wordpress: 'latest' 23 | php: 'latest' 24 | allowed_failure: false 25 | # Check upcoming WP. 26 | - wordpress: 'trunk' 27 | php: 'latest' 28 | allowed_failure: true 29 | # Check upcoming PHP - only needed when a new version has been forked (typically Sep-Nov) 30 | # - wordpress: 'trunk' 31 | # php: 'nightly' 32 | # allowed_failure: true 33 | fail-fast: false 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up PHP ${{ matrix.php }} 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php }} 43 | extensions: ${{ matrix.extensions }} 44 | ini-values: ${{ matrix.ini-values }} 45 | coverage: ${{ matrix.coverage }} 46 | 47 | - name: Install Composer dependencies 48 | uses: ramsey/composer-install@v3 49 | with: 50 | composer-options: --ignore-platform-req=php+ 51 | 52 | - name: Set up problem matchers for PHP 53 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 54 | 55 | - name: Set up problem matchers for PHPUnit 56 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 57 | 58 | - name: Show PHP and PHPUnit version info 59 | run: | 60 | php --version 61 | ./vendor/bin/phpunit --version 62 | 63 | - name: Start MySQL service 64 | run: sudo systemctl start mysql.service 65 | 66 | - name: Install WordPress environment 67 | run: composer prepare ${{ matrix.wordpress }} 68 | 69 | - name: Run integration tests (single site) 70 | run: composer test-integration 71 | 72 | - name: Run integration tests (multisite) 73 | run: composer test-integration-ms 74 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: WP ${{ matrix.wordpress }} on PHP ${{ matrix.php }} 8 | runs-on: ubuntu-latest 9 | continue-on-error: ${{ matrix.allowed_failure }} 10 | 11 | env: 12 | WP_VERSION: ${{ matrix.wordpress }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | # Check lowest supported WP version, with the lowest supported PHP. 18 | - wordpress: '5.9' 19 | php: '7.4' 20 | allowed_failure: false 21 | # Check latest WP with the highest supported PHP. 22 | - wordpress: 'latest' 23 | php: 'latest' 24 | allowed_failure: false 25 | # Check upcoming WP. 26 | - wordpress: 'trunk' 27 | php: 'latest' 28 | allowed_failure: true 29 | # Check upcoming PHP - only needed when a new version has been forked (typically Sep-Nov) 30 | # - wordpress: 'trunk' 31 | # php: 'nightly' 32 | # allowed_failure: true 33 | fail-fast: false 34 | 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up PHP ${{ matrix.php }} 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php }} 43 | extensions: ${{ matrix.extensions }} 44 | ini-values: ${{ matrix.ini-values }} 45 | coverage: ${{ matrix.coverage }} 46 | 47 | - name: Install Composer dependencies 48 | uses: ramsey/composer-install@v3 49 | with: 50 | composer-options: --ignore-platform-req=php+ 51 | 52 | - name: Set up problem matchers for PHP 53 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 54 | 55 | - name: Set up problem matchers for PHPUnit 56 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 57 | 58 | - name: Show PHP and PHPUnit version info 59 | run: | 60 | php --version 61 | ./vendor/bin/phpunit --version 62 | 63 | - name: Run unit tests (single site) 64 | run: composer test-unit 65 | 66 | - name: Run unit tests (multisite) 67 | run: composer test-unit-ms 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /vendor/ 3 | /composer.lock 4 | /.phpcs.xml 5 | /phpcs.xml 6 | /phpunit.xml 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom ruleset for wpcom-legacy-redirector plugin. 4 | 5 | 6 | 7 | 8 | 9 | . 10 | 12 | /vendor/ 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 33 | 34 | 35 | 37 | 38 | 39 | 40 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | tests/ 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | os: linux 3 | dist: xenial 4 | 5 | services: 6 | - mysql 7 | 8 | notifications: 9 | email: 10 | on_success: never 11 | on_failure: change 12 | 13 | php: 14 | - 5.6 15 | - 7.0 16 | - 7.1 17 | - 7.2 18 | - 7.3 19 | - 7.4 20 | - 8.0 21 | - "nightly" 22 | 23 | env: 24 | - WP_VERSION=latest WP_MULTISITE=0 25 | - WP_VERSION=latest WP_MULTISITE=1 26 | 27 | jobs: 28 | allow_failures: 29 | - php: "nightly" 30 | 31 | before_install: 32 | # Speed up build time by disabling Xdebug. 33 | # https://johnblackbourn.com/reducing-travis-ci-build-times-for-wordpress-projects/ 34 | # https://twitter.com/kelunik/status/954242454676475904 35 | - phpenv config-rm xdebug.ini || echo 'No xdebug config.' 36 | 37 | install: 38 | - | 39 | if [[ $TRAVIS_PHP_VERSION == "nightly" || $TRAVIS_PHP_VERSION == "8.0" ]]; then 40 | # PHPUnit 7.x does not allow for installation on PHP 8, so ignore platform 41 | # requirements to get PHPUnit 7.x to install on nightly. 42 | travis_retry composer update --ignore-platform-reqs 43 | else 44 | travis_retry composer update 45 | fi 46 | - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION 47 | 48 | script: 49 | - composer test 50 | - composer integration-test 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log for WPCOM Legacy Redirector 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | Requires PHP 7.4. 10 | Requires WordPress 5.9. 11 | 12 | ### Added 13 | - Admin pages to view, add, delete, and validate redirects. Uses new `manage_redirects` capability. 14 | - `wpcom-legacy-redirector find-domains` CLI command. For sites that need to update their allowed_redirect_hosts filter, this command will list all unique domains that are redirected to. 15 | - CI tests for PHP 7.0 and 7.1. 16 | - Output errors for failed imports of redirects. 17 | - Progress bar to `import-from-meta` command. 18 | - `--verbose` flag to `import-from-meta` and `import-from-csv` commands. 19 | - `--skip-validation` flag, but set validation to true by default. 20 | 21 | ## Changed 22 | - Improved adherence to WPCS and VIPCS coding standards. 23 | - Drop PHP 5.3-7.3 support. 24 | - Use WP_CLI:error to halt operation on failed insert using `insert-redirect`. 25 | - Return an error if no redirects were found for a meta key. 26 | - Added performance improvement for `import-from-meta` command. 27 | - Improved CLI commands documentation. 28 | - Project / code cleanup. 29 | 30 | ## Fixed 31 | - Trim whitespace around CSV file path, to support dragging a file into the terminal window to add the path. 32 | - Ensure `POST` var is set during CLI command. 33 | 34 | ## [1.3.0] - 2016-03-29 35 | 36 | ### Added 37 | - `wpcom_legacy_redirector_preserve_query_params` filter to allow for the safelisting of params that should be passed through to the redirected URL. 38 | 39 | ## Changed 40 | - Updated logic to check `wp_parse_url()` query component as the Request value will not be set for test purposes. 41 | - Updated unit tests. 42 | 43 | ### Fixed 44 | - Fix "Undefined variable $row at line 98" PHP notice. 45 | 46 | ## [1.2.0] - 2016-07-07 47 | 48 | ### Added 49 | 50 | - Composer support 51 | - `wpcom_legacy_redirector_redirect_status` filter for redirect status code (props spacedmonkey) 52 | - `wpcom_legacy_redirector_redirect_allow_insert` filter to enable inserts outside of WP-CLI. 53 | 54 | ### Fixed 55 | - Reset cache when a redirect post does not exist. 56 | - Fix for WP-CLI check. 57 | 58 | ## [1.1.0] - 2016-03-29 59 | 60 | ### Added 61 | - Unit tests 62 | 63 | ### Fixed 64 | - Fix bug with query string URLs 65 | 66 | ## 1.0.0 - 2016-02-27 67 | 68 | Initial release. 69 | 70 | [Unreleased]: https://github.com/Automattic/WPCOM-Legacy-Redirector/compare/1.3.0...HEAD 71 | [1.3.0]: https://github.com/Automattic/WPCOM-Legacy-Redirector/compare/1.2.0...1.3.0 72 | [1.2.0]: https://github.com/Automattic/WPCOM-Legacy-Redirector/compare/1.1.0...1.2.0 73 | [1.1.0]: https://github.com/Automattic/WPCOM-Legacy-Redirector/compare/1.0.0...1.1.0 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WPCOM Legacy Redirector 2 | 3 | WordPress plugin for handling legacy redirects in a scalable manner. 4 | 5 | Please see our [wiki](https://github.com/Automattic/WPCOM-Legacy-Redirector/wiki) for detailed documentation. 6 | 7 | ## Requirements 8 | 9 | - PHP 7.4+ 10 | - WordPress 5.9+ 11 | 12 | ## Change Log 13 | 14 | See [CHANGELOG.md](CHANGELOG.md) for the full list of changes. 15 | 16 | ## License 17 | 18 | Licensed under `GPL-2.0-or-later`. 19 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | default: 4 | contexts: 5 | - Automattic\LegacyRedirector\Tests\Behat\FeatureContext 6 | paths: 7 | - features 8 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | TMPDIR=${TMPDIR-/tmp} 16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") 17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} 18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} 19 | 20 | download() { 21 | if [ `which curl` ]; then 22 | curl -s "$1" > "$2"; 23 | elif [ `which wget` ]; then 24 | wget -nv -O "$2" "$1" 25 | fi 26 | } 27 | 28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then 29 | WP_BRANCH=${WP_VERSION%\-*} 30 | WP_TESTS_TAG="branches/$WP_BRANCH" 31 | 32 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then 33 | WP_TESTS_TAG="branches/$WP_VERSION" 34 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then 35 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 36 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 37 | WP_TESTS_TAG="tags/${WP_VERSION%??}" 38 | else 39 | WP_TESTS_TAG="tags/$WP_VERSION" 40 | fi 41 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 42 | WP_TESTS_TAG="trunk" 43 | else 44 | # http serves a single offer, whereas https serves multiple. we only want one 45 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 46 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 47 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 48 | if [[ -z "$LATEST_VERSION" ]]; then 49 | echo "Latest WordPress version could not be found" 50 | exit 1 51 | fi 52 | WP_TESTS_TAG="tags/$LATEST_VERSION" 53 | fi 54 | set -ex 55 | 56 | install_wp() { 57 | 58 | if [ -d $WP_CORE_DIR ]; then 59 | return; 60 | fi 61 | 62 | mkdir -p $WP_CORE_DIR 63 | 64 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 65 | mkdir -p $TMPDIR/wordpress-trunk 66 | rm -rf $TMPDIR/wordpress-trunk/* 67 | svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress 68 | mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR 69 | else 70 | if [ $WP_VERSION == 'latest' ]; then 71 | local ARCHIVE_NAME='latest' 72 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then 73 | # https serves multiple offers, whereas http serves single. 74 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json 75 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then 76 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x 77 | LATEST_VERSION=${WP_VERSION%??} 78 | else 79 | # otherwise, scan the releases and get the most up to date minor version of the major release 80 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` 81 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) 82 | fi 83 | if [[ -z "$LATEST_VERSION" ]]; then 84 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 85 | else 86 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION" 87 | fi 88 | else 89 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 90 | fi 91 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz 92 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR 93 | fi 94 | 95 | download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 96 | } 97 | 98 | install_test_suite() { 99 | # portable in-place argument for both GNU sed and Mac OSX sed 100 | if [[ $(uname -s) == 'Darwin' ]]; then 101 | local ioption='-i.bak' 102 | else 103 | local ioption='-i' 104 | fi 105 | 106 | # set up testing suite if it doesn't yet exist 107 | if [ ! -d $WP_TESTS_DIR ]; then 108 | # set up testing suite 109 | mkdir -p $WP_TESTS_DIR 110 | rm -rf $WP_TESTS_DIR/{includes,data} 111 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 112 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 113 | fi 114 | 115 | if [ ! -f wp-tests-config.php ]; then 116 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 117 | # remove all forward slashes in the end 118 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 119 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 120 | sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 121 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 122 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 123 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 124 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 125 | fi 126 | 127 | } 128 | 129 | recreate_db() { 130 | shopt -s nocasematch 131 | if [[ $1 =~ ^(y|yes)$ ]] 132 | then 133 | mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA 134 | create_db 135 | echo "Recreated the database ($DB_NAME)." 136 | else 137 | echo "Leaving the existing database ($DB_NAME) in place." 138 | fi 139 | shopt -u nocasematch 140 | } 141 | 142 | create_db() { 143 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 144 | } 145 | 146 | install_db() { 147 | 148 | if [ ${SKIP_DB_CREATE} = "true" ]; then 149 | return 0 150 | fi 151 | 152 | # parse DB_HOST for port or socket references 153 | local PARTS=(${DB_HOST//\:/ }) 154 | local DB_HOSTNAME=${PARTS[0]}; 155 | local DB_SOCK_OR_PORT=${PARTS[1]}; 156 | local EXTRA="" 157 | 158 | if ! [ -z $DB_HOSTNAME ] ; then 159 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 160 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 161 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 162 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 163 | elif ! [ -z $DB_HOSTNAME ] ; then 164 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 165 | fi 166 | fi 167 | 168 | # create database 169 | if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] 170 | then 171 | echo "Reinstalling will delete the existing test database ($DB_NAME)" 172 | read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB 173 | recreate_db $DELETE_EXISTING_DB 174 | else 175 | create_db 176 | fi 177 | } 178 | 179 | install_wp 180 | install_test_suite 181 | install_db 182 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automattic/wpcom-legacy-redirector", 3 | "description": "WordPress plugin for handling large volumes of legacy redirects in a scalable manner.", 4 | "license": "GPL-2.0-or-later", 5 | "type": "wordpress-plugin", 6 | "authors": [ 7 | { 8 | "name": "Automattic", 9 | "homepage": "http://automattic.com/" 10 | } 11 | ], 12 | "homepage": "https://github.com/Automattic/WPCOM-Legacy-Redirector", 13 | "support": { 14 | "issues": "https://github.com/Automattic/WPCOM-Legacy-Redirector/issues", 15 | "source": "https://github.com/Automattic/WPCOM-Legacy-Redirector" 16 | }, 17 | "require": { 18 | "php": ">=7.4", 19 | "composer/installers": "^1 || ^2" 20 | }, 21 | "require-dev": { 22 | "phpcompatibility/phpcompatibility-wp": "^2", 23 | "wp-cli/entity-command": "^2", 24 | "wp-cli/extension-command": "^2", 25 | "wp-cli/wp-cli-tests": "^4", 26 | "wp-coding-standards/wpcs": "^3", 27 | "yoast/wp-test-utils": "^1.2" 28 | }, 29 | "autoload": { 30 | "classmap": [ 31 | "includes/" 32 | ] 33 | }, 34 | "autoload-dev": { 35 | "psr-4": { 36 | "Automattic\\LegacyRedirector\\Tests\\": "tests/" 37 | } 38 | }, 39 | "config": { 40 | "allow-plugins": { 41 | "composer/installers": true, 42 | "dealerdirect/phpcodesniffer-composer-installer": true 43 | } 44 | }, 45 | "scripts": { 46 | "behat": "run-behat-tests", 47 | "behat-rerun": "rerun-behat-tests", 48 | "cs": "@php ./vendor/bin/phpcs", 49 | "prepare": [ 50 | "bash bin/install-wp-tests.sh wordpress_test root root localhost" 51 | ], 52 | "prepare-behat-tests": "install-package-tests", 53 | "test": [ 54 | "@php composer test-unit", 55 | "@php composer test-integration" 56 | ], 57 | "test-integration": [ 58 | "@php ./vendor/phpunit/phpunit/phpunit --testdox -c phpunit-integration.xml.dist --no-coverage" 59 | ], 60 | "test-integration-ms": [ 61 | "@putenv WP_MULTISITE=1", 62 | "@test-integration" 63 | ], 64 | "test-unit": [ 65 | "@php ./vendor/phpunit/phpunit/phpunit --testdox --no-coverage" 66 | ], 67 | "test-unit-ms": [ 68 | "@putenv WP_MULTISITE=1", 69 | "@test-unit" 70 | ], 71 | "test-coverage": [ 72 | "@php ./vendor/phpunit/phpunit/phpunit --coverage-text --coverage-html=coverage && echo coverage/index.html" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /features/find-domains.feature: -------------------------------------------------------------------------------- 1 | Feature: Finding domains of redirects 2 | As a user 3 | I want to find the unique outbound domains 4 | So I can populate the allowed_redirect_hosts filter 5 | 6 | Background: 7 | Given a WP installation with the WPCOM Legacy Redirector plugin 8 | 9 | Scenario: Find zero domains when there are no redirects saved 10 | When I run `wp wpcom-legacy-redirector find-domains` 11 | Then STDOUT should contain: 12 | """ 13 | Found 0 unique outbound domains. 14 | """ 15 | 16 | Scenario: Find zero domains when only path redirects are saved 17 | Given I run `wp wpcom-legacy-redirector insert-redirect /foo /hello-world` 18 | When I run `wp wpcom-legacy-redirector find-domains` 19 | Then STDOUT should contain: 20 | """ 21 | Found 0 unique outbound domains. 22 | """ 23 | 24 | Scenario: Find one domain from one URL redirect 25 | Given "google.com" is allowed to be redirected 26 | 27 | When I run `wp wpcom-legacy-redirector insert-redirect /foo https://google.com` 28 | And I run `wp wpcom-legacy-redirector find-domains` 29 | Then STDOUT should contain: 30 | """ 31 | Found 1 unique outbound domain. 32 | google.com 33 | """ 34 | 35 | Scenario: Find one domain from multiple redirects to the same host 36 | Given "google.com" is allowed to be redirected 37 | 38 | When I run `wp wpcom-legacy-redirector insert-redirect /foo https://google.com` 39 | And I run `wp wpcom-legacy-redirector insert-redirect /foo1 https://google.com/1` 40 | 41 | When I run `wp wpcom-legacy-redirector find-domains` 42 | Then STDOUT should contain: 43 | """ 44 | Found 1 unique outbound domain. 45 | google.com 46 | """ 47 | 48 | Scenario: Find multiple domains from multiple redirects to different domains 49 | Given "google.com" is allowed to be redirected 50 | And "google.co.uk" is allowed to be redirected 51 | 52 | When I run `wp wpcom-legacy-redirector insert-redirect /foo https://google.com` 53 | And I run `wp wpcom-legacy-redirector insert-redirect /foo1 https://google.co.uk` 54 | 55 | When I run `wp wpcom-legacy-redirector find-domains` 56 | Then STDOUT should contain: 57 | """ 58 | Found 2 unique outbound domains. 59 | google.com 60 | google.co.uk 61 | """ 62 | -------------------------------------------------------------------------------- /features/insert-redirect.feature: -------------------------------------------------------------------------------- 1 | Feature: Inserting a redirect 2 | As a user 3 | I want to insert a redirect 4 | So that specific requests are redirected 5 | 6 | Background: 7 | Given a WP installation with the WPCOM Legacy Redirector plugin 8 | 9 | Scenario: Insert a redirect to a path 10 | Given there is a published post with a slug of "bar" 11 | 12 | When I run `wp wpcom-legacy-redirector insert-redirect /foo /bar` 13 | Then STDOUT should contain: 14 | """ 15 | Success: Inserted /foo -> /bar 16 | """ 17 | 18 | Scenario: Insert a redirect to a safe URL 19 | # example.com seems to have an automatic bypass on allowed_redirect_hosts filter unlike other hosts. 20 | When I run `wp wpcom-legacy-redirector insert-redirect /foo https://example.com` 21 | Then STDOUT should contain: 22 | """ 23 | Success: Inserted /foo -> https://example.com 24 | """ 25 | 26 | Scenario: Redirect to disallowed host is not allowed 27 | When I try `wp wpcom-legacy-redirector insert-redirect /foo https://google.com` 28 | Then STDERR should contain: 29 | """ 30 | Error: Couldn't insert /foo -> https://google.com (If you are doing an external redirect, make sure you safelist the domain using the "allowed_redirect_hosts" filter.) 31 | """ 32 | 33 | @broken 34 | # Maybe Behat can't handle the wp_remote_get() check? 35 | Scenario: Can't insert a redirect from a page that doesn't have a 404 status 36 | Given there is a published post with a slug of "bar" 37 | 38 | When I try `wp wpcom-legacy-redirector insert-redirect /bar /hello-world` 39 | Then STDERR should contain: 40 | """ 41 | Error: Couldn't insert /bar -> /hello-world (Redirects need to be from URLs that have a 404 status.) 42 | """ 43 | 44 | Scenario: Can't insert a redirect to itself 45 | When I try `wp wpcom-legacy-redirector insert-redirect /foo /foo` 46 | Then STDERR should contain: 47 | """ 48 | Error: Couldn't insert /foo -> /foo ("Redirect From" and "Redirect To" values are required and should not match.) 49 | """ 50 | 51 | 52 | @broken 53 | # See https://github.com/Automattic/WPCOM-Legacy-Redirector/issues/117. 54 | Scenario: Insert a redirect to a post ID 55 | Given I run `wp post create --post_title='Test post' --post_status="publish" --porcelain` 56 | And save STDOUT as {POST_ID} 57 | 58 | When I run `wp wpcom-legacy-redirector insert-redirect /foo1 {POST_ID}` 59 | Then STDOUT should contain: 60 | """ 61 | Success: Inserted /foo1 -> {POST_ID} 62 | """ 63 | -------------------------------------------------------------------------------- /features/testing.feature: -------------------------------------------------------------------------------- 1 | Feature: The Behat tests are configured correctly 2 | 3 | Scenario: WP-CLI loads for your tests 4 | Given a WP install 5 | 6 | When I run `wp eval 'echo "Hello world.";'` 7 | Then STDOUT should be: 8 | """ 9 | Hello world. 10 | """ 11 | 12 | Scenario: WP-CLI recognises plugin commands 13 | Given a WP install 14 | 15 | When I run `wp plugin --help` 16 | Then STDOUT should contain: 17 | """ 18 | Manages plugins, including installs, activations, and updates. 19 | """ 20 | 21 | Scenario: WP-CLI recognises wpcom-legacy-redirector commands when the plugin is loaded 22 | Given a WP installation with the WPCOM Legacy Redirector plugin 23 | 24 | When I run `wp wpcom-legacy-redirector --help` 25 | Then STDOUT should contain: 26 | """ 27 | Manage redirects added via the WPCOM Legacy Redirector plugin. 28 | """ 29 | -------------------------------------------------------------------------------- /includes/class-capability.php: -------------------------------------------------------------------------------- 1 | get_capabilities_version_key(); 24 | 25 | // We disable capabilities register unless there is a version increment. 26 | if ( self::CAPABILITIES_VER <= get_option( $capabilities_version_key, 0 ) ) { 27 | return false; 28 | } 29 | 30 | if ( function_exists( 'wpcom_vip_add_role_caps' ) ) { 31 | wpcom_vip_add_role_caps( 'administrator', self::MANAGE_REDIRECTS_CAPABILITY ); 32 | wpcom_vip_add_role_caps( 'editor', self::MANAGE_REDIRECTS_CAPABILITY ); 33 | } else { 34 | $roles = array( 'administrator', 'editor' ); 35 | foreach ( $roles as $role ) { 36 | $role_obj = get_role( $role ); 37 | $role_obj->add_cap( self::MANAGE_REDIRECTS_CAPABILITY ); 38 | } 39 | } 40 | 41 | update_option( $capabilities_version_key, self::CAPABILITIES_VER ); 42 | 43 | return true; 44 | } 45 | 46 | /** 47 | * Unregister the capabilities. 48 | */ 49 | public function unregister() { 50 | $capabilities_version_key = $this->get_capabilities_version_key(); 51 | 52 | if ( function_exists( 'wpcom_vip_remove_role_caps' ) ) { 53 | wpcom_vip_remove_role_caps( 'administrator', self::MANAGE_REDIRECTS_CAPABILITY ); 54 | wpcom_vip_remove_role_caps( 'editor', self::MANAGE_REDIRECTS_CAPABILITY ); 55 | } else { 56 | $roles = array( 'administrator', 'editor' ); 57 | foreach ( $roles as $role ) { 58 | $role_obj = get_role( $role ); 59 | $role_obj->remove_cap( self::MANAGE_REDIRECTS_CAPABILITY ); 60 | } 61 | } 62 | 63 | delete_option( $capabilities_version_key, self::CAPABILITIES_VER ); 64 | 65 | return true; 66 | } 67 | 68 | /** 69 | * Gets capabilities version key 70 | * 71 | * @return string 72 | */ 73 | private function get_capabilities_version_key() { 74 | return self::MANAGE_REDIRECTS_CAPABILITY . '_capability_version'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /includes/class-list-redirects.php: -------------------------------------------------------------------------------- 1 | '', 30 | 'from' => __( 'Redirect From', 'wpcom-legacy-redirector' ), 31 | 'to' => __( 'Redirect To', 'wpcom-legacy-redirector' ), 32 | 'date' => __( 'Date', 'wpcom-legacy-redirector' ), 33 | ); 34 | } 35 | 36 | /** 37 | * Add the data to the custom columns for the vip-legacy-redirects post type. 38 | * Provide warnings for possibly bad redirects. 39 | * 40 | * @param string $column The Column for the post_type table. 41 | * @param int $post_id The Post ID. 42 | */ 43 | public function posts_custom_column( $column, $post_id ) { 44 | switch ( $column ) { 45 | case 'from': 46 | echo esc_html( get_the_title( $post_id ) ); 47 | break; 48 | case 'to': 49 | $post = get_post( $post_id ); 50 | $excerpt = get_the_excerpt( $post_id ); 51 | $parent = get_post( $post->post_parent ); 52 | 53 | // Check if the Post is Published. 54 | if ( ! empty( $excerpt ) ) { 55 | // Check if it's the Home URL. 56 | if ( true === \WPCOM_Legacy_Redirector::check_if_excerpt_is_home( $excerpt ) ) { 57 | echo esc_html( $excerpt ); 58 | } elseif ( 0 === strpos( $excerpt, 'http' ) ) { 59 | echo esc_url_raw( $excerpt ); 60 | } elseif ( 'private' === \WPCOM_Legacy_Redirector::vip_legacy_redirect_check_if_public( $excerpt ) ) { 61 | echo esc_html( $excerpt ) . '
' . esc_html__( 'Warning: Redirect is not a public URL.', 'wpcom-legacy-redirector' ) . ''; 62 | } else { 63 | echo esc_html( $excerpt ); 64 | } 65 | } else { 66 | switch ( \WPCOM_Legacy_Redirector::vip_legacy_redirect_parent_id( $post ) ) { 67 | case false: 68 | echo '' . esc_html__( 'Redirect is pointing to a Post ID that does not exist.', 'wpcom-legacy-redirector' ) . ''; 69 | break; 70 | case 'private': 71 | echo ( esc_html( get_permalink( $parent ) ) . '
' . esc_html__( 'Warning: Redirect is not a public URL.', 'wpcom-legacy-redirector' ) . '' ); 72 | break; 73 | default: 74 | echo esc_html( str_replace( home_url(), '', get_permalink( $parent ) ) ); 75 | } 76 | } 77 | break; 78 | } 79 | } 80 | 81 | /** 82 | * Modify the Row Actions for the vip-legacy-redirect post type. 83 | * 84 | * @param array $actions Default Actions. 85 | * @param object $post The current Post. 86 | */ 87 | public function modify_list_row_actions( $actions, $post ) { 88 | // Check for your post type. 89 | if ( Post_Type::POST_TYPE === $post->post_type ) { 90 | $url = admin_url( 'post.php?post=vip-legacy-redirect&post=' . $post->ID ); 91 | 92 | if ( isset( $_GET['post_status'] ) && 'trash' === $_GET['post_status'] ) { 93 | return $actions; 94 | } 95 | $trash = $actions['trash']; 96 | $actions = array(); 97 | 98 | if ( current_user_can( Capability::MANAGE_REDIRECTS_CAPABILITY ) ) { 99 | // Add a nonce to Validate Link. 100 | $validate_link = wp_nonce_url( 101 | add_query_arg( 102 | array( 103 | 'action' => 'validate', 104 | ), 105 | $url 106 | ), 107 | 'validate_vip_legacy_redirect', 108 | '_validate_redirect' 109 | ); 110 | 111 | // We need to keep here a code legacy for how original urls have been saved in DB. 112 | $follow_home_domain = Utils::get_home_domain_without_path(); 113 | 114 | // Add the Validate Link. 115 | $actions = array_merge( 116 | $actions, 117 | array( 118 | 'validate' => sprintf( 119 | '%2$s', 120 | esc_url( $validate_link ), 121 | esc_html__( 'Validate', 'wpcom-legacy-redirector' ) 122 | ), 123 | 'follow' => sprintf( 124 | '%2$s', 125 | esc_url( $follow_home_domain . $post->post_title ), 126 | esc_html__( 'Follow', 'wpcom-legacy-redirector' ) 127 | ), 128 | ) 129 | ); 130 | // Re-insert thrash link preserved from the default $actions. 131 | $actions['trash'] = $trash; 132 | } 133 | } 134 | return $actions; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /includes/class-lookup.php: -------------------------------------------------------------------------------- 1 | post_parent ) { 51 | // Add preserved params to the destination URL. 52 | return add_query_arg( $preservable_params, get_permalink( $redirect_post->post_parent ) ); 53 | } elseif ( ! empty( $redirect_post->post_excerpt ) ) { 54 | // Add preserved params to the destination URL. 55 | // We need to add here the home_url() if the target starts with /. 56 | $redirect_url = esc_url_raw( $redirect_post->post_excerpt ); 57 | 58 | if ( strpos( $redirect_post->post_excerpt, '/' ) === 0 ) { 59 | $redirect_url = home_url() . $redirect_url; 60 | } 61 | 62 | return add_query_arg( $preservable_params, $redirect_url ); 63 | } 64 | } 65 | return false; 66 | } 67 | 68 | /** 69 | * Get the preservable query string parameters from a given URL. 70 | * 71 | * Does not edit the URL. 72 | * 73 | * @throws \UnexpectedValueException Invalid value from filter. 74 | * 75 | * @param string $url Normalized source URL with or without querystring. 76 | * @return array Associative array of preserved keys and values that were stripped. 77 | */ 78 | public static function get_preservable_querystring_params_from_url( $url ) { 79 | /** 80 | * Filter the list of preservable querystring parameter keys. 81 | * 82 | * The plugin supports providing a list of querystring keys that should be ignored 83 | * when calculating the URL hash. These keys and their values are stripped, the 84 | * redirect lookup is done on the remaining URL, and then the keys and values are appended 85 | * to the destination URL. 86 | * 87 | * Note that if you amend this list after URLs that include the preserved keys have been 88 | * saved to the database, then the redirect lookup will fail for those URLs. 89 | * 90 | * @since 1.3.0 91 | * 92 | * @param string[] $preservable_param_keys Indexed array of strings containing the querystring keys 93 | * that should be preserved on the destination URL. 94 | * @param string $url Normalized source URL. 95 | */ 96 | $preservable_param_keys = apply_filters( 'wpcom_legacy_redirector_preserve_query_params', array(), $url ); 97 | 98 | if ( ! is_array( $preservable_param_keys ) ) { 99 | throw new \UnexpectedValueException( 'wpcom_legacy_redirector_preserve_query_params must return an array.' ); 100 | } 101 | if ( ! empty( $preservable_param_keys ) && array_keys( $preservable_param_keys ) !== range( 0, count( $preservable_param_keys ) - 1 ) ) { 102 | throw new \UnexpectedValueException( 'wpcom_legacy_redirector_preserve_query_params must return an indexed array.' ); 103 | } 104 | 105 | $preserved_param_values = array(); 106 | $preserved_params = array(); 107 | 108 | // Parse URL to get querystring parameters. 109 | $url_query_params = Utils::mb_parse_url( $url, PHP_URL_QUERY ); 110 | 111 | // No parameters in URL, so return early. 112 | if ( empty( $url_query_params ) ) { 113 | return array(); 114 | } 115 | 116 | // Parse querystring parameters to associative array. 117 | parse_str( $url_query_params, $url_params ); 118 | 119 | // Extract and return the list of preservable keys (and their values). 120 | return array_intersect_key( $url_params, array_flip( $preservable_param_keys ) ); 121 | } 122 | 123 | /** 124 | * Get redirect data status and URL based on the provided URL. 125 | * 126 | * To make the redirection match, we take a full URL as $url parameter, decode it and keep 127 | * only the PATH and QUERY part of it to look for known matches. 128 | * 129 | * @param string $url URL to find redirection for, can include a protocol and domain name, 130 | * we do the necessary stripping inside 131 | * @return false|array We return false or an array with target redirection path 132 | * and redirection code 133 | */ 134 | public static function get_redirect_data( $url ) { 135 | 136 | // We need to decode the URL here to prevent $_SERVER issue from parsed data. 137 | $url_info = Utils::mb_parse_url( urldecode( $url ) ); 138 | 139 | $path_to_match = $url_info['path']; 140 | if ( isset( $url_info['query'] ) ) { 141 | $path_to_match .= '?' . $url_info['query']; 142 | } 143 | 144 | $request_path = apply_filters( 'wpcom_legacy_redirector_request_path', $path_to_match ); 145 | 146 | if ( ! $request_path ) { 147 | return false; 148 | } 149 | 150 | $redirect_uri = self::get_redirect_uri( $request_path ); 151 | if ( ! $redirect_uri ) { 152 | return false; 153 | } 154 | 155 | $redirect_status = apply_filters( 'wpcom_legacy_redirector_redirect_status', 301, $url ); 156 | 157 | return array( 158 | 'redirect_uri' => $redirect_uri, 159 | 'redirect_status' => $redirect_status, 160 | ); 161 | } 162 | 163 | /** 164 | * Get Redirect Post ID. 165 | * 166 | * @param string $url URL to redirect (source). 167 | * @return string|int Redirect post ID (as string) if one was found; otherwise 0. 168 | */ 169 | public static function get_redirect_post_id( $url ) { 170 | global $wpdb; 171 | 172 | $url_hash = \WPCOM_Legacy_Redirector::get_url_hash( $url ); 173 | 174 | $redirect_post_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type = %s AND post_name = %s LIMIT 1", Post_Type::POST_TYPE, $url_hash ) ); 175 | 176 | if ( ! $redirect_post_id ) { 177 | $redirect_post_id = 0; 178 | } 179 | 180 | return $redirect_post_id; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /includes/class-post-type.php: -------------------------------------------------------------------------------- 1 | get_args() ); 23 | } 24 | 25 | /** 26 | * Get the post type labels. 27 | * 28 | * @return array 29 | */ 30 | protected function get_labels() { 31 | return array( 32 | 'name' => _x( 'Redirect Manager', 'Post type general name', 'wpcom-legacy-redirector' ), 33 | 'singular_name' => _x( 'Redirect Manager', 'Post type singular name', 'wpcom-legacy-redirector' ), 34 | 'menu_name' => _x( 'Redirect Manager', 'Admin Menu text', 'wpcom-legacy-redirector' ), 35 | 'name_admin_bar' => _x( 'Redirect Manager', 'Add New on Toolbar', 'wpcom-legacy-redirector' ), 36 | 'add_new' => __( 'Add New', 'wpcom-legacy-redirector' ), 37 | 'add_new_item' => __( 'Add New Redirect', 'wpcom-legacy-redirector' ), 38 | 'new_item' => __( 'New Redirect', 'wpcom-legacy-redirector' ), 39 | 'all_items' => __( 'All Redirects', 'wpcom-legacy-redirector' ), 40 | 'search_items' => __( 'Search Redirects', 'wpcom-legacy-redirector' ), 41 | 'not_found' => __( 'No redirects found.', 'wpcom-legacy-redirector' ), 42 | 'not_found_in_trash' => __( 'No redirects found in Trash.', 'wpcom-legacy-redirector' ), 43 | 'filter_items_list' => _x( 'Filter redirects list', 'Screen reader text for the filter links heading on the post type listing screen. Default “Filter posts list”/”Filter pages list”. Added in 4.4', 'wpcom-legacy-redirector' ), 44 | 'items_list_navigation' => _x( 'Redirect list navigation', 'Screen reader text for the pagination heading on the post type listing screen. Default “Posts list navigation”/”Pages list navigation”. Added in 4.4', 'wpcom-legacy-redirector' ), 45 | 'items_list' => _x( 'Redirects list', 'Screen reader text for the items list heading on the post type listing screen. Default “Posts list”/”Pages list”. Added in 4.4', 'wpcom-legacy-redirector' ), 46 | ); 47 | } 48 | 49 | /** 50 | * Get the post type arguments. 51 | * 52 | * @return array 53 | */ 54 | protected function get_args() { 55 | return array( 56 | 'labels' => $this->get_labels(), 57 | 'public' => false, 58 | 'publicly_queryable' => true, 59 | 'show_ui' => true, 60 | 'rewrite' => false, 61 | 'query_var' => false, 62 | 'capability_type' => 'post', 63 | 'hierarchical' => false, 64 | 'menu_position' => 100, 65 | 'show_in_nav_menus' => false, 66 | 'show_in_rest' => false, 67 | 'capabilities' => array( 'create_posts' => 'do_not_allow' ), 68 | 'map_meta_cap' => true, 69 | 'menu_icon' => 'dashicons-randomize', 70 | 'supports' => array( 'page-attributes' ), 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /includes/class-utils.php: -------------------------------------------------------------------------------- 1 | https 13 | * [host] => www.example1.org 14 | * [path] => //فوتوغرافيا/ 15 | * [query] => test=فوتوغرافيا 16 | * ) . 17 | * 18 | * @throws \InvalidArgumentException Malformed URL. 19 | * 20 | * @param string $url The URL to parse. We will try and encode all url characters except 21 | * reserved URL chars https://developers.google.com/maps/documentation/urls/url-encoding. 22 | * @param int $component Optional. The specific component to retrieve. Use one of the 23 | * PHP predefined constants to specify which one. Defaults 24 | * to -1 (= return all parts as an array). 25 | * @return string|array|null Array of URL components on success; When a specific component has been 26 | * requested: null if the component doesn't exist in the given URL; a 27 | * string (or in the case of PHP_URL_PORT, integer) when it does. 28 | */ 29 | public static function mb_parse_url( $url, $component = -1 ) { 30 | $encoded_url = preg_replace_callback( 31 | '|[^!*\'();:@&=+$,\/?%#\[\]]+|usD', 32 | function ( $matches ) { 33 | return urlencode( $matches[0] ); 34 | }, 35 | $url 36 | ); 37 | 38 | $parts = wp_parse_url( $encoded_url, $component ); 39 | 40 | if ( null === $parts ) { 41 | return null; 42 | } 43 | 44 | if ( false === $parts ) { 45 | throw new \InvalidArgumentException( 'Malformed URL: ' . $url ); 46 | } 47 | 48 | if ( is_array( $parts ) ) { 49 | foreach ( $parts as $name => $value ) { 50 | $parts[ $name ] = urldecode( $value ); 51 | } 52 | } else { 53 | $parts = urldecode( $parts ); 54 | } 55 | 56 | return $parts; 57 | } 58 | 59 | /** 60 | * Get WP Home URL without path suffix. 61 | * 62 | * @return string 63 | */ 64 | public static function get_home_domain_without_path() { 65 | $home_url_info = self::mb_parse_url( home_url() ); 66 | $return_url = $home_url_info['scheme'] . '://' . $home_url_info['host']; 67 | 68 | if ( !empty( $home_url_info['port'] ) ) { 69 | $return_url .= ':' . $home_url_info['port']; 70 | } 71 | 72 | return $return_url; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /includes/class-wpcom-legacy-redirector-cli.php: -------------------------------------------------------------------------------- 1 | get_var( 40 | $wpdb->prepare( 41 | "SELECT COUNT( ID ) FROM $wpdb->posts WHERE post_type = %s AND post_excerpt LIKE %s", 42 | Post_Type::POST_TYPE, 43 | 'http%' 44 | ) 45 | ); 46 | 47 | $progress = \WP_CLI\Utils\make_progress_bar( 'Finding domains', (int) $total_redirects ); 48 | do { 49 | $redirect_urls = $wpdb->get_col( 50 | $wpdb->prepare( 51 | "SELECT post_excerpt FROM $wpdb->posts WHERE post_type = %s AND post_excerpt LIKE %s ORDER BY ID ASC LIMIT %d, %d", 52 | Post_Type::POST_TYPE, 53 | 'http%', 54 | ( $paged * $posts_per_page ), 55 | $posts_per_page 56 | ) 57 | ); 58 | 59 | foreach ( $redirect_urls as $redirect_url ) { 60 | $progress->tick(); 61 | if ( ! empty( $redirect_url ) ) { 62 | $redirect_host = wp_parse_url( $redirect_url, PHP_URL_HOST ); 63 | if ( $redirect_host ) { 64 | $domains[] = $redirect_host; 65 | } 66 | } 67 | } 68 | 69 | // Pause. 70 | sleep( 1 ); 71 | ++$paged; 72 | } while ( count( $redirect_urls ) ); 73 | 74 | $progress->finish(); 75 | 76 | $domains = array_unique( $domains ); 77 | $domains_count = count( $domains ); 78 | 79 | /* translators: %s = count of the domains */ 80 | $translatable_text = _n( 'Found %s unique outbound domain.', 'Found %s unique outbound domains.', $domains_count, 'wpcom-legacy-redirector' ); 81 | 82 | WP_CLI::line( sprintf( $translatable_text, number_format( $domains_count ) ) ); 83 | 84 | foreach ( $domains as $domain ) { 85 | WP_CLI::line( $domain ); 86 | } 87 | } 88 | 89 | /** 90 | * Insert a single redirect. 91 | * 92 | * ## OPTIONS 93 | * 94 | * 95 | * : The path to redirect from. 96 | * 97 | * 98 | * : The redirect destination. Can be a full URL, or an integer post ID. 99 | * 100 | * ## EXAMPLES 101 | * 102 | * # Insert redirect from /foo (must not exist) to /bar. 103 | * $ wp wpcom-legacy-redirector insert-redirect /foo /bar 104 | * Success: Inserted /foo -> /bar 105 | * 106 | * # Insert redirect from /bar to post ID 5. 107 | * $ wp wpcom-legacy-redirector insert-redirect bar 5 108 | * 109 | * @subcommand insert-redirect 110 | * @synopsis 111 | * 112 | * @param array $args Positional arguments. 113 | * @param array $assoc_args Key-value associative arguments. 114 | */ 115 | public function insert_redirect( $args, $assoc_args ) { 116 | $from_url = esc_url_raw( $args[0] ); 117 | 118 | if ( is_numeric( $args[1] ) ) { 119 | $to_url = absint( $args[1] ); 120 | } else { 121 | $to_url = esc_url_raw( $args[1] ); 122 | } 123 | 124 | $inserted = WPCOM_Legacy_Redirector::insert_legacy_redirect( $from_url, $to_url ); 125 | 126 | if ( ! $inserted || is_wp_error( $inserted ) ) { 127 | $error_text = ''; 128 | if ( is_wp_error( $inserted ) ) { 129 | $error_text = $inserted->get_error_message(); 130 | } 131 | WP_CLI::error( sprintf( "Couldn't insert %s -> %s (%s)", $from_url, $to_url, $error_text ) ); 132 | } 133 | 134 | WP_CLI::success( sprintf( 'Inserted %s -> %s', $from_url, $to_url ) ); 135 | } 136 | 137 | /** 138 | * Bulk import redirects from URLs stored as meta values for posts. 139 | * 140 | * ## OPTIONS 141 | * 142 | * --meta-key= 143 | * : Name of the meta key to import from. The meta value contains the From value. The To is the post ID that the meta key is for. 144 | * 145 | * [--start=] 146 | * : Starting offset. Defaults to 0. 147 | * 148 | * [--end=] 149 | * : Ending offset. Defaults to 99999999. 150 | * 151 | * [--skip_dupes=] 152 | * : If set to true, then redirects for a From URL with an existing redirect will be skipped. Defaults to false. 153 | * 154 | * [--format=] 155 | * : Render output in a particular format. 156 | * --- 157 | * default: csv 158 | * options: 159 | * - table 160 | * - json 161 | * - yaml 162 | * - csv 163 | * --- 164 | * 165 | * [--dry_run] 166 | * : If set, redirects are not imported. Defaults to false. 167 | * 168 | * [--verbose] 169 | * : Display notices for successful imports and duplicates (if skip_dupes is used). Defaults to false. 170 | * 171 | * ## EXAMPLES 172 | * 173 | * # Bulk import from a my-redirect meta key. 174 | * $ wp wpcom-legacy-redirector import-from-meta --meta-key=my-redirect 175 | * ---Live Run--- 176 | * Importing 143 redirects 177 | * All of your redirects have been imported. Nice work! 178 | * 179 | * @subcommand import-from-meta 180 | * @synopsis --meta_key= [--start=] [--end=] [--skip_dupes=] [--format=] [--dry_run] [--verbose] 181 | * 182 | * @param array $args Positional arguments. 183 | * @param array $assoc_args Key-value associative arguments. 184 | */ 185 | public function import_from_meta( $args, $assoc_args ) { 186 | define( 'WP_IMPORTING', true ); 187 | 188 | global $wpdb; 189 | $offset = isset( $assoc_args['start'] ) ? intval( $assoc_args['start'] ) : 0; 190 | $end_offset = isset( $assoc_args['end'] ) ? intval( $assoc_args['end'] ) : 99999999; 191 | 192 | $meta_key = isset( $assoc_args['meta_key'] ) ? sanitize_key( $assoc_args['meta_key'] ) : ''; 193 | $skip_dupes = isset( $assoc_args['skip_dupes'] ) ? (bool) intval( $assoc_args['skip_dupes'] ) : false; 194 | $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ); 195 | $dry_run = isset( $assoc_args['dry_run'] ) ? true : false; 196 | $verbose = isset( $assoc_args['verbose'] ) ? true : false; 197 | $notices = array(); 198 | 199 | if ( true === $dry_run ) { 200 | WP_CLI::line( '---Dry Run---' ); 201 | } else { 202 | WP_CLI::line( '---Live Run---' ); 203 | } 204 | 205 | $total_redirects = $wpdb->get_var( 206 | $wpdb->prepare( 207 | "SELECT COUNT( post_id ) FROM $wpdb->postmeta WHERE meta_key = %s", 208 | $meta_key 209 | ) 210 | ); 211 | 212 | if ( 0 === absint( $total_redirects ) ) { 213 | WP_CLI::error( sprintf( 'No redirects found for meta_key: %s', $meta_key ) ); 214 | } 215 | 216 | $progress = \WP_CLI\Utils\make_progress_bar( sprintf( 'Importing %s redirects', number_format( $total_redirects ) ), $total_redirects ); 217 | 218 | do { 219 | $redirects = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM $wpdb->postmeta WHERE meta_key = %s ORDER BY post_id ASC LIMIT %d, 1000", $meta_key, $offset ) ); 220 | $i = 0; 221 | $total = count( $redirects ); 222 | 223 | foreach ( $redirects as $redirect ) { 224 | ++$i; 225 | $progress->tick(); 226 | 227 | if ( true === $skip_dupes && 0 !== WPCOM_Legacy_Redirector::get_redirect_post_id( wp_parse_url( $redirect->meta_value, PHP_URL_PATH ) ) ) { 228 | if ( $verbose ) { 229 | $notices[] = array( 230 | 'redirect_from' => $redirect->meta_value, 231 | 'redirect_to' => $redirect->post_id, 232 | 'message' => sprintf( 'Skipped - Redirect for this from URL already exists (%s)', $redirect->meta_value ), 233 | ); 234 | } 235 | continue; 236 | } 237 | 238 | if ( false === $dry_run ) { 239 | // Set `redirect_to` flag to have the validate URL perform the correct checks. 240 | $is_unset = false; 241 | if ( ! isset( $_POST['redirect_to'] ) ) { 242 | $is_unset = true; 243 | $_POST['redirect_to'] = true; 244 | } 245 | 246 | $inserted = WPCOM_Legacy_Redirector::insert_legacy_redirect( $redirect->meta_value, $redirect->post_id ); 247 | 248 | // Clean up. 249 | if ( $is_unset ) { 250 | unset( $_POST['redirect_to'] ); 251 | } 252 | 253 | if ( ! $inserted || is_wp_error( $inserted ) ) { 254 | $failure_message = is_wp_error( $inserted ) ? implode( PHP_EOL, $inserted->get_error_messages() ) : 'Could not insert redirect'; 255 | $notices[] = array( 256 | 'redirect_from' => $redirect->meta_value, 257 | 'redirect_to' => $redirect->post_id, 258 | 'message' => $failure_message, 259 | ); 260 | } elseif ( $verbose ) { 261 | $notices[] = array( 262 | 'redirect_from' => $redirect->meta_value, 263 | 'redirect_to' => $redirect->post_id, 264 | 'message' => 'Successfully imported', 265 | ); 266 | } 267 | } 268 | 269 | if ( 0 === $i % 100 ) { 270 | if ( function_exists( 'vip_inmemory_cleanup' ) ) { 271 | vip_inmemory_cleanup(); 272 | } 273 | sleep( 1 ); 274 | } 275 | } 276 | $offset += 1000; 277 | } while ( $total >= 1000 && $offset < $end_offset ); 278 | 279 | $progress->finish(); 280 | 281 | if ( count( $notices ) > 0 ) { 282 | WP_CLI\Utils\format_items( $format, $notices, array( 'redirect_from', 'redirect_to', 'message' ) ); 283 | } else { 284 | WP_CLI::log( WP_CLI::colorize( '%GAll of your redirects have been imported. Nice work!%n ' ) ); 285 | } 286 | } 287 | 288 | /** 289 | * Bulk import redirects from a CSV file. 290 | * 291 | * CSV should match the following structure: 292 | * redirect_from_path,(redirect_to_post_id|redirect_to_path|redirect_to_url) 293 | * 294 | * ## OPTIONS 295 | * 296 | * --csv= 297 | * : Path to CSV file. 298 | * 299 | * [--skip-validation] 300 | * : If set, validation of from and to values will be skipped. Defaults to false. 301 | * 302 | * [--verbose] 303 | * : If set, more verbose logging will be output. Defaults to false. 304 | * 305 | * [--format=] 306 | * : Render output in a particular format. 307 | * --- 308 | * default: csv 309 | * options: 310 | * - table 311 | * - json 312 | * - yaml 313 | * - csv 314 | * --- 315 | * 316 | * ## EXAMPLES 317 | * 318 | * # Import redirects from a redirects.csv file. 319 | * $ wp wpcom-legacy-redirector import-from-csv --csv=path/to/redirects.csv 320 | * 321 | * @subcommand import-from-csv 322 | * @synopsis --csv= [--format=] [--verbose] [--skip-validation] 323 | * 324 | * @param array $args Positional arguments. 325 | * @param array $assoc_args Key-value associative arguments. 326 | */ 327 | public function import_from_csv( $args, $assoc_args ) { 328 | define( 'WP_IMPORTING', true ); 329 | $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format' ); 330 | $csv = trim( \WP_CLI\Utils\get_flag_value( $assoc_args, 'csv' ) ); 331 | $verbose = isset( $assoc_args['verbose'] ) ? true : false; 332 | $validate = isset( $assoc_args['skip-validation'] ) ? false : true; 333 | $notices = array(); 334 | 335 | if ( empty( $csv ) || ! file_exists( $csv ) ) { 336 | WP_CLI::error( "Invalid 'csv' file" ); 337 | } 338 | 339 | if ( ! $verbose ) { 340 | WP_CLI::line( 'Processing...' ); 341 | } 342 | 343 | /* 344 | * Only applicable for the current CLI request to this function call only. 345 | * The configuration option will keep this value during the script's execution, and will be restored at the script's ending. 346 | */ 347 | ini_set( 'auto_detect_line_endings', true ); 348 | 349 | global $wpdb; 350 | $row = 0; 351 | $handle = fopen( $csv, 'r' ); 352 | if ( false !== $handle ) { 353 | while ( ( $data = fgetcsv( $handle, 2000, ',' ) ) !== false ) { 354 | ++$row; 355 | $redirect_from = $data[0]; 356 | $redirect_to = $data[1]; 357 | if ( $verbose ) { 358 | WP_CLI::line( "Adding (CSV) redirect for {$redirect_from} to {$redirect_to}" ); 359 | WP_CLI::line( "-- at $row" ); 360 | } elseif ( 0 === $row % 100 ) { 361 | WP_CLI::line( "Processing row $row" ); 362 | } 363 | 364 | $inserted = WPCOM_Legacy_Redirector::insert_legacy_redirect( $redirect_from, $redirect_to, $validate ); 365 | if ( ! $inserted || is_wp_error( $inserted ) ) { 366 | $failure_message = is_wp_error( $inserted ) ? implode( PHP_EOL, $inserted->get_error_messages() ) : 'Could not insert redirect'; 367 | $notices[] = array( 368 | 'redirect_from' => $redirect_from, 369 | 'redirect_to' => $redirect_to, 370 | 'message' => $failure_message, 371 | ); 372 | } elseif ( $verbose ) { 373 | $notices[] = array( 374 | 'redirect_from' => $redirect_from, 375 | 'redirect_to' => $redirect_to, 376 | 'message' => 'Successfully imported', 377 | ); 378 | } 379 | 380 | if ( 0 === $row % 100 ) { 381 | if ( function_exists( 'stop_the_insanity' ) ) { 382 | stop_the_insanity(); 383 | } 384 | sleep( 1 ); 385 | } 386 | } 387 | fclose( $handle ); 388 | 389 | if ( count( $notices ) > 0 ) { 390 | WP_CLI\Utils\format_items( $format, $notices, array( 'redirect_from', 'redirect_to', 'message' ) ); 391 | } else { 392 | WP_CLI::log( WP_CLI::colorize( '%GAll of your redirects have been imported. Nice work!%n ' ) ); 393 | } 394 | } 395 | } 396 | 397 | /** 398 | * Export non-trashed redirects to a CSV file. 399 | * 400 | * Matches the following structure: 401 | * redirect_from_path,(redirect_to_post_id|redirect_to_path|redirect_to_url) 402 | * 403 | * ## OPTIONS 404 | * 405 | * 406 | * : Path to CSV. 407 | * 408 | * [--overwrite] 409 | * : Whether to overwrite an existing file. Defaults to false. 410 | * 411 | * ## EXAMPLES 412 | * 413 | * # Export redirects to a redirects.csv file. 414 | * $ wp wpcom-legacy-redirector export-to-csv --csv=path/to/redirects.csv 415 | * 416 | * @subcommand export-to-csv 417 | * @synopsis --csv= [--overwrite] 418 | * 419 | * @param array $args Positional arguments. 420 | * @param array $assoc_args Key-value associative arguments. 421 | */ 422 | public function export_to_csv( $args, $assoc_args ) { 423 | $filename = $assoc_args['csv'] ? $assoc_args['csv'] : false; 424 | $overwrite = isset( $assoc_args['overwrite'] ) ? (bool) $assoc_args['overwrite'] : false; 425 | 426 | if ( ! $filename ) { 427 | WP_CLI::error( 'Invalid CSV file!' ); 428 | } 429 | 430 | if ( file_exists( $filename ) && ! $overwrite ) { 431 | WP_CLI::error( 'CSV file already exists!' ); 432 | } elseif ( file_exists( $filename ) && $overwrite ) { 433 | WP_CLI::warning( 'Overwriting file ' . $filename ); 434 | } 435 | 436 | $file_descriptor = fopen( $filename, 'wb' ); 437 | 438 | if ( ! $file_descriptor ) { 439 | WP_CLI::error( 'Invalid CSV filename!' ); 440 | } 441 | 442 | $posts_per_page = 100; 443 | $paged = 1; 444 | $post_count = array_sum( (array) wp_count_posts( Post_Type::POST_TYPE ) ); 445 | $progress = \WP_CLI\Utils\make_progress_bar( 'Exporting ' . number_format( $post_count ) . ' redirects', $post_count ); 446 | $output = array(); 447 | 448 | do { 449 | $posts = get_posts( 450 | array( 451 | 'posts_per_page' => $posts_per_page, 452 | 'paged' => $paged, 453 | 'post_type' => Post_Type::POST_TYPE, 454 | 'post_status' => 'any', 455 | 'suppress_filters' => 'false', 456 | ) 457 | ); 458 | 459 | foreach ( $posts as $post ) { 460 | $redirect_from = $post->post_title; 461 | $redirect_to = ( $post->post_parent && 0 !== $post->post_parent ) ? $post->post_parent : $post->post_excerpt; 462 | $output[] = array( $redirect_from, $redirect_to ); 463 | } 464 | $progress->tick( $posts_per_page ); 465 | 466 | if ( function_exists( 'vip_inmemory_cleanup' ) ) { 467 | vip_inmemory_cleanup(); 468 | } 469 | 470 | ++$paged; 471 | } while ( count( $posts ) ); 472 | 473 | $progress->finish(); 474 | WP_CLI\Utils\write_csv( $file_descriptor, $output ); 475 | fclose( $file_descriptor ); 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /includes/class-wpcom-legacy-redirector-ui.php: -------------------------------------------------------------------------------- 1 |

' . esc_html( $redirect_not_valid_text ) . '
' . esc_html__( 'If you are doing an external redirect, make sure you safelist the domain using the "allowed_redirect_hosts" filter.', 'wpcom-legacy-redirector' ) . '

'; 56 | break; 57 | case '404': 58 | echo '

' . esc_html( $redirect_not_valid_text ) . '
' . esc_html__( 'Redirect is pointing to a page with the HTTP status of 404.', 'wpcom-legacy-redirector' ) . '

'; 59 | break; 60 | case 'valid': 61 | echo '

' . esc_html__( 'Redirect Valid.', 'wpcom-legacy-redirector' ) . '

'; 62 | break; 63 | case 'private': 64 | echo '

' . esc_html( $redirect_not_valid_text ) . '
' . esc_html__( 'The redirect is pointing to content that is not publiclly accessible.', 'wpcom-legacy-redirector' ) . '

'; 65 | break; 66 | case 'null': 67 | echo '

' . esc_html( $redirect_not_valid_text ) . '
' . esc_html__( 'The redirect is pointing to a Post ID that does not exist.', 'wpcom-legacy-redirector' ) . '

'; 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * Remove "draft" from the status filters for vip-legacy-redirect post type. 74 | * 75 | * @param array $views Status filters. 76 | * @return array 77 | */ 78 | public function vip_redirects_custom_post_status_filters( $views ) { 79 | unset( $views['draft'] ); 80 | return $views; 81 | } 82 | 83 | 84 | /** 85 | * Return error data when validate check fails. 86 | * 87 | * @param string $validate String that passes back the validate result in order to output the right notice. 88 | * @param int $post_id The Post ID. 89 | */ 90 | public function vip_legacy_redirect_sendback( $validate, $post_id ) { 91 | $sendback = remove_query_arg( array( 'validate', 'ids' ), wp_get_referer() ); 92 | wp_safe_redirect( 93 | add_query_arg( 94 | array( 95 | 'validate' => $validate, 96 | 'ids' => $post_id, 97 | ), 98 | $sendback 99 | ) 100 | ); 101 | exit(); 102 | } 103 | /** 104 | * Validate the Redirect To URL. 105 | */ 106 | public function validate_vip_legacy_redirect() { 107 | if ( isset( $_GET['action'] ) && 'validate' === $_GET['action'] ) { 108 | $post = get_post( $_GET['post'] ); 109 | if ( ! isset( $_REQUEST['_validate_redirect'] ) || ! wp_verify_nonce( $_REQUEST['_validate_redirect'], 'validate_vip_legacy_redirect' ) ) { 110 | return; 111 | } else { 112 | $redirect = WPCOM_Legacy_Redirector::get_redirect( $post ); 113 | $status = WPCOM_Legacy_Redirector::check_if_404( $redirect ); 114 | 115 | // Check if $redirect is invalid. 116 | if ( ! wp_validate_redirect( $redirect, false ) ) { 117 | $this->vip_legacy_redirect_sendback( 'invalid', $post->ID ); 118 | } 119 | // Check if $redirect is a 404. 120 | if ( 404 === $status ) { 121 | $this->vip_legacy_redirect_sendback( '404', $post->ID ); 122 | } 123 | // Check if $redirect is not publicly visible. 124 | if ( 'private' === $redirect ) { 125 | $this->vip_legacy_redirect_sendback( 'private', $post->ID ); 126 | } 127 | // Check if $redirect is pointing to a null Post ID. 128 | if ( 'null' === $redirect ) { 129 | $this->vip_legacy_redirect_sendback( 'null', $post->ID ); 130 | } 131 | // Check if $redirect is valid. 132 | if ( ( wp_validate_redirect( $redirect, false ) && 404 !== $status ) || 'valid' === $redirect ) { 133 | $this->vip_legacy_redirect_sendback( 'valid', $post->ID ); 134 | } 135 | } 136 | } 137 | } 138 | /** 139 | * Validate the redirect that is being added. 140 | */ 141 | public function add_redirect_validation() { 142 | if ( ! current_user_can( Capability::MANAGE_REDIRECTS_CAPABILITY ) ) { 143 | return; 144 | } 145 | $errors = array(); 146 | $messages = array(); 147 | if ( isset( $_POST['redirect_from'] ) && isset( $_POST['redirect_to'] ) ) { 148 | if ( 149 | ! isset( $_POST['redirect_nonce_field'] ) 150 | || ! wp_verify_nonce( $_POST['redirect_nonce_field'], 'add_redirect_nonce' ) 151 | ) { 152 | $errors[] = array( 153 | 'label' => __( 'Error', 'wpcom-legacy-redirector' ), 154 | 'message' => __( 'Sorry, your nonce did not verify.', 'wpcom-legacy-redirector' ), 155 | ); 156 | } else { 157 | $redirect_from = sanitize_text_field( $_POST['redirect_from'] ); 158 | 159 | // We apply the home_url() prefix to $redirect_from. 160 | $redirect_url_info = Utils::mb_parse_url( home_url() . $redirect_from); 161 | $redirect_from = $redirect_url_info['path']; 162 | if( !empty( $redirect_url_info['query'] ) ) { 163 | $redirect_from .= '?' . $redirect_url_info['query']; 164 | } 165 | 166 | $redirect_to = sanitize_text_field( $_POST['redirect_to'] ); 167 | if ( WPCOM_Legacy_Redirector::validate( $redirect_from, $redirect_to ) ) { 168 | $output = WPCOM_Legacy_Redirector::insert_legacy_redirect( $redirect_from, $redirect_to, true ); 169 | if ( true === $output ) { 170 | $follow_home_domain = Utils::get_home_domain_without_path(); 171 | $link = '' . esc_html( $redirect_from ) . ''; 172 | $messages[] = __( 'The redirect was added successfully. Check Redirect: ', 'wpcom-legacy-redirector' ) . $link; 173 | } elseif ( is_wp_error( $output ) ) { 174 | foreach ( $output->get_error_messages() as $error ) { 175 | $errors[] = array( 176 | 'label' => __( 'Error', 'wpcom-legacy-redirector' ), 177 | 'message' => $error, 178 | ); 179 | } 180 | } 181 | } else { 182 | $errors[] = array( 183 | 'label' => __( 'Error', 'wpcom-legacy-redirector' ), 184 | 'message' => __( 'Check the values you are using to save the redirect. All fields are required. "Redirect From" and "Redirect To" should not match.', 'wpcom-legacy-redirector' ), 185 | ); 186 | } 187 | } 188 | } 189 | return array( $errors, $messages ); 190 | } 191 | /** 192 | * Generate the Add Redirect page. 193 | */ 194 | public function generate_page_html() { 195 | $array = $this->add_redirect_validation(); 196 | 197 | $errors = $array[0]; 198 | $messages = $array[1]; 199 | 200 | $redirect_from_value = isset( $_POST['redirect_from'], $errors[0] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_from'] ) ) : '/'; 201 | $redirect_to_value = isset( $_POST['redirect_to'], $errors[0] ) ? sanitize_text_field( wp_unslash( $_POST['redirect_to'] ) ) : '/'; 202 | ?> 203 | 218 |
219 |

220 | 221 |
222 | 223 |

224 | 225 |
226 | 227 | 228 |
229 | 230 |

:

231 | 232 |
233 | 234 | 235 |
236 | 237 | 238 | 239 | 240 | 241 | 244 | 249 | 250 | 251 | 254 | 259 | 260 | 261 |
242 | 243 | 245 |

246 | 247 |

248 |
252 | 253 | 255 |

256 | 257 |

258 |
262 | 263 | 264 | 265 |
266 | 267 |
268 | register(); 40 | } 41 | 42 | /** 43 | * Initialize and register other classes. 44 | */ 45 | public static function init() { 46 | $post_type = new Post_Type(); 47 | $post_type->register(); 48 | 49 | $list_redirects = new List_Redirects(); 50 | $list_redirects->init(); 51 | } 52 | 53 | /** 54 | * Remove Bulk Edit from the Bulk Actions drop-down on the CPT's edit screen UI. 55 | * 56 | * @param array $actions Current bulk actions available to drop-down. 57 | * @return array Available bulk actions minus edit functionality. 58 | */ 59 | public static function remove_bulk_edit( $actions ) { 60 | unset( $actions['edit'] ); 61 | return $actions; 62 | } 63 | 64 | /** 65 | * Performs redirect if current URL is a 404 and redirect rule exists. 66 | */ 67 | public static function maybe_do_redirect() { 68 | // Avoid the overhead of running this on every single pageload. 69 | // We move the overhead to the 404 page but the trade-off for site performance is worth it. 70 | if ( ! is_404() ) { 71 | return; 72 | } 73 | 74 | if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { 75 | return; 76 | } 77 | 78 | // $_SERVER is being sanitized in self::normalise_url(). 79 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash 80 | $redirect_data = Lookup::get_redirect_data( self::normalise_url( $_SERVER['REQUEST_URI'] ) ); 81 | 82 | if ( ! $redirect_data || ! isset( $redirect_data['redirect_uri'] ) ) { 83 | return; 84 | } 85 | 86 | // Third argument introduced to support the x_redirect_by header to denote WP redirect source. 87 | if ( version_compare( get_bloginfo( 'version' ), '5.1.0', '>=' ) ) { 88 | wp_safe_redirect( $redirect_data['redirect_uri'], $redirect_data['redirect_status'], WPCOM_LEGACY_REDIRECTOR_PLUGIN_NAME ); 89 | } else { 90 | header( 'X-legacy-redirect: HIT' ); 91 | wp_safe_redirect( $redirect_data['redirect_uri'], $redirect_data['redirect_status'] ); 92 | } 93 | 94 | // We need this here to make sure wp_safe_redirect() redirects correctly. 95 | exit; 96 | } 97 | 98 | /** 99 | * Enqueue the JS that builds the link previews. 100 | * 101 | * @since 1.4.0 102 | * 103 | * @param string $hook Get the current page hook. 104 | */ 105 | public static function wpcom_legacy_add_redirect_js( $hook ) { 106 | if ( 'vip-legacy-redirect_page_wpcom-legacy-redirector' !== $hook ) { 107 | return; 108 | } 109 | 110 | wp_enqueue_script( 'wpcom-legacy-redirector', plugins_url( '/../js/admin-add-redirects.js', __FILE__ ), array(), WPCOM_LEGACY_REDIRECTOR_VERSION, true ); 111 | wp_localize_script( 'wpcom-legacy-redirector', 'wpcomLegacyRedirector', array( 'siteurl' => home_url() ) ); 112 | } 113 | 114 | /** 115 | * Insert redirect as CPT in the database. 116 | * 117 | * @param string $from_url URL or path that should be redirected; should have leading slash if path. 118 | * @param int|string $redirect_to The post ID or URL to redirect to. 119 | * @param bool $validate Validate $from_url and $redirect_to values. 120 | * @param bool $return_id Return the insertd post ID if successful, rather than boolean true. 121 | * @return bool|WP_Error True or post ID if inserted; false if not permitted; otherwise error upon validation issue. 122 | */ 123 | public static function insert_legacy_redirect( $from_url, $redirect_to, $validate = true, $return_id = false ) { 124 | if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! is_admin() && ! apply_filters( 'wpcom_legacy_redirector_allow_insert', false ) ) { 125 | // Never run on the front end. 126 | return false; 127 | } 128 | 129 | $from_url = self::normalise_url( $from_url ); 130 | if ( is_wp_error( $from_url ) ) { 131 | return $from_url; 132 | } 133 | $from_url_hash = self::get_url_hash( $from_url ); 134 | 135 | if ( $validate ) { 136 | $valid_urls = self::validate_urls( $from_url, $redirect_to ); 137 | if ( is_object( $valid_urls ) ) { 138 | return $valid_urls; 139 | } else { 140 | $valid_urls[0] = $from_url; 141 | $valid_urls[1] = $redirect_to; 142 | } 143 | } 144 | 145 | $args = array( 146 | 'post_name' => $from_url_hash, 147 | 'post_title' => $from_url, 148 | 'post_type' => Post_Type::POST_TYPE, 149 | ); 150 | 151 | if ( is_numeric( $redirect_to ) ) { 152 | $args['post_parent'] = $redirect_to; 153 | } elseif ( false !== Utils::mb_parse_url( $redirect_to ) ) { 154 | $args['post_excerpt'] = esc_url_raw( $redirect_to ); 155 | } else { 156 | return self::throw_error( 'invalid-redirect-url', 'Invalid redirect_to param; should be a post_id or a URL' ); 157 | } 158 | 159 | $inserted_post_id = wp_insert_post( $args ); 160 | 161 | wp_cache_delete( $from_url_hash, self::CACHE_GROUP ); 162 | 163 | if ( $return_id ) { 164 | return $inserted_post_id; 165 | } 166 | 167 | return true; 168 | } 169 | 170 | /** 171 | * Validate the URLs. 172 | * 173 | * @param string $from_url URL to redirect (source). 174 | * @param string $redirect_to URL to redirect to (destination). 175 | * @return array|WP_Error Error if invalid redirect URL specified; returns array of params otherwise. 176 | */ 177 | public static function validate_urls( $from_url, $redirect_to ) { 178 | if ( false !== Lookup::get_redirect_uri( $from_url ) ) { 179 | return new WP_Error( 'duplicate-redirect-uri', 'A redirect for this URI already exists' ); 180 | } 181 | if ( is_numeric( $redirect_to ) || false !== strpos( $redirect_to, 'http' ) ) { 182 | if ( is_numeric( $redirect_to ) && true !== self::vip_legacy_redirect_parent_id( $redirect_to ) ) { 183 | $message = __( 'Redirect is pointing to a Post ID that does not exist.', 'wpcom-legacy-redirector' ); 184 | return new WP_Error( 'empty-postid', $message ); 185 | } 186 | if ( ! wp_validate_redirect( $redirect_to ) ) { 187 | $message = __( 'If you are doing an external redirect, make sure you safelist the domain using the "allowed_redirect_hosts" filter.', 'wpcom-legacy-redirector' ); 188 | return new WP_Error( 'external-url-not-allowed', $message ); 189 | } 190 | } else { 191 | if ( false === self::validate( $from_url, $redirect_to ) ) { 192 | $message = __( '"Redirect From" and "Redirect To" values are required and should not match.', 'wpcom-legacy-redirector' ); 193 | return new WP_Error( 'invalid-values', $message ); 194 | } 195 | if ( 404 !== absint( self::check_if_404( home_url() . $from_url ) ) ) { 196 | $message = __( 'Redirects need to be from URLs that have a 404 status.', 'wpcom-legacy-redirector' ); 197 | return new WP_Error( 'non-404', $message ); 198 | } 199 | if ( 'private' === self::vip_legacy_redirect_check_if_public( $from_url ) ) { 200 | $message = __( 'You are trying to redirect from a URL that is currently private.', 'wpcom-legacy-redirector' ); 201 | return new WP_Error( 'private-url', $message ); 202 | } 203 | if ( 'private' === self::vip_legacy_redirect_check_if_public( $redirect_to ) && '/' !== $redirect_to ) { 204 | $message = __( 'You are trying to redirect to a URL that is currently not public.', 'wpcom-legacy-redirector' ); 205 | return new WP_Error( 'non-public', $message ); 206 | } 207 | if ( 'null' === self::vip_legacy_redirect_check_if_public( $redirect_to ) && '/' !== $redirect_to ) { 208 | $message = __( 'You are trying to redirect to a URL that does not exist.', 'wpcom-legacy-redirector' ); 209 | return new WP_Error( 'invalid', $message ); 210 | } 211 | } 212 | /** 213 | * Filter the result of the redirect validation. 214 | * 215 | * @param array $params { 216 | * Array containing the URLs to validate. 217 | * 218 | * @type string $from_url URL to redirect (source). 219 | * @type string $redirect_to URL to redirect to (destination). 220 | * } 221 | */ 222 | return apply_filters( 'wpcom_legacy_redirector_validate_urls', array( $from_url, $redirect_to ) ); 223 | } 224 | 225 | /** 226 | * Utility to get MD5 hash of URL. 227 | * 228 | * @param string $url URL to hash. 229 | * @return string Hash representation of string. 230 | */ 231 | public static function get_url_hash( $url ) { 232 | return md5( $url ); 233 | } 234 | 235 | /** 236 | * Throws new error method 237 | * 238 | * @throws \Exception $message. 239 | * 240 | * @param string $code Error code. 241 | * @param string $message Error message. 242 | * @return \WP_Error | void 243 | */ 244 | public static function throw_error( $code, $message ) { 245 | 246 | if ( class_exists( '\WP_Error' ) ) { 247 | return new \WP_Error( $code, $message ); 248 | } 249 | 250 | throw new \Exception( $message ); 251 | } 252 | 253 | /** 254 | * Takes a request URL and "normalises" it, stripping common elements. 255 | * Removes scheme and host from the URL, as redirects should be independent of these. 256 | * 257 | * @param string $url URL to transform. 258 | * @return string|WP_Error Transformed URL; error if validation failed. 259 | */ 260 | public static function normalise_url( $url ) { 261 | 262 | // Sanitise the URL first rather than trying to normalise a non-URL. 263 | $url = esc_url_raw( $url ); 264 | if ( empty( $url ) ) { 265 | return self::throw_error( 'invalid-redirect-url', 'The URL does not validate' ); 266 | } 267 | 268 | // Break up the URL into it's constituent parts. 269 | $components = Utils::mb_parse_url( $url ); 270 | 271 | // Avoid playing with unexpected data. 272 | if ( ! is_array( $components ) ) { 273 | return self::throw_error( 'url-parse-failed', 'The URL could not be parsed' ); 274 | } 275 | 276 | // We should have at least a path or query. 277 | if ( ! isset( $components['path'] ) && ! isset( $components['query'] ) ) { 278 | return self::throw_error( 'url-parse-failed', 'The URL contains neither a path nor query string' ); 279 | } 280 | 281 | // Make sure $components['query'] is set, to avoid errors. 282 | $components['query'] = ( isset( $components['query'] ) ) ? $components['query'] : ''; 283 | 284 | // All we want is path and query strings 285 | // Note this strips hashes (#) too 286 | // @todo should we destory the query strings and rebuild with `add_query_arg()`? 287 | $normalised_url = $components['path']; 288 | 289 | // Only append '?' and the query if there is one. 290 | if ( ! empty( $components['query'] ) ) { 291 | $normalised_url = $components['path'] . '?' . $components['query']; 292 | } 293 | 294 | return $normalised_url; 295 | } 296 | 297 | /** 298 | * Utility function to lowercase a string. 299 | * 300 | * @param string $a_string To apply lowercase. 301 | * @return string Lowercase representation of string. 302 | */ 303 | public static function lowercase( $a_string ) { 304 | return ! empty( $a_string ) ? strtolower( $a_string ) : $a_string; 305 | } 306 | 307 | /** 308 | * Utility function to lowercase, trim, and remove trailing slashes from URL. 309 | * Trailing slashes would not be removed if query string was present. 310 | * 311 | * @param string $url URL to be transformed. 312 | * @return string Transformed URL. 313 | */ 314 | public static function transform( $url ) { 315 | return trim( self::lowercase( $url ), '/' ); 316 | } 317 | 318 | /** 319 | * Check redirect source and destination URL's are different. 320 | * 321 | * @param string $from_url URL to redirect (source). 322 | * @param string $redirect_to URL to redirect to (destination). 323 | * @return bool True if URL's are different; false if they match or either param is empty. 324 | */ 325 | public static function validate( $from_url, $redirect_to ) { 326 | return ( ! empty( $from_url ) && ! empty( $redirect_to ) && self::transform( $from_url ) !== self::transform( $redirect_to ) ); 327 | } 328 | 329 | /** 330 | * Get response code to later check if URL is a 404. 331 | * 332 | * @param string $url The URL. 333 | * @return int|string HTTP response code; empty string if no response code. 334 | */ 335 | public static function check_if_404( $url ) { 336 | if ( function_exists( 'vip_safe_wp_remote_get' ) ) { 337 | $response = vip_safe_wp_remote_get( $url ); 338 | } else { 339 | $response = wp_remote_get( $url ); 340 | // If it was an error, try again with no SSL verification, in case it was a self-signed certificate: https://github.com/Automattic/WPCOM-Legacy-Redirector/issues/64. 341 | if ( is_wp_error( $response ) ) { 342 | $args = array( 343 | 'sslverify' => false, 344 | ); 345 | $response = wp_remote_get( $url, $args ); 346 | } 347 | } 348 | $response_code = ''; 349 | if ( is_array( $response ) ) { 350 | $response_code = wp_remote_retrieve_response_code( $response ); 351 | } 352 | return $response_code; 353 | } 354 | 355 | /** 356 | * Check if $redirect is a public Post. 357 | * 358 | * @param string $excerpt The Excerpt. 359 | * @return string If post status not published returns 'private'; otherwise 'null'. 360 | */ 361 | public static function vip_legacy_redirect_check_if_public( $excerpt ) { 362 | $post_types = get_post_types(); 363 | $post_obj = get_page_by_path( $excerpt, OBJECT, $post_types ); 364 | 365 | if ( ! is_null( $post_obj ) ) { 366 | if ( 'publish' !== get_post_status( $post_obj->ID ) ) { 367 | return 'private'; 368 | } 369 | } else { 370 | return 'null'; 371 | } 372 | } 373 | 374 | /** 375 | * Get the redirect URL to pass on to validate. 376 | * We look for the excerpt, root, check if private, and check post parent IDs. 377 | * 378 | * @param object $post The Post. 379 | * @return string The redirect URL. 380 | */ 381 | public static function get_redirect( $post ) { 382 | if ( has_excerpt( $post->ID ) ) { 383 | $excerpt = get_the_excerpt( $post->ID ); 384 | 385 | // Check if redirect is a full URL or not. 386 | if ( 0 === strpos( $excerpt, 'http' ) ) { 387 | $redirect = $excerpt; 388 | } elseif ( '/' === $excerpt ) { 389 | $redirect = 'valid'; 390 | } elseif ( 'private' === self::vip_legacy_redirect_check_if_public( $excerpt ) ) { 391 | $redirect = 'private'; 392 | } else { 393 | $redirect = home_url() . $excerpt; 394 | } 395 | } else { 396 | // If it's not stored as an Excerpt, it will be stored as a post_parent ID. 397 | // Post Parent IDs are always internal redirects. 398 | $redirect = self::vip_legacy_redirect_parent_id( $post ); 399 | } 400 | return $redirect; 401 | } 402 | 403 | /** 404 | * Check if the excerpt is the home URL. 405 | * 406 | * @param string $excerpt The Excerpt of a post. 407 | * @return bool True if is home URL matches param. 408 | */ 409 | public static function check_if_excerpt_is_home( $excerpt ) { 410 | if ( '/' === $excerpt || home_url() === $excerpt ) { 411 | return true; 412 | } 413 | } 414 | 415 | /** 416 | * Run checks for the Post Parent ID of the redirect. 417 | * 418 | * @param object $post The Post. 419 | * @return bool|string True on success, false if parent not found, 'private' if not published. 420 | */ 421 | public static function vip_legacy_redirect_parent_id( $post ) { 422 | if ( isset( $_POST['redirect_to'] ) && true !== self::check_if_excerpt_is_home( $post ) ) { 423 | if ( null !== get_post( $post ) && 'publish' === get_post_status( $post ) ) { 424 | return true; 425 | } 426 | } else { 427 | if ( is_int( $post ) ) { 428 | $post = get_post( $post ); 429 | } 430 | $parent = get_post( $post->post_parent ); 431 | if ( null === get_post( $post->post_parent ) ) { 432 | return false; 433 | } elseif ( 'publish' !== get_post_status( $parent ) ) { 434 | return 'private'; 435 | } else { 436 | $parent_slug = $parent->post_name; 437 | return $parent_slug; 438 | } 439 | } 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /js/admin-add-redirects.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides helpful preview of what redirect URLs will look like. 3 | */ 4 | function update_redirect_preview( redirectFieldId, previewHolderId ) { 5 | let redirectField = document.getElementById( redirectFieldId ); 6 | 7 | redirectField.onkeyup = function() { 8 | let prefix = ''; 9 | let siteUrl = wpcomLegacyRedirector.siteurl; 10 | 11 | // If it just contains an integer, we assume it is a Post ID. 12 | if ( redirectField.value.match( /^\d+$/ ) ) { 13 | prefix = '?p='; 14 | } 15 | 16 | // If it starts with `http`, then we assume it is an absolute URL. 17 | if ( redirectField.value.match( /^http.+/ ) ) { 18 | prefix = ''; 19 | siteUrl = ''; 20 | } 21 | 22 | document.getElementById( previewHolderId ).textContent = siteUrl + prefix + redirectField.value; 23 | } 24 | } 25 | 26 | update_redirect_preview( 'redirect_from', 'redirect_from_preview' ); 27 | update_redirect_preview( 'redirect_to', 'redirect_to_preview' ); 28 | -------------------------------------------------------------------------------- /phpunit-integration.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./includes 16 | 17 | 18 | 19 | 20 | ./tests/Integration/ 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./includes 16 | 17 | 18 | 19 | 20 | ./tests/Unit/ 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tests/Behat/FeatureContext.php: -------------------------------------------------------------------------------- 1 | install_wp(); 30 | 31 | // Symlink the current project folder into the WP folder as a plugin. 32 | $project_dir = realpath( self::get_vendor_dir() . '/../' ); 33 | $plugin_dir = $this->variables['RUN_DIR'] . '/wp-content/plugins'; 34 | $this->ensure_dir_exists( $plugin_dir ); 35 | $this->proc( "ln -s {$project_dir} {$plugin_dir}/wpcom-legacy-redirector" )->run_check(); 36 | 37 | // Activate the plugin. 38 | $this->proc( 'wp plugin activate wpcom-legacy-redirector' )->run_check(); 39 | } 40 | 41 | /** 42 | * Ensure that a requested directory exists and create it recursively as needed. 43 | * 44 | * Copied as is from the Tradutorre repo as well. 45 | * 46 | * @param string $directory Directory to ensure the existence of. 47 | * @throws \RuntimeException Directory could not be created. 48 | */ 49 | private function ensure_dir_exists( $directory ) { 50 | $parent = dirname( $directory ); 51 | 52 | if ( ! empty( $parent ) && ! is_dir( $parent ) ) { 53 | $this->ensure_dir_exists( $parent ); 54 | } 55 | 56 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir 57 | if ( ! is_dir( $directory ) && ! mkdir( $directory ) && ! is_dir( $directory ) ) { 58 | throw new \RuntimeException( esc_html( "Could not create directory '{$directory}'." ) ); 59 | } 60 | } 61 | 62 | /** 63 | * Add host to allowed_redirect_hosts. 64 | * 65 | * @Given :host is allowed to be redirected 66 | * 67 | * @param string $host Host name to add. 68 | */ 69 | public function i_add_host_to_allowed_redirect_hosts( $host ) { 70 | $filter_allowed_redirect_hosts = <<variables['RUN_DIR'] . "/wp-content/mu-plugins/allowed_redirect_hosts-{$host}.php", 76 | $filter_allowed_redirect_hosts 77 | ); 78 | } 79 | 80 | /** 81 | * Add a published post. 82 | * 83 | * @Given there is a published post with a slug of :post_name 84 | * 85 | * @param string $post_name Post name to use. 86 | */ 87 | public function there_is_a_published_post( $post_name ) { 88 | $this->proc( "wp post create --post_title='{$post_name}' --post_name='{$post_name}' --post_status='publish'" )->run_check(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Integration/CapabilityTest.php: -------------------------------------------------------------------------------- 1 | unregister(); 27 | } 28 | 29 | /** 30 | * Test capabilities for Capability::MANAGE_REDIRECTS_CAPABILITY. 31 | * 32 | * @return void 33 | */ 34 | public function test_new_admin_capability() { 35 | $capability = new Capability(); 36 | 37 | // We need to force clear capabilities here as the wp_options `roles` option might not get cleared after a failed test. 38 | $capability->unregister(); 39 | 40 | // in WP_User class, if multisite and user is administrator, all capabilities are allowed, so this test is not useful. 41 | if ( ! is_multisite() ) { 42 | // We check if a new admin user does not have the redirect capability. 43 | $user_id = 1; 44 | $this->assertUserNotHasRedirectCapability( $user_id ); 45 | } 46 | 47 | $this->assertRoleNotHasRedirectsCapability( 'administrator' ); 48 | $this->assertRoleNotHasRedirectsCapability( 'editor' ); 49 | $this->assertRoleNotHasRedirectsCapability( 'subscriber' ); 50 | $this->assertRoleNotHasRedirectsCapability( '' ); 51 | 52 | $capability->register(); 53 | 54 | $this->assertRoleHasRedirectsCapability( 'administrator' ); 55 | $this->assertRoleHasRedirectsCapability( 'editor' ); 56 | $this->assertRoleNotHasRedirectsCapability( 'subscriber' ); // Should be no change. 57 | $this->assertRoleNotHasRedirectsCapability( '' ); // Should be no change. 58 | } 59 | 60 | /** 61 | * Test the Capability unregister method. 62 | * 63 | * @return void 64 | */ 65 | public function test_capability_can_be_unregistered() { 66 | $capability = new Capability(); 67 | $capability->register(); 68 | 69 | $this->assertFalse( $capability->register() ); 70 | 71 | $this->assertRoleHasRedirectsCapability( 'administrator' ); 72 | 73 | $capability->unregister(); 74 | 75 | $this->assertRoleNotHasRedirectsCapability( 'administrator' ); 76 | 77 | $this->assertTrue( $capability->register() ); 78 | } 79 | 80 | /** 81 | * Check if a specific user has Redirects capability. 82 | * 83 | * @param int|WP_User $user ID of the user, or WP_User object. 84 | * @return bool True if the user has the redirects capability, false otherwise. 85 | */ 86 | private function assertUserHasRedirectCapability( $user ) { 87 | if ( is_numeric( $user ) ) { 88 | $user = wp_set_current_user( $user ); 89 | } 90 | 91 | return $this->assertTrue( $user->has_cap( Capability::MANAGE_REDIRECTS_CAPABILITY ) ); 92 | } 93 | 94 | /** 95 | * Check if a specific user does NOT have Redirects capability. 96 | * 97 | * @param int|WP_User $user ID of the user, or WP_User object. 98 | * @return bool True if the user does not have the redirects capability, false otherwise. 99 | */ 100 | private function assertUserNotHasRedirectCapability( $user ) { 101 | if ( is_numeric( $user ) ) { 102 | $user = wp_set_current_user( $user ); 103 | } 104 | 105 | return $this->assertFalse( $user->has_cap( Capability::MANAGE_REDIRECTS_CAPABILITY ) ); 106 | } 107 | 108 | /** 109 | * Check if a role has Redirects capability. 110 | * 111 | * @param string $role Name of the role to check e.g. administrator. 112 | * @return bool True if the role has the redirects capability, false otherwise. 113 | */ 114 | private function assertRoleHasRedirectsCapability( $role ) { 115 | $user_id = self::factory()->user->create( array( 'role' => $role ) ); 116 | $user = wp_set_current_user( $user_id ); 117 | 118 | return $this->assertUserHasRedirectCapability( $user ); 119 | } 120 | 121 | /** 122 | * Check if a role does NOT have Redirects capability. 123 | * 124 | * @param string $role Name of the role to check e.g. administrator. 125 | * @return bool True if the role does not have the redirects capability, false otherwise. 126 | */ 127 | private function assertRoleNotHasRedirectsCapability( $role ) { 128 | $user_id = self::factory()->user->create( array( 'role' => $role ) ); 129 | $user = wp_set_current_user( $user_id ); 130 | 131 | return $this->assertUserNotHasRedirectCapability( $user ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/Integration/LookupTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( $to_url, $redirect_data['redirect_uri'] ); 31 | $this->assertEquals( $redirect_status, $redirect_data['redirect_status'] ); 32 | 33 | } 34 | 35 | /** 36 | * Data provider for tests methods 37 | * 38 | * @return array 39 | */ 40 | public function get_protected_redirect_data() { 41 | return array( 42 | 'redirect unicode characters with querystring' => array( 43 | '/فوتوغرافيا/?test=فوتوغرافيا', 44 | 'http://example.com/some_other_page', 45 | '301', 46 | ), 47 | 'redirect_simple' => array( 48 | '/test', 49 | 'http://example.com/', 50 | '301', 51 | ), 52 | 'redirect_unicode_no_query' => array( 53 | '/فوتوغرافيا/', 54 | 'http://example.com/', 55 | '301', 56 | ), 57 | ); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /tests/Integration/PostTypeTest.php: -------------------------------------------------------------------------------- 1 | assertTrue( post_type_exists( Post_Type::POST_TYPE ) ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Integration/RedirectsTest.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | public function get_redirect_data() { 28 | return array( 29 | 'redirect_relative_path' => array( 30 | '/non-existing-page', 31 | '/test2', 32 | home_url() . '/test2', 33 | ), 34 | 35 | 'redirect_unicode_in_path' => array( 36 | // https://www.w3.org/International/articles/idn-and-iri/ . 37 | '/JP納豆', 38 | 'http://example.com', 39 | ), 40 | 41 | 'redirect Arabic in path' => array( 42 | // https://www.w3.org/International/articles/idn-and-iri/ . 43 | '/فوتوغرافيا/?test=فوتوغرافيا', 44 | 'http://example.com', 45 | ), 46 | 47 | 'redirect_simple' => array( 48 | '/simple-redirect', 49 | 'http://example.com', 50 | ), 51 | 52 | 'redirect_with_querystring' => array( 53 | '/a-redirect?with=query-string', 54 | 'http://example.com', 55 | ), 56 | 57 | 'redirect_with_hashes' => array( 58 | // The plugin should strip the hash and only store the URL path. 59 | '/hash-redirect#with-hash', 60 | 'http://example.com', 61 | ), 62 | ); 63 | } 64 | 65 | /** 66 | * Test redirect is inserted successfully and returns true. 67 | * 68 | * @dataProvider get_redirect_data 69 | * @covers WPCOM_Legacy_Redirector::insert_legacy_redirect 70 | * @param string $from From path. 71 | * @param string $to Destination. 72 | */ 73 | public function test_redirect_is_inserted_successfully_and_returns_true( $from, $to, $expected = null ) { 74 | $redirect = WPCOM_Legacy_Redirector::insert_legacy_redirect( $from, $to, false ); 75 | $this->assertTrue( $redirect, 'insert_legacy_redirect() and return true, failed' ); 76 | 77 | $redirect = Lookup::get_redirect_uri( $from ); 78 | 79 | if ( \is_null( $expected ) ) { 80 | $expected = $to; 81 | } 82 | $this->assertEquals( $expected, $redirect, 'get_redirect_uri(), failed - got "' . $redirect . '", expected "' . $to . '"' ); 83 | } 84 | 85 | /** 86 | * Test redirect is inserted successfully and returns a post ID. 87 | * 88 | * @covers WPCOM_Legacy_Redirector::insert_legacy_redirect 89 | */ 90 | public function test_redirect_is_inserted_successfully_and_returns_post_id() { 91 | $redirect = WPCOM_Legacy_Redirector::insert_legacy_redirect( '/simple-redirect', 'http://example.com', false, true ); 92 | self::assertIsInt( $redirect, 'insert_legacy_redirect() and return post ID, failed' ); 93 | } 94 | 95 | /** 96 | * Data Provider of Redirect Rules and test urls for Protected Params 97 | * 98 | * @return array 99 | */ 100 | public function get_protected_redirect_data() { 101 | return array( 102 | 'redirect_simple_protected' => array( 103 | '/simple-redirectA/', 104 | 'http://example.com/', 105 | '/simple-redirectA/?utm_source=XYZ', 106 | 'http://example.com/?utm_source=XYZ', 107 | ), 108 | 109 | 'redirect_protected_with_querystring' => array( 110 | '/b-redirect/?with=query-string', 111 | 'http://example.com/', 112 | '/b-redirect/?with=query-string&utm_medium=123', 113 | 'http://example.com/?utm_medium=123', 114 | ), 115 | 116 | 'redirect_protected_with_hashes' => array( 117 | // The plugin should strip the hash and only store the URL path. 118 | '/hash-redirectA/#with-hash', 119 | 'http://example.com/', 120 | '/hash-redirectA/?utm_source=SDF#with-hash', 121 | 'http://example.com/?utm_source=SDF', 122 | ), 123 | 124 | 'redirect_multiple_protected' => array( 125 | '/simple-redirectC/', 126 | 'http://example.com/', 127 | '/simple-redirectC/?utm_source=XYZ&utm_medium=FALSE&utm_campaign=543', 128 | 'http://example.com/?utm_source=XYZ&utm_medium=FALSE&utm_campaign=543', 129 | ), 130 | ); 131 | } 132 | 133 | /** 134 | * Verify that safelisted parameters are maintained on final redirect URLs. 135 | * 136 | * @dataProvider get_protected_redirect_data 137 | * @covers WPCOM_Legacy_Redirector::insert_legacy_redirect 138 | * @covers \Automattic\LegacyRedirector\Lookup::get_redirect_uri 139 | * @param string $from From path. 140 | * @param string $to Destination. 141 | * @param string $protected_from From path with preserved params. 142 | * @param string $protected_to Destination. with preserved params. 143 | */ 144 | public function test_protected_query_redirect( $from, $to, $protected_from, $protected_to ) { 145 | add_filter( 146 | 'wpcom_legacy_redirector_preserve_query_params', 147 | function ( $preserved_params ) { 148 | array_push( 149 | $preserved_params, 150 | 'utm_source', 151 | 'utm_medium', 152 | 'utm_campaign' 153 | ); 154 | return $preserved_params; 155 | } 156 | ); 157 | 158 | $redirect = WPCOM_Legacy_Redirector::insert_legacy_redirect( $from, $to, false ); 159 | $this->assertTrue( $redirect, 'insert_legacy_redirect failed' ); 160 | 161 | $redirect = Lookup::get_redirect_uri( $protected_from ); 162 | $this->assertEquals( $redirect, $protected_to, 'get_redirect_uri failed' ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/Integration/TestCase.php: -------------------------------------------------------------------------------- 1 | register method to make sure update_option is only called once and mocking wpcom_vip_add_role_caps function 21 | * 22 | * @covers \Automattic\LegacyRedirector\Capability::register 23 | * @uses \Automattic\LegacyRedirector\Capability::get_capabilities_version_key 24 | * @return void 25 | */ 26 | public function test_register_method_is_only_called_once() { 27 | $capability = new Capability(); 28 | 29 | Functions\when( 'wpcom_vip_add_role_caps' ) 30 | ->justReturn( true ); 31 | 32 | Functions\expect( 'get_option' ) 33 | ->once() 34 | ->andReturn( 0 ); 35 | 36 | Functions\expect( 'update_option' ) 37 | ->once() 38 | ->andReturn( true ); 39 | 40 | $capability->register(); 41 | 42 | Functions\expect( 'get_option' ) 43 | ->once() 44 | ->andReturn( $capability::CAPABILITIES_VER ); 45 | 46 | Functions\expect( 'update_option' ) 47 | ->never(); 48 | 49 | $capability->register(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Unit/CliTest.php: -------------------------------------------------------------------------------- 1 | static function ( $url, $component ) { 21 | return parse_url( $url, $component ); 22 | }, 23 | 'esc_url_raw', // Return 1st param unchanged. 24 | ) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Unit/PreservableParamsTest.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function data_get_preservable_querystring_params_from_url() { 30 | return array( 31 | 'No querystring' => array( 32 | 'https://example.com', 33 | array( 'foo', 'bar', 'baz' ), 34 | array(), 35 | ), 36 | 'Empty list of param keys' => array( 37 | 'https://example.com?foo=123&bar=456', 38 | array(), 39 | array(), 40 | ), 41 | 'Single key' => array( 42 | 'https://example.com?foo=123&bar=qwerty&baz=456', 43 | array( 'foo' ), 44 | array( 45 | 'foo' => '123', 46 | ), 47 | ), 48 | 'Multiple keys' => array( 49 | 'https://example.com?foo=123&bar=qwerty&baz=456', 50 | array( 'foo', 'bar' ), 51 | array( 52 | 'foo' => '123', 53 | 'bar' => 'qwerty', 54 | ), 55 | ), 56 | 'Multiple instance of preservable keys' => array( 57 | 'https://example.com?foo=123&bar=qwerty&baz=456', 58 | array( 'foo', 'bar', 'foo' ), 59 | array( 60 | 'foo' => '123', 61 | 'bar' => 'qwerty', 62 | ), 63 | ), 64 | 'Multiple instance of URL keys' => array( 65 | 'https://example.com?foo=123&bar=qwerty&foo=456', 66 | array( 'foo', 'bar', 'foo' ), 67 | array( 68 | 'foo' => '123', 69 | 'bar' => 'qwerty', 70 | // phpcs:ignore Universal.Arrays.DuplicateArrayKey.Found -- intentional duplicate. 71 | 'foo' => '456', 72 | ), 73 | ), 74 | 'URL key is an array' => array( 75 | 'https://example.com?foo[]=123&bar=qwerty&foo[]=456', 76 | array( 'foo', 'bar', 'foo' ), 77 | array( 78 | 'foo' => array( 79 | '123', 80 | '456', 81 | ), 82 | 'bar' => 'qwerty', 83 | ), 84 | ), 85 | 'String returned from filter' => array( 86 | 'https://example.com?foo=123&bar=456', 87 | 'foo', 88 | new UnexpectedValueException(), 89 | ), 90 | 'Int returned from filter' => array( 91 | 'https://example.com?foo=123&bar=456', 92 | 0, 93 | new UnexpectedValueException(), 94 | ), 95 | 'Associative array returned from filter' => array( 96 | 'https://example.com?foo=123&bar=456', 97 | array( 98 | 'foo' => 0, 99 | 'baz' => 1, 100 | ), 101 | new UnexpectedValueException(), 102 | ), 103 | ); 104 | } 105 | 106 | /** 107 | * Test that preservable parameters from the querystring are preserved. 108 | * 109 | * @covers \Automattic\LegacyRedirector\Lookup::get_preservable_querystring_params_from_url 110 | * @uses. \Automattic\LegacyRedirector\Utils::mb_parse_url 111 | * @dataProvider data_get_preservable_querystring_params_from_url 112 | * @param string $url The URL to parse. 113 | * @param array $preservable_param_keys The keys that should be preserved. 114 | * @param array $expected The expected outcome. 115 | */ 116 | public function test_get_preservable_querystring_params_from_url( $url, $preservable_param_keys, $expected ) { 117 | Monkey\Filters\expectApplied( 'wpcom_legacy_redirector_preserve_query_params' ) 118 | ->once() 119 | ->andReturn( $preservable_param_keys ); 120 | 121 | Monkey\Functions\stubs( 122 | array( 123 | 'wp_parse_url' => static function ( $url, $component ) { 124 | // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url 125 | return parse_url( $url, $component ); 126 | }, 127 | ) 128 | ); 129 | 130 | if ( ! is_array( $expected ) ) { 131 | $this->expectException( get_class( $expected ) ); 132 | } 133 | 134 | $actual = Lookup::get_preservable_querystring_params_from_url( $url ); 135 | 136 | self::assertSame( $expected, $actual, 'Preserved keys and values do not match.' ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/Unit/RedirectsTest.php: -------------------------------------------------------------------------------- 1 | expectException( $expected_domain ); 31 | } 32 | 33 | $this->assertSame( $expected_return, WPCOM_Legacy_Redirector::normalise_url( $url ) ); 34 | 35 | } 36 | 37 | /** 38 | * Data provider for tests methods for normalise_url tests 39 | * 40 | * @return array 41 | */ 42 | public function get_protected_redirect_data_full_url_only() { 43 | return array( 44 | 'redirect_simple_url_no_end_slash' => array( 45 | 'https://www.example1.org', 46 | 'error', 47 | 'Exception', 48 | '', 49 | '', 50 | ), 51 | 'redirect_simple_url_with_end_slash' => array( 52 | 'https://www.example1.org/', 53 | 'https', 54 | 'www.example1.org', 55 | '/', 56 | '', 57 | ), 58 | 'redirect_ascii_path_with_multiple_slashes' => array( 59 | 'https://www.example1.org///test///?test2=123&test=456', 60 | 'https', 61 | 'www.example1.org', 62 | '///test///', 63 | 'test2=123&test=456', 64 | ), 65 | 'redirect_unicode_path_with_multiple_slashes_and_query' => array( 66 | 'https://www.example1.org///test///?فوتوغرافيا/?test=فوتوغرافيا', 67 | 'https', 68 | 'www.example1.org', 69 | '///test///', 70 | 'فوتوغرافيا/?test=فوتوغرافيا', 71 | ), 72 | 'redirect_unicode_path_with_multiple_slashes' => array( 73 | 'https://www.example1.org//فوتوغرافيا/?test=فوتوغرافيا', 74 | 'https', 75 | 'www.example1.org', 76 | '//فوتوغرافيا/', 77 | 'test=فوتوغرافيا', 78 | ), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Unit/UtilsTest.php: -------------------------------------------------------------------------------- 1 | do_assertion_mb_parse_url( $url, $expected_schema, $expected_domain, $expected_path, $expected_query ); 28 | 29 | } 30 | 31 | /** 32 | * Data provider for tests methods 33 | * 34 | * @return array 35 | */ 36 | public function get_protected_redirect_data() { 37 | return array( 38 | 'redirect_simple_url_no_end_slash' => array( 39 | 'https://www.example1.org', 40 | 'https', 41 | 'www.example1.org', 42 | '', 43 | '', 44 | ), 45 | 'redirect_simple_url_with_end_slash' => array( 46 | 'http://www.example2.org/', 47 | 'http', 48 | 'www.example2.org', 49 | '/', 50 | '', 51 | ), 52 | 'redirect_url_with_path' => array( 53 | 'https://www.example3.com/test', 54 | 'https', 55 | 'www.example3.com', 56 | '/test', 57 | '', 58 | ), 59 | 'redirect_unicode_url_with_query' => array( 60 | 'http://www.example4.com//فوتوغرافيا/?test=فوتوغرافيا', 61 | 'http', 62 | 'www.example4.com', 63 | '//فوتوغرافيا/', 64 | 'test=فوتوغرافيا', 65 | ), 66 | 'redirect_unicode_path_with_query' => array( 67 | '/فوتوغرافيا/?test=فوتوغرافيا', 68 | '', 69 | '', 70 | '/فوتوغرافيا/', 71 | 'test=فوتوغرافيا', 72 | ), 73 | 'redirect_unicode_path_with_multiple_parameters' => array( 74 | '/فوتوغرافيا/?test2=فوتوغرافيا&test=فوتوغرافيا', 75 | '', 76 | '', 77 | '/فوتوغرافيا/', 78 | 'test2=فوتوغرافيا&test=فوتوغرافيا', 79 | ), 80 | 'redirect_malformed_url' => array( 81 | 'http://', 82 | 'exception', 83 | 'InvalidArgumentException', 84 | '', 85 | '', 86 | ), 87 | 88 | ); 89 | } 90 | 91 | /** 92 | * Do assertion method for testing mb_parse_url(). 93 | * 94 | * @param string $url URL to test redirection against, can be a full blown URL with schema. 95 | * @param string $expected_scheme Expected URL schema return. | `exception` string for Exceptions. 96 | * @param string $expected_host Expected URL hostname return. | Exception Type String, like `InvalidArgumentException`. 97 | * @param string $expected_path Expected URL path return. 98 | * @param string $expected_query Expected URL query return. 99 | * @return void 100 | */ 101 | private function do_assertion_mb_parse_url( $url, $expected_scheme, $expected_host, $expected_path, $expected_query ) { 102 | 103 | if ( 'exception' === $expected_scheme ) { 104 | $this->expectException( $expected_host ); 105 | } 106 | 107 | $path_info = Utils::mb_parse_url( $url ); 108 | 109 | if ( ! isset( $path_info['scheme'] ) ) { 110 | $path_info['scheme'] = ''; 111 | } 112 | if ( ! isset( $path_info['host'] ) ) { 113 | $path_info['host'] = ''; 114 | } 115 | if ( ! isset( $path_info['path'] ) ) { 116 | $path_info['path'] = ''; 117 | } 118 | if ( ! isset( $path_info['query'] ) ) { 119 | $path_info['query'] = ''; 120 | } 121 | 122 | $this->assertIsArray( $path_info ); 123 | $this->assertGreaterThan( 1, count( $path_info ) ); 124 | $this->assertSame( $expected_host, $path_info['host'] ); 125 | $this->assertSame( $expected_path, $path_info['path'] ); 126 | $this->assertSame( $expected_query, $path_info['query'] ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Unit/bootstrap.php: -------------------------------------------------------------------------------- 1 |